feat: 打手分内部/外部,内部免审核;支持后台指派打手

- worker_users 新增 worker_type(internal/external,迁移 017)
- 内部打手提交验收后自动通过进入待打款(accepted+结算),外部打手保持后台审核
- 打手管理页支持设为内部/外部并显示类型标签
- 新增后台指派打手:未分配/待抢单工单可指定已注册打手,进入其我的订单并冻结押金
- 指派带审计与事件记录,校验打手状态/接单上限/余额押金
This commit is contained in:
yml2213
2026-08-14 16:35:15 +08:00
parent 6a17113374
commit 7bb3142958
13 changed files with 403 additions and 16 deletions
@@ -5,6 +5,7 @@ import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
import {
acceptWorkOrderAndSettle,
addWorkerWalletCredit,
assignWorkOrderToWorker,
countWorkerAcceptedOrders,
countWorkerActiveOrders,
countWorkCategoryUsages,
@@ -88,7 +89,7 @@ import {
saveWorkerFinanceConfig,
} from './worker-finance-config-service.js'
import { DEFAULT_CATEGORY_KEY, DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, DEFAULT_LEVEL_KEY, DEFAULT_LEVEL_NAME, mapFinanceRequest, mapWallet, mapWorkCategory, mapWorkOrderAdmin, mapWorkOrderShare, mapWorkProductRule, mapWorkerLevel, mapWorkerUser, normalizeAdminFinanceReviewStatus, normalizeAmountFen, normalizeBoolean, normalizeEnabledStatus, normalizeFinanceRequestStatus, normalizeFinanceRequestType, normalizeInteger, normalizeMatchType, normalizeOptionalId, normalizePositiveInteger, normalizeProblemResolutionAction, normalizeRequirementFields, normalizeRequirementFieldsFromPayload, normalizeReviewStatus, normalizeSessionVersion, normalizeSlugKey, normalizeSubmittedFields, normalizeUploadedFiles, resolveMatchingProductRule, resolveRequirementFields } from './mappers.js'
import { DEFAULT_CATEGORY_KEY, DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, DEFAULT_LEVEL_KEY, DEFAULT_LEVEL_NAME, mapFinanceRequest, mapWallet, mapWorkCategory, mapWorkOrderAdmin, mapWorkOrderShare, mapWorkProductRule, mapWorkerLevel, mapWorkerUser, normalizeAdminFinanceReviewStatus, normalizeAmountFen, normalizeBoolean, normalizeEnabledStatus, normalizeFinanceRequestStatus, normalizeFinanceRequestType, normalizeInteger, normalizeMatchType, normalizeOptionalId, normalizePositiveInteger, normalizeProblemResolutionAction, normalizeRequirementFields, normalizeRequirementFieldsFromPayload, normalizeReviewStatus, normalizeSessionVersion, normalizeSlugKey, normalizeSubmittedFields, normalizeUploadedFiles, normalizeWorkerType, resolveFreezeDepositAmount, resolveMatchingProductRule, resolveRequirementFields, resolveWorkerPermissions } from './mappers.js'
import { ensureWorkerPlatformDefaults, getRequiredWorkOrder, getRequiredWorker, normalizeWorkOrderTimeoutPolicy } from './worker-service.js'
export async function listAdminWorkerLevels() {
@@ -346,6 +347,7 @@ export async function listAdminWorkerUsers(query: JsonObject = {}) {
export async function reviewAdminWorkerUser(workerId: number | string, payload: JsonObject = {}) {
const worker = await getRequiredWorker(workerId)
const status = normalizeReviewStatus(payload.status)
const workerType = normalizeWorkerType(payload.workerType ?? payload.worker_type)
const requestedLevelId = normalizeOptionalId(payload.levelId)
let levelId = worker.level_id || null
if (requestedLevelId) {
@@ -364,6 +366,7 @@ export async function reviewAdminWorkerUser(workerId: number | string, payload:
const now = nowIso()
const updated = await updateWorkerUser(worker.id, {
status,
worker_type: workerType,
level_id: levelId,
review_note: String(payload.reviewNote || payload.note || '').trim(),
session_version: normalizeSessionVersion(worker.session_version) + 1,
@@ -373,6 +376,91 @@ export async function reviewAdminWorkerUser(workerId: number | string, payload:
return { worker: mapWorkerUser(updated || worker) }
}
export async function assignAdminWorkOrderToWorker(
workOrderId: number | string,
payload: JsonObject = {},
) {
const workOrder = await getRequiredWorkOrder(workOrderId)
if (
!['unassigned', 'open'].includes(workOrder.status) ||
workOrder.assigned_worker_id
) {
throw createHttpError('只有未分配或待抢单且未指派打手的订单才能指派', {
statusCode: 409,
errorCode: 'work_order_not_assignable',
})
}
const workerId = normalizeOptionalId(payload.workerId ?? payload.worker_id)
if (!workerId) {
throw createHttpError('请选择要指派的打手', {
statusCode: 400,
errorCode: 'work_order_assign_worker_required',
})
}
const worker = await getWorkerUserById(workerId)
if (!worker || worker.status !== 'active') {
throw createHttpError('打手账号不存在或不可用', {
statusCode: 409,
errorCode: 'work_order_assign_worker_invalid',
})
}
const permissions = resolveWorkerPermissions(worker)
const activeCount = await countWorkerActiveOrders(worker.id)
if (activeCount >= permissions.maxActiveOrders) {
throw createHttpError('该打手已达到最大同时接单量', {
statusCode: 409,
errorCode: 'worker_active_order_limit',
})
}
const freezeAmount = resolveFreezeDepositAmount(workOrder, permissions)
if (Number(worker.available_amount || 0) < freezeAmount) {
throw createHttpError('该打手余额不足,无法冻结所需押金', {
statusCode: 409,
errorCode: 'worker_deposit_insufficient',
})
}
const now = nowIso()
const { order: updated, failureReason } = await assignWorkOrderToWorker({
workOrderId: workOrder.id,
workerId: worker.id,
depositAmount: freezeAmount,
maxActiveOrders: permissions.maxActiveOrders,
deadlineAt: resolveAssignedDeadlineAt(Number(workOrder.timeout_minutes || 0), now),
now,
})
if (failureReason === 'work_order_not_assignable') {
throw createHttpError('订单状态已变化,无法指派', {
statusCode: 409,
errorCode: 'work_order_not_assignable',
})
}
if (failureReason === 'worker_active_order_limit') {
throw createHttpError('该打手已达到最大同时接单量', {
statusCode: 409,
errorCode: 'worker_active_order_limit',
})
}
if (failureReason === 'worker_deposit_insufficient') {
throw createHttpError('该打手余额不足,无法冻结所需押金', {
statusCode: 409,
errorCode: 'worker_deposit_insufficient',
})
}
if (!updated) {
throw createHttpError('指派失败,订单状态可能已变化', {
statusCode: 409,
errorCode: 'work_order_assign_conflict',
})
}
return { order: mapWorkOrderAdmin(updated) }
}
function resolveAssignedDeadlineAt(timeoutMinutes: number, now: string): string | null {
if (timeoutMinutes <= 0) return null
return new Date(new Date(now).getTime() + timeoutMinutes * 60 * 1000).toISOString()
}
export async function creditAdminWorkerWallet(workerId: number | string, payload: JsonObject = {}) {
await getRequiredWorker(workerId)
const amount = normalizeAmountFen(payload.amount ?? payload.amountYuan, 0)
@@ -258,6 +258,10 @@ export function mapWorkProductRule(rule: WorkProductRuleRow | null | undefined)
}
}
export function normalizeWorkerType(value: unknown): 'internal' | 'external' {
return String(value || '').trim() === 'internal' ? 'internal' : 'external'
}
export function mapWorkerUser(worker: WorkerUserRow) {
return {
workerId: Number(worker.id),
@@ -265,6 +269,7 @@ export function mapWorkerUser(worker: WorkerUserRow) {
displayName: worker.display_name || worker.username,
phone: worker.phone,
status: worker.status,
workerType: normalizeWorkerType(worker.worker_type),
reviewNote: worker.review_note || '',
inviteCode: worker.invite_code || '',
inviter: worker.inviter_id
@@ -1144,10 +1144,43 @@ export async function submitWorkerOrderAcceptance(
payloadJson: JSON.stringify(acceptance),
now,
})
const worker = await getRequiredWorker(session.workerId)
if (worker.worker_type === 'internal') {
const { order: acceptedOrder, failureReason } = await acceptWorkOrderAndSettle({
workOrderId: workOrder.id,
unfreezeDays: getWorkerFinanceConfig().depositUnfreezeDays,
now,
})
if (failureReason === 'sharing_pending_submissions') {
throw createHttpError('还有拼单打手未提交验收资料,暂不能统一验收', {
statusCode: 409,
errorCode: 'work_order_sharing_pending_submissions',
})
}
if (!acceptedOrder) {
throw createHttpError('内部打手免审核验收失败,请稍后重试', {
statusCode: 409,
errorCode: 'work_order_internal_auto_accept_failed',
})
}
await createWorkOrderEvent({
workOrderId: workOrder.id,
actorType: 'worker',
actorId: String(session.workerId),
eventType: 'accepted_by_internal',
fromStatus: WORK_ORDER_STATUS.PENDING_ACCEPTANCE,
toStatus: WORK_ORDER_STATUS.ACCEPTED,
payloadJson: JSON.stringify({ autoAccepted: true }),
now,
})
return {
order: mapWorkOrderForWorker(acceptedOrder, resolveWorkerPermissions(worker)),
}
}
return {
order: mapWorkOrderForWorker(
updated || workOrder,
resolveWorkerPermissions(await getRequiredWorker(session.workerId)),
resolveWorkerPermissions(worker),
),
}
}