增加接单分类和大厅分类配额

This commit is contained in:
yml2213
2026-08-23 17:37:32 +08:00
parent 844f071aab
commit 1cebdd0487
18 changed files with 297 additions and 61 deletions
@@ -101,5 +101,21 @@ test('大厅容量先确定全局候选集合,再应用页面筛选条件', ()
assert.match(query.whereClause, /LIMIT \$4/)
const candidateSql = query.whereClause.slice(query.whereClause.indexOf('candidate.status'))
assert.doesNotMatch(candidateSql, /candidate\.category_id|candidate.*测试商品/)
assert.deepEqual(query.params, [['open'], '%测试商品%', 3, 50, '2026-08-21T00:00:00.000Z'])
assert.deepEqual(query.params, [['open'], '%测试商品%', 3, 50, '{}', '2026-08-21T00:00:00.000Z'])
})
test('后台接单工单支持按接单分类筛选', () => {
const query = buildWorkOrderWhere({ categoryId: 7 })
assert.match(query.whereClause, /wo\.category_id = \$1/)
assert.deepEqual(query.params, [7])
})
test('大厅候选按分类上限取队首后再受全局上限限制', () => {
const query = buildWorkOrderWhere({
hallCandidateLimit: 20,
hallCategoryLimits: { '1': 3, '2': 5 },
})
assert.match(query.whereClause, /ROW_NUMBER\(\) OVER/)
assert.match(query.whereClause, /category_rank/)
assert.deepEqual(query.params, [20, '{"1":3,"2":5}'])
})
@@ -431,6 +431,7 @@ export type ListInput = {
workerSharingId?: number
/** 大厅候选集合的总展示上限;筛选与分页在候选集合内执行。 */
hallCandidateLimit?: number
hallCategoryLimits?: Record<string, number>
visibleAfterIso?: string
/** 抢单大厅隐藏已被拼完的工单。 */
excludeFilledSharing?: boolean
@@ -14,6 +14,7 @@ export async function grabWorkOrder(input: {
depositAmount: number
maxActiveOrders: number
hallCandidateLimit?: number
hallCategoryLimits?: Record<string, number>
visibleAfterIso?: string
deadlineAt: string | null
now: string
@@ -42,6 +43,7 @@ export async function grabWorkOrder(input: {
!(await isWorkOrderWithinHallCapacityWithClient(client, {
workOrderId: input.workOrderId,
hallCandidateLimit: input.hallCandidateLimit,
...(input.hallCategoryLimits ? { hallCategoryLimits: input.hallCategoryLimits } : {}),
visibleAfterIso: input.visibleAfterIso,
}))
) {
@@ -226,6 +226,7 @@ export async function listWorkOrders({
categoryId = 0,
workerSharingId = 0,
hallCandidateLimit = 0,
hallCategoryLimits = {},
visibleAfterIso = '',
excludeFilledSharing = false,
vipEvidencePending = false,
@@ -247,6 +248,7 @@ export async function listWorkOrders({
categoryId,
workerSharingId,
hallCandidateLimit,
hallCategoryLimits,
visibleAfterIso,
excludeFilledSharing,
vipEvidencePending,
@@ -652,6 +654,7 @@ export function buildWorkOrderWhere({
categoryId = 0,
workerSharingId = 0,
hallCandidateLimit = 0,
hallCategoryLimits = {},
visibleAfterIso = '',
excludeFilledSharing = false,
vipEvidencePending = false,
@@ -843,7 +846,9 @@ export function buildWorkOrderWhere({
}
if (hallCandidateLimit > 0) {
params.push(hallCandidateLimit)
filters.push(`wo.id IN (${buildHallCandidateIdsSql(`$${params.length}`)})`)
const limitPlaceholder = `$${params.length}`
params.push(JSON.stringify(hallCategoryLimits))
filters.push(`wo.id IN (${buildHallCandidateIdsSql(limitPlaceholder, `$${params.length}`)})`)
}
if (visibleAfterIso) {
params.push(visibleAfterIso)
@@ -863,23 +868,34 @@ export function buildWorkOrderWhere({
}
/** 大厅容量先选出全局队首,分类、关键词和 VIP 延迟只能在该集合内继续筛选。 */
function buildHallCandidateIdsSql(limitPlaceholder: string) {
function buildHallCandidateIdsSql(limitPlaceholder: string, categoryLimitsPlaceholder: string) {
return `
SELECT candidate.id
FROM work_orders candidate
LEFT JOIN (
SELECT
work_order_id,
SUM(quantity) FILTER (WHERE status != 'cancelled') AS active_share_quantity
FROM work_order_shares
GROUP BY work_order_id
) candidate_shares ON candidate_shares.work_order_id = candidate.id
WHERE candidate.status = 'open'
AND (
candidate.sharing_enabled IS NOT TRUE
OR COALESCE(candidate_shares.active_share_quantity, 0) < candidate.sharing_total_quantity
)
ORDER BY candidate.hall_queued_at ASC NULLS LAST, candidate.id ASC
SELECT ranked.id
FROM (
SELECT candidate.id, candidate.category_id, candidate.hall_queued_at,
ROW_NUMBER() OVER (
PARTITION BY candidate.category_id
ORDER BY candidate.hall_queued_at ASC NULLS LAST, candidate.id ASC
) AS category_rank
FROM work_orders candidate
LEFT JOIN (
SELECT
work_order_id,
SUM(quantity) FILTER (WHERE status != 'cancelled') AS active_share_quantity
FROM work_order_shares
GROUP BY work_order_id
) candidate_shares ON candidate_shares.work_order_id = candidate.id
WHERE candidate.status = 'open'
AND (
candidate.sharing_enabled IS NOT TRUE
OR COALESCE(candidate_shares.active_share_quantity, 0) < candidate.sharing_total_quantity
)
) ranked
WHERE ranked.category_rank <= COALESCE(
NULLIF(${categoryLimitsPlaceholder}::jsonb ->> ranked.category_id::text, '')::int,
${limitPlaceholder}
)
ORDER BY ranked.hall_queued_at ASC NULLS LAST, ranked.id ASC
LIMIT ${limitPlaceholder}
`
}
@@ -887,7 +903,12 @@ function buildHallCandidateIdsSql(limitPlaceholder: string) {
/** 抢单操作在事务中复核容量与当前打手的可见延迟,避免通过订单 ID 绕过大厅队列。 */
export async function isWorkOrderWithinHallCapacityWithClient(
client: PoolClient,
input: { workOrderId: number; hallCandidateLimit: number; visibleAfterIso?: string | undefined },
input: {
workOrderId: number
hallCandidateLimit: number
hallCategoryLimits?: Record<string, number>
visibleAfterIso?: string | undefined
},
) {
const result = await client.query<{ visible: boolean }>(
`
@@ -895,13 +916,14 @@ export async function isWorkOrderWithinHallCapacityWithClient(
SELECT 1
FROM work_orders wo
WHERE wo.id = $1
AND wo.id IN (${buildHallCandidateIdsSql('$2')})
AND ($3::timestamptz IS NULL OR wo.hall_queued_at IS NULL OR wo.hall_queued_at <= $3)
AND wo.id IN (${buildHallCandidateIdsSql('$2', '$3')})
AND ($4::timestamptz IS NULL OR wo.hall_queued_at IS NULL OR wo.hall_queued_at <= $4)
) AS visible
`,
[
input.workOrderId,
input.hallCandidateLimit,
JSON.stringify(input.hallCategoryLimits || {}),
input.visibleAfterIso ? input.visibleAfterIso : null,
],
)
@@ -103,6 +103,7 @@ export async function joinWorkOrderShare(input: {
takeRemaining?: boolean
maxActiveOrders: number
hallCandidateLimit?: number
hallCategoryLimits?: Record<string, number>
visibleAfterIso?: string
now: string
}): Promise<{
@@ -137,6 +138,7 @@ export async function joinWorkOrderShare(input: {
!(await isWorkOrderWithinHallCapacityWithClient(client, {
workOrderId: input.workOrderId,
hallCandidateLimit: input.hallCandidateLimit,
...(input.hallCategoryLimits ? { hallCategoryLimits: input.hallCategoryLimits } : {}),
visibleAfterIso: input.visibleAfterIso,
}))
) {
@@ -6,6 +6,7 @@ import {
import {
createWorkOrder,
createWorkOrderEvent,
getWorkCategoryById,
listWorkOrdersByPlatformOrderId,
} from '../../repositories/worker-platform/index.js'
import type { JsonObject } from '../../types/json.js'
@@ -47,6 +48,18 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) {
: 0
: normalizeAmountFen(payload.requiredDepositAmount, 0)
const paymentFen = input.paymentAmount
const requestedCategoryId = Number(payload.categoryId || payload.category_id || 0)
let categoryId = defaults.category?.id || null
if (requestedCategoryId > 0) {
const category = await getWorkCategoryById(requestedCategoryId)
if (!category || category.status !== 'active') {
throw createHttpError('所选接单分类不存在或已停用', {
statusCode: 409,
errorCode: 'mock_work_order_category_invalid',
})
}
categoryId = Number(category.id)
}
const materialComplete = payload.materialComplete === true || payload.materialComplete === 'true'
const fields = normalizeRequirementFields(payload.fields)
const { platformOrderId, productName, shop } = input
@@ -92,7 +105,7 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) {
workOrderNo: randomId('WO'),
platformOrderId,
productName,
categoryId: defaults.category?.id || null,
categoryId,
status: materialComplete ? WORK_ORDER_STATUS.UNASSIGNED : WORK_ORDER_STATUS.PENDING_MATERIAL,
rewardAmount,
requiredDepositAmount,
@@ -250,6 +250,7 @@ export async function publishAdminWorkOrder(workOrderId: number | string, actorN
workOrderId: Number(workOrder.id),
statuses: [WORK_ORDER_STATUS.OPEN],
hallCandidateLimit: hallConfig.maxVisibleOrders,
hallCategoryLimits: hallConfig.categoryMaxVisibleOrders,
excludeFilledSharing: true,
})
return {
@@ -30,6 +30,7 @@ export async function listAdminWorkOrders(query: JsonObject = {}) {
const page = normalizePage(query.page)
const pageSize = normalizePageSize(query.pageSize)
const workOrderId = normalizeOptionalId(query.workOrderId ?? query.work_order_id) || 0
const categoryId = normalizeOptionalId(query.categoryId ?? query.category_id) || 0
const workerId = normalizeOptionalId(query.workerId ?? query.worker_id) || 0
const workerLevelIds = normalizeStatuses(query.workerLevelIds ?? query.worker_level_ids)
.map(normalizeOptionalId)
@@ -43,6 +44,7 @@ export async function listAdminWorkOrders(query: JsonObject = {}) {
const filters = {
statuses,
workOrderId,
categoryId,
keyword: String(query.keyword || '').trim(),
workerSharingId: workerId,
workerLevelIds,
@@ -5,9 +5,44 @@ import { getWorkerHallConfig, saveWorkerHallConfig } from './worker-hall-config-
export async function getAdminWorkerPlatformSummary() {
const hallConfig = getWorkerHallConfig()
const [pendingWorkers, pendingMaterial, openOrders, inProgressOrders] = await Promise.all([
listWorkerUsers({ page: 1, pageSize: 1, status: 'pending_review' }),
listWorkOrders({ page: 1, pageSize: 1, statuses: [WORK_ORDER_STATUS.PENDING_MATERIAL] }),
const [pendingWorkers, pendingMaterial, openOrders, visibleOpenOrders, inProgressOrders] =
await Promise.all([
listWorkerUsers({ page: 1, pageSize: 1, status: 'pending_review' }),
listWorkOrders({ page: 1, pageSize: 1, statuses: [WORK_ORDER_STATUS.PENDING_MATERIAL] }),
listWorkOrders({
page: 1,
pageSize: 1,
statuses: [WORK_ORDER_STATUS.OPEN],
excludeFilledSharing: true,
}),
listWorkOrders({
page: 1,
pageSize: 1,
statuses: [WORK_ORDER_STATUS.OPEN],
hallCandidateLimit: hallConfig.maxVisibleOrders,
hallCategoryLimits: hallConfig.categoryMaxVisibleOrders,
excludeFilledSharing: true,
}),
listWorkOrders({
page: 1,
pageSize: 1,
statuses: [WORK_ORDER_STATUS.IN_PROGRESS],
useOperationalStatus: true,
}),
])
return {
pendingWorkerCount: pendingWorkers.total,
pendingMaterialCount: pendingMaterial.total,
openOrderCount: visibleOpenOrders.total,
queuedHallOrderCount: Math.max(0, openOrders.total - visibleOpenOrders.total),
hallMaxVisibleOrders: hallConfig.maxVisibleOrders,
inProgressOrderCount: inProgressOrders.total,
}
}
export async function getAdminWorkerHallConfig() {
const config = getWorkerHallConfig()
const [openOrders, visibleOpenOrders] = await Promise.all([
listWorkOrders({
page: 1,
pageSize: 1,
@@ -17,32 +52,16 @@ export async function getAdminWorkerPlatformSummary() {
listWorkOrders({
page: 1,
pageSize: 1,
statuses: [WORK_ORDER_STATUS.IN_PROGRESS],
useOperationalStatus: true,
statuses: [WORK_ORDER_STATUS.OPEN],
hallCandidateLimit: config.maxVisibleOrders,
hallCategoryLimits: config.categoryMaxVisibleOrders,
excludeFilledSharing: true,
}),
])
return {
pendingWorkerCount: pendingWorkers.total,
pendingMaterialCount: pendingMaterial.total,
openOrderCount: Math.min(openOrders.total, hallConfig.maxVisibleOrders),
queuedHallOrderCount: Math.max(0, openOrders.total - hallConfig.maxVisibleOrders),
hallMaxVisibleOrders: hallConfig.maxVisibleOrders,
inProgressOrderCount: inProgressOrders.total,
}
}
export async function getAdminWorkerHallConfig() {
const config = getWorkerHallConfig()
const openOrders = await listWorkOrders({
page: 1,
pageSize: 1,
statuses: [WORK_ORDER_STATUS.OPEN],
excludeFilledSharing: true,
})
return {
...config,
visibleOrderCount: Math.min(openOrders.total, config.maxVisibleOrders),
queuedOrderCount: Math.max(0, openOrders.total - config.maxVisibleOrders),
visibleOrderCount: visibleOpenOrders.total,
queuedOrderCount: Math.max(0, openOrders.total - visibleOpenOrders.total),
}
}
@@ -55,6 +55,7 @@ export async function grabWorkerHallOrder(workOrderId: number | string, session:
takeRemaining: true,
maxActiveOrders: permissions.maxActiveOrders,
hallCandidateLimit: hallConfig.maxVisibleOrders,
hallCategoryLimits: hallConfig.categoryMaxVisibleOrders,
visibleAfterIso,
now,
})
@@ -91,6 +92,7 @@ export async function grabWorkerHallOrder(workOrderId: number | string, session:
depositAmount: freezeAmount,
maxActiveOrders: permissions.maxActiveOrders,
hallCandidateLimit: hallConfig.maxVisibleOrders,
hallCategoryLimits: hallConfig.categoryMaxVisibleOrders,
visibleAfterIso,
deadlineAt: resolveWorkOrderDeadlineAt(workOrder, now),
now,
@@ -143,6 +145,7 @@ export async function joinWorkerSharingOrder(
quantity,
maxActiveOrders: permissions.maxActiveOrders,
hallCandidateLimit: hallConfig.maxVisibleOrders,
hallCategoryLimits: hallConfig.categoryMaxVisibleOrders,
visibleAfterIso,
now,
})
@@ -7,17 +7,36 @@ import {
} from './worker-hall-config-service.js'
test('大厅配置默认展示 50 个订单', () => {
assert.deepEqual(createDefaultWorkerHallConfig(), { maxVisibleOrders: 50 })
assert.deepEqual(normalizeWorkerHallConfig({}), { maxVisibleOrders: 50 })
assert.deepEqual(createDefaultWorkerHallConfig(), {
maxVisibleOrders: 50,
categoryMaxVisibleOrders: {},
})
assert.deepEqual(normalizeWorkerHallConfig({}), {
maxVisibleOrders: 50,
categoryMaxVisibleOrders: {},
})
})
test('大厅配置只接受 1 到 500 的整数', () => {
assert.deepEqual(normalizeWorkerHallConfig({ maxVisibleOrders: 88 }), { maxVisibleOrders: 88 })
assert.deepEqual(normalizeWorkerHallConfig({ maxVisibleOrders: 88 }), {
maxVisibleOrders: 88,
categoryMaxVisibleOrders: {},
})
assert.deepEqual(normalizeWorkerHallConfig({ max_visible_orders: 999 }), {
maxVisibleOrders: 500,
categoryMaxVisibleOrders: {},
})
assert.deepEqual(normalizeWorkerHallConfig({ maxVisibleOrders: 0 }), {
maxVisibleOrders: 1,
categoryMaxVisibleOrders: {},
})
assert.deepEqual(normalizeWorkerHallConfig({ maxVisibleOrders: 0 }), { maxVisibleOrders: 1 })
assert.deepEqual(normalizeWorkerHallConfig({ maxVisibleOrders: 1.5 }), {
maxVisibleOrders: 50,
categoryMaxVisibleOrders: {},
})
assert.deepEqual(
normalizeWorkerHallConfig({ categoryMaxVisibleOrders: { '1': 12, '2': 0, '3': 501 } })
.categoryMaxVisibleOrders,
{ '1': 12 },
)
})
@@ -4,6 +4,7 @@ import { readAppConfigEntry, saveAppConfigEntry } from '../config/app-config-sto
export type WorkerHallConfig = {
maxVisibleOrders: number
categoryMaxVisibleOrders: Record<string, number>
}
export function getWorkerHallConfig(): WorkerHallConfig {
@@ -23,7 +24,7 @@ export async function saveWorkerHallConfig(rawValue: unknown): Promise<WorkerHal
}
export function createDefaultWorkerHallConfig(): WorkerHallConfig {
return { maxVisibleOrders: 50 }
return { maxVisibleOrders: 50, categoryMaxVisibleOrders: {} }
}
export function normalizeWorkerHallConfig(rawValue: unknown): WorkerHallConfig {
@@ -31,9 +32,24 @@ export function normalizeWorkerHallConfig(rawValue: unknown): WorkerHallConfig {
const value = Number(source.maxVisibleOrders ?? source.max_visible_orders)
return {
maxVisibleOrders: Number.isInteger(value) ? Math.min(500, Math.max(1, value)) : 50,
categoryMaxVisibleOrders: normalizeCategoryMaxVisibleOrders(
source.categoryMaxVisibleOrders ?? source.category_max_visible_orders,
),
}
}
function normalizeCategoryMaxVisibleOrders(value: unknown): Record<string, number> {
if (!isPlainObject(value)) return {}
return Object.entries(value).reduce<Record<string, number>>((result, [categoryId, raw]) => {
const id = Number(categoryId)
const limit = Number(raw)
if (Number.isInteger(id) && id > 0 && Number.isInteger(limit) && limit >= 1 && limit <= 500) {
result[String(id)] = limit
}
return result
}, {})
}
function isPlainObject(value: unknown): value is JsonObject {
return Object.prototype.toString.call(value) === '[object Object]'
}
@@ -39,6 +39,7 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work
keyword: String(query.keyword || '').trim(),
categoryId: categoryId || 0,
hallCandidateLimit: hallConfig.maxVisibleOrders,
hallCategoryLimits: hallConfig.categoryMaxVisibleOrders,
visibleAfterIso,
excludeFilledSharing: true,
pinnedFirst: true,