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

This commit is contained in:
2026-10-23 14:26:41 +08:00
parent 72a758fd7c
commit bfd2325ac5
28 changed files with 1332 additions and 347 deletions
@@ -77,18 +77,25 @@ export async function createWorkerAcceptanceAdminNotification(input: {
productName: string
workerName: string
imageCount: number
/** 拼单份额:每份提交各生成一条待办(按 shareId 去重) */
shareId?: number | null
}) {
const shareId = Number(input.shareId || 0)
return createConfiguredWorkerPlatformNotification('acceptance_submitted', {
notificationType: 'worker_acceptance_submitted',
priority: 'normal',
entityType: 'work_order',
entityId: input.workOrderId,
dedupeKey: acceptanceNotificationKey(input.workOrderId),
dedupeKey:
shareId > 0
? acceptanceNotificationKey(input.workOrderId, shareId)
: acceptanceNotificationKey(input.workOrderId),
title: '有订单待验收',
body: [
`订单:${input.workOrderNo || input.workOrderId}`,
`商品:${input.productName || '-'}`,
`打手:${input.workerName || '-'}`,
...(shareId > 0 ? [`份额:#${shareId}`] : []),
`验收图片:${input.imageCount} 张`,
].join(';'),
actionPath: `${ADMIN_WORKER_PLATFORM_PATH}?tab=orders`,
@@ -96,6 +103,28 @@ export async function createWorkerAcceptanceAdminNotification(input: {
})
}
/** 整单(含拼单全部份额)验收完成:单独一条完成通知,不与待验收待办混用。 */
export async function createWorkOrderFullyAcceptedAdminNotification(input: {
workOrderId: number
workOrderNo: string
productName: string
}) {
return createConfiguredWorkerPlatformNotification('order_accepted', {
notificationType: 'worker_order_accepted',
priority: 'normal',
entityType: 'work_order',
entityId: input.workOrderId,
dedupeKey: `work_order_accepted:${input.workOrderId}`,
title: '订单已全部验收',
body: [
`订单:${input.workOrderNo || input.workOrderId}`,
`商品:${input.productName || '-'}`,
].join(';'),
actionPath: `${ADMIN_WORKER_PLATFORM_PATH}?tab=orders`,
now: new Date().toISOString(),
})
}
export async function createWorkerCancelRequestAdminNotification(input: {
requestId: number
workOrderId: number
@@ -279,8 +308,10 @@ async function createConfiguredWorkerPlatformNotification(
return notification
}
function acceptanceNotificationKey(workOrderId: number) {
return `work_order_acceptance:${workOrderId}`
function acceptanceNotificationKey(workOrderId: number, shareId = 0) {
return shareId > 0
? `work_order_acceptance:${workOrderId}:share:${shareId}`
: `work_order_acceptance:${workOrderId}`
}
function formatAmount(amountFen: number) {
@@ -11,7 +11,10 @@ import type { JsonObject } from '../../types/json.js'
import { createHttpError } from '../../utils/http.js'
import { getWorkerFinanceConfig } from './worker-finance-config-service.js'
import { nowIso } from '../../utils/time.js'
import { resolveAdminNotificationEntity } from '../admin/admin-notification-service.js'
import {
createWorkOrderFullyAcceptedAdminNotification,
resolveAdminNotificationEntity,
} from '../admin/admin-notification-service.js'
import {
publishWorkerWalletRealtimeChange,
publishWorkOrderRealtimeChange,
@@ -80,6 +83,11 @@ export async function acceptAdminWorkOrder(workOrderId: number | string, actorNa
})
}
await resolveAdminNotificationEntity('work_order', Number(workOrder.id), 'accepted')
await createWorkOrderFullyAcceptedAdminNotification({
workOrderId: Number(workOrder.id),
workOrderNo: workOrder.work_order_no,
productName: workOrder.product_name,
})
const workerIds = await resolveWorkOrderRealtimeWorkerIds(workOrder)
publishWorkOrderRealtimeChange({ workOrderId: Number(workOrder.id), workerIds })
for (const workerId of workerIds) publishWorkerWalletRealtimeChange(workerId)
@@ -119,6 +127,11 @@ export async function acceptAdminWorkOrderShare(
if (workerId > 0) publishWorkerWalletRealtimeChange(workerId)
if (result.order.status === WORK_ORDER_STATUS.ACCEPTED) {
await resolveAdminNotificationEntity('work_order', Number(workOrderId), 'accepted')
await createWorkOrderFullyAcceptedAdminNotification({
workOrderId: Number(workOrderId),
workOrderNo: result.order.work_order_no,
productName: result.order.product_name,
})
}
return { order: mapWorkOrderAdmin(result.order), share: mapWorkOrderShare(result.share) }
}
@@ -423,6 +423,8 @@ export function mapWorkOrderAdmin(
export type WorkerOrderMapOptions = {
isTimedOutForMe?: boolean
/** 同一打手在该工单的全部有效份额;不传时回退为 myShare 单条 */
myShares?: WorkOrderShareRow[]
timeoutNotice?:
| {
isTimedOut: boolean
@@ -435,6 +437,72 @@ export type WorkerOrderMapOptions = {
| undefined
}
/** 同一打手在一个工单里的多份份额汇总(列表一行一工单展示用)。 */
export type WorkerShareSummary = {
quantity: number
rewardAmount: number
depositAmount: number
firstJoinedAt: string | null
/** 未完成份额里最早的截止时间;用于父行倒计时,避免只看到其中一份。 */
earliestActiveDeadlineAt: string | null
}
export function summarizeWorkerShares(
shares: WorkOrderShareRow[],
fallbackTimeoutMinutes = 0,
): WorkerShareSummary {
let quantity = 0
let rewardAmount = 0
let depositAmount = 0
/** 有有效份额时取第一份有效份额的参与时间;只剩已取消份额时回退到最早的取消份额。 */
let firstActiveJoinedAtMs = Number.POSITIVE_INFINITY
let firstAnyJoinedAtMs = Number.POSITIVE_INFINITY
let earliestDeadlineMs = Number.POSITIVE_INFINITY
for (const share of shares) {
const createdAtMs = Date.parse(String(share.created_at || ''))
if (Number.isFinite(createdAtMs) && createdAtMs < firstAnyJoinedAtMs) {
firstAnyJoinedAtMs = createdAtMs
}
if (share.status === 'cancelled') continue
if (Number.isFinite(createdAtMs) && createdAtMs < firstActiveJoinedAtMs) {
firstActiveJoinedAtMs = createdAtMs
}
quantity += Number(share.quantity || 0)
rewardAmount += Number(share.share_reward || 0)
depositAmount += Number(share.share_deposit || 0)
if (share.status === 'joined') {
const timeoutMinutes = Number(share.timeout_minutes || fallbackTimeoutMinutes || 0)
const deadlineMs = share.deadline_at
? Date.parse(String(share.deadline_at))
: Number.isFinite(createdAtMs) && timeoutMinutes > 0
? createdAtMs + timeoutMinutes * 60_000
: Number.NaN
if (Number.isFinite(deadlineMs) && deadlineMs < earliestDeadlineMs) {
earliestDeadlineMs = deadlineMs
}
}
}
return {
quantity,
rewardAmount,
depositAmount,
firstJoinedAt: Number.isFinite(firstActiveJoinedAtMs)
? new Date(firstActiveJoinedAtMs).toISOString()
: Number.isFinite(firstAnyJoinedAtMs)
? new Date(firstAnyJoinedAtMs).toISOString()
: null,
earliestActiveDeadlineAt: Number.isFinite(earliestDeadlineMs)
? new Date(earliestDeadlineMs).toISOString()
: null,
}
}
export function mapWorkOrderForWorker(
workOrder: WorkOrderRow,
permissions: { depositFreeAmount: number },
@@ -461,35 +529,47 @@ export function mapWorkOrderForWorker(
const personalStatus = isTimedOutForMe
? WORK_ORDER_STATUS.OPEN
: resolveWorkerShareStatus(workOrder.status, myShare?.status)
const workerShares =
options?.myShares && options.myShares.length > 0 ? options.myShares : myShare ? [myShare] : []
const shareSummary = summarizeWorkerShares(workerShares, Number(workOrder.timeout_minutes || 0))
const hasActiveShares = workerShares.some((share) => share.status !== 'cancelled')
return {
...visibleOrder,
productName: myShare
? resolveWorkerShareProductName(workOrder.product_name, Number(myShare.quantity || 0))
productName: hasActiveShares
? resolveWorkerShareProductName(workOrder.product_name, shareSummary.quantity)
: visibleOrder.productName,
status: personalStatus,
rewardAmount: isTimedOutForMe
? 0
: myShare
? Number(myShare.share_reward || 0)
: hasActiveShares
? shareSummary.rewardAmount
: visibleOrder.rewardAmount,
freezeDepositAmount: isTimedOutForMe
? 0
: myShare
? Number(myShare.share_deposit || 0)
: hasActiveShares
? shareSummary.depositAmount
: resolveFreezeDepositAmount(workOrder, permissions),
/** 用于大厅拼单按当前剩余份数准确计算个人免押后的押金。 */
depositFreeAmount: Number(permissions.depositFreeAmount || 0),
assignedAt: myShare?.created_at || visibleOrder.assignedAt,
assignedAt: shareSummary.firstJoinedAt || 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,
deadlineAt: isTimedOutForMe
? visibleOrder.deadlineAt
: shareSummary.earliestActiveDeadlineAt ||
(myShare && Number(workOrder.timeout_minutes || 0) > 0
? String(myShare.deadline_at || '') ||
new Date(
new Date(myShare.created_at).getTime() +
Number(workOrder.timeout_minutes) * 60 * 1000,
).toISOString()
: visibleOrder.deadlineAt),
workerNote,
myShare: myShare ? mapWorkOrderShare(myShare) : null,
/** 同一打手在该工单的全部有效份额(一行一工单 + 展开份额) */
myShares: workerShares.map(mapWorkOrderShare),
...(viewedAt === undefined ? {} : { isUnread: !viewedAt }),
...(options?.timeoutNotice ? { timeoutNotice: options.timeoutNotice } : {}),
}
@@ -533,6 +613,11 @@ export function mapWorkOrderShare(share: WorkOrderShareRow) {
acceptedAt: share.accepted_at,
createdAt: share.created_at,
updatedAt: share.updated_at,
/** 本份额独立截止时间(参与时固化);null = 不限时 */
deadlineAt: share.deadline_at || null,
timeoutMinutes: Number(share.timeout_minutes || 0),
timeoutPolicy: share.timeout_policy || '',
cancelledReason: share.cancelled_reason || '',
worker: share.worker_id
? {
workerId: Number(share.worker_id),
@@ -9,7 +9,7 @@ import {
acceptWorkOrderShareAndSettle,
countWorkOrderPendingSharingSubmissions,
createWorkOrderEvent,
getWorkOrderShare,
listWorkOrderSharesByWorkerInOrder,
settleOverdueWorkOrder,
settleOverdueWorkOrderShare,
submitWorkOrderShareAcceptance,
@@ -74,9 +74,21 @@ export async function submitWorkerOrderAcceptance(
const workerId = Number(session.workerId)
const worker = await getRequiredWorker(session.workerId)
if (!options.asConsignmentSupplier) {
const sharingShare = await getWorkOrderShare(workOrder.id, workerId)
if (sharingShare) {
return submitWorkerSharingAcceptance(workOrder, sharingShare, payload, session)
const target = await resolveWorkerTargetShare(workOrder.id, workerId, payload)
if (target.error === 'required') {
throw createHttpError('该工单有多条拼单份额,请指定 shareId', {
statusCode: 400,
errorCode: 'work_order_share_id_required',
})
}
if (target.error === 'not_found') {
throw createHttpError('未找到该拼单份额', {
statusCode: 404,
errorCode: 'work_order_sharing_share_not_found',
})
}
if (target.share) {
return submitWorkerSharingAcceptance(workOrder, target.share, payload, session)
}
}
const canAutoAcceptWithoutEvidence = isFriendGiftAcceptanceMode(workOrder.acceptance_mode)
@@ -340,9 +352,21 @@ export async function supplementWorkerAcceptedOrderEvidence(
requireActiveWorkerSession(session)
const workOrder = await getRequiredWorkOrder(workOrderId)
const worker = await getRequiredWorker(session.workerId)
const sharingShare = await getWorkOrderShare(workOrder.id, session.workerId)
if (sharingShare) {
return supplementWorkerAcceptedSharingEvidence(workOrder, sharingShare, payload, session)
const target = await resolveWorkerTargetShare(workOrder.id, session.workerId, payload)
if (target.error === 'required') {
throw createHttpError('该工单有多条拼单份额,请指定 shareId', {
statusCode: 400,
errorCode: 'work_order_share_id_required',
})
}
if (target.error === 'not_found') {
throw createHttpError('未找到该拼单份额', {
statusCode: 404,
errorCode: 'work_order_sharing_share_not_found',
})
}
if (target.share) {
return supplementWorkerAcceptedSharingEvidence(workOrder, target.share, payload, session)
}
if (Number(workOrder.assigned_worker_id || 0) !== Number(session.workerId)) {
throw createHttpError('只能补充自己的订单验收图片', {
@@ -411,16 +435,23 @@ async function submitWorkerSharingAcceptance(
})
}
const now = nowIso()
const timeoutMinutes = Number(workOrder.timeout_minutes || 0)
const timeoutMinutes = Number(sharingShare.timeout_minutes ?? workOrder.timeout_minutes ?? 0)
const snapshotDeadline = String(sharingShare.deadline_at || '').trim()
const deadlineMs = snapshotDeadline
? new Date(snapshotDeadline).getTime()
: new Date(sharingShare.created_at).getTime() + timeoutMinutes * 60 * 1000
if (
sharingShare.status === 'joined' &&
timeoutMinutes > 0 &&
new Date(sharingShare.created_at).getTime() + timeoutMinutes * 60 * 1000 <= Date.now()
Number.isFinite(deadlineMs) &&
deadlineMs <= Date.now()
) {
await settleOverdueWorkOrderShare({
workOrderId: workOrder.id,
shareId: sharingShare.id,
policy: normalizeWorkOrderTimeoutPolicy(workOrder.timeout_policy),
policy: normalizeWorkOrderTimeoutPolicy(
sharingShare.timeout_policy || workOrder.timeout_policy,
),
now,
})
publishWorkOrderRealtimeChange({
@@ -463,6 +494,7 @@ async function submitWorkerSharingAcceptance(
const result = await submitWorkOrderShareAcceptance({
workOrderId: Number(workOrder.id),
workerId: Number(session.workerId),
shareId: Number(sharingShare.id),
acceptanceJson: JSON.stringify(acceptance),
now,
})
@@ -509,6 +541,14 @@ async function submitWorkerSharingAcceptance(
sharingPendingCount: await countWorkOrderPendingSharingSubmissions(Number(workOrder.id)),
}
}
await createWorkerAcceptanceAdminNotification({
workOrderId: Number(workOrder.id),
workOrderNo: workOrder.work_order_no,
productName: workOrder.product_name,
workerName: worker.display_name || worker.username,
imageCount: acceptance.imageUrls.length,
shareId: Number(sharingShare.id),
})
await publishWorkOrderParticipantChange(Number(workOrder.id), {
workerIds: [Number(session.workerId)],
})
@@ -552,6 +592,7 @@ async function supplementWorkerAcceptedSharingEvidence(
const updated = await updateWorkOrderShareAcceptanceRecord({
workOrderId: Number(workOrder.id),
workerId: Number(session.workerId),
shareId: Number(sharingShare.id),
acceptanceJson: JSON.stringify(acceptance),
now,
})
@@ -575,7 +616,33 @@ async function supplementWorkerAcceptedSharingEvidence(
return { share: mapWorkOrderShare(updated || sharingShare) }
}
/** 从提交或草稿载荷中解析验收图片与说明。 */
/**
* 定位本次操作要处理的份额:
* - 传了 shareId:必须是本人该工单下的有效份额(否则 not_found)
* - 没传且只有 1 条:直接用
* - 没传且有多条:要求前端明确指定(required)
* - 没有任何份额:返回空(走整单逻辑)
*/
async function resolveWorkerTargetShare(
workOrderId: number,
workerId: number | string,
payload: JsonObject,
): Promise<{ share: WorkOrderShareRow | null; error: 'required' | 'not_found' | null }> {
const requested = Number(payload.shareId ?? payload.workOrderShareId ?? 0)
const shares = await listWorkOrderSharesByWorkerInOrder(workOrderId, workerId)
if (Number.isFinite(requested) && requested > 0) {
const found = shares.find((share) => Number(share.id) === requested) || null
return { share: found, error: found ? null : 'not_found' }
}
if (shares.length === 0) {
return { share: null, error: null }
}
if (shares.length === 1) {
return { share: shares[0] ?? null, error: null }
}
return { share: null, error: 'required' }
}
function resolveAcceptanceImages(payload: JsonObject, draftValue: unknown) {
const files = normalizeUploadedFiles(payload.files)
const imageUrls = [
@@ -606,9 +673,21 @@ export async function saveWorkerOrderAcceptanceDraft(
requireActiveWorkerSession(session)
const workOrder = await getRequiredWorkOrder(workOrderId)
const workerId = Number(session.workerId)
const sharingShare = await getWorkOrderShare(workOrder.id, workerId)
if (sharingShare) {
return saveWorkerSharingAcceptanceDraft(workOrder, sharingShare, payload, session)
const target = await resolveWorkerTargetShare(workOrder.id, workerId, payload)
if (target.error === 'required') {
throw createHttpError('该工单有多条拼单份额,请指定 shareId', {
statusCode: 400,
errorCode: 'work_order_share_id_required',
})
}
if (target.error === 'not_found') {
throw createHttpError('未找到该拼单份额', {
statusCode: 404,
errorCode: 'work_order_sharing_share_not_found',
})
}
if (target.share) {
return saveWorkerSharingAcceptanceDraft(workOrder, target.share, payload, session)
}
if (Number(workOrder.assigned_worker_id || 0) !== workerId) {
throw createHttpError('只能操作自己的订单', {
@@ -728,6 +807,7 @@ async function saveWorkerSharingAcceptanceDraft(
const share = await updateWorkOrderShareAcceptanceRecord({
workOrderId: Number(workOrder.id),
workerId: Number(session.workerId),
shareId: Number(sharingShare.id),
acceptanceJson: JSON.stringify(acceptance),
now,
})
@@ -765,6 +845,7 @@ async function saveWorkerSharingAcceptanceDraft(
const share = await updateWorkOrderShareDraftAcceptance({
workOrderId: Number(workOrder.id),
workerId: Number(session.workerId),
shareId: Number(sharingShare.id),
draftJson: JSON.stringify(draft),
now,
})
@@ -30,12 +30,17 @@ import {
publishWorkOrderRealtimeChange,
} from '../realtime/realtime-event-service.js'
export async function grabWorkerHallOrder(workOrderId: number | string, session: WorkerSession) {
export async function grabWorkerHallOrder(
workOrderId: number | string,
session: WorkerSession,
payload: JsonObject = {},
) {
requireActiveWorkerSession(session)
const worker = await getRequiredWorker(session.workerId)
const permissions = resolveWorkerPermissions(worker)
const hallConfig = getWorkerHallConfig()
const now = nowIso()
const requestId = String(payload.requestId || '').trim()
const visibleDelaySeconds = Number(permissions.visibleDelaySeconds || 0)
const visibleAfterIso =
visibleDelaySeconds > 0 ? new Date(Date.now() - visibleDelaySeconds * 1000).toISOString() : ''
@@ -58,6 +63,7 @@ export async function grabWorkerHallOrder(workOrderId: number | string, session:
hallCandidateLimit: hallConfig.maxVisibleOrders,
hallCategoryLimits: hallConfig.categoryMaxVisibleOrders,
visibleAfterIso,
...(requestId ? { requestId } : {}),
now,
})
if (!result.share) {
@@ -68,7 +74,11 @@ export async function grabWorkerHallOrder(workOrderId: number | string, session:
hallChanged: true,
walletChangedWorkerIds: [Number(worker.id)],
})
return { order: mapWorkOrderForWorker(workOrder, permissions, result.share) }
return {
order: mapWorkOrderForWorker(workOrder, permissions, result.share),
share: mapWorkOrderShare(result.share),
duplicated: result.duplicated,
}
}
const activeCount = await countWorkerActiveOrders(worker.id)
@@ -139,6 +149,7 @@ export async function joinWorkerSharingOrder(
const visibleAfterIso =
visibleDelaySeconds > 0 ? new Date(Date.now() - visibleDelaySeconds * 1000).toISOString() : ''
const quantity = normalizePositiveInteger(payload.quantity, 1)
const requestId = String(payload.requestId || '').trim()
const now = nowIso()
const result = await joinWorkOrderShare({
workOrderId: Number(workOrderId),
@@ -149,6 +160,7 @@ export async function joinWorkerSharingOrder(
hallCandidateLimit: hallConfig.maxVisibleOrders,
hallCategoryLimits: hallConfig.categoryMaxVisibleOrders,
visibleAfterIso,
...(requestId ? { requestId } : {}),
now,
})
if (!result.share) {
@@ -159,7 +171,7 @@ export async function joinWorkerSharingOrder(
hallChanged: true,
walletChangedWorkerIds: [Number(worker.id)],
})
return { share: mapWorkOrderShare(result.share) }
return { share: mapWorkOrderShare(result.share), duplicated: result.duplicated }
}
function resolveWorkOrderDeadlineAt(workOrder: WorkOrderRow, now: string): string | null {
@@ -118,8 +118,14 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker
sort: 'worker_claimed_at_desc',
})
const workOrderIds = items.map((item) => Number(item.id))
const myShares = await listWorkerSharesByWorker(worker.id, workOrderIds)
const myShareByOrderId = new Map(myShares.map((share) => [Number(share.work_order_id), share]))
const workerShares = await listWorkerSharesByWorker(worker.id, workOrderIds)
const sharesByOrderId = new Map<number, typeof workerShares>()
for (const share of workerShares) {
const orderId = Number(share.work_order_id)
const current = sharesByOrderId.get(orderId) || []
current.push(share)
sharesByOrderId.set(orderId, current)
}
const [
notes,
views,
@@ -152,15 +158,20 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker
return {
items: items.map((item) => {
const workOrderId = Number(item.id)
const activeShare = myShareByOrderId.get(workOrderId)
const activeShares = sharesByOrderId.get(workOrderId) || []
// 主份额用于列表摘要:优先未完成(joined/submitted),否则最新一条
const activeShare =
activeShares.find((share) => ['joined', 'submitted'].includes(String(share.status))) ||
activeShares[0] ||
null
const cancelledShare = cancelledShareByOrderId.get(workOrderId)
const timeoutEvent = timeoutEventsByOrderId.get(workOrderId)
// 判断当前打手是否仍持有该单
// 判断当前打手是否仍持有该单(任一有效份额即算持有)
const isOwnerCurrently =
(item.assigned_worker_id != null &&
Number(item.assigned_worker_id) === Number(worker.id)) ||
Boolean(activeShare)
activeShares.length > 0
// 若不是当前持有人,但有该打手的超时事件或取消份额,则判定为该打手的已超时历史单
const isTimedOutForMe =
@@ -211,7 +222,7 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker
activeShare || cancelledShare || null,
noteByOrderId.get(workOrderId) || '',
viewedAtByOrderId.get(workOrderId) || null,
{ isTimedOutForMe, timeoutNotice },
{ isTimedOutForMe, timeoutNotice, myShares: activeShares },
),
// 问题单分组内的来源角标:平台标记 / 退单审核中 / 反馈处理中。
problemMarked: !isTimedOutForMe && item.status === 'problem',
@@ -372,9 +383,13 @@ export async function submitWorkerCancelRequest(
})
}
const now = nowIso()
const requestedShareId = Number(payload.shareId ?? payload.workOrderShareId ?? 0)
const shareId =
Number.isFinite(requestedShareId) && requestedShareId > 0 ? requestedShareId : null
const result = await createWorkerCancelRequest({
workOrderId: Number(workOrderId),
workerId: Number(session.workerId),
shareId,
reason,
now,
})
@@ -7,6 +7,7 @@ export const WORKER_PLATFORM_NOTIFICATION_EVENT_KEYS = [
'recharge_requested',
'acceptance_submitted',
'acceptance_reminded',
'order_accepted',
'material_required',
'cancel_requested',
'feedback_submitted',
@@ -48,6 +49,7 @@ export function createDefaultWorkerPlatformNotificationConfig(): WorkerPlatformN
recharge_requested: createEnabledEventConfig(),
acceptance_submitted: createEnabledEventConfig(),
acceptance_reminded: createEnabledEventConfig(),
order_accepted: createEnabledEventConfig(),
material_required: {
todoEnabled: false,
soundEnabled: false,
@@ -18,10 +18,11 @@ import {
buildWorkOrderWhere,
buildWorkerUserWhere,
resolveWorkOrderShareCancellationStatus,
resolveWorkOrderShareDepositAmount,
resolveWorkOrderShareDepositPlan,
resolveWorkOrderSharingCancellationPlan,
resolveWorkOrderShareJoinQuantity,
resolveOpenSharingSyncReward,
recomputeWorkOrderSharingStatusWithClient,
} from '../../repositories/worker-platform/index.js'
import { resolveProblemReturnToWorkerStatus } from '../../repositories/worker-platform/work-order-issue-repo.js'
import type { WorkOrderRow } from '../../repositories/worker-platform/types.js'
@@ -1471,25 +1472,63 @@ test('个人免押额度与等级免押额度叠加', () => {
)
})
test('拼单按打手累计份额扣除个人免押额度', () => {
assert.equal(
resolveWorkOrderShareDepositAmount({
test('拼单按打手在母单内的累计份额扣除免押额度', () => {
// 首次参与 2 份:累计需求 2000,本次冻结 2000
assert.deepEqual(
resolveWorkOrderShareDepositPlan({
requiredDepositAmount: 10_000,
totalQuantity: 10,
nextQuantity: 5,
depositFreeAmount: 3_000,
quantityBefore: 0,
nextQuantity: 2,
depositFreeAmount: 0,
}),
2_000,
{ cumulativeRequired: 2_000, cumulativeDeposit: 2_000, increment: 2_000 },
)
assert.equal(
resolveWorkOrderShareDepositAmount({
// 已冻结 1500 后再追加 1 份:累计需求 2000,只补差额 500,不回退
assert.deepEqual(
resolveWorkOrderShareDepositPlan({
requiredDepositAmount: 10_000,
totalQuantity: 10,
nextQuantity: 5,
depositFreeAmount: 8_000,
currentFrozenDeposit: 1_500,
quantityBefore: 1,
nextQuantity: 1,
depositFreeAmount: 0,
frozenDepositSoFar: 1_500,
}),
1_500,
{ cumulativeRequired: 2_000, cumulativeDeposit: 2_000, increment: 500 },
)
})
test('拼单免押额度跟着母单走,在整个工单内只抵扣一次', () => {
const base = {
requiredDepositAmount: 10_000,
totalQuantity: 10,
depositFreeAmount: 5_000,
}
// 累计 2 份:需求 2000 < 免押 5000,不用冻结
assert.equal(
resolveWorkOrderShareDepositPlan({ ...base, quantityBefore: 0, nextQuantity: 2 }).increment,
0,
)
// 累计 4 份:需求 4000 仍低于免押额度,继续不冻结(免押额没有被重复消耗)
assert.deepEqual(
resolveWorkOrderShareDepositPlan({
...base,
quantityBefore: 2,
nextQuantity: 2,
frozenDepositSoFar: 0,
}),
{ cumulativeRequired: 4_000, cumulativeDeposit: 0, increment: 0 },
)
// 累计 6 份:需求 6000 超出免押 5000,仅对超出的 1000 冻结
assert.deepEqual(
resolveWorkOrderShareDepositPlan({
...base,
quantityBefore: 4,
nextQuantity: 2,
frozenDepositSoFar: 0,
}),
{ cumulativeRequired: 6_000, cumulativeDeposit: 1_000, increment: 1_000 },
)
})
@@ -1759,7 +1798,10 @@ test('打手订单状态筛选使用个人拼单份额状态', () => {
assert.deepEqual(params, [42, ['in_progress', 'pending_acceptance']])
assert.match(joins, /work_order_shares share_source/)
assert.match(joins, /worker_share\.worker_id = \$1/)
// 份额关联改为按工单取一条代表份额,避免多份拼单把列表/统计行放大。
assert.match(joins, /LEFT JOIN LATERAL \(/)
assert.match(joins, /wos\.worker_id = \$1/)
assert.match(joins, /\) worker_share ON true/)
assert.match(whereClause, /WHEN 'joined' THEN 'in_progress'/)
assert.match(whereClause, /WHEN 'submitted' THEN 'pending_acceptance'/)
assert.match(whereClause, /worker_share\.status/)
@@ -3185,3 +3227,131 @@ test('拼单超时处置链路完整性验证', async () => {
'settleOverdueWorkOrderShare 必须将超时份额置为 cancelled 并释放/扣除押金',
)
})
type ShareAggregate = {
allocated: number
joined_count: number
submitted_count: number
accepted_count: number
}
async function runRecompute(
workOrder: Partial<WorkOrderRow>,
aggregate: ShareAggregate,
): Promise<{ status: string | null; updates: unknown[][]; shareQueries: number }> {
const updates: unknown[][] = []
let shareQueries = 0
const client = {
query: async (sql: string, params: unknown[] = []) => {
if (sql.includes('UPDATE work_orders')) {
updates.push(params)
return { rows: [{ ...buildWorkOrderRow(workOrder), status: params[0] }] }
}
if (sql.includes('FROM work_orders')) {
return { rows: [buildWorkOrderRow(workOrder)] }
}
if (sql.includes('FROM work_order_shares')) {
shareQueries += 1
return { rows: [aggregate] }
}
return { rows: [] }
},
} as unknown as PoolClient
const updated = await recomputeWorkOrderSharingStatusWithClient(
client,
1,
'2026-10-23T12:00:00.000Z',
)
return { status: updated ? String(updated.status) : null, updates, shareQueries }
}
test('拼单母单状态由份额集合唯一推导:满额且无待履约份额才转待验收', async () => {
const pending = await runRecompute(
{ status: 'open', sharing_total_quantity: 10 },
{ allocated: 10, joined_count: 0, submitted_count: 2, accepted_count: 0 },
)
assert.equal(pending.status, 'pending_acceptance')
assert.equal(pending.updates.length, 1)
// 一份超时后另一份仍在履约(joined_count > 0):母单回到 open,不能提前待验收
const stillOpen = await runRecompute(
{ status: 'pending_acceptance', sharing_total_quantity: 10 },
{ allocated: 9, joined_count: 1, submitted_count: 1, accepted_count: 0 },
)
assert.equal(stillOpen.status, 'open')
assert.equal(stillOpen.updates.length, 1)
// 未满额也必须是 open
const partial = await runRecompute(
{ status: 'pending_acceptance', sharing_total_quantity: 10 },
{ allocated: 4, joined_count: 0, submitted_count: 1, accepted_count: 0 },
)
assert.equal(partial.status, 'open')
})
test('拼单母单已验收或已取消时 recompute 不回头改状态', async () => {
for (const status of ['accepted', 'cancelled']) {
const result = await runRecompute(
{ status, sharing_total_quantity: 10 },
{ allocated: 10, joined_count: 0, submitted_count: 0, accepted_count: 3 },
)
assert.equal(result.status, status)
assert.equal(result.updates.length, 0)
assert.equal(result.shareQueries, 0, '终态母单连份额聚合都不该再跑')
}
})
test('份额超时/撤单只改份额本身,母单状态统一交给 recompute', () => {
const src = fs.readFileSync(
new URL('../../repositories/worker-platform/work-order-settlement-repo.ts', import.meta.url),
'utf8',
)
const blocks = [
[
'export async function cancelWorkOrderShare(',
'export async function settleOverdueWorkOrderShare(',
],
[
'export async function settleOverdueWorkOrderShare(',
'export async function cancelWorkOrderSharing(',
],
]
for (const [start, end] of blocks) {
const block = src.slice(src.indexOf(start!), src.indexOf(end!))
assert.ok(block.length > 0, `${start} 片段必须存在`)
assert.ok(
block.includes('recomputeWorkOrderSharingStatusWithClient('),
`${start} 必须用 recompute 收敛母单状态`,
)
assert.ok(
!/UPDATE\s+work_orders\s+SET[\s\S]{0,80}?status\s*=/i.test(block),
`${start} 不得自己写母单 status(只能由 recompute 推导)`,
)
}
})
test('拼单每次参与都新增一条份额,并按 requestId 幂等、按参与时间固化截止时间', () => {
const src = fs.readFileSync(
new URL('../../repositories/worker-platform/work-order-share-repo.ts', import.meta.url),
'utf8',
)
assert.ok(
src.includes('wos.worker_id = $1 AND wos.request_id = $2'),
'参与前必须按 (worker_id, request_id) 查既有份额',
)
assert.ok(src.includes('duplicated: true'), '命中幂等键必须返回 duplicated 标记')
assert.ok(
src.includes('INSERT INTO work_order_shares (') &&
!/UPDATE\s+work_order_shares[\s\S]{0,120}?quantity\s*=\s*quantity\s*\+/i.test(src),
'每次参与都必须 INSERT 新份额,不得把份数加到原记录上',
)
assert.ok(
src.includes('timeoutMinutes * 60 * 1000') && src.includes('deadline_at, timeout_minutes'),
'deadline 必须按本次参与时间 + 母单当时的 timeout_minutes 固化',
)
assert.ok(
src.includes('quantityBefore') && src.includes('frozenDepositSoFar: frozenBefore'),
'押金必须按该打手在本工单的累计份数与已冻结合计计算增量',
)
})