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
@@ -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),
),
}
}
@@ -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<WorkOrder | null>(null)
const [materialScreenshots, setMaterialScreenshots] = useState<UploadedFile[]>([])
const [sharingOrder, setSharingOrder] = useState<WorkOrder | null>(null)
const [assignOrder, setAssignOrder] = useState<WorkOrder | null>(null)
const [assignWorkerId, setAssignWorkerId] = useState<number | undefined>(undefined)
const [editOrder, setEditOrder] = useState<WorkOrder | null>(null)
const [deductOrder, setDeductOrder] = useState<WorkOrder | null>(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() {
</Button>
) : null}
{['unassigned', 'open'].includes(row.status) ? (
<Button icon={<UserAddOutlined />} onClick={() => openAssignModal(row)}>
</Button>
) : null}
{row.status === 'pending_material' ? (
<Button
icon={<FormOutlined />}
@@ -1142,6 +1175,41 @@ export default function WorkOrdersPanel() {
</Form>
</Modal>
<Modal
title={`指派打手 · ${assignOrder?.productName || ''}`}
open={Boolean(assignOrder)}
onCancel={() => {
setAssignOrder(null)
setAssignWorkerId(undefined)
}}
onOk={() => submitAssign()}
okText="确认指派"
okButtonProps={{ disabled: !assignWorkerId }}
destroyOnHidden
width={520}
>
<Space direction="vertical" size={12} className="full-width">
<Typography.Paragraph type="secondary">
</Typography.Paragraph>
<Select
style={{ width: '100%' }}
placeholder="选择要指派的打手"
loading={assignWorkersQuery.isLoading}
showSearch
optionFilterProp="label"
value={assignWorkerId}
onChange={setAssignWorkerId}
options={(assignWorkersQuery.data?.data.items || []).map((worker) => ({
value: worker.workerId,
label: `${worker.displayName || worker.username}${worker.username}${
worker.workerType === 'internal' ? '· 内部' : ''
}`,
}))}
/>
</Space>
</Modal>
<Modal
title={`拼单配置 · ${sharingOrder?.productName || ''}`}
open={Boolean(sharingOrder)}
@@ -84,7 +84,7 @@ import { formatAdminDateTime } from '@/utils/admin-time'
import { formatMoney, formatWorkerStatus } from './shared'
export default function WorkersPanel() {
const { message } = App.useApp()
const { message, modal } = App.useApp()
const queryClient = useQueryClient()
const [status, setStatus] = useState('')
const [page, setPage] = useState(1)
@@ -125,6 +125,30 @@ export default function WorkersPanel() {
}
}
function toggleWorkerType(worker: WorkerUser, workerType: 'internal' | 'external') {
const isInternal = workerType === 'internal'
modal.confirm({
title: isInternal ? '设为内部打手?' : '设为外部打手?',
content: isInternal
? '内部打手提交验收后将免审核,直接进入待打款;请确认该打手可信。'
: '外部打手提交验收后需后台审核通过,才进入待打款。',
okText: '确认',
cancelText: '取消',
onOk: async () => {
try {
await reviewAdminWorkerUser(worker.workerId, {
status: worker.status,
workerType,
})
message.success('打手类型已更新')
await refreshWorkers()
} catch (error) {
message.error(error instanceof Error ? error.message : '操作失败')
}
},
})
}
async function submitCredit(values: { amount?: number; note?: string }) {
if (!creditWorker) return
try {
@@ -164,9 +188,14 @@ export default function WorkersPanel() {
minWidth: 220,
render: (_, row) => (
<div className="cell-stack">
<Typography.Text strong>
{row.displayName || row.username}
</Typography.Text>
<Space size={6} wrap>
<Typography.Text strong>
{row.displayName || row.username}
</Typography.Text>
<Tag color={row.workerType === 'internal' ? 'blue' : 'default'}>
{row.workerType === 'internal' ? '内部' : '外部'}
</Tag>
</Space>
<Typography.Text type="secondary">{row.username}</Typography.Text>
</div>
),
@@ -227,6 +256,11 @@ export default function WorkersPanel() {
{row.status !== 'rejected' ? (
<Button onClick={() => review(row, 'rejected')}></Button>
) : null}
{row.workerType !== 'internal' ? (
<Button onClick={() => toggleWorkerType(row, 'internal')}></Button>
) : (
<Button onClick={() => toggleWorkerType(row, 'external')}></Button>
)}
<Button
onClick={() => {
setLevelWorker(row)
@@ -456,6 +456,9 @@ export default function WorkerProfilePage() {
<Tag color={resolveWorkerStatusColor(worker.status)}>
{formatWorkerStatus(worker.status)}
</Tag>
<Tag color={worker.workerType === 'internal' ? 'blue' : 'default'}>
{worker.workerType === 'internal' ? '内部打手' : '外部打手'}
</Tag>
<Tag>{worker.level?.name || '未分级'}</Tag>
</Space>
</Space>
@@ -124,7 +124,7 @@ export function deleteAdminWorkProductRule(ruleId: number) {
export function reviewAdminWorkerUser(
workerId: number,
payload: { status: string; reviewNote?: string; levelId?: number },
payload: { status: string; reviewNote?: string; levelId?: number; workerType?: string },
) {
return apiPost<{ worker: WorkerUser }>(
`/api/v1/admin/worker-platform/workers/${workerId}/review`,
@@ -132,6 +132,16 @@ export function reviewAdminWorkerUser(
)
}
export function assignAdminWorkOrderToWorker(
workOrderId: number,
payload: { workerId: number },
) {
return apiPost<{ order: WorkOrder }>(
`/api/v1/admin/worker-platform/orders/${workOrderId}/assign`,
payload,
)
}
export function creditAdminWorkerWallet(
workerId: number,
payload: { amount: number; note?: string },
@@ -4,6 +4,7 @@ export type WorkerUser = {
displayName: string
phone: string
status: string
workerType: string
reviewNote: string
inviteCode: string
inviter: null | {