feat(category): 接单平台支持二级分类目录与接单大厅联动展示及限额优化

This commit is contained in:
yml2213
2026-09-12 18:45:56 +08:00
parent 111f400bdb
commit d1e06f362e
17 changed files with 819 additions and 135 deletions
@@ -0,0 +1,11 @@
-- 为接单分类表增加二级目录支持与图标支持
ALTER TABLE work_categories
ADD COLUMN IF NOT EXISTS parent_id bigint REFERENCES work_categories(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS icon_url text;
CREATE INDEX IF NOT EXISTS idx_work_categories_parent_id
ON work_categories(parent_id);
COMMENT ON COLUMN work_categories.parent_id IS '父级分类 ID,NULL 表示一级分类,非空表示二级分类';
COMMENT ON COLUMN work_categories.icon_url IS '分类图标 URL,用于接单大厅等位置展示';
@@ -256,6 +256,8 @@ export type WorkCategoryRow = {
id: number
category_key: string
name: string
parent_id?: number | null
icon_url?: string | null
sort_order: number
status: string
created_at: string
@@ -402,6 +404,8 @@ export type WorkOrderRow = {
active_share_quantity?: number
pending_share_count?: number
category_name?: string
parent_category_id?: number | null
parent_category_name?: string
worker_username?: string
worker_display_name?: string
/** 取消信息,由工单查询从取消类事件推导,仅已取消工单有值。 */
@@ -36,19 +36,30 @@ export async function listAllWorkCategories(): Promise<WorkCategoryRow[]> {
export async function upsertWorkCategory(input: {
categoryKey: string
name: string
parentId?: number | null
iconUrl?: string | null
sortOrder: number
status: string
now: string
}): Promise<WorkCategoryRow | null> {
const result = await query<WorkCategoryRow>(
`
INSERT INTO work_categories (category_key, name, sort_order, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6)
INSERT INTO work_categories (category_key, name, parent_id, icon_url, sort_order, status, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (category_key) DO UPDATE
SET name = EXCLUDED.name, sort_order = EXCLUDED.sort_order, status = EXCLUDED.status, updated_at = EXCLUDED.updated_at
SET name = EXCLUDED.name, parent_id = EXCLUDED.parent_id, icon_url = EXCLUDED.icon_url, sort_order = EXCLUDED.sort_order, status = EXCLUDED.status, updated_at = EXCLUDED.updated_at
RETURNING *
`,
[input.categoryKey, input.name, input.sortOrder, input.status, input.now, input.now],
[
input.categoryKey,
input.name,
input.parentId ?? null,
input.iconUrl ?? null,
input.sortOrder,
input.status,
input.now,
input.now,
],
)
return result.rows[0] || null
}
@@ -56,8 +67,9 @@ export async function upsertWorkCategory(input: {
export async function countWorkCategoryUsages(categoryId: number | string): Promise<{
workOrderCount: number
productRuleCount: number
childCategoryCount: number
}> {
const [orders, rules] = await Promise.all([
const [orders, rules, children] = await Promise.all([
query<{ total: number }>(
'SELECT COUNT(*)::int AS total FROM work_orders WHERE category_id = $1',
[Number(categoryId)],
@@ -66,10 +78,15 @@ export async function countWorkCategoryUsages(categoryId: number | string): Prom
'SELECT COUNT(*)::int AS total FROM work_product_rules WHERE category_id = $1',
[Number(categoryId)],
),
query<{ total: number }>(
'SELECT COUNT(*)::int AS total FROM work_categories WHERE parent_id = $1',
[Number(categoryId)],
),
])
return {
workOrderCount: Number(orders.rows[0]?.total || 0),
productRuleCount: Number(rules.rows[0]?.total || 0),
childCategoryCount: Number(children.rows[0]?.total || 0),
}
}
@@ -11,6 +11,8 @@ export const WORK_ORDER_SELECT = `
COALESCE(share_summary.active_share_quantity, 0)::int AS active_share_quantity,
COALESCE(share_summary.pending_share_count, 0)::int AS pending_share_count,
wc.name AS category_name,
parent_wc.id AS parent_category_id,
parent_wc.name AS parent_category_name,
wu.username AS worker_username,
wu.display_name AS worker_display_name,
cancel_info.cancel_reason,
@@ -20,6 +22,7 @@ export const WORK_ORDER_SELECT = `
cancel_info.cancelled_at
FROM work_orders wo
LEFT JOIN work_categories wc ON wc.id = wo.category_id
LEFT JOIN work_categories parent_wc ON parent_wc.id = wc.parent_id
LEFT JOIN worker_users wu ON wu.id = wo.assigned_worker_id
LEFT JOIN LATERAL (
SELECT
@@ -862,7 +865,9 @@ export function buildWorkOrderWhere({
}
if (categoryId) {
params.push(categoryId)
filters.push(`wo.category_id = $${params.length}`)
filters.push(
`(wo.category_id = $${params.length} OR wo.category_id IN (SELECT id FROM work_categories WHERE parent_id = $${params.length}))`,
)
}
if (acceptanceMode) {
params.push(normalizeWorkOrderAcceptanceMode(acceptanceMode))
@@ -917,12 +922,21 @@ function buildHallCandidateIdsSql(limitPlaceholder: string, categoryLimitsPlaceh
return `
SELECT ranked.id
FROM (
SELECT candidate.id, candidate.category_id, candidate.hall_queued_at,
SELECT
candidate.id,
candidate.category_id,
candidate.hall_queued_at,
COALESCE(candidate_wc.parent_id, candidate.category_id) AS primary_category_id,
ROW_NUMBER() OVER (
PARTITION BY COALESCE(candidate_wc.parent_id, candidate.category_id)
ORDER BY candidate.hall_queued_at ASC NULLS LAST, candidate.id ASC
) AS primary_category_rank,
ROW_NUMBER() OVER (
PARTITION BY candidate.category_id
ORDER BY candidate.hall_queued_at ASC NULLS LAST, candidate.id ASC
) AS category_rank
) AS sub_category_rank
FROM work_orders candidate
LEFT JOIN work_categories candidate_wc ON candidate_wc.id = candidate.category_id
LEFT JOIN (
SELECT
work_order_id,
@@ -936,10 +950,15 @@ function buildHallCandidateIdsSql(limitPlaceholder: string, categoryLimitsPlaceh
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,
WHERE ranked.primary_category_rank <= COALESCE(
NULLIF(${categoryLimitsPlaceholder}::jsonb ->> ranked.primary_category_id::text, '')::int,
${limitPlaceholder}
)
AND (
NULLIF(${categoryLimitsPlaceholder}::jsonb ->> ranked.category_id::text, '') IS NULL
OR ranked.category_id = ranked.primary_category_id
OR ranked.sub_category_rank <= NULLIF(${categoryLimitsPlaceholder}::jsonb ->> ranked.category_id::text, '')::int
)
ORDER BY ranked.hall_queued_at ASC NULLS LAST, ranked.id ASC
LIMIT ${limitPlaceholder}
`
@@ -2,6 +2,7 @@ import {
countWorkCategoryUsages,
deleteWorkCategory,
getWorkCategoryById,
getWorkCategoryByKey,
listAllWorkCategories,
upsertWorkCategory,
} from '../../repositories/worker-platform/work-category-repo.js'
@@ -75,10 +76,57 @@ export async function saveAdminWorkCategory(payload: JsonObject = {}) {
DEFAULT_CATEGORY_KEY,
)
const name = String(payload.name || '').trim() || '默认分类'
const rawParentId = payload.parentId ?? payload.parent_id
const parentId =
rawParentId !== undefined && rawParentId !== null && String(rawParentId).trim() !== ''
? Number(rawParentId)
: null
const iconUrl =
payload.iconUrl !== undefined
? String(payload.iconUrl || '').trim() || null
: payload.icon_url !== undefined
? String(payload.icon_url || '').trim() || null
: null
if (parentId) {
const parent = await getWorkCategoryById(parentId)
if (!parent) {
throw createHttpError('所选上级分类不存在', {
statusCode: 400,
errorCode: 'parent_category_not_found',
})
}
if (parent.category_key === categoryKey) {
throw createHttpError('上级分类不能是当前分类自身', {
statusCode: 400,
errorCode: 'invalid_parent_category',
})
}
if (parent.parent_id) {
throw createHttpError('暂不支持三级及以上分类,上级分类必须是一级分类', {
statusCode: 400,
errorCode: 'parent_cannot_be_subcategory',
})
}
}
const existing = await getWorkCategoryByKey(categoryKey)
if (existing && parentId) {
const usages = await countWorkCategoryUsages(existing.id)
if (usages.childCategoryCount > 0) {
throw createHttpError('该分类下已有子分类,无法再作为二级分类', {
statusCode: 400,
errorCode: 'category_has_children',
})
}
}
const category = await upsertWorkCategory({
categoryKey,
name,
sortOrder: normalizeInteger(payload.sortOrder, 100),
parentId,
iconUrl,
sortOrder: normalizeInteger(payload.sortOrder ?? payload.sort_order, 100),
status: normalizeEnabledStatus(payload.status),
now,
})
@@ -94,14 +142,15 @@ export async function deleteAdminWorkCategory(categoryId: number | string) {
})
}
const usages = await countWorkCategoryUsages(Number(categoryId))
if (usages.workOrderCount > 0 || usages.productRuleCount > 0) {
throw createHttpError(
`该分类已被 ${usages.workOrderCount} 个工单和 ${usages.productRuleCount} 条物品规则使用,无法删除,请先停用`,
{
statusCode: 409,
errorCode: 'work_category_in_use',
},
)
if (usages.workOrderCount > 0 || usages.productRuleCount > 0 || usages.childCategoryCount > 0) {
const reasons: string[] = []
if (usages.workOrderCount > 0) reasons.push(`${usages.workOrderCount} 个工单`)
if (usages.productRuleCount > 0) reasons.push(`${usages.productRuleCount} 条物品规则`)
if (usages.childCategoryCount > 0) reasons.push(`${usages.childCategoryCount} 个子分类`)
throw createHttpError(`该分类已被 ${reasons.join('、')} 引用,无法删除,请先处理关联数据`, {
statusCode: 409,
errorCode: 'work_category_in_use',
})
}
const { deleted } = await deleteWorkCategory(Number(categoryId))
return { deleted }
@@ -33,6 +33,8 @@ export function mapWorkCategory(category: WorkCategoryRow | null | undefined) {
categoryId: Number(category.id),
categoryKey: category.category_key,
name: category.name,
parentId: category.parent_id ? Number(category.parent_id) : null,
iconUrl: category.icon_url || null,
sortOrder: Number(category.sort_order || 100),
status: category.status,
createdAt: category.created_at,
@@ -9,29 +9,36 @@ import {
test('大厅配置默认展示 50 个订单', () => {
assert.deepEqual(createDefaultWorkerHallConfig(), {
maxVisibleOrders: 50,
showSubcategories: true,
categoryMaxVisibleOrders: {},
})
assert.deepEqual(normalizeWorkerHallConfig({}), {
maxVisibleOrders: 50,
showSubcategories: true,
categoryMaxVisibleOrders: {},
})
assert.equal(normalizeWorkerHallConfig({ showSubcategories: false }).showSubcategories, false)
})
test('大厅配置只接受 1 到 500 的整数', () => {
assert.deepEqual(normalizeWorkerHallConfig({ maxVisibleOrders: 88 }), {
maxVisibleOrders: 88,
showSubcategories: true,
categoryMaxVisibleOrders: {},
})
assert.deepEqual(normalizeWorkerHallConfig({ max_visible_orders: 999 }), {
maxVisibleOrders: 500,
showSubcategories: true,
categoryMaxVisibleOrders: {},
})
assert.deepEqual(normalizeWorkerHallConfig({ maxVisibleOrders: 0 }), {
maxVisibleOrders: 1,
showSubcategories: true,
categoryMaxVisibleOrders: {},
})
assert.deepEqual(normalizeWorkerHallConfig({ maxVisibleOrders: 1.5 }), {
maxVisibleOrders: 50,
showSubcategories: true,
categoryMaxVisibleOrders: {},
})
assert.deepEqual(
@@ -4,6 +4,7 @@ import { readAppConfigEntry, saveAppConfigEntry } from '../config/app-config-sto
export type WorkerHallConfig = {
maxVisibleOrders: number
showSubcategories: boolean
categoryMaxVisibleOrders: Record<string, number>
}
@@ -24,14 +25,21 @@ export async function saveWorkerHallConfig(rawValue: unknown): Promise<WorkerHal
}
export function createDefaultWorkerHallConfig(): WorkerHallConfig {
return { maxVisibleOrders: 50, categoryMaxVisibleOrders: {} }
return { maxVisibleOrders: 50, showSubcategories: true, categoryMaxVisibleOrders: {} }
}
export function normalizeWorkerHallConfig(rawValue: unknown): WorkerHallConfig {
const source = isPlainObject(rawValue) ? rawValue : {}
const value = Number(source.maxVisibleOrders ?? source.max_visible_orders)
const showSubcategories =
source.showSubcategories !== undefined
? Boolean(source.showSubcategories)
: source.show_subcategories !== undefined
? Boolean(source.show_subcategories)
: true
return {
maxVisibleOrders: Number.isInteger(value) ? Math.min(500, Math.max(1, value)) : 50,
showSubcategories,
categoryMaxVisibleOrders: normalizeCategoryMaxVisibleOrders(
source.categoryMaxVisibleOrders ?? source.category_max_visible_orders,
),
@@ -90,6 +90,7 @@ export async function listWorkerHallOrders(query: JsonObject = {}, session: Work
}),
pagination: { page, pageSize, total },
categories,
showSubcategories: hallConfig.showSubcategories,
visibleDelaySeconds,
}
})