接单平台新增拼单与取消接单能力

拼单功能:
- 工单/物品规则支持拼单配置(启用、总数量、单价),总价=单价×数量三方联动,启用拼单时工单金额自动同步拼单总价
- 拼单参与按数量冻结比例押金,验收通过后按份额结算报酬,升级统计按数量计入
- 抢单大厅拼单入口:紫色渐变大按钮带单价展示,拼单进度/剩余份数,弹窗输入数量实时预估报酬与押金
- 我的订单展示拼单份额(数量×单价、状态),拼单者各自提交验收,全部提交后管理员统一验收
- 新增 work_order_shares 表与迁移 008

取消接单:
- 进行中订单可取消(押金全额退还、工单回大厅),30 天内限 5 次防滥用
This commit is contained in:
yml2213
2026-08-01 16:31:27 +08:00
parent 52a85f7f9e
commit c33e859e96
12 changed files with 1745 additions and 61 deletions
@@ -11,6 +11,9 @@ import {
countWorkerActiveOrders,
countWorkCategoryUsages,
countWorkerLevelUsages,
countWorkOrderPendingSharingSubmissions,
cancelWorkerWorkOrder,
countWorkerCancellationsSince,
createWorkOrder,
createWorkOrderEvent,
createWorkerFinanceRequest,
@@ -24,23 +27,29 @@ import {
getWorkerLevelById,
getWorkerLevelByKey,
getWorkerUserByInviteCode,
incrementWorkerSessionVersion,
getWorkOrderById,
getWorkOrderByOrderItemId,
getWorkerUserById,
getWorkerUserByUsername,
listAllWorkCategories,
listWorkerFinanceRequests,
listPendingMaterialWorkOrdersByPlatformOrderId,
listWorkerWalletLedgers,
getWorkOrderById,
getWorkOrderByOrderItemId,
getWorkOrderShare,
grabWorkOrder,
incrementWorkerSessionVersion,
joinWorkOrderShare,
listAllWorkCategories,
listPendingMaterialWorkOrdersByPlatformOrderId,
listWorkerFinanceRequests,
listWorkerSharesByWorker,
listWorkerWalletLedgers,
listWorkCategories,
listWorkOrders,
listWorkProductRules,
listWorkerLevels,
listWorkerUsers,
listWorkOrderShares,
listWorkOrderSharesByOrderIds,
resolveProblemWorkOrder,
reviewWorkerFinanceRequest,
submitWorkOrderShareAcceptance,
updateWorkOrder,
updateWorkerPassword,
updateWorkerUser,
@@ -52,6 +61,7 @@ import {
type WorkOrderRow,
type WorkProductRuleRow,
type WorkerLevelRow,
type WorkOrderShareRow,
type WorkerWalletLedgerRow,
type WorkerUserRow,
type GrabWorkOrderFailureReason,
@@ -88,6 +98,8 @@ const DEFAULT_CATEGORY_KEY = 'default'
const DEFAULT_DEPOSIT_THRESHOLD_AMOUNT = 20_000
const INVITE_CODE_LENGTH = 8
const INVITE_CODE_RETRY_LIMIT = 5
const WORKER_CANCEL_LIMIT_PER_WINDOW = 5
const WORKER_CANCEL_LIMIT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000
const IGNORABLE_WORKER_AUTH_ERROR_CODES = new Set([
'worker_auth_required',
'worker_auth_invalid',
@@ -652,8 +664,16 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work
const worker = await getRequiredWorker(session.workerId)
const permissions = resolveWorkerPermissions(worker)
const categories = (await listWorkCategories()).map(mapWorkCategory)
const myShares = await listWorkerSharesByWorker(worker.id)
const myShareByOrderId = new Map(
myShares
.filter((share) => Number(share.work_order_id))
.map((share) => [Number(share.work_order_id), share]),
)
return {
items: items.map((item) => mapWorkOrderForWorker(item, permissions)),
items: items.map((item) =>
mapWorkOrderForWorker(item, permissions, myShareByOrderId.get(Number(item.id))),
),
pagination: { page, pageSize, total },
categories,
}
@@ -711,15 +731,104 @@ export async function listWorkerMyOrders(query: JsonObject = {}, session: Worker
pageSize,
status: String(query.status || '').trim(),
keyword: String(query.keyword || '').trim(),
workerId: worker.id,
workerSharingId: worker.id,
})
const permissions = resolveWorkerPermissions(worker)
const myShares = await listWorkerSharesByWorker(worker.id)
const myShareByOrderId = new Map(
myShares.map((share) => [Number(share.work_order_id), share]),
)
return {
items: items.map((item) => mapWorkOrderForWorker(item, permissions)),
items: items.map((item) =>
mapWorkOrderForWorker(item, permissions, myShareByOrderId.get(Number(item.id))),
),
pagination: { page, pageSize, total },
}
}
export async function joinWorkerSharingOrder(
workOrderId: number | string,
payload: JsonObject = {},
session: WorkerSession,
) {
requireActiveWorkerSession(session)
const worker = await getRequiredWorker(session.workerId)
const permissions = resolveWorkerPermissions(worker)
const quantity = normalizePositiveInteger(payload.quantity, 1)
const now = nowIso()
const result = await joinWorkOrderShare({
workOrderId: Number(workOrderId),
workerId: Number(worker.id),
quantity,
maxActiveOrders: permissions.maxActiveOrders,
now,
})
if (!result.share) {
throw resolveWorkOrderShareJoinError(result.failureReason)
}
return { share: mapWorkOrderShare(result.share) }
}
function resolveWorkOrderShareJoinError(
failureReason: 'work_order_not_open' | 'sharing_disabled' | 'work_order_owner_conflict' | 'sharing_quantity_full' | 'worker_active_order_limit' | 'worker_deposit_insufficient' | null,
): Error {
const messages: Record<string, string> = {
work_order_not_open: '该订单当前不可拼单',
sharing_disabled: '该订单未启用拼单',
work_order_owner_conflict: '你已整单接取该订单,无需拼单',
sharing_quantity_full: '拼单数量已满或超出剩余数量',
worker_active_order_limit: '已达到当前等级最大同时接单量',
worker_deposit_insufficient: '余额不足,无法冻结所需押金',
}
return createHttpError(messages[failureReason || ''] || '拼单失败', {
statusCode: 409,
errorCode: `work_order_sharing_${failureReason || 'failed'}`,
})
}
export async function cancelWorkerOrder(
workOrderId: number | string,
session: WorkerSession,
) {
requireActiveWorkerSession(session)
const worker = await getRequiredWorker(session.workerId)
const now = nowIso()
const since = new Date(Date.now() - WORKER_CANCEL_LIMIT_WINDOW_MS).toISOString()
const cancelCount = await countWorkerCancellationsSince(worker.id, since)
if (cancelCount >= WORKER_CANCEL_LIMIT_PER_WINDOW) {
throw createHttpError(
`30 天内取消接单已达 ${WORKER_CANCEL_LIMIT_PER_WINDOW} 次上限,请谨慎接单`,
{
statusCode: 409,
errorCode: 'worker_cancel_limit_reached',
},
)
}
const result = await cancelWorkerWorkOrder({
workOrderId: Number(workOrderId),
workerId: Number(worker.id),
now,
})
if (result.failureReason === 'work_order_owner_required') {
throw createHttpError('只能取消自己接的订单', {
statusCode: 403,
errorCode: 'work_order_owner_required',
})
}
if (!result.order) {
throw createHttpError('当前订单状态不能取消,仅进行中的订单可取消', {
statusCode: 409,
errorCode: 'work_order_cancel_status_invalid',
})
}
return {
order: mapWorkOrderForWorker(
result.order,
resolveWorkerPermissions(await getRequiredWorker(session.workerId)),
),
}
}
export async function submitWorkerOrderAcceptance(
workOrderId: number | string,
payload: JsonObject = {},
@@ -727,7 +836,12 @@ export async function submitWorkerOrderAcceptance(
) {
requireActiveWorkerSession(session)
const workOrder = await getRequiredWorkOrder(workOrderId)
if (Number(workOrder.assigned_worker_id || 0) !== Number(session.workerId)) {
const workerId = Number(session.workerId)
const sharingShare = await getWorkOrderShare(workOrder.id, workerId)
if (sharingShare) {
return submitWorkerSharingAcceptance(workOrder, sharingShare, payload, session)
}
if (Number(workOrder.assigned_worker_id || 0) !== workerId) {
throw createHttpError('只能提交自己的订单', {
statusCode: 403,
errorCode: 'work_order_owner_required',
@@ -784,6 +898,60 @@ export async function submitWorkerOrderAcceptance(
}
}
async function submitWorkerSharingAcceptance(
workOrder: WorkOrderRow,
sharingShare: WorkOrderShareRow,
payload: JsonObject = {},
session: WorkerSession,
) {
if (workOrder.status === WORK_ORDER_STATUS.ACCEPTED) {
throw createHttpError('该拼单已验收完成', {
statusCode: 409,
errorCode: 'work_order_sharing_accepted',
})
}
const now = nowIso()
const files = normalizeUploadedFiles(payload.files)
const imageUrls = [
...files.map((file) => file.url || file.mediumUrl || file.thumbnailUrl).filter(Boolean),
...normalizeStringArray(payload.imageUrls),
]
if (imageUrls.length === 0) {
throw createHttpError('请上传验收图片', {
statusCode: 400,
errorCode: 'work_order_acceptance_image_required',
})
}
const acceptance = {
note: String(payload.note || '').trim(),
files,
imageUrls: [...new Set(imageUrls)],
submittedAt: now,
}
const result = await submitWorkOrderShareAcceptance({
workOrderId: Number(workOrder.id),
workerId: Number(session.workerId),
acceptanceJson: JSON.stringify(acceptance),
now,
})
if (result.failureReason === 'share_status_invalid') {
throw createHttpError('当前拼单份额已提交,请勿重复提交', {
statusCode: 409,
errorCode: 'work_order_sharing_share_submitted',
})
}
if (!result.share) {
throw createHttpError('未找到拼单份额', {
statusCode: 404,
errorCode: 'work_order_sharing_share_not_found',
})
}
return {
share: mapWorkOrderShare(result.share),
sharingPendingCount: await countWorkOrderPendingSharingSubmissions(Number(workOrder.id)),
}
}
export async function collectLookupWorkOrder(payload: JsonObject = {}) {
const orderNo = String(payload.orderNo || payload.platformOrderId || '').trim()
if (!orderNo) {
@@ -995,12 +1163,39 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
}
const rewardAmount = normalizeAmountFen(payload.rewardAmount ?? payload.rewardAmountYuan, 0)
if (rewardAmount <= 0) {
const sharingEnabled = normalizeBoolean(
payload.sharingEnabled ?? payload.sharing_enabled,
false,
)
const sharingUnitReward = normalizeAmountFen(
payload.sharingUnitReward ?? payload.sharingUnitRewardYuan,
0,
)
const sharingTotalAmount = normalizeAmountFen(
payload.sharingTotalAmount ?? payload.sharingTotalAmountYuan,
0,
)
const sharingTotalQuantity = resolveSharingQuantity(
sharingTotalAmount,
normalizePositiveInteger(payload.sharingTotalQuantity ?? payload.sharing_total_quantity, 1),
sharingUnitReward,
)
const resolvedSharingTotalAmount =
sharingTotalAmount > 0 ? sharingTotalAmount : sharingUnitReward * sharingTotalQuantity
if (sharingEnabled) {
if (resolvedSharingTotalAmount <= 0) {
throw createHttpError('启用拼单时请填写拼单总价或单价', {
statusCode: 400,
errorCode: 'work_product_rule_sharing_amount_invalid',
})
}
} else if (rewardAmount <= 0) {
throw createHttpError('接单金额必须大于 0', {
statusCode: 400,
errorCode: 'work_product_rule_reward_invalid',
})
}
const finalRewardAmount = sharingEnabled ? resolvedSharingTotalAmount : rewardAmount
const depositThresholdAmount = normalizeAmountFen(
payload.depositThresholdAmount ?? payload.depositThresholdAmountYuan,
@@ -1008,8 +1203,8 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
)
const requiredDepositAmount =
payload.requiredDepositAmount === undefined && payload.requiredDepositAmountYuan === undefined
? rewardAmount > depositThresholdAmount
? rewardAmount
? finalRewardAmount > depositThresholdAmount
? finalRewardAmount
: 0
: normalizeAmountFen(payload.requiredDepositAmount ?? payload.requiredDepositAmountYuan, 0)
const fields = normalizeRequirementFieldsFromPayload(payload)
@@ -1030,9 +1225,12 @@ export async function saveAdminWorkProductRule(payload: JsonObject = {}) {
null,
enabled: normalizeBoolean(payload.enabled, true),
autoCreate: normalizeBoolean(payload.autoCreate ?? payload.auto_create, false),
rewardAmount,
rewardAmount: finalRewardAmount,
requiredDepositAmount,
depositThresholdAmount,
sharingEnabled,
sharingTotalQuantity,
sharingUnitReward,
requirementJson: JSON.stringify({ fields }),
sortOrder: normalizeInteger(payload.sortOrder, 100),
now: nowIso(),
@@ -1188,12 +1386,113 @@ export async function listAdminWorkOrders(query: JsonObject = {}) {
status: String(query.status || '').trim(),
keyword: String(query.keyword || '').trim(),
})
const shares = await listWorkOrderSharesByOrderIds(items.map((item) => Number(item.id)))
const sharesByOrderId = new Map<number, WorkOrderShareRow[]>()
for (const share of shares) {
const orderId = Number(share.work_order_id)
const bucket = sharesByOrderId.get(orderId) || []
bucket.push(share)
sharesByOrderId.set(orderId, bucket)
}
return {
items: items.map(mapWorkOrderAdmin),
items: items.map((item) => {
const orderShares = sharesByOrderId.get(Number(item.id)) || []
const joinedQuantity = orderShares
.filter((share) => share.status !== 'cancelled')
.reduce((sum, share) => sum + Number(share.quantity || 0), 0)
const pendingSubmissionCount = orderShares.filter(
(share) => share.status === 'joined',
).length
return {
...mapWorkOrderAdmin(item),
sharingProgress: {
joinedQuantity,
pendingSubmissionCount,
},
}
}),
pagination: { page, pageSize, total },
}
}
export async function getAdminWorkOrderSharing(workOrderId: number | string) {
const workOrder = await getRequiredWorkOrder(workOrderId)
const shares = await listWorkOrderShares(workOrder.id)
return {
order: mapWorkOrderAdmin(workOrder),
shares: shares.map(mapWorkOrderShare),
}
}
export async function updateAdminWorkOrderSharing(
workOrderId: number | string,
payload: JsonObject = {},
) {
const workOrder = await getRequiredWorkOrder(workOrderId)
if (![WORK_ORDER_STATUS.PENDING_MATERIAL, WORK_ORDER_STATUS.UNASSIGNED, WORK_ORDER_STATUS.OPEN].includes(workOrder.status as never)) {
throw createHttpError('只有待完善、未分配或待抢单的工单可以配置拼单', {
statusCode: 409,
errorCode: 'work_order_sharing_config_status_invalid',
})
}
const enabled = normalizeBoolean(
payload.enabled ?? payload.sharingEnabled ?? payload.sharing_enabled,
false,
)
const unitReward = normalizeAmountFen(
payload.unitReward ?? payload.sharingUnitReward ?? payload.sharingUnitRewardYuan,
0,
)
const totalAmount = normalizeAmountFen(
payload.totalAmount ?? payload.sharingTotalAmount ?? payload.sharingTotalAmountYuan,
0,
)
const totalQuantity = resolveSharingQuantity(
totalAmount,
normalizePositiveInteger(
payload.totalQuantity ?? payload.sharingTotalQuantity ?? payload.sharing_total_quantity,
1,
),
unitReward,
)
const resolvedTotalAmount = totalAmount > 0 ? totalAmount : unitReward * totalQuantity
const now = nowIso()
const updated = await updateWorkOrder(workOrder.id, {
sharing_enabled: enabled,
sharing_total_quantity: totalQuantity,
sharing_unit_reward: unitReward,
reward_amount: enabled ? resolvedTotalAmount : workOrder.reward_amount,
updated_at: now,
})
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'admin',
actorId: '',
eventType: 'sharing_config_updated',
fromStatus: workOrder.status,
toStatus: updated?.status || workOrder.status,
payloadJson: JSON.stringify({
enabled,
totalQuantity,
unitReward,
totalAmount: resolvedTotalAmount,
}),
now,
})
return { order: mapWorkOrderAdmin(updated || workOrder) }
}
function resolveSharingQuantity(
totalAmount: number,
fallbackQuantity: number,
unitReward: number,
): number {
if (totalAmount > 0 && unitReward > 0) {
return Math.max(1, Math.round(totalAmount / unitReward))
}
return Math.max(1, fallbackQuantity)
}
export async function submitAdminWorkOrderMaterial(
workOrderId: number | string,
payload: JsonObject = {},
@@ -1450,7 +1749,16 @@ export async function acceptAdminWorkOrder(workOrderId: number | string) {
})
}
const now = nowIso()
const updated = await acceptWorkOrderAndSettle({ workOrderId: workOrder.id, now })
const { order: updated, failureReason } = await acceptWorkOrderAndSettle({
workOrderId: workOrder.id,
now,
})
if (failureReason === 'sharing_pending_submissions') {
throw createHttpError('还有拼单打手未提交验收资料,暂不能统一验收', {
statusCode: 409,
errorCode: 'work_order_sharing_pending_submissions',
})
}
if (!updated) {
throw createHttpError('验收失败,订单状态可能已变化', {
statusCode: 409,
@@ -1521,6 +1829,9 @@ export async function syncWorkerOrdersForSourceOrder(
rewardAmount,
requiredDepositAmount,
depositThresholdAmount,
sharingEnabled: rule.sharing_enabled === true,
sharingTotalQuantity: Number(rule.sharing_total_quantity || 1),
sharingUnitReward: Number(rule.sharing_unit_reward || 0),
materialJson: JSON.stringify({
source: {
orderId: Number(order.id),
@@ -1781,6 +2092,11 @@ function mapWorkProductRule(rule: WorkProductRuleRow | null | undefined) {
depositThresholdAmount: Number(
rule.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT,
),
sharing: {
enabled: rule.sharing_enabled === true,
totalQuantity: Number(rule.sharing_total_quantity || 1),
unitReward: Number(rule.sharing_unit_reward || 0),
},
requirement: {
fields: normalizeRequirementFields(requirement.fields),
},
@@ -1883,6 +2199,11 @@ function mapWorkOrderAdmin(workOrder: WorkOrderRow) {
status: workOrder.status,
rewardAmount: Number(workOrder.reward_amount || 0),
requiredDepositAmount: Number(workOrder.required_deposit_amount || 0),
sharing: {
enabled: workOrder.sharing_enabled === true,
totalQuantity: Number(workOrder.sharing_total_quantity || 1),
unitReward: Number(workOrder.sharing_unit_reward || 0),
},
depositThresholdAmount: Number(
workOrder.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT,
),
@@ -1909,6 +2230,7 @@ function mapWorkOrderAdmin(workOrder: WorkOrderRow) {
function mapWorkOrderForWorker(
workOrder: WorkOrderRow,
permissions: { depositFreeAmount: number },
myShare?: WorkOrderShareRow | null,
) {
const mapped = mapWorkOrderAdmin(workOrder)
const {
@@ -1922,6 +2244,32 @@ function mapWorkOrderForWorker(
return {
...visibleOrder,
freezeDepositAmount: resolveFreezeDepositAmount(workOrder, permissions),
myShare: myShare ? mapWorkOrderShare(myShare) : null,
}
}
function mapWorkOrderShare(share: WorkOrderShareRow) {
return {
shareId: Number(share.id),
workOrderId: Number(share.work_order_id),
workerId: Number(share.worker_id),
quantity: Number(share.quantity || 0),
unitReward: Number(share.unit_reward || 0),
shareReward: Number(share.share_reward || 0),
shareDeposit: Number(share.share_deposit || 0),
status: share.status,
acceptance: safeParseJson(share.acceptance_json),
submittedAt: share.submitted_at,
acceptedAt: share.accepted_at,
createdAt: share.created_at,
updatedAt: share.updated_at,
worker: share.worker_id
? {
workerId: Number(share.worker_id),
username: share.worker_username || '',
displayName: share.worker_display_name || share.worker_username || '',
}
: null,
}
}