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
@@ -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,
}
})