feat: 打手分内部/外部,内部免审核;支持后台指派打手
- worker_users 新增 worker_type(internal/external,迁移 017) - 内部打手提交验收后自动通过进入待打款(accepted+结算),外部打手保持后台审核 - 打手管理页支持设为内部/外部并显示类型标签 - 新增后台指派打手:未分配/待抢单工单可指定已注册打手,进入其我的订单并冻结押金 - 指派带审计与事件记录,校验打手状态/接单上限/余额押金
This commit is contained in:
@@ -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 外部需审核';
|
||||
@@ -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
|
||||
|
||||
@@ -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<WorkOrderRow>(
|
||||
`
|
||||
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
|
||||
|
||||
@@ -159,9 +159,9 @@ export async function createWorkerUser(input: CreateWorkerInput): Promise<Worker
|
||||
`
|
||||
INSERT INTO worker_users (
|
||||
username, password_hash, display_name, phone, level_id, status,
|
||||
inviter_id, invite_code,
|
||||
worker_type, inviter_id, invite_code,
|
||||
created_at, updated_at
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||
RETURNING id
|
||||
`,
|
||||
[
|
||||
@@ -171,6 +171,7 @@ export async function createWorkerUser(input: CreateWorkerInput): Promise<Worker
|
||||
input.phone,
|
||||
input.levelId,
|
||||
input.status,
|
||||
input.workerType || 'external',
|
||||
input.inviterId || null,
|
||||
input.inviteCode,
|
||||
input.now,
|
||||
@@ -188,7 +189,13 @@ export async function updateWorkerUser(
|
||||
patch: Partial<
|
||||
Pick<
|
||||
WorkerUserRow,
|
||||
'status' | 'review_note' | 'level_id' | 'session_version' | 'updated_at' | 'reviewed_at'
|
||||
| 'status'
|
||||
| 'worker_type'
|
||||
| 'review_note'
|
||||
| 'level_id'
|
||||
| 'session_version'
|
||||
| 'updated_at'
|
||||
| 'reviewed_at'
|
||||
>
|
||||
>,
|
||||
): Promise<WorkerUserRow | null> {
|
||||
@@ -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),
|
||||
|
||||
@@ -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<string, unknown>) : {},
|
||||
}),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.delete(
|
||||
'/worker-platform/orders/:workOrderId',
|
||||
requireAdminRoles(['admin', 'operator']),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user