diff --git a/apps/backend/src/db/migrations/017_worker_internal_type.sql b/apps/backend/src/db/migrations/017_worker_internal_type.sql new file mode 100644 index 00000000..af9fd0b1 --- /dev/null +++ b/apps/backend/src/db/migrations/017_worker_internal_type.sql @@ -0,0 +1,5 @@ +-- 打手类型:internal 内部打手(提交验收后免审核,直接进入待打款)/ external 外部打手(默认,需后台验收)。 +ALTER TABLE worker_users + ADD COLUMN worker_type TEXT NOT NULL DEFAULT 'external'; + +COMMENT ON COLUMN worker_users.worker_type IS '打手类型:internal 内部免审核 / external 外部需审核'; diff --git a/apps/backend/src/repositories/worker-platform/types.ts b/apps/backend/src/repositories/worker-platform/types.ts index 79501587..772ddb77 100644 --- a/apps/backend/src/repositories/worker-platform/types.ts +++ b/apps/backend/src/repositories/worker-platform/types.ts @@ -19,6 +19,7 @@ export type WorkerUserRow = { phone: string level_id: number | null status: string + worker_type: string review_note: string session_version: number created_at: string @@ -241,6 +242,7 @@ export type CreateWorkerInput = { phone: string levelId: number | null status: string + workerType?: string inviterId?: number | null inviteCode: string now: string 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 2d562027..3b375269 100644 --- a/apps/backend/src/repositories/worker-platform/work-order-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-order-repo.ts @@ -859,6 +859,115 @@ export async function grabWorkOrder(input: { }) } +export async function assignWorkOrderToWorker(input: { + workOrderId: number + workerId: number + depositAmount: number + maxActiveOrders: number + deadlineAt: string | null + now: string +}): Promise<{ + order: WorkOrderRow | null + failureReason: + | 'work_order_not_assignable' + | 'worker_active_order_limit' + | 'worker_deposit_insufficient' + | null +}> { + return withTransaction(async (client) => { + const currentResult = await client.query( + ` + SELECT * + FROM work_orders + WHERE id = $1 + FOR UPDATE + `, + [input.workOrderId], + ) + const workOrder = currentResult.rows[0] || null + if ( + !workOrder || + !['unassigned', 'open'].includes(workOrder.status) || + workOrder.assigned_worker_id + ) { + return { order: null, failureReason: 'work_order_not_assignable' } + } + const fromStatus = workOrder.status + + await ensureWorkerWalletWithClient(client, input.workerId, input.now) + const wallet = await getWorkerWalletWithClient(client, input.workerId) + const activeOrderCount = await countWorkerActiveOrdersWithClient(client, input.workerId) + if (activeOrderCount >= input.maxActiveOrders) { + return { order: null, failureReason: 'worker_active_order_limit' } + } + + const available = Number(wallet?.available_amount || 0) + if (available < input.depositAmount) { + return { order: null, failureReason: 'worker_deposit_insufficient' } + } + + await client.query( + ` + UPDATE work_orders + SET + status = 'in_progress', + assigned_worker_id = $1, + last_assigned_worker_id = $1, + assigned_at = $2, + deadline_at = $3, + updated_at = $2 + WHERE id = $4 + `, + [input.workerId, input.now, input.deadlineAt, input.workOrderId], + ) + + if (input.depositAmount > 0) { + const nextAvailable = available - input.depositAmount + const nextFrozen = Number(wallet?.frozen_deposit_amount || 0) + input.depositAmount + 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, input.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_freeze', $2, $3, $4, $5, '后台指派冻结押金', '{}'::jsonb, $6) + `, + [ + input.workerId, + -input.depositAmount, + nextAvailable, + nextFrozen, + input.workOrderId, + input.now, + ], + ) + } + + await createWorkOrderEventWithClient(client, { + workOrderId: input.workOrderId, + actorType: 'admin', + actorId: '', + eventType: 'assigned_by_admin', + fromStatus, + toStatus: 'in_progress', + payloadJson: JSON.stringify({ workerId: input.workerId, depositAmount: input.depositAmount }), + now: input.now, + }) + + return { + order: await getWorkOrderByIdWithClient(client, input.workOrderId), + failureReason: null, + } + }) +} + export async function cancelWorkerWorkOrder(input: { workOrderId: number workerId: number diff --git a/apps/backend/src/repositories/worker-platform/worker-repo.ts b/apps/backend/src/repositories/worker-platform/worker-repo.ts index 75b4619a..979a32a5 100644 --- a/apps/backend/src/repositories/worker-platform/worker-repo.ts +++ b/apps/backend/src/repositories/worker-platform/worker-repo.ts @@ -159,9 +159,9 @@ export async function createWorkerUser(input: CreateWorkerInput): Promise >, ): Promise { @@ -201,15 +208,17 @@ export async function updateWorkerUser( UPDATE worker_users SET status = $1, - review_note = $2, - level_id = $3, - session_version = $4, - reviewed_at = $5, - updated_at = $6 - WHERE id = $7 + worker_type = $2, + review_note = $3, + level_id = $4, + session_version = $5, + reviewed_at = $6, + updated_at = $7 + WHERE id = $8 `, [ next.status, + next.worker_type || 'external', next.review_note || '', next.level_id || null, Number(next.session_version || 1), diff --git a/apps/backend/src/routes/admin/worker-platform.ts b/apps/backend/src/routes/admin/worker-platform.ts index 6a41c250..d7c40d4c 100644 --- a/apps/backend/src/routes/admin/worker-platform.ts +++ b/apps/backend/src/routes/admin/worker-platform.ts @@ -2,6 +2,7 @@ import { Router } from 'express' import { acceptAdminWorkOrder, + assignAdminWorkOrderToWorker, creditAdminWorkerWallet, createAdminMockWorkOrder, deductAdminWorkOrderPendingDeposit, @@ -341,6 +342,25 @@ router.put( ), ) +router.post( + '/worker-platform/orders/:workOrderId/assign', + requireAdminRoles(['admin', 'operator']), + createJsonHandler( + (req) => assignAdminWorkOrderToWorker(String(req.params.workOrderId || ''), req.body || {}), + { + successMessage: '已指派打手', + errorMessage: '指派打手失败', + scope: '[admin/worker-platform/orders/:workOrderId/assign]', + audit: (req, data) => ({ + action: 'work_order_worker_assigned', + targetType: 'work_order', + targetId: String(req.params.workOrderId || ''), + data: data && typeof data === 'object' ? (data as Record) : {}, + }), + }, + ), +) + router.delete( '/worker-platform/orders/:workOrderId', requireAdminRoles(['admin', 'operator']), diff --git a/apps/backend/src/services/worker-platform/admin-service.ts b/apps/backend/src/services/worker-platform/admin-service.ts index fd4e08ec..b89ca1a9 100644 --- a/apps/backend/src/services/worker-platform/admin-service.ts +++ b/apps/backend/src/services/worker-platform/admin-service.ts @@ -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) diff --git a/apps/backend/src/services/worker-platform/mappers.ts b/apps/backend/src/services/worker-platform/mappers.ts index 98f3d1c7..84a5ad49 100644 --- a/apps/backend/src/services/worker-platform/mappers.ts +++ b/apps/backend/src/services/worker-platform/mappers.ts @@ -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 diff --git a/apps/backend/src/services/worker-platform/worker-service.ts b/apps/backend/src/services/worker-platform/worker-service.ts index 516d7e20..5319a859 100644 --- a/apps/backend/src/services/worker-platform/worker-service.ts +++ b/apps/backend/src/services/worker-platform/worker-service.ts @@ -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), ), } } diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index e5a5abee..741fd58a 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -8,6 +8,7 @@ import { SearchOutlined, SendOutlined, TeamOutlined, + UserAddOutlined, WarningOutlined, } from '@ant-design/icons' import { useQuery, useQueryClient } from '@tanstack/react-query' @@ -42,6 +43,7 @@ import ImagePreviewList from '@/components/files/ImagePreviewList' import PageHeader from '@/components/admin/PageHeader' import { acceptAdminWorkOrder, + assignAdminWorkOrderToWorker, createAdminMockWorkOrder, creditAdminWorkerWallet, deductAdminWorkOrderPendingDeposit, @@ -125,6 +127,8 @@ export default function WorkOrdersPanel() { const [materialOrder, setMaterialOrder] = useState(null) const [materialScreenshots, setMaterialScreenshots] = useState([]) const [sharingOrder, setSharingOrder] = useState(null) + const [assignOrder, setAssignOrder] = useState(null) + const [assignWorkerId, setAssignWorkerId] = useState(undefined) const [editOrder, setEditOrder] = useState(null) const [deductOrder, setDeductOrder] = useState(null) const [problemForm] = Form.useForm() @@ -162,6 +166,30 @@ export default function WorkOrdersPanel() { queryFn: () => fetchAdminWorkOrderSharing(Number(sharingOrder?.workOrderId)), enabled: Boolean(sharingOrder), }) + const assignWorkersQuery = useQuery({ + queryKey: ['admin-worker-platform-workers', 'active', 1, 200], + queryFn: () => fetchAdminWorkerUsers({ status: 'active', page: 1, pageSize: 200 }), + enabled: Boolean(assignOrder), + }) + + function openAssignModal(row: WorkOrder) { + setAssignOrder(row) + setAssignWorkerId(undefined) + } + + async function submitAssign() { + if (!assignOrder || !assignWorkerId) return + const succeeded = await runAction( + () => + assignAdminWorkOrderToWorker(assignOrder.workOrderId, { + workerId: assignWorkerId, + }), + '已指派打手', + ) + if (!succeeded) return + setAssignOrder(null) + setAssignWorkerId(undefined) + } function openSharingConfigModal(row: WorkOrder) { setSharingOrder(row) @@ -551,6 +579,11 @@ export default function WorkOrdersPanel() { 拼单配置 ) : null} + {['unassigned', 'open'].includes(row.status) ? ( + + ) : null} {row.status === 'pending_material' ? (