From 3e058354084760f68e7c0dbf8e83710c40479126 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Fri, 17 Jul 2026 16:54:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=B6=E6=95=9B=E7=9B=98=E7=82=B9?= =?UTF-8?q?=E8=8C=83=E5=9B=B4=E7=AD=9B=E9=80=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/controllers/inventoryController.ts | 44 +++-- backend/src/routes/inventory.ts | 2 + .../__tests__/inventoryScopeContext.test.ts | 157 +++++++++++++++ backend/src/services/inventoryService.ts | 180 ++++++++++++++++-- frontend/src/api/inventory.ts | 6 + frontend/src/types/index.ts | 10 +- .../src/views/inventory/InventoryCreate.vue | 168 +++++++++++++--- 7 files changed, 500 insertions(+), 67 deletions(-) create mode 100644 backend/src/services/__tests__/inventoryScopeContext.test.ts diff --git a/backend/src/controllers/inventoryController.ts b/backend/src/controllers/inventoryController.ts index 2b43a7a..322e498 100644 --- a/backend/src/controllers/inventoryController.ts +++ b/backend/src/controllers/inventoryController.ts @@ -5,6 +5,7 @@ import { closeInventoryTask, createInventoryTask, deleteInventoryTask, + getInventoryScopeContext, getInventoryTaskDetail, getTaskUnassignedItems, listInventoryTasks, @@ -19,19 +20,29 @@ const toOptionalNumber = (value: unknown) => { return Number.isNaN(numericValue) ? null : numericValue } +const getNormalizedScopeFromRequest = (req: Request) => normalizeInventoryScopeInput({ + department_id: toOptionalNumber(req.body?.department_id), + department_ids: req.body?.department_ids, + asset_type_id: toOptionalNumber(req.body?.asset_type_id), + asset_type_ids: req.body?.asset_type_ids, + storage_location_id: toOptionalNumber(req.body?.storage_location_id), + storage_location_ids: req.body?.storage_location_ids, + person_id: toOptionalNumber(req.body?.person_id), + person_ids: req.body?.person_ids +}) + +export const getInventoryScopeContextController = async (req: Request, res: Response) => { + try { + const result = await getInventoryScopeContext(getNormalizedScopeFromRequest(req)) + successResponse(res, result, '获取盘点范围成功') + } catch (error: any) { + errorResponse(res, error.message || '获取盘点范围失败', 422) + } +} + export const previewInventoryTaskController = async (req: Request, res: Response) => { try { - const normalizedScope = normalizeInventoryScopeInput({ - department_id: toOptionalNumber(req.body?.department_id), - department_ids: req.body?.department_ids, - asset_type_id: toOptionalNumber(req.body?.asset_type_id), - asset_type_ids: req.body?.asset_type_ids, - storage_location_id: toOptionalNumber(req.body?.storage_location_id), - storage_location_ids: req.body?.storage_location_ids, - person_id: toOptionalNumber(req.body?.person_id), - person_ids: req.body?.person_ids - }) - const result = await previewInventoryTask(normalizedScope) + const result = await previewInventoryTask(getNormalizedScopeFromRequest(req)) successResponse(res, result, '预览成功') } catch (error: any) { errorResponse(res, error.message || '预览盘点任务失败', 422) @@ -45,16 +56,7 @@ export const createInventoryTaskController = async (req: Request, res: Response) return errorResponse(res, '未获取到当前登录用户', 401) } - const normalizedScope = normalizeInventoryScopeInput({ - department_id: toOptionalNumber(req.body?.department_id), - department_ids: req.body?.department_ids, - asset_type_id: toOptionalNumber(req.body?.asset_type_id), - asset_type_ids: req.body?.asset_type_ids, - storage_location_id: toOptionalNumber(req.body?.storage_location_id), - storage_location_ids: req.body?.storage_location_ids, - person_id: toOptionalNumber(req.body?.person_id), - person_ids: req.body?.person_ids - }) + const normalizedScope = getNormalizedScopeFromRequest(req) const result = await createInventoryTask( { diff --git a/backend/src/routes/inventory.ts b/backend/src/routes/inventory.ts index 5fdcd10..42f342a 100644 --- a/backend/src/routes/inventory.ts +++ b/backend/src/routes/inventory.ts @@ -4,6 +4,7 @@ import { closeInventoryTaskController, createInventoryTaskController, deleteInventoryTaskController, + getInventoryScopeContextController, getInventoryTaskDetailController, getInventoryTaskListController, getInventoryTaskUnassignedController, @@ -17,6 +18,7 @@ const router = Router() router.use(protect(), requirePermission(PERMISSIONS.INVENTORY_MANAGE)) +router.post('/tasks/scope-context', getInventoryScopeContextController) router.post('/tasks/preview', previewInventoryTaskController) router.post('/tasks', createInventoryTaskController) router.get('/tasks', getInventoryTaskListController) diff --git a/backend/src/services/__tests__/inventoryScopeContext.test.ts b/backend/src/services/__tests__/inventoryScopeContext.test.ts new file mode 100644 index 0000000..ad426d8 --- /dev/null +++ b/backend/src/services/__tests__/inventoryScopeContext.test.ts @@ -0,0 +1,157 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import prisma from '../../utils/prisma' +import { buildInventoryScope, getInventoryScopeContext } from '../inventoryService' + +const equipmentRows = [ + { + id: 100, + equipment_code: 'JS-PC-0100', + equipment_name: '笔记本电脑', + equipment_model: null, + equipment_type: '笔记本', + asset_type_id: 1, + status: '可用', + parent_id: null, + assetType: { id: 1, name: '笔记本' }, + department: { id: 1, name: '研发部' }, + storageLocation: { id: 10, name: '杭州' } + }, + { + id: 101, + equipment_code: 'JS-CPU-0101', + equipment_name: 'CPU', + equipment_model: null, + equipment_type: 'CPU', + asset_type_id: 9, + status: '可用', + parent_id: 100, + assetType: { id: 9, name: 'CPU' }, + department: { id: 1, name: '研发部' }, + storageLocation: { id: 99, name: '备件柜' } + }, + { + id: 200, + equipment_code: 'JS-MN-0200', + equipment_name: '显示器', + equipment_model: null, + equipment_type: '显示器', + asset_type_id: 2, + status: '可用', + parent_id: null, + assetType: { id: 2, name: '显示器' }, + department: { id: 1, name: '研发部' }, + storageLocation: { id: 20, name: '常州' } + } +] + +const rootRows = [ + { + id: 100, + asset_type_id: 1, + storage_location_id: 10, + assetType: { id: 1, name: '笔记本', status: 'active' }, + storageLocation: { id: 10, name: '杭州', status: 'active' } + }, + { + id: 200, + asset_type_id: 2, + storage_location_id: 20, + assetType: { id: 2, name: '显示器', status: 'active' }, + storageLocation: { id: 20, name: '常州', status: 'active' } + } +] + +test('盘点范围只按父资产提供类型和地区,并会把筛选应用到整棵资产树', async () => { + const originalEquipmentFindMany = prisma.equipment.findMany + const originalBorrowRecordFindMany = prisma.borrowRecord.findMany + const originalPersonFindMany = prisma.person.findMany + const originalAssetTypeFindMany = prisma.assetType.findMany + const originalStorageLocationFindMany = prisma.storageLocation.findMany + + try { + ;(prisma.equipment.findMany as any) = async ({ select }: any) => { + if (select?.assetType?.select?.status) return rootRows + if (select?.equipment_code) return equipmentRows + return [{ id: 100, parent_id: null }] + } + ;(prisma.borrowRecord.findMany as any) = async () => [ + { + id: 11, + equipment_id: 100, + status: '已借出', + borrow_time: new Date('2026-07-17T00:00:00.000Z'), + person: { + id: 7, + real_name: '魔礼红', + nickname: '魔礼红', + source_userid: 'ding-7', + department: '研发部', + main_department_id: 1, + mainDepartment: { id: 1, name: '研发部' }, + status: 'active' + } + }, + { + id: 12, + equipment_id: 200, + status: '已借出', + borrow_time: new Date('2026-07-17T00:00:00.000Z'), + person: { + id: 8, + real_name: '无邪', + nickname: '无邪', + source_userid: 'ding-8', + department: '研发部', + main_department_id: 1, + mainDepartment: { id: 1, name: '研发部' }, + status: 'active' + } + } + ] + ;(prisma.person.findMany as any) = async () => [ + { + id: 7, + real_name: '魔礼红', + nickname: '魔礼红', + source_userid: 'ding-7', + department: '研发部', + mainDepartment: { name: '研发部' } + }, + { + id: 8, + real_name: '无邪', + nickname: '无邪', + source_userid: 'ding-8', + department: '研发部', + mainDepartment: { name: '研发部' } + } + ] + ;(prisma.assetType.findMany as any) = async () => [{ id: 1, name: '笔记本' }] + ;(prisma.storageLocation.findMany as any) = async () => [{ id: 10, name: '杭州' }] + + const context = await getInventoryScopeContext({ personIds: [7, 8] }) + assert.deepEqual(context.asset_types, [ + { id: 1, name: '笔记本' }, + { id: 2, name: '显示器' } + ]) + assert.deepEqual(context.storage_locations, [ + { id: 20, name: '常州' }, + { id: 10, name: '杭州' } + ]) + assert.equal(context.participants_count, 2) + assert.equal('person_source_userid' in context.participants[0], false) + + const filteredScope = await buildInventoryScope({ + personIds: [7, 8], + assetTypeIds: [1] + }) + assert.deepEqual(filteredScope.items.map((item) => item.equipment_id), [100, 101]) + } finally { + ;(prisma.equipment.findMany as any) = originalEquipmentFindMany + ;(prisma.borrowRecord.findMany as any) = originalBorrowRecordFindMany + ;(prisma.person.findMany as any) = originalPersonFindMany + ;(prisma.assetType.findMany as any) = originalAssetTypeFindMany + ;(prisma.storageLocation.findMany as any) = originalStorageLocationFindMany + } +}) diff --git a/backend/src/services/inventoryService.ts b/backend/src/services/inventoryService.ts index a1fe6e2..6af2064 100644 --- a/backend/src/services/inventoryService.ts +++ b/backend/src/services/inventoryService.ts @@ -71,6 +71,16 @@ type FrozenItemDraft = { is_unassigned: boolean } +type ScopeRootAsset = { + id: number + asset_type_id: number | null + asset_type_name: string | null + asset_type_active: boolean + storage_location_id: number | null + storage_location_name: string | null + storage_location_active: boolean +} + type InventoryScopeBuildResult = { scopeDepartments: ScopeEntity[] scopeAssetTypes: ScopeEntity[] @@ -78,6 +88,7 @@ type InventoryScopeBuildResult = { scopePeople: ScopePerson[] participants: FrozenParticipantDraft[] items: FrozenItemDraft[] + rootAssets: ScopeRootAsset[] } const INVENTORY_TASK_STATUSES = { @@ -99,11 +110,7 @@ const INVENTORY_PARTICIPANT_STATUS = { const EXCLUDED_EQUIPMENT_STATUSES = ['已报废'] -const buildScopeWhere = ({ - departmentIds, - assetTypeIds, - storageLocationIds -}: NormalizedInventoryScopeInput) => { +const buildScopeWhere = ({ departmentIds }: NormalizedInventoryScopeInput) => { const where: Record = { status: { notIn: EXCLUDED_EQUIPMENT_STATUSES @@ -116,18 +123,6 @@ const buildScopeWhere = ({ } } - if (assetTypeIds.length > 0) { - where.asset_type_id = { - in: assetTypeIds - } - } - - if (storageLocationIds.length > 0) { - where.storage_location_id = { - in: storageLocationIds - } - } - return where } @@ -360,6 +355,107 @@ const resolveBorrowAnchorId = ( return equipmentId } +const resolveRootEquipmentId = ( + equipmentId: number, + equipmentMap: Map +) => { + let current = equipmentMap.get(equipmentId) || null + const visitedIds = new Set() + + while (current?.parent_id && !visitedIds.has(current.id)) { + visitedIds.add(current.id) + const parent = equipmentMap.get(current.parent_id) + if (!parent) break + current = parent + } + + return current?.id || equipmentId +} + +const matchesRootAssetFilters = ( + rootAsset: ScopeRootAsset | undefined, + scope: NormalizedInventoryScopeInput +) => { + if (!rootAsset) { + return scope.assetTypeIds.length === 0 && scope.storageLocationIds.length === 0 + } + + if ( + scope.assetTypeIds.length > 0 + && (!rootAsset.asset_type_id || !scope.assetTypeIds.includes(rootAsset.asset_type_id)) + ) { + return false + } + + if ( + scope.storageLocationIds.length > 0 + && (!rootAsset.storage_location_id || !scope.storageLocationIds.includes(rootAsset.storage_location_id)) + ) { + return false + } + + return true +} + +const buildScopeRootAssets = async ( + rootEquipmentIds: number[] +): Promise => { + if (rootEquipmentIds.length === 0) return [] + + const rootRows = await prisma.equipment.findMany({ + where: { + id: { in: rootEquipmentIds }, + status: { notIn: EXCLUDED_EQUIPMENT_STATUSES } + }, + select: { + id: true, + asset_type_id: true, + storage_location_id: true, + assetType: { + select: { + id: true, + name: true, + status: true + } + }, + storageLocation: { + select: { + id: true, + name: true, + status: true + } + } + } + }) + + return rootRows.map((root) => ({ + id: root.id, + asset_type_id: root.asset_type_id, + asset_type_name: root.assetType?.name || null, + asset_type_active: root.assetType?.status === 'active', + storage_location_id: root.storage_location_id, + storage_location_name: root.storageLocation?.name || null, + storage_location_active: root.storageLocation?.status === 'active' + })) +} + +const buildScopeFilterEntities = ( + rootAssets: ScopeRootAsset[], + type: 'assetType' | 'storageLocation' +) => { + const entities = new Map() + + for (const rootAsset of rootAssets) { + const id = type === 'assetType' ? rootAsset.asset_type_id : rootAsset.storage_location_id + const name = type === 'assetType' ? rootAsset.asset_type_name : rootAsset.storage_location_name + const isActive = type === 'assetType' ? rootAsset.asset_type_active : rootAsset.storage_location_active + if (!id || !name || !isActive) continue + entities.set(id, { id, name }) + } + + return Array.from(entities.values()).sort((left, right) => left.name.localeCompare(right.name, 'zh-CN')) +} + const buildDepartmentBreakdown = (items: FrozenItemDraft[]) => Array.from( items.reduce((map, item) => { @@ -437,13 +533,25 @@ export const buildInventoryScope = async ( }) const equipmentMap = await loadAncestorMap(equipments) + const rootEquipmentIds = Array.from( + new Set(equipments.map((equipment) => resolveRootEquipmentId(equipment.id, equipmentMap))) + ) + const rootAssets = await buildScopeRootAssets(rootEquipmentIds) + const rootAssetMap = new Map(rootAssets.map((rootAsset) => [rootAsset.id, rootAsset])) const borrowTargetIds = Array.from(new Set(Array.from(equipmentMap.keys()))) const activeBorrowRecordMap = await buildActiveBorrowRecordMap(borrowTargetIds) const participantMap = new Map() const items: FrozenItemDraft[] = [] + const includedRootAssetIds = new Set() for (const equipment of equipments) { + const rootEquipmentId = resolveRootEquipmentId(equipment.id, equipmentMap) + const rootAsset = rootAssetMap.get(rootEquipmentId) + if (!matchesRootAssetFilters(rootAsset, normalizedScope)) { + continue + } + const borrowAnchorId = resolveBorrowAnchorId(equipment.id, equipmentMap, activeBorrowRecordMap) const activeBorrowRecords = activeBorrowRecordMap.get(borrowAnchorId) || [] const activeBorrowRecord = resolveUniqueActiveBorrowRecord(activeBorrowRecords) @@ -474,6 +582,9 @@ export const buildInventoryScope = async ( } items.push(itemDraft) + if (rootAsset) { + includedRootAssetIds.add(rootAsset.id) + } } return { @@ -484,7 +595,8 @@ export const buildInventoryScope = async ( participants: Array.from(participantMap.values()).sort((left, right) => left.person_nickname.localeCompare(right.person_nickname, 'zh-CN') ), - items + items, + rootAssets: rootAssets.filter((rootAsset) => includedRootAssetIds.has(rootAsset.id)) } } @@ -509,6 +621,38 @@ export const previewInventoryTask = async (options: InventoryScopeOptions) => { } } +export const getInventoryScopeContext = async (options: InventoryScopeOptions) => { + const normalizedScope = normalizeInventoryScopeInput(options) + const primaryScope = await buildInventoryScope({ + departmentIds: normalizedScope.departmentIds, + personIds: normalizedScope.personIds, + assetTypeIds: [], + storageLocationIds: [] + }) + + const rootAssetsForAssetTypes = normalizedScope.storageLocationIds.length > 0 + ? primaryScope.rootAssets.filter((rootAsset) => + Boolean(rootAsset.storage_location_id) + && normalizedScope.storageLocationIds.includes(rootAsset.storage_location_id as number) + ) + : primaryScope.rootAssets + const rootAssetsForStorageLocations = normalizedScope.assetTypeIds.length > 0 + ? primaryScope.rootAssets.filter((rootAsset) => + Boolean(rootAsset.asset_type_id) + && normalizedScope.assetTypeIds.includes(rootAsset.asset_type_id as number) + ) + : primaryScope.rootAssets + + return { + participants: primaryScope.participants.map(sanitizeInventoryParticipant), + participants_count: primaryScope.participants.length, + items_count: primaryScope.items.length, + unassigned_count: primaryScope.items.filter((item) => item.is_unassigned).length, + asset_types: buildScopeFilterEntities(rootAssetsForAssetTypes, 'assetType'), + storage_locations: buildScopeFilterEntities(rootAssetsForStorageLocations, 'storageLocation') + } +} + const buildTaskScopeSnapshot = (scope: InventoryScopeBuildResult) => ({ scope_department: scope.scopeDepartments.length === 1 ? scope.scopeDepartments[0] : null, scope_asset_type: scope.scopeAssetTypes.length === 1 ? scope.scopeAssetTypes[0] : null, diff --git a/frontend/src/api/inventory.ts b/frontend/src/api/inventory.ts index 883ae40..102a93a 100644 --- a/frontend/src/api/inventory.ts +++ b/frontend/src/api/inventory.ts @@ -4,6 +4,7 @@ import type { InventoryTaskDetail, InventoryTaskItem, InventoryTaskListItem, + InventoryScopeContext, InventoryTaskPreview, PaginatedResponse } from '@/types' @@ -30,6 +31,11 @@ export const previewInventoryTask = async (data: InventoryTaskPreviewPayload) => return response.data } +export const getInventoryScopeContext = async (data: InventoryTaskPreviewPayload) => { + const response = await api.post>('/inventory/tasks/scope-context', data) + return response.data +} + export const createInventoryTask = async (data: CreateInventoryTaskPayload) => { const response = await api.post>('/inventory/tasks', data) return response.data diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 4b31662..d243823 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -293,12 +293,20 @@ export interface InventoryPreviewParticipantSample { person_id: number person_name: string person_nickname: string - person_source_userid: string | null dingtalk_ready?: boolean department_id: number | null department_name: string | null } +export interface InventoryScopeContext { + participants: InventoryPreviewParticipantSample[] + participants_count: number + items_count: number + unassigned_count: number + asset_types: InventoryScopeEntity[] + storage_locations: InventoryScopeEntity[] +} + export interface InventoryPreviewItemSample { participant_person_id: number | null equipment_id: number diff --git a/frontend/src/views/inventory/InventoryCreate.vue b/frontend/src/views/inventory/InventoryCreate.vue index 2000b2e..e3806a1 100644 --- a/frontend/src/views/inventory/InventoryCreate.vue +++ b/frontend/src/views/inventory/InventoryCreate.vue @@ -119,6 +119,7 @@ /> {{ extraFilterCount }} -
+

+ 先选择盘点对象 +

+

+ 正在同步可选资产... +

+

+ {{ scopeContextError }} +

+

资产类型

@@ -274,7 +284,7 @@

- 没有匹配的资产类型 + 当前范围没有可筛选的主资产类型

@@ -312,7 +322,7 @@

- 没有匹配的地区 + 当前范围没有可筛选的地区

@@ -329,12 +339,39 @@
范围
{{ currentScopeLabel }}
+
+
实际盘点人
+
+ {{ scopeContextLoading ? '计算中...' : `${scopeContext?.participants_count || 0} 人` }} +
+
截止
{{ formattedDeadline }}
+
+ + {{ scopeParticipantSummaryLabel }} + +
+ + {{ participant.person_nickname || participant.person_name }} + + · {{ participant.department_name }} + + +
+
+

正在计算...