feat: 收敛盘点范围筛选
This commit is contained in:
parent
6e1441f7a4
commit
3e05835408
@ -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(
|
||||
{
|
||||
|
||||
@ -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)
|
||||
|
||||
157
backend/src/services/__tests__/inventoryScopeContext.test.ts
Normal file
157
backend/src/services/__tests__/inventoryScopeContext.test.ts
Normal file
@ -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
|
||||
}
|
||||
})
|
||||
@ -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<string, any> = {
|
||||
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<number, { id: number; parent_id: number | null }>
|
||||
) => {
|
||||
let current = equipmentMap.get(equipmentId) || null
|
||||
const visitedIds = new Set<number>()
|
||||
|
||||
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<ScopeRootAsset[]> => {
|
||||
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<number, ScopeEntity>()
|
||||
|
||||
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<number, FrozenParticipantDraft>()
|
||||
const items: FrozenItemDraft[] = []
|
||||
const includedRootAssetIds = new Set<number>()
|
||||
|
||||
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,
|
||||
|
||||
@ -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<ApiResponse<InventoryScopeContext>>('/inventory/tasks/scope-context', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const createInventoryTask = async (data: CreateInventoryTaskPayload) => {
|
||||
const response = await api.post<ApiResponse<InventoryTaskListItem>>('/inventory/tasks', data)
|
||||
return response.data
|
||||
|
||||
@ -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
|
||||
|
||||
@ -119,6 +119,7 @@
|
||||
/>
|
||||
<input
|
||||
id="inventory-person-search"
|
||||
ref="personSearchInput"
|
||||
v-model.trim="personKeyword"
|
||||
type="search"
|
||||
class="input !pl-10"
|
||||
@ -240,7 +241,16 @@
|
||||
<span v-if="extraFilterCount" class="badge badge-neutral">{{ extraFilterCount }}</span>
|
||||
</summary>
|
||||
|
||||
<div class="mt-4 grid gap-6 border-t border-border pt-4 lg:grid-cols-2">
|
||||
<p v-if="!hasCompletePrimaryScope" class="mt-4 border-t border-border pt-4 text-xs text-text-tertiary">
|
||||
先选择盘点对象
|
||||
</p>
|
||||
<p v-else-if="scopeContextLoading" class="mt-4 border-t border-border pt-4 text-xs text-text-tertiary">
|
||||
正在同步可选资产...
|
||||
</p>
|
||||
<p v-else-if="scopeContextError" class="mt-4 border-t border-border pt-4 text-xs text-status-error">
|
||||
{{ scopeContextError }}
|
||||
</p>
|
||||
<div v-else class="mt-4 grid gap-6 border-t border-border pt-4 lg:grid-cols-2">
|
||||
<section class="space-y-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<p class="text-sm font-semibold text-text-primary">资产类型</p>
|
||||
@ -274,7 +284,7 @@
|
||||
</div>
|
||||
</button>
|
||||
<p v-if="filteredAssetTypes.length === 0" class="py-4 text-center text-xs text-text-tertiary">
|
||||
没有匹配的资产类型
|
||||
当前范围没有可筛选的主资产类型
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@ -312,7 +322,7 @@
|
||||
</div>
|
||||
</button>
|
||||
<p v-if="filteredStorageLocations.length === 0" class="py-4 text-center text-xs text-text-tertiary">
|
||||
没有匹配的地区
|
||||
当前范围没有可筛选的地区
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
@ -329,12 +339,39 @@
|
||||
<dt class="shrink-0 text-text-tertiary">范围</dt>
|
||||
<dd class="text-right font-medium text-text-primary">{{ currentScopeLabel }}</dd>
|
||||
</div>
|
||||
<div v-if="hasCompletePrimaryScope" class="flex items-start justify-between gap-4 py-3 text-sm">
|
||||
<dt class="shrink-0 text-text-tertiary">实际盘点人</dt>
|
||||
<dd class="text-right text-text-primary">
|
||||
{{ scopeContextLoading ? '计算中...' : `${scopeContext?.participants_count || 0} 人` }}
|
||||
</dd>
|
||||
</div>
|
||||
<div class="flex items-start justify-between gap-4 py-3 text-sm">
|
||||
<dt class="shrink-0 text-text-tertiary">截止</dt>
|
||||
<dd class="text-right text-text-primary">{{ formattedDeadline }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<details
|
||||
v-if="displayedScopeParticipants.length"
|
||||
class="rounded-lg border border-border bg-bg-elevated px-3 py-3"
|
||||
>
|
||||
<summary class="cursor-pointer text-sm font-medium text-text-primary">
|
||||
{{ scopeParticipantSummaryLabel }}
|
||||
</summary>
|
||||
<div class="mt-3 flex max-h-48 flex-wrap gap-2 overflow-auto border-t border-border pt-3">
|
||||
<span
|
||||
v-for="participant in displayedScopeParticipants"
|
||||
:key="participant.person_id"
|
||||
class="badge badge-neutral"
|
||||
>
|
||||
{{ participant.person_nickname || participant.person_name }}
|
||||
<span v-if="participant.department_name" class="ml-1 text-text-tertiary">
|
||||
· {{ participant.department_name }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<p v-if="previewLoading" class="text-xs text-text-tertiary">正在计算...</p>
|
||||
|
||||
<template v-if="preview && !previewDirty">
|
||||
@ -497,7 +534,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import {
|
||||
AdjustmentsHorizontalIcon,
|
||||
@ -507,12 +544,15 @@ import {
|
||||
MagnifyingGlassIcon,
|
||||
UsersIcon
|
||||
} from '@heroicons/vue/24/outline'
|
||||
import { getAssetTypeOptions } from '@/api/assetType'
|
||||
import { getDepartmentOptions } from '@/api/department'
|
||||
import { createInventoryTask, previewInventoryTask } from '@/api/inventory'
|
||||
import { createInventoryTask, getInventoryScopeContext, previewInventoryTask } from '@/api/inventory'
|
||||
import { getPersonOptions, type PersonOption } from '@/api/person'
|
||||
import { getStorageLocationOptions } from '@/api/storageLocation'
|
||||
import type { AssetType, Department, InventoryTaskPreview, StorageLocation } from '@/types'
|
||||
import type {
|
||||
Department,
|
||||
InventoryScopeContext,
|
||||
InventoryScopeEntity,
|
||||
InventoryTaskPreview
|
||||
} from '@/types'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
|
||||
type ScopeMode = 'people' | 'departments' | 'all'
|
||||
@ -521,25 +561,29 @@ type FilterKey = 'department_ids' | 'asset_type_ids' | 'storage_location_ids'
|
||||
const router = useRouter()
|
||||
|
||||
const departments = ref<Department[]>([])
|
||||
const assetTypes = ref<AssetType[]>([])
|
||||
const storageLocations = ref<StorageLocation[]>([])
|
||||
const assetTypes = ref<InventoryScopeEntity[]>([])
|
||||
const storageLocations = ref<InventoryScopeEntity[]>([])
|
||||
const personOptions = ref<PersonOption[]>([])
|
||||
const selectedPeople = ref<PersonOption[]>([])
|
||||
const scopeMode = ref<ScopeMode | null>(null)
|
||||
const scopeContext = ref<InventoryScopeContext | null>(null)
|
||||
const personKeyword = ref('')
|
||||
const departmentKeyword = ref('')
|
||||
const assetTypeKeyword = ref('')
|
||||
const storageLocationKeyword = ref('')
|
||||
const personLoading = ref(false)
|
||||
const scopeContextLoading = ref(false)
|
||||
const preview = ref<InventoryTaskPreview | null>(null)
|
||||
const previewLoading = ref(false)
|
||||
const creating = ref(false)
|
||||
const showConfirm = ref(false)
|
||||
const optionsError = ref('')
|
||||
const personError = ref('')
|
||||
const scopeContextError = ref('')
|
||||
const previewError = ref('')
|
||||
const submitError = ref('')
|
||||
const previewFingerprint = ref('')
|
||||
const personSearchInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const toLocalDateTimeInput = (date: Date) => {
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
@ -582,17 +626,13 @@ const filteredDepartments = computed(() => {
|
||||
const filteredAssetTypes = computed(() => {
|
||||
const keyword = normalizeKeyword(assetTypeKeyword.value)
|
||||
if (!keyword) return assetTypes.value
|
||||
return assetTypes.value.filter((assetType) =>
|
||||
includesKeyword(assetType.name, keyword) || includesKeyword(assetType.short_name, keyword)
|
||||
)
|
||||
return assetTypes.value.filter((assetType) => includesKeyword(assetType.name, keyword))
|
||||
})
|
||||
|
||||
const filteredStorageLocations = computed(() => {
|
||||
const keyword = normalizeKeyword(storageLocationKeyword.value)
|
||||
if (!keyword) return storageLocations.value
|
||||
return storageLocations.value.filter((location) =>
|
||||
includesKeyword(location.name, keyword) || includesKeyword(location.code, keyword)
|
||||
)
|
||||
return storageLocations.value.filter((location) => includesKeyword(location.name, keyword))
|
||||
})
|
||||
|
||||
const hasCompletePrimaryScope = computed(() => {
|
||||
@ -626,6 +666,25 @@ const currentScopeLabel = computed(() => {
|
||||
return [primaryLabel, ...filters].join(' · ')
|
||||
})
|
||||
|
||||
const displayedScopeParticipants = computed(() => {
|
||||
if (scopeMode.value === 'people') {
|
||||
return selectedPeople.value.map((person) => ({
|
||||
person_id: person.id,
|
||||
person_name: person.real_name,
|
||||
person_nickname: person.nickname || person.real_name,
|
||||
department_id: person.main_department?.id || null,
|
||||
department_name: person.main_department?.name || person.department || null,
|
||||
dingtalk_ready: person.dingtalk_ready
|
||||
}))
|
||||
}
|
||||
return scopeContext.value?.participants || []
|
||||
})
|
||||
|
||||
const scopeParticipantSummaryLabel = computed(() => {
|
||||
if (scopeMode.value === 'people') return `已选人员 · ${displayedScopeParticipants.value.length} 人`
|
||||
return `实际盘点人员 · ${displayedScopeParticipants.value.length} 人`
|
||||
})
|
||||
|
||||
const deadlineError = computed(() => {
|
||||
if (!form.deadline_at) return ''
|
||||
const deadline = new Date(form.deadline_at)
|
||||
@ -688,6 +747,10 @@ const selectScopeMode = (mode: ScopeMode) => {
|
||||
personOptions.value = []
|
||||
form.department_ids = []
|
||||
departmentKeyword.value = ''
|
||||
form.asset_type_ids = []
|
||||
form.storage_location_ids = []
|
||||
assetTypeKeyword.value = ''
|
||||
storageLocationKeyword.value = ''
|
||||
personError.value = ''
|
||||
previewError.value = ''
|
||||
submitError.value = ''
|
||||
@ -715,6 +778,12 @@ const togglePerson = (person: PersonOption) => {
|
||||
form.person_ids = [...form.person_ids, person.id].sort((left, right) => left - right)
|
||||
selectedPeople.value = [...selectedPeople.value, person]
|
||||
.sort((left, right) => (left.nickname || left.real_name).localeCompare(right.nickname || right.real_name, 'zh-CN'))
|
||||
personSearchVersion += 1
|
||||
personKeyword.value = ''
|
||||
personOptions.value = []
|
||||
personLoading.value = false
|
||||
personError.value = ''
|
||||
void nextTick(() => personSearchInput.value?.focus())
|
||||
}
|
||||
|
||||
let personSearchTimer: number | null = null
|
||||
@ -754,24 +823,67 @@ const searchPeople = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
let scopeContextRequestVersion = 0
|
||||
|
||||
const resetScopeContext = () => {
|
||||
scopeContextRequestVersion += 1
|
||||
scopeContext.value = null
|
||||
assetTypes.value = []
|
||||
storageLocations.value = []
|
||||
form.asset_type_ids = []
|
||||
form.storage_location_ids = []
|
||||
scopeContextLoading.value = false
|
||||
scopeContextError.value = ''
|
||||
}
|
||||
|
||||
const syncScopeContext = async () => {
|
||||
if (!hasCompletePrimaryScope.value) {
|
||||
resetScopeContext()
|
||||
return
|
||||
}
|
||||
|
||||
const requestFingerprint = currentScopeFingerprint.value
|
||||
const requestVersion = ++scopeContextRequestVersion
|
||||
scopeContextLoading.value = true
|
||||
scopeContextError.value = ''
|
||||
scopeContext.value = null
|
||||
assetTypes.value = []
|
||||
storageLocations.value = []
|
||||
|
||||
try {
|
||||
const response = await getInventoryScopeContext(buildPreviewPayload())
|
||||
if (requestVersion !== scopeContextRequestVersion || requestFingerprint !== currentScopeFingerprint.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const context = response.data
|
||||
scopeContext.value = context
|
||||
assetTypes.value = context.asset_types || []
|
||||
storageLocations.value = context.storage_locations || []
|
||||
|
||||
const validAssetTypeIds = new Set(assetTypes.value.map((assetType) => assetType.id))
|
||||
const validStorageLocationIds = new Set(storageLocations.value.map((location) => location.id))
|
||||
form.asset_type_ids = form.asset_type_ids.filter((id) => validAssetTypeIds.has(id))
|
||||
form.storage_location_ids = form.storage_location_ids.filter((id) => validStorageLocationIds.has(id))
|
||||
} catch (error: any) {
|
||||
if (requestVersion === scopeContextRequestVersion && requestFingerprint === currentScopeFingerprint.value) {
|
||||
scopeContextError.value = error?.message || '同步可选资产失败,请重试。'
|
||||
}
|
||||
} finally {
|
||||
if (requestVersion === scopeContextRequestVersion) {
|
||||
scopeContextLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const loadOptions = async () => {
|
||||
optionsError.value = ''
|
||||
try {
|
||||
const [departmentResponse, assetTypeResponse, storageLocationResponse] = await Promise.all([
|
||||
getDepartmentOptions(),
|
||||
getAssetTypeOptions(),
|
||||
getStorageLocationOptions()
|
||||
])
|
||||
const departmentResponse = await getDepartmentOptions()
|
||||
|
||||
departments.value = (departmentResponse.data || [])
|
||||
.filter((department) => department.status === 'active')
|
||||
.sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'))
|
||||
assetTypes.value = (assetTypeResponse.data || [])
|
||||
.filter((assetType) => assetType.status === 'active')
|
||||
.sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'))
|
||||
storageLocations.value = (storageLocationResponse.data || [])
|
||||
.filter((location) => location.status === 'active')
|
||||
.sort((left, right) => left.name.localeCompare(right.name, 'zh-CN'))
|
||||
} catch (error: any) {
|
||||
optionsError.value = error?.message || '加载盘点范围选项失败,请刷新页面重试。'
|
||||
}
|
||||
@ -855,6 +967,7 @@ watch(personKeyword, () => {
|
||||
watch(currentScopeFingerprint, () => {
|
||||
previewError.value = ''
|
||||
submitError.value = ''
|
||||
void syncScopeContext()
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
@ -864,6 +977,7 @@ onMounted(() => {
|
||||
|
||||
onUnmounted(() => {
|
||||
if (personSearchTimer) window.clearTimeout(personSearchTimer)
|
||||
scopeContextRequestVersion += 1
|
||||
window.removeEventListener('keydown', handleEscape)
|
||||
})
|
||||
</script>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user