feat(category): 接单平台支持二级分类目录与接单大厅联动展示及限额优化
This commit is contained in:
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user