feat: 收敛盘点范围筛选

This commit is contained in:
WuYongJie 2026-07-17 16:54:21 +08:00
parent 6e1441f7a4
commit 3e05835408
7 changed files with 500 additions and 67 deletions

View File

@ -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(
{

View File

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

View 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
}
})

View File

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

View File

@ -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

View File

@ -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

View File

@ -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>