feat: 全量开放钉钉登录并升级资产盘点
This commit is contained in:
parent
2e316a7104
commit
c29751059e
@ -0,0 +1,11 @@
|
||||
-- 盘点任务增加截止时间,供钉钉派发和移动端明确展示。
|
||||
ALTER TABLE `inventory_task`
|
||||
ADD COLUMN `deadline_at` DATETIME(3) NULL AFTER `created_by`,
|
||||
ADD COLUMN `identity_mode` VARCHAR(20) NOT NULL DEFAULT 'legacy' AFTER `status`;
|
||||
|
||||
-- 历史盘点链接继续兼容手工确认身份;本次发布后新任务默认强制使用钉钉身份。
|
||||
ALTER TABLE `inventory_task`
|
||||
MODIFY COLUMN `identity_mode` VARCHAR(20) NOT NULL DEFAULT 'dingtalk';
|
||||
|
||||
CREATE INDEX `inventory_task_status_deadline_at_idx`
|
||||
ON `inventory_task`(`status`, `deadline_at`);
|
||||
@ -449,6 +449,7 @@ model InventoryTask {
|
||||
title String @db.VarChar(150)
|
||||
description String? @db.Text
|
||||
status String @default("active") @db.VarChar(20)
|
||||
identity_mode String @default("dingtalk") @db.VarChar(20)
|
||||
public_token String @unique @db.VarChar(120)
|
||||
scope_department_id Int?
|
||||
scope_department_name String? @db.VarChar(100)
|
||||
@ -461,6 +462,7 @@ model InventoryTask {
|
||||
exception_count Int @default(0)
|
||||
unassigned_count Int @default(0)
|
||||
created_by Int
|
||||
deadline_at DateTime?
|
||||
closed_at DateTime?
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @updatedAt
|
||||
@ -476,6 +478,7 @@ model InventoryTask {
|
||||
@@index([scope_department_id, status])
|
||||
@@index([scope_asset_type_id, status])
|
||||
@@index([created_by])
|
||||
@@index([status, deadline_at])
|
||||
@@map("inventory_task")
|
||||
}
|
||||
|
||||
|
||||
@ -6,6 +6,8 @@ import { loginWithYouyidianTicket } from '../dingtalkController'
|
||||
const originalFetch = global.fetch
|
||||
const originalFindFirst = prisma.user.findFirst
|
||||
const originalFindUnique = prisma.user.findUnique
|
||||
const originalUserUpdate = prisma.user.update
|
||||
const originalPersonFindUnique = prisma.person.findUnique
|
||||
const envKeys = [
|
||||
'YYD_NOTIFICATION_CENTER_URL',
|
||||
'YYD_NOTIFICATION_PROJECT_CODE',
|
||||
@ -75,12 +77,17 @@ test.after(() => {
|
||||
global.fetch = originalFetch
|
||||
;(prisma.user.findFirst as any) = originalFindFirst
|
||||
;(prisma.user.findUnique as any) = originalFindUnique
|
||||
;(prisma.user.update as any) = originalUserUpdate
|
||||
;(prisma.person.findUnique as any) = originalPersonFindUnique
|
||||
for (const key of envKeys) {
|
||||
if (originalEnv[key] === undefined) delete process.env[key]
|
||||
else process.env[key] = originalEnv[key]
|
||||
}
|
||||
})
|
||||
|
||||
;(prisma.user.update as any) = async (args: any) => ({ id: args.where.id })
|
||||
;(prisma.person.findUnique as any) = async () => null
|
||||
|
||||
test('一次性票据兑换成功后建立绑定userid的会话并303进入内部完成页', async () => {
|
||||
configureLogin()
|
||||
let capturedUrl = ''
|
||||
@ -128,7 +135,7 @@ test('一次性票据兑换成功后建立绑定userid的会话并303进入内
|
||||
assert.equal(capturedUrl.includes('A'.repeat(43)), false)
|
||||
})
|
||||
|
||||
test('票据用户未绑定启用且在职账号时不建立会话', async () => {
|
||||
test('票据用户不在启用花名册时不建立会话', async () => {
|
||||
configureLogin()
|
||||
global.fetch = (async () => new Response(JSON.stringify({
|
||||
dingtalkUserId: 'ding-user-1',
|
||||
@ -147,7 +154,7 @@ test('票据用户未绑定启用且在职账号时不建立会话', async () =>
|
||||
assert.equal(userLookups, 1)
|
||||
assert.equal(req.session.user, undefined)
|
||||
assert.equal(state.statusCode, 403)
|
||||
assert.match(state.body, /未绑定启用且在职/)
|
||||
assert.match(state.body, /未匹配到启用且在职/)
|
||||
})
|
||||
|
||||
test('通知中心返回外部跳转地址时按服务异常拒绝建立会话', async () => {
|
||||
|
||||
@ -11,6 +11,10 @@ test('钉钉免登只接受白名单内的系统路径', () => {
|
||||
'/workflows/disposals/123?source=ding'
|
||||
)
|
||||
assert.equal(normalizeInternalRedirect('/my-assets'), '/my-assets')
|
||||
assert.equal(
|
||||
normalizeInternalRedirect('/inventory/task/secure-public-token'),
|
||||
'/inventory/task/secure-public-token'
|
||||
)
|
||||
})
|
||||
|
||||
test('钉钉免登拒绝外链、协议相对地址和非白名单路径', () => {
|
||||
|
||||
@ -55,6 +55,7 @@ test('业务人员选项只返回工作所需字段,不泄露联系方式和
|
||||
assert.equal(capturedArgs.skip, 20)
|
||||
assert.equal(capturedArgs.take, 20)
|
||||
assert.equal(JSON.stringify(capturedArgs.where).includes('魔礼红'), true)
|
||||
assert.equal(JSON.stringify(capturedArgs.where).includes('__public_asset__'), true)
|
||||
|
||||
const option = state.body.data.list[0]
|
||||
assert.equal(option.dingtalk_ready, true)
|
||||
|
||||
@ -12,7 +12,7 @@ import {
|
||||
prepareNotificationCenterResumePayload,
|
||||
redeemYouyidianLoginTicket
|
||||
} from '../services/youyidianNotificationClient'
|
||||
import { findEligibleDingTalkLoginUser } from '../services/dingtalkBrokerLoginService'
|
||||
import { findOrProvisionEligibleDingTalkLoginUser } from '../services/dingtalkBrokerLoginService'
|
||||
import {
|
||||
buildDingTalkBrokerCompletionPath,
|
||||
normalizeInternalRedirect
|
||||
@ -101,12 +101,16 @@ export const loginWithYouyidianTicket = async (req: Request, res: Response) => {
|
||||
if (!isNotificationCenterLoginAllowedForUser(redeemed.dingtalkUserId)) {
|
||||
return sendBrokerLoginError(res, 403, '当前钉钉账号不在本次免登灰度范围内,请使用原登录方式。')
|
||||
}
|
||||
const user = await findEligibleDingTalkLoginUser(redeemed.dingtalkUserId)
|
||||
const user = await findOrProvisionEligibleDingTalkLoginUser(redeemed.dingtalkUserId)
|
||||
if (!user) {
|
||||
return sendBrokerLoginError(res, 403, '当前钉钉账号未绑定启用且在职的固定资产系统账号,请联系管理员。')
|
||||
return sendBrokerLoginError(res, 403, '当前钉钉账号未匹配到启用且在职的花名册人员,请联系管理员。')
|
||||
}
|
||||
|
||||
await resolveAuthUserById(user.id)
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { last_login_at: new Date() }
|
||||
})
|
||||
await establishUserSession(req, user.id, 'dingtalk', redeemed.dingtalkUserId)
|
||||
return res.redirect(303, buildDingTalkBrokerCompletionPath(safeRedirect))
|
||||
} catch (error: any) {
|
||||
|
||||
@ -27,7 +27,9 @@ export const previewInventoryTaskController = async (req: Request, res: Response
|
||||
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
|
||||
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)
|
||||
successResponse(res, result, '预览成功')
|
||||
@ -49,16 +51,20 @@ export const createInventoryTaskController = async (req: Request, res: Response)
|
||||
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
|
||||
storage_location_ids: req.body?.storage_location_ids,
|
||||
person_id: toOptionalNumber(req.body?.person_id),
|
||||
person_ids: req.body?.person_ids
|
||||
})
|
||||
|
||||
const result = await createInventoryTask(
|
||||
{
|
||||
title: req.body?.title,
|
||||
description: req.body?.description,
|
||||
deadline_at: req.body?.deadline_at,
|
||||
department_ids: normalizedScope.departmentIds,
|
||||
asset_type_ids: normalizedScope.assetTypeIds,
|
||||
storage_location_ids: normalizedScope.storageLocationIds
|
||||
storage_location_ids: normalizedScope.storageLocationIds,
|
||||
person_ids: normalizedScope.personIds
|
||||
},
|
||||
currentUserId
|
||||
)
|
||||
@ -187,9 +193,9 @@ export const submitAdminInventoryScanController = async (req: Request, res: Resp
|
||||
}
|
||||
|
||||
const item = await submitAdminInventoryScan(taskId, equipmentCode, currentUserId)
|
||||
successResponse(res, item, '管理员代扫成功')
|
||||
successResponse(res, item, '管理人员代扫成功')
|
||||
} catch (error: any) {
|
||||
const message = error.message || '管理员扫码盘点失败'
|
||||
const message = error.message || '管理人员扫码盘点失败'
|
||||
errorResponse(
|
||||
res,
|
||||
message,
|
||||
|
||||
@ -2,7 +2,10 @@ import { Request, Response } from 'express'
|
||||
import prisma from '../utils/prisma'
|
||||
import { successResponse, errorResponse } from '../utils/response'
|
||||
import { buildSyncedBusinessDepartmentWhere } from '../utils/organization'
|
||||
import { isSystemReservedPerson } from '../utils/systemReservedPerson'
|
||||
import {
|
||||
isSystemReservedPerson,
|
||||
PUBLIC_ASSET_PERSON_SOURCE_USERID
|
||||
} from '../utils/systemReservedPerson'
|
||||
|
||||
const departmentSelect = {
|
||||
id: true,
|
||||
@ -510,7 +513,17 @@ export const getPersonOptions = async (req: Request, res: Response) => {
|
||||
const requestedPageSize = req.query.page_size ?? req.query.pageSize
|
||||
const pageSize = Math.min(parsePositiveInteger(requestedPageSize, 50), 100)
|
||||
const status = includeInactive ? undefined : (req.query.status || 'active')
|
||||
const where = buildPersonWhere(req.query.keyword, status)
|
||||
const where = {
|
||||
AND: [
|
||||
buildPersonWhere(req.query.keyword, status),
|
||||
{
|
||||
OR: [
|
||||
{ source_userid: null },
|
||||
{ source_userid: { not: PUBLIC_ASSET_PERSON_SOURCE_USERID } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
const [persons, total] = await Promise.all([
|
||||
prisma.person.findMany({
|
||||
where,
|
||||
|
||||
@ -1,7 +1,10 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import { errorResponse, successResponse } from '../utils/response'
|
||||
import { resolveAuthUserById } from '../utils/authIdentity'
|
||||
import { findEligibleDingTalkSessionUser } from '../services/dingtalkBrokerLoginService'
|
||||
import {
|
||||
confirmInventoryParticipant,
|
||||
confirmLegacyInventoryParticipant,
|
||||
confirmInventoryParticipantByPerson,
|
||||
getInventoryTaskPublicInfo,
|
||||
getParticipantItems,
|
||||
getParticipantTaskContext,
|
||||
@ -12,7 +15,7 @@ import {
|
||||
|
||||
const INVENTORY_SESSION_KEY = 'inventoryParticipant'
|
||||
|
||||
const getInventorySession = (req: Request) => ((req.session as any)?.[INVENTORY_SESSION_KEY] || null) as
|
||||
const getInventorySession = (req: Request) => (((req as any).session as any)?.[INVENTORY_SESSION_KEY] || null) as
|
||||
| {
|
||||
taskId: number
|
||||
participantId: number
|
||||
@ -28,12 +31,38 @@ const setInventorySession = (
|
||||
publicToken: string
|
||||
}
|
||||
) => {
|
||||
;(req.session as any)[INVENTORY_SESSION_KEY] = payload
|
||||
;((req as any).session as any)[INVENTORY_SESSION_KEY] = payload
|
||||
}
|
||||
|
||||
const clearInventorySession = (req: Request) => {
|
||||
if ((req.session as any)?.[INVENTORY_SESSION_KEY]) {
|
||||
delete (req.session as any)[INVENTORY_SESSION_KEY]
|
||||
if (((req as any).session as any)?.[INVENTORY_SESSION_KEY]) {
|
||||
delete ((req as any).session as any)[INVENTORY_SESSION_KEY]
|
||||
}
|
||||
}
|
||||
|
||||
const ensureCurrentInventoryIdentity = async (req: Request, participantPersonId: number | null) => {
|
||||
const sessionUser = ((req as any).session as any)?.user
|
||||
const userId = Number(sessionUser?.id)
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
throw new Error('请重新使用钉钉登录后进入盘点任务')
|
||||
}
|
||||
|
||||
if (sessionUser?.auth_source === 'dingtalk') {
|
||||
const dingtalkUserId = String(sessionUser?.dingtalk_userid || '').trim()
|
||||
const eligibleUser = await findEligibleDingTalkSessionUser(userId, dingtalkUserId)
|
||||
if (!eligibleUser) {
|
||||
;((req as any).session as any).user = undefined
|
||||
throw new Error('钉钉身份或员工绑定已失效,请重新登录')
|
||||
}
|
||||
}
|
||||
|
||||
const currentUser = await resolveAuthUserById(userId)
|
||||
if (
|
||||
!participantPersonId ||
|
||||
currentUser.person_id !== participantPersonId ||
|
||||
currentUser.person?.status !== 'active'
|
||||
) {
|
||||
throw new Error('当前登录账号与盘点责任人不一致,请重新进入本人任务')
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,6 +78,15 @@ const ensureInventorySession = async (req: Request) => {
|
||||
throw new Error('盘点会话已失效,请重新确认身份')
|
||||
}
|
||||
|
||||
if (taskContext.task.identity_mode === 'dingtalk') {
|
||||
try {
|
||||
await ensureCurrentInventoryIdentity(req, taskContext.participant.person_id)
|
||||
} catch (error) {
|
||||
clearInventorySession(req)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
session,
|
||||
...taskContext
|
||||
@ -72,7 +110,7 @@ export const searchPublicInventoryParticipantsController = async (req: Request,
|
||||
successResponse(res, list, '获取成功')
|
||||
} catch (error: any) {
|
||||
const message = error.message || '搜索盘点人员失败'
|
||||
errorResponse(res, message, message.includes('不存在') ? 404 : 500)
|
||||
errorResponse(res, message, message.includes('不存在') ? 404 : message.includes('钉钉登录') ? 403 : 500)
|
||||
}
|
||||
}
|
||||
|
||||
@ -83,7 +121,7 @@ export const confirmPublicInventoryParticipantController = async (req: Request,
|
||||
return errorResponse(res, '请选择盘点人员', 422)
|
||||
}
|
||||
|
||||
const data = await confirmInventoryParticipant(req.params.token, participantId)
|
||||
const data = await confirmLegacyInventoryParticipant(req.params.token, participantId)
|
||||
setInventorySession(req, {
|
||||
taskId: data.task_id,
|
||||
participantId: data.participant_id,
|
||||
@ -92,7 +130,27 @@ export const confirmPublicInventoryParticipantController = async (req: Request,
|
||||
successResponse(res, data, '确认成功')
|
||||
} catch (error: any) {
|
||||
const message = error.message || '确认盘点身份失败'
|
||||
errorResponse(res, message, message.includes('不存在') ? 404 : 422)
|
||||
errorResponse(res, message, message.includes('不存在') ? 404 : message.includes('钉钉登录') ? 403 : 422)
|
||||
}
|
||||
}
|
||||
|
||||
export const confirmCurrentInventoryParticipantController = async (req: Request, res: Response) => {
|
||||
try {
|
||||
const personId = Number(res.locals.user?.person_id)
|
||||
const data = await confirmInventoryParticipantByPerson(req.params.token, personId)
|
||||
setInventorySession(req, {
|
||||
taskId: data.task_id,
|
||||
participantId: data.participant_id,
|
||||
publicToken: req.params.token
|
||||
})
|
||||
successResponse(res, data, '已按当前钉钉身份进入盘点任务')
|
||||
} catch (error: any) {
|
||||
const message = error.message || '确认当前盘点身份失败'
|
||||
errorResponse(
|
||||
res,
|
||||
message,
|
||||
message.includes('不存在') ? 404 : message.includes('未登录') ? 401 : 403
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -106,11 +164,13 @@ export const getPublicInventoryMeController = async (req: Request, res: Response
|
||||
id: task.id,
|
||||
title: task.title,
|
||||
status: task.status,
|
||||
identity_mode: task.identity_mode,
|
||||
scope_department_name: task.scope_department_name,
|
||||
scope_asset_type_name: task.scope_asset_type_name,
|
||||
completed_count: task.completed_count,
|
||||
exception_count: task.exception_count,
|
||||
items_count: task.items_count,
|
||||
deadline_at: task.deadline_at,
|
||||
closed_at: task.closed_at
|
||||
},
|
||||
participant
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { Router } from 'express'
|
||||
import {
|
||||
confirmPublicInventoryParticipantController,
|
||||
confirmCurrentInventoryParticipantController,
|
||||
getPublicInventoryItemsController,
|
||||
getPublicInventoryMeController,
|
||||
getPublicInventoryTaskController,
|
||||
@ -8,12 +9,14 @@ import {
|
||||
submitPublicInventoryExceptionController,
|
||||
submitPublicInventoryScanController
|
||||
} from '../controllers/publicInventoryController'
|
||||
import { protect } from '../middleware/auth'
|
||||
|
||||
const router = Router()
|
||||
|
||||
router.get('/tasks/:token', getPublicInventoryTaskController)
|
||||
router.get('/tasks/:token/participants', searchPublicInventoryParticipantsController)
|
||||
router.post('/tasks/:token/participants/confirm', confirmPublicInventoryParticipantController)
|
||||
router.post('/tasks/:token/participants/confirm-me', protect(), confirmCurrentInventoryParticipantController)
|
||||
router.get('/me', getPublicInventoryMeController)
|
||||
router.get('/me/items', getPublicInventoryItemsController)
|
||||
router.post('/me/scan', submitPublicInventoryScanController)
|
||||
|
||||
@ -0,0 +1,137 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import {
|
||||
ensureOrdinaryUserForActivePerson,
|
||||
provisionDingTalkLoginUser
|
||||
} from '../dingtalkUserProvisionService'
|
||||
|
||||
const activePerson = {
|
||||
id: 18,
|
||||
real_name: '测试姓名',
|
||||
nickname: '测试花名',
|
||||
source_userid: 'ding-user-sensitive'
|
||||
}
|
||||
|
||||
test('在职花名册人员首次钉钉登录只自动建立普通用户', async () => {
|
||||
let createdData: any = null
|
||||
const client = {
|
||||
person: {
|
||||
findUnique: async () => ({ id: activePerson.id, status: 'active' }),
|
||||
findFirst: async () => activePerson
|
||||
},
|
||||
user: {
|
||||
findUnique: async () => null,
|
||||
create: async (args: any) => {
|
||||
createdData = args.data
|
||||
return { id: 91, status: args.data.status, role: args.data.role }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const user = await provisionDingTalkLoginUser(activePerson.source_userid, client as any)
|
||||
|
||||
assert.equal(user?.id, 91)
|
||||
assert.equal(createdData.role, 'user')
|
||||
assert.equal(createdData.status, 'active')
|
||||
assert.equal(createdData.person_id, activePerson.id)
|
||||
assert.equal(createdData.name, activePerson.nickname)
|
||||
assert.match(createdData.username, /^ding_[a-f0-9]{32}$/)
|
||||
assert.equal(createdData.username.includes(activePerson.source_userid), false)
|
||||
assert.match(createdData.password, /^\$2[aby]\$/)
|
||||
})
|
||||
|
||||
test('已有管理员绑定时复用原角色,绝不降级或新建账号', async () => {
|
||||
let creates = 0
|
||||
const client = {
|
||||
person: {
|
||||
findFirst: async () => activePerson
|
||||
},
|
||||
user: {
|
||||
findUnique: async () => ({ id: 6, status: 'active', role: 'admin' }),
|
||||
create: async () => {
|
||||
creates += 1
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const user = await ensureOrdinaryUserForActivePerson(
|
||||
activePerson.id,
|
||||
activePerson.source_userid,
|
||||
client as any
|
||||
)
|
||||
|
||||
assert.deepEqual(user, { id: 6, status: 'active', role: 'admin' })
|
||||
assert.equal(creates, 0)
|
||||
})
|
||||
|
||||
test('离职人员或已停用绑定账号不能通过自动建号重新进入', async () => {
|
||||
const inactivePersonClient = {
|
||||
person: {
|
||||
findUnique: async () => ({ id: activePerson.id, status: 'inactive' })
|
||||
},
|
||||
user: {}
|
||||
}
|
||||
assert.equal(
|
||||
await provisionDingTalkLoginUser(activePerson.source_userid, inactivePersonClient as any),
|
||||
null
|
||||
)
|
||||
|
||||
const inactiveUserClient = {
|
||||
person: { findFirst: async () => activePerson },
|
||||
user: {
|
||||
findUnique: async () => ({ id: 7, status: 'inactive', role: 'user' })
|
||||
}
|
||||
}
|
||||
assert.equal(
|
||||
await ensureOrdinaryUserForActivePerson(activePerson.id, activePerson.source_userid, inactiveUserClient as any),
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
test('钉钉 userid 必须与花名册精确匹配', async () => {
|
||||
let userLookups = 0
|
||||
const client = {
|
||||
person: {
|
||||
findFirst: async () => null
|
||||
},
|
||||
user: {
|
||||
findUnique: async () => {
|
||||
userLookups += 1
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
await ensureOrdinaryUserForActivePerson(activePerson.id, 'another-ding-user', client as any),
|
||||
null
|
||||
)
|
||||
assert.equal(userLookups, 0)
|
||||
})
|
||||
|
||||
test('同一员工并发首次触达时唯一约束失败的一方复用已创建账号', async () => {
|
||||
let lookups = 0
|
||||
const client = {
|
||||
person: { findFirst: async () => activePerson },
|
||||
user: {
|
||||
findUnique: async () => {
|
||||
lookups += 1
|
||||
return lookups === 1 ? null : { id: 92, status: 'active', role: 'user' }
|
||||
},
|
||||
create: async () => {
|
||||
const error: any = new Error('unique')
|
||||
error.code = 'P2002'
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const user = await ensureOrdinaryUserForActivePerson(
|
||||
activePerson.id,
|
||||
activePerson.source_userid,
|
||||
client as any
|
||||
)
|
||||
assert.deepEqual(user, { id: 92, status: 'active', role: 'user' })
|
||||
assert.equal(lookups, 2)
|
||||
})
|
||||
136
backend/src/services/__tests__/inventoryIdentity.test.ts
Normal file
136
backend/src/services/__tests__/inventoryIdentity.test.ts
Normal file
@ -0,0 +1,136 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import prisma from '../../utils/prisma'
|
||||
import { getPublicInventoryMeController } from '../../controllers/publicInventoryController'
|
||||
import {
|
||||
confirmInventoryParticipantByPerson,
|
||||
searchTaskParticipants
|
||||
} from '../inventoryService'
|
||||
|
||||
test('已登录员工只能按绑定 person_id 进入自己的盘点清单', async () => {
|
||||
const originalTaskFindUnique = prisma.inventoryTask.findUnique
|
||||
const originalParticipantFindFirst = prisma.inventoryTaskParticipant.findFirst
|
||||
let participantWhere: any = null
|
||||
|
||||
try {
|
||||
;(prisma.inventoryTask.findUnique as any) = async () => ({ id: 33 })
|
||||
;(prisma.inventoryTaskParticipant.findFirst as any) = async (args: any) => {
|
||||
participantWhere = args.where
|
||||
return null
|
||||
}
|
||||
|
||||
await assert.rejects(
|
||||
confirmInventoryParticipantByPerson('public-task-token', 108),
|
||||
/当前登录人员不在本次盘点范围内/
|
||||
)
|
||||
assert.deepEqual(participantWhere, {
|
||||
task_id: 33,
|
||||
person_id: 108
|
||||
})
|
||||
} finally {
|
||||
;(prisma.inventoryTask.findUnique as any) = originalTaskFindUnique
|
||||
;(prisma.inventoryTaskParticipant.findFirst as any) = originalParticipantFindFirst
|
||||
}
|
||||
})
|
||||
|
||||
test('未绑定员工档案的系统账号不能自动确认盘点身份', async () => {
|
||||
await assert.rejects(
|
||||
confirmInventoryParticipantByPerson('public-task-token', 0),
|
||||
/未绑定员工档案/
|
||||
)
|
||||
})
|
||||
|
||||
test('新盘点任务拒绝通过共享链接搜索并冒充其他参与人', async () => {
|
||||
const originalTaskFindUnique = prisma.inventoryTask.findUnique
|
||||
const originalParticipantFindMany = prisma.inventoryTaskParticipant.findMany
|
||||
let participantQueries = 0
|
||||
|
||||
try {
|
||||
;(prisma.inventoryTask.findUnique as any) = async () => ({
|
||||
id: 33,
|
||||
status: 'active',
|
||||
identity_mode: 'dingtalk'
|
||||
})
|
||||
;(prisma.inventoryTaskParticipant.findMany as any) = async () => {
|
||||
participantQueries += 1
|
||||
return []
|
||||
}
|
||||
|
||||
await assert.rejects(
|
||||
searchTaskParticipants('public-task-token', '其他员工'),
|
||||
/必须使用钉钉登录/
|
||||
)
|
||||
assert.equal(participantQueries, 0)
|
||||
} finally {
|
||||
;(prisma.inventoryTask.findUnique as any) = originalTaskFindUnique
|
||||
;(prisma.inventoryTaskParticipant.findMany as any) = originalParticipantFindMany
|
||||
}
|
||||
})
|
||||
|
||||
test('新盘点任务后续请求持续核验当前账号与盘点责任人一致', async () => {
|
||||
const originalTaskFindUnique = prisma.inventoryTask.findUnique
|
||||
const originalParticipantFindFirst = prisma.inventoryTaskParticipant.findFirst
|
||||
const originalUserFindUnique = prisma.user.findUnique
|
||||
|
||||
try {
|
||||
;(prisma.inventoryTask.findUnique as any) = async () => ({
|
||||
id: 33,
|
||||
public_token: 'public-task-token',
|
||||
identity_mode: 'dingtalk',
|
||||
status: 'active'
|
||||
})
|
||||
;(prisma.inventoryTaskParticipant.findFirst as any) = async () => ({
|
||||
id: 77,
|
||||
task_id: 33,
|
||||
person_id: 108
|
||||
})
|
||||
;(prisma.user.findUnique as any) = async () => ({
|
||||
id: 9,
|
||||
username: 'bound-user',
|
||||
name: '绑定用户',
|
||||
role: 'user',
|
||||
status: 'active',
|
||||
person_id: 109,
|
||||
person: {
|
||||
id: 109,
|
||||
real_name: '其他员工',
|
||||
nickname: '其他员工',
|
||||
status: 'active',
|
||||
source_employee_status: '在职',
|
||||
source_userid: 'ding-other-user'
|
||||
}
|
||||
})
|
||||
|
||||
const state = { status: 200, body: null as any }
|
||||
const response = {
|
||||
status(code: number) {
|
||||
state.status = code
|
||||
return this
|
||||
},
|
||||
json(body: any) {
|
||||
state.body = body
|
||||
return this
|
||||
}
|
||||
}
|
||||
const request = {
|
||||
session: {
|
||||
user: { id: 9 },
|
||||
inventoryParticipant: {
|
||||
taskId: 33,
|
||||
participantId: 77,
|
||||
publicToken: 'public-task-token'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await getPublicInventoryMeController(request as any, response as any)
|
||||
|
||||
assert.equal(state.status, 401)
|
||||
assert.match(state.body.message, /当前登录账号与盘点责任人不一致/)
|
||||
assert.equal((request.session as any).inventoryParticipant, undefined)
|
||||
} finally {
|
||||
;(prisma.inventoryTask.findUnique as any) = originalTaskFindUnique
|
||||
;(prisma.inventoryTaskParticipant.findFirst as any) = originalParticipantFindFirst
|
||||
;(prisma.user.findUnique as any) = originalUserFindUnique
|
||||
}
|
||||
})
|
||||
@ -1,6 +1,8 @@
|
||||
import prisma from '../utils/prisma'
|
||||
import { provisionDingTalkLoginUser } from './dingtalkUserProvisionService'
|
||||
|
||||
type UserLookupClient = Pick<typeof prisma, 'user'>
|
||||
type LoginProvisionClient = Pick<typeof prisma, 'user' | 'person'>
|
||||
|
||||
export const findEligibleDingTalkLoginUser = async (
|
||||
dingtalkUserId: string,
|
||||
@ -47,3 +49,12 @@ export const findEligibleDingTalkSessionUser = async (
|
||||
select: { id: true }
|
||||
})
|
||||
}
|
||||
|
||||
export const findOrProvisionEligibleDingTalkLoginUser = async (
|
||||
dingtalkUserId: string,
|
||||
client: LoginProvisionClient = prisma
|
||||
) => {
|
||||
const existing = await findEligibleDingTalkLoginUser(dingtalkUserId, client)
|
||||
if (existing) return existing
|
||||
return provisionDingTalkLoginUser(dingtalkUserId, client as any)
|
||||
}
|
||||
|
||||
95
backend/src/services/dingtalkUserProvisionService.ts
Normal file
95
backend/src/services/dingtalkUserProvisionService.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import { createHash, randomBytes } from 'crypto'
|
||||
import bcrypt from 'bcryptjs'
|
||||
import type { Prisma } from '@prisma/client'
|
||||
import prisma from '../utils/prisma'
|
||||
import { isUserRole } from '../utils/permissions'
|
||||
|
||||
type ProvisionClient = Prisma.TransactionClient | typeof prisma
|
||||
|
||||
let disabledLocalPasswordHash: Promise<string> | null = null
|
||||
|
||||
const getDisabledLocalPasswordHash = () => {
|
||||
if (!disabledLocalPasswordHash) {
|
||||
// 钉钉自动建立的普通账号没有可用的本地密码。随机明文不会保存,
|
||||
// 因此不能通过用户名/密码绕过钉钉身份校验。
|
||||
disabledLocalPasswordHash = bcrypt.hash(randomBytes(48).toString('base64url'), 10)
|
||||
}
|
||||
return disabledLocalPasswordHash
|
||||
}
|
||||
|
||||
const buildDingTalkUsername = (dingtalkUserId: string) =>
|
||||
`ding_${createHash('sha256').update(dingtalkUserId).digest('hex').slice(0, 32)}`
|
||||
|
||||
const isEligibleExistingUser = (user: { status: string; role: string } | null) =>
|
||||
Boolean(user && user.status === 'active' && isUserRole(user.role))
|
||||
|
||||
export const ensureOrdinaryUserForActivePerson = async (
|
||||
personId: number,
|
||||
expectedDingTalkUserId?: string,
|
||||
client: ProvisionClient = prisma
|
||||
) => {
|
||||
if (!Number.isInteger(personId) || personId <= 0) return null
|
||||
|
||||
const person = await client.person.findFirst({
|
||||
where: {
|
||||
id: personId,
|
||||
status: 'active',
|
||||
source_userid: expectedDingTalkUserId?.trim() || { not: null }
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
real_name: true,
|
||||
nickname: true,
|
||||
source_userid: true
|
||||
}
|
||||
})
|
||||
const dingtalkUserId = person?.source_userid?.trim() || ''
|
||||
if (!person || !dingtalkUserId) return null
|
||||
if (expectedDingTalkUserId && dingtalkUserId !== expectedDingTalkUserId.trim()) return null
|
||||
|
||||
const existing = await client.user.findUnique({
|
||||
where: { person_id: person.id },
|
||||
select: { id: true, status: true, role: true }
|
||||
})
|
||||
if (existing) return isEligibleExistingUser(existing) ? existing : null
|
||||
|
||||
try {
|
||||
const created = await client.user.create({
|
||||
data: {
|
||||
username: buildDingTalkUsername(dingtalkUserId),
|
||||
password: await getDisabledLocalPasswordHash(),
|
||||
name: (person.nickname?.trim() || person.real_name.trim()).slice(0, 50),
|
||||
role: 'user',
|
||||
status: 'active',
|
||||
person_id: person.id
|
||||
},
|
||||
select: { id: true, status: true, role: true }
|
||||
})
|
||||
return created
|
||||
} catch (error: any) {
|
||||
if (error?.code !== 'P2002') throw error
|
||||
|
||||
// 两次钉钉登录或任务派发同时首次触达同一员工时,只允许一个账号落库。
|
||||
const raced = await client.user.findUnique({
|
||||
where: { person_id: person.id },
|
||||
select: { id: true, status: true, role: true }
|
||||
})
|
||||
return isEligibleExistingUser(raced) ? raced : null
|
||||
}
|
||||
}
|
||||
|
||||
export const provisionDingTalkLoginUser = async (
|
||||
dingtalkUserId: string,
|
||||
client: ProvisionClient = prisma
|
||||
) => {
|
||||
const normalizedUserId = String(dingtalkUserId || '').trim()
|
||||
if (!normalizedUserId) return null
|
||||
|
||||
const person = await client.person.findUnique({
|
||||
where: { source_userid: normalizedUserId },
|
||||
select: { id: true, status: true }
|
||||
})
|
||||
if (!person || person.status !== 'active') return null
|
||||
|
||||
return ensureOrdinaryUserForActivePerson(person.id, normalizedUserId, client)
|
||||
}
|
||||
@ -11,12 +11,27 @@ import {
|
||||
import { buildInventoryScopeItemDraft } from '../utils/inventoryTaskDraft'
|
||||
import { getInventoryTaskDeleteBlockedReason } from '../utils/inventoryTaskLifecycle'
|
||||
import { PUBLIC_ASSET_PERSON_SOURCE_USERID } from '../utils/systemReservedPerson'
|
||||
import {
|
||||
createBusinessNotification,
|
||||
findActiveDingTalkUsersByIds,
|
||||
formatBeijingDateTime,
|
||||
getCurrentDingTalkRecipientRollout
|
||||
} from './businessNotificationService'
|
||||
import { ensureOrdinaryUserForActivePerson } from './dingtalkUserProvisionService'
|
||||
|
||||
type ScopeEntity = {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
type ScopePerson = {
|
||||
id: number
|
||||
name: string
|
||||
real_name: string
|
||||
department_name: string | null
|
||||
dingtalk_ready: boolean
|
||||
}
|
||||
|
||||
type InventoryScopeOptions = NormalizeInventoryScopeInputParams | NormalizedInventoryScopeInput
|
||||
|
||||
type FrozenParticipantDraft = {
|
||||
@ -32,7 +47,8 @@ const sanitizeInventoryParticipant = <T extends { person_source_userid?: string
|
||||
const { person_source_userid: sourceUserId, ...safeParticipant } = participant
|
||||
return {
|
||||
...safeParticipant,
|
||||
is_public_asset_person: sourceUserId === PUBLIC_ASSET_PERSON_SOURCE_USERID
|
||||
is_public_asset_person: sourceUserId === PUBLIC_ASSET_PERSON_SOURCE_USERID,
|
||||
dingtalk_ready: Boolean(sourceUserId?.trim() && sourceUserId !== PUBLIC_ASSET_PERSON_SOURCE_USERID)
|
||||
}
|
||||
}
|
||||
|
||||
@ -59,6 +75,7 @@ type InventoryScopeBuildResult = {
|
||||
scopeDepartments: ScopeEntity[]
|
||||
scopeAssetTypes: ScopeEntity[]
|
||||
scopeStorageLocations: ScopeEntity[]
|
||||
scopePeople: ScopePerson[]
|
||||
participants: FrozenParticipantDraft[]
|
||||
items: FrozenItemDraft[]
|
||||
}
|
||||
@ -114,12 +131,43 @@ const buildScopeWhere = ({
|
||||
return where
|
||||
}
|
||||
|
||||
const expandDepartmentScopeIds = async (selectedIds: number[]) => {
|
||||
if (selectedIds.length === 0) return []
|
||||
|
||||
const departments = await prisma.department.findMany({
|
||||
where: {
|
||||
AND: [buildBusinessDepartmentWhere(), { status: 'active' }]
|
||||
},
|
||||
select: { id: true, parent_id: true }
|
||||
})
|
||||
const childrenByParent = new Map<number, number[]>()
|
||||
for (const department of departments) {
|
||||
if (!department.parent_id) continue
|
||||
const children = childrenByParent.get(department.parent_id) || []
|
||||
children.push(department.id)
|
||||
childrenByParent.set(department.parent_id, children)
|
||||
}
|
||||
|
||||
const expanded = new Set(selectedIds)
|
||||
const pending = [...selectedIds]
|
||||
while (pending.length > 0) {
|
||||
const current = pending.shift() as number
|
||||
for (const childId of childrenByParent.get(current) || []) {
|
||||
if (expanded.has(childId)) continue
|
||||
expanded.add(childId)
|
||||
pending.push(childId)
|
||||
}
|
||||
}
|
||||
return [...expanded]
|
||||
}
|
||||
|
||||
const getScopeMeta = async ({
|
||||
departmentIds,
|
||||
assetTypeIds,
|
||||
storageLocationIds
|
||||
storageLocationIds,
|
||||
personIds
|
||||
}: NormalizedInventoryScopeInput) => {
|
||||
const [departments, assetTypes, storageLocations] = await Promise.all([
|
||||
const [departments, assetTypes, storageLocations, people] = await Promise.all([
|
||||
departmentIds.length > 0
|
||||
? prisma.department.findMany({
|
||||
where: {
|
||||
@ -158,6 +206,27 @@ const getScopeMeta = async ({
|
||||
orderBy: { name: 'asc' },
|
||||
select: { id: true, name: true }
|
||||
})
|
||||
: Promise.resolve([]),
|
||||
personIds.length > 0
|
||||
? prisma.person.findMany({
|
||||
where: {
|
||||
id: { in: personIds },
|
||||
status: 'active',
|
||||
OR: [
|
||||
{ source_userid: null },
|
||||
{ source_userid: { not: PUBLIC_ASSET_PERSON_SOURCE_USERID } }
|
||||
]
|
||||
},
|
||||
orderBy: [{ nickname: 'asc' }, { real_name: 'asc' }],
|
||||
select: {
|
||||
id: true,
|
||||
real_name: true,
|
||||
nickname: true,
|
||||
source_userid: true,
|
||||
department: true,
|
||||
mainDepartment: { select: { name: true } }
|
||||
}
|
||||
})
|
||||
: Promise.resolve([])
|
||||
])
|
||||
|
||||
@ -173,10 +242,23 @@ const getScopeMeta = async ({
|
||||
throw new Error('所选地区不存在或已停用')
|
||||
}
|
||||
|
||||
if (personIds.length > 0 && people.length !== personIds.length) {
|
||||
throw new Error('所选盘点人员不存在或已离职/停用')
|
||||
}
|
||||
|
||||
return {
|
||||
scopeDepartments: departments,
|
||||
scopeAssetTypes: assetTypes,
|
||||
scopeStorageLocations: storageLocations
|
||||
scopeStorageLocations: storageLocations,
|
||||
scopePeople: people.map((person) => ({
|
||||
id: person.id,
|
||||
name: person.nickname?.trim() || person.real_name,
|
||||
real_name: person.real_name,
|
||||
department_name: person.mainDepartment?.name || person.department || null,
|
||||
dingtalk_ready: Boolean(
|
||||
person.source_userid?.trim() && person.source_userid !== PUBLIC_ASSET_PERSON_SOURCE_USERID
|
||||
)
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@ -312,8 +394,12 @@ export const buildInventoryScope = async (
|
||||
): Promise<InventoryScopeBuildResult> => {
|
||||
const normalizedScope = normalizeInventoryScopeInput(options)
|
||||
const meta = await getScopeMeta(normalizedScope)
|
||||
const expandedDepartmentIds = await expandDepartmentScopeIds(normalizedScope.departmentIds)
|
||||
const equipments = await prisma.equipment.findMany({
|
||||
where: buildScopeWhere(normalizedScope),
|
||||
where: buildScopeWhere({
|
||||
...normalizedScope,
|
||||
departmentIds: expandedDepartmentIds
|
||||
}),
|
||||
orderBy: [
|
||||
{ storage_location_id: 'asc' },
|
||||
{ department_id: 'asc' },
|
||||
@ -366,6 +452,13 @@ export const buildInventoryScope = async (
|
||||
activeBorrowRecord
|
||||
})
|
||||
|
||||
if (
|
||||
normalizedScope.personIds.length > 0 &&
|
||||
(!itemDraft.participant_person_id || !normalizedScope.personIds.includes(itemDraft.participant_person_id))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (activeBorrowRecord?.person && itemDraft.participant_person_id) {
|
||||
participantMap.set(itemDraft.participant_person_id, {
|
||||
person_id: activeBorrowRecord.person.id,
|
||||
@ -387,6 +480,7 @@ export const buildInventoryScope = async (
|
||||
scopeDepartments: meta.scopeDepartments,
|
||||
scopeAssetTypes: meta.scopeAssetTypes,
|
||||
scopeStorageLocations: meta.scopeStorageLocations,
|
||||
scopePeople: meta.scopePeople,
|
||||
participants: Array.from(participantMap.values()).sort((left, right) =>
|
||||
left.person_nickname.localeCompare(right.person_nickname, 'zh-CN')
|
||||
),
|
||||
@ -404,6 +498,7 @@ export const previewInventoryTask = async (options: InventoryScopeOptions) => {
|
||||
scope_departments: scope.scopeDepartments,
|
||||
scope_asset_types: scope.scopeAssetTypes,
|
||||
scope_storage_locations: scope.scopeStorageLocations,
|
||||
scope_people: scope.scopePeople,
|
||||
participants_count: scope.participants.length,
|
||||
items_count: scope.items.length,
|
||||
unassigned_count: unassignedCount,
|
||||
@ -420,6 +515,7 @@ const buildTaskScopeSnapshot = (scope: InventoryScopeBuildResult) => ({
|
||||
scope_departments: scope.scopeDepartments,
|
||||
scope_asset_types: scope.scopeAssetTypes,
|
||||
scope_storage_locations: scope.scopeStorageLocations,
|
||||
scope_people: scope.scopePeople,
|
||||
participants_count: scope.participants.length,
|
||||
items_count: scope.items.length,
|
||||
unassigned_count: scope.items.filter((item) => item.is_unassigned).length,
|
||||
@ -535,11 +631,16 @@ const parseScopeSnapshot = (task: any) => {
|
||||
? snapshot.scope_storage_locations
|
||||
: []
|
||||
|
||||
const scopePeople = Array.isArray(snapshot?.scope_people)
|
||||
? snapshot.scope_people
|
||||
: []
|
||||
|
||||
return {
|
||||
scope_snapshot: snapshot,
|
||||
scope_departments: scopeDepartments,
|
||||
scope_asset_types: scopeAssetTypes,
|
||||
scope_storage_locations: scopeStorageLocations
|
||||
scope_storage_locations: scopeStorageLocations,
|
||||
scope_people: scopePeople
|
||||
}
|
||||
}
|
||||
|
||||
@ -551,6 +652,7 @@ const serializeTaskListItem = (task: any) => {
|
||||
title: task.title,
|
||||
description: task.description,
|
||||
status: task.status,
|
||||
identity_mode: task.identity_mode,
|
||||
public_token: task.public_token,
|
||||
scope_department_id: task.scope_department_id,
|
||||
scope_department_name: task.scope_department_name,
|
||||
@ -563,6 +665,7 @@ const serializeTaskListItem = (task: any) => {
|
||||
unassigned_count: task.unassigned_count,
|
||||
created_by: task.created_by,
|
||||
created_by_name: task.creator?.name || task.creator?.username || null,
|
||||
deadline_at: task.deadline_at,
|
||||
closed_at: task.closed_at,
|
||||
created_at: task.created_at,
|
||||
updated_at: task.updated_at,
|
||||
@ -574,12 +677,15 @@ export const createInventoryTask = async (
|
||||
input: {
|
||||
title: string
|
||||
description?: string | null
|
||||
department_id?: number | null
|
||||
asset_type_id?: number | null
|
||||
storage_location_id?: number | null
|
||||
department_ids?: number[] | null
|
||||
asset_type_ids?: number[] | null
|
||||
storage_location_ids?: number[] | null
|
||||
deadline_at?: Date | string | null
|
||||
department_id?: number | null
|
||||
asset_type_id?: number | null
|
||||
storage_location_id?: number | null
|
||||
person_id?: number | null
|
||||
department_ids?: number[] | null
|
||||
asset_type_ids?: number[] | null
|
||||
storage_location_ids?: number[] | null
|
||||
person_ids?: number[] | null
|
||||
},
|
||||
operatorUserId: number
|
||||
) => {
|
||||
@ -588,13 +694,23 @@ export const createInventoryTask = async (
|
||||
throw new Error('盘点任务标题不能为空')
|
||||
}
|
||||
|
||||
const deadlineAt = input.deadline_at ? new Date(input.deadline_at) : null
|
||||
if (deadlineAt && Number.isNaN(deadlineAt.getTime())) {
|
||||
throw new Error('盘点截止时间格式无效')
|
||||
}
|
||||
if (deadlineAt && deadlineAt.getTime() <= Date.now()) {
|
||||
throw new Error('盘点截止时间必须晚于当前时间')
|
||||
}
|
||||
|
||||
const scope = await buildInventoryScope({
|
||||
department_id: input.department_id || null,
|
||||
asset_type_id: input.asset_type_id || null,
|
||||
storage_location_id: input.storage_location_id || null,
|
||||
person_id: input.person_id || null,
|
||||
department_ids: input.department_ids || [],
|
||||
asset_type_ids: input.asset_type_ids || [],
|
||||
storage_location_ids: input.storage_location_ids || []
|
||||
storage_location_ids: input.storage_location_ids || [],
|
||||
person_ids: input.person_ids || []
|
||||
})
|
||||
|
||||
const task = await prisma.$transaction(async (tx) => {
|
||||
@ -603,13 +719,15 @@ export const createInventoryTask = async (
|
||||
title,
|
||||
description: input.description?.trim() || null,
|
||||
status: INVENTORY_TASK_STATUSES.active,
|
||||
identity_mode: 'dingtalk',
|
||||
public_token: createPublicToken(),
|
||||
scope_department_id: scope.scopeDepartments.length === 1 ? scope.scopeDepartments[0].id : null,
|
||||
scope_department_name: scope.scopeDepartments.length === 1 ? scope.scopeDepartments[0].name : null,
|
||||
scope_asset_type_id: scope.scopeAssetTypes.length === 1 ? scope.scopeAssetTypes[0].id : null,
|
||||
scope_asset_type_name: scope.scopeAssetTypes.length === 1 ? scope.scopeAssetTypes[0].name : null,
|
||||
scope_snapshot_json: JSON.stringify(buildTaskScopeSnapshot(scope)),
|
||||
created_by: operatorUserId
|
||||
created_by: operatorUserId,
|
||||
deadline_at: deadlineAt
|
||||
}
|
||||
})
|
||||
|
||||
@ -627,7 +745,11 @@ export const createInventoryTask = async (
|
||||
where: { task_id: createdTask.id },
|
||||
select: {
|
||||
id: true,
|
||||
person_id: true
|
||||
person_id: true,
|
||||
person_name: true,
|
||||
person_nickname: true,
|
||||
person_source_userid: true,
|
||||
department_name: true
|
||||
}
|
||||
})
|
||||
const participantIdByPersonId = new Map(
|
||||
@ -678,6 +800,94 @@ export const createInventoryTask = async (
|
||||
}
|
||||
})
|
||||
|
||||
let missingDingTalkRecipients = 0
|
||||
const notificationRollout = getCurrentDingTalkRecipientRollout()
|
||||
for (const participant of savedParticipants) {
|
||||
const dingtalkUserId = participant.person_source_userid?.trim() || ''
|
||||
if (dingtalkUserId === PUBLIC_ASSET_PERSON_SOURCE_USERID) {
|
||||
continue
|
||||
}
|
||||
if (!participant.person_id || !dingtalkUserId) {
|
||||
missingDingTalkRecipients += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const user = await ensureOrdinaryUserForActivePerson(
|
||||
participant.person_id,
|
||||
dingtalkUserId,
|
||||
tx
|
||||
)
|
||||
if (!user) {
|
||||
missingDingTalkRecipients += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const dingTalkRecipients = await findActiveDingTalkUsersByIds(tx, [user.id], notificationRollout)
|
||||
if (dingTalkRecipients.length === 0) {
|
||||
// 灰度模式名单外人员属于主动抑制,不制造历史积压;全量模式下由下方汇总提醒管理员。
|
||||
if (notificationRollout.fullRollout) {
|
||||
missingDingTalkRecipients += 1
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
const participantItemCount = scope.items.filter(
|
||||
(item) => item.participant_person_id === participant.person_id
|
||||
).length
|
||||
await createBusinessNotification(tx, {
|
||||
eventType: 'inventory_task_assigned',
|
||||
businessType: 'inventory',
|
||||
businessId: createdTask.id,
|
||||
title: `盘点任务:${title}`,
|
||||
content: `${participant.person_nickname}有 ${participantItemCount} 件资产需要盘点。`,
|
||||
level: 'info',
|
||||
actionUrl: `/inventory/task/${createdTask.public_token}`,
|
||||
metadata: {
|
||||
participant_id: participant.id,
|
||||
participant_person_id: participant.person_id,
|
||||
items_count: participantItemCount,
|
||||
deadline_at: deadlineAt?.toISOString() || null
|
||||
},
|
||||
stationRecipients: [],
|
||||
dingTalkRecipients,
|
||||
dingTalk: {
|
||||
title: '资产盘点待完成',
|
||||
content: [
|
||||
`盘点任务:${title}`,
|
||||
`责任人:${participant.person_nickname}|${participant.department_name || '未归属部门'}`,
|
||||
`待盘资产:${participantItemCount} 件`,
|
||||
`截止时间:${deadlineAt ? formatBeijingDateTime(deadlineAt) : '未设置'}`,
|
||||
'操作要求:打开任务后使用手机摄像头扫描资产二维码;未找到或不在本人处的资产请直接上报异常。'
|
||||
].join('\n'),
|
||||
actionTitle: '打开并开始盘点',
|
||||
messageVersion: 'v1'
|
||||
},
|
||||
rollout: notificationRollout
|
||||
})
|
||||
}
|
||||
|
||||
if (missingDingTalkRecipients > 0) {
|
||||
await createBusinessNotification(tx, {
|
||||
eventType: 'inventory_task_recipient_missing',
|
||||
businessType: 'inventory',
|
||||
businessId: createdTask.id,
|
||||
title: `盘点任务有 ${missingDingTalkRecipients} 人无法接收钉钉通知`,
|
||||
content: `盘点任务“${title}”已创建,但有 ${missingDingTalkRecipients} 名参与人缺少有效钉钉身份或系统账号不可用。`,
|
||||
level: 'error',
|
||||
actionUrl: `/inventory/${createdTask.id}`,
|
||||
metadata: { missing_recipient_count: missingDingTalkRecipients },
|
||||
stationRecipients: [],
|
||||
dingTalkRecipients: [],
|
||||
dingTalk: {
|
||||
title: '盘点任务收件人异常',
|
||||
content: '请管理员检查参与人的花名册状态和钉钉身份。',
|
||||
actionTitle: '查看盘点任务',
|
||||
messageVersion: 'v1'
|
||||
},
|
||||
rollout: notificationRollout
|
||||
})
|
||||
}
|
||||
|
||||
return tx.inventoryTask.findUnique({
|
||||
where: { id: createdTask.id },
|
||||
include: {
|
||||
@ -873,6 +1083,7 @@ export const getInventoryTaskPublicInfo = async (publicToken: string) => {
|
||||
title: true,
|
||||
description: true,
|
||||
status: true,
|
||||
identity_mode: true,
|
||||
scope_department_name: true,
|
||||
scope_asset_type_name: true,
|
||||
participants_count: true,
|
||||
@ -881,6 +1092,7 @@ export const getInventoryTaskPublicInfo = async (publicToken: string) => {
|
||||
exception_count: true,
|
||||
unassigned_count: true,
|
||||
scope_snapshot_json: true,
|
||||
deadline_at: true,
|
||||
created_at: true,
|
||||
closed_at: true
|
||||
}
|
||||
@ -903,7 +1115,8 @@ export const searchTaskParticipants = async (publicToken: string, keyword: strin
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true
|
||||
status: true,
|
||||
identity_mode: true
|
||||
}
|
||||
})
|
||||
|
||||
@ -911,6 +1124,10 @@ export const searchTaskParticipants = async (publicToken: string, keyword: strin
|
||||
throw new Error('盘点任务不存在')
|
||||
}
|
||||
|
||||
if (task.identity_mode === 'dingtalk') {
|
||||
throw new Error('该盘点任务必须使用钉钉登录后进入')
|
||||
}
|
||||
|
||||
const normalizedKeyword = keyword.trim()
|
||||
return prisma.inventoryTaskParticipant.findMany({
|
||||
where: {
|
||||
@ -996,6 +1213,51 @@ export const confirmInventoryParticipant = async (publicToken: string, participa
|
||||
}
|
||||
}
|
||||
|
||||
export const confirmLegacyInventoryParticipant = async (
|
||||
publicToken: string,
|
||||
participantId: number
|
||||
) => {
|
||||
const task = await prisma.inventoryTask.findUnique({
|
||||
where: { public_token: publicToken },
|
||||
select: { id: true, identity_mode: true }
|
||||
})
|
||||
if (!task) throw new Error('盘点任务不存在')
|
||||
if (task.identity_mode === 'dingtalk') {
|
||||
throw new Error('该盘点任务必须使用钉钉登录后进入')
|
||||
}
|
||||
return confirmInventoryParticipant(publicToken, participantId)
|
||||
}
|
||||
|
||||
export const confirmInventoryParticipantByPerson = async (
|
||||
publicToken: string,
|
||||
personId: number
|
||||
) => {
|
||||
if (!Number.isInteger(personId) || personId <= 0) {
|
||||
throw new Error('当前系统账号未绑定员工档案')
|
||||
}
|
||||
|
||||
const task = await prisma.inventoryTask.findUnique({
|
||||
where: { public_token: publicToken },
|
||||
select: { id: true }
|
||||
})
|
||||
if (!task) {
|
||||
throw new Error('盘点任务不存在')
|
||||
}
|
||||
|
||||
const participant = await prisma.inventoryTaskParticipant.findFirst({
|
||||
where: {
|
||||
task_id: task.id,
|
||||
person_id: personId
|
||||
},
|
||||
select: { id: true }
|
||||
})
|
||||
if (!participant) {
|
||||
throw new Error('当前登录人员不在本次盘点范围内')
|
||||
}
|
||||
|
||||
return confirmInventoryParticipant(publicToken, participant.id)
|
||||
}
|
||||
|
||||
export const getParticipantTaskContext = async (taskId: number, participantId: number) => {
|
||||
const [task, participant] = await Promise.all([
|
||||
prisma.inventoryTask.findUnique({
|
||||
|
||||
@ -0,0 +1,20 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
const migrationPath = path.resolve(
|
||||
__dirname,
|
||||
'../../../prisma/migrations/20260717_inventory_dingtalk_dispatch/migration.sql'
|
||||
)
|
||||
|
||||
test('盘点钉钉派发迁移只增量增加截止时间和查询索引', () => {
|
||||
const sql = readFileSync(migrationPath, 'utf8')
|
||||
assert.match(sql, /ALTER TABLE `inventory_task`/i)
|
||||
assert.match(sql, /ADD COLUMN `deadline_at` DATETIME\(3\) NULL/i)
|
||||
assert.match(sql, /ADD COLUMN `identity_mode` VARCHAR\(20\) NOT NULL DEFAULT 'legacy'/i)
|
||||
assert.match(sql, /MODIFY COLUMN `identity_mode` VARCHAR\(20\) NOT NULL DEFAULT 'dingtalk'/i)
|
||||
assert.match(sql, /CREATE INDEX `inventory_task_status_deadline_at_idx`/i)
|
||||
assert.doesNotMatch(sql, /DROP\s+(TABLE|COLUMN)/i)
|
||||
assert.doesNotMatch(sql, /DELETE\s+FROM/i)
|
||||
})
|
||||
@ -15,7 +15,8 @@ test('盘点范围会把旧单选字段归一成数组', () => {
|
||||
{
|
||||
departmentIds: [3],
|
||||
assetTypeIds: [9],
|
||||
storageLocationIds: []
|
||||
storageLocationIds: [],
|
||||
personIds: []
|
||||
}
|
||||
)
|
||||
})
|
||||
@ -31,7 +32,23 @@ test('盘点范围会合并新旧字段、去重并过滤非法值', () => {
|
||||
{
|
||||
departmentIds: [3, 8],
|
||||
assetTypeIds: [5, 12],
|
||||
storageLocationIds: [2, 7]
|
||||
storageLocationIds: [2, 7],
|
||||
personIds: []
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test('盘点范围会归一指定人员并过滤重复和非法编号', () => {
|
||||
assert.deepEqual(
|
||||
normalizeInventoryScopeInput({
|
||||
person_id: 6,
|
||||
person_ids: [6, '9', 0, 'bad', 9]
|
||||
}),
|
||||
{
|
||||
departmentIds: [],
|
||||
assetTypeIds: [],
|
||||
storageLocationIds: [],
|
||||
personIds: [6, 9]
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
@ -21,12 +21,13 @@ test('行政可以登记和审批但不能借还、调拨或挂载', () => {
|
||||
assert.ok(!permissions.includes(PERMISSIONS.ASSETS_MOUNT))
|
||||
})
|
||||
|
||||
test('IT可以流转和发起报废但不能审批或管理用户', () => {
|
||||
test('IT可以流转、发起报废和盘点,但不能审批或管理用户', () => {
|
||||
const permissions = getPermissionsForRole('it')
|
||||
assert.ok(permissions.includes(PERMISSIONS.ASSETS_BORROW))
|
||||
assert.ok(permissions.includes(PERMISSIONS.ASSETS_TRANSFER))
|
||||
assert.ok(permissions.includes(PERMISSIONS.ASSETS_PRINT_LABELS))
|
||||
assert.ok(permissions.includes(PERMISSIONS.DISPOSALS_APPLY))
|
||||
assert.ok(permissions.includes(PERMISSIONS.INVENTORY_MANAGE))
|
||||
assert.ok(!permissions.includes(PERMISSIONS.DISPOSALS_APPROVE))
|
||||
assert.ok(!permissions.includes(PERMISSIONS.USERS_MANAGE))
|
||||
})
|
||||
|
||||
@ -2,6 +2,7 @@ const allowedRedirectPrefixes = [
|
||||
'/workflows/disposals',
|
||||
'/maintenance',
|
||||
'/my-assets',
|
||||
'/inventory/task',
|
||||
'/equipment',
|
||||
'/notifications',
|
||||
'/dashboard'
|
||||
|
||||
@ -8,12 +8,16 @@ export interface NormalizeInventoryScopeInputParams {
|
||||
storage_location_id?: unknown
|
||||
storage_location_ids?: unknown
|
||||
storageLocationIds?: unknown
|
||||
person_id?: unknown
|
||||
person_ids?: unknown
|
||||
personIds?: unknown
|
||||
}
|
||||
|
||||
export interface NormalizedInventoryScopeInput {
|
||||
departmentIds: number[]
|
||||
assetTypeIds: number[]
|
||||
storageLocationIds: number[]
|
||||
personIds: number[]
|
||||
}
|
||||
|
||||
const toNormalizedScopeIds = (values: unknown[]) =>
|
||||
@ -51,7 +55,8 @@ export const normalizeInventoryScopeInput = (
|
||||
storageLocationIds: normalizeScopeIdArray(
|
||||
input.storageLocationIds ?? input.storage_location_ids,
|
||||
input.storage_location_id
|
||||
)
|
||||
),
|
||||
personIds: normalizeScopeIdArray(input.personIds ?? input.person_ids, input.person_id)
|
||||
})
|
||||
|
||||
export const resolveUniqueActiveBorrowRecord = <T extends { person?: { status?: string | null } | null }>(
|
||||
|
||||
@ -51,6 +51,7 @@ const ROLE_PERMISSIONS: Record<UserRole, readonly Permission[]> = {
|
||||
PERMISSIONS.ASSETS_MOUNT,
|
||||
PERMISSIONS.ASSETS_PRINT_LABELS,
|
||||
PERMISSIONS.DISPOSALS_APPLY,
|
||||
PERMISSIONS.INVENTORY_MANAGE,
|
||||
PERMISSIONS.NOTIFICATIONS_VIEW,
|
||||
PERMISSIONS.REPORTS_VIEW
|
||||
],
|
||||
|
||||
@ -5,6 +5,7 @@ export interface DingTalkPublicConfig {
|
||||
configured: boolean
|
||||
mode: 'notification_center'
|
||||
loginEntryUrl?: string
|
||||
rolloutMode?: 'disabled' | 'test' | 'full'
|
||||
}
|
||||
|
||||
export interface DingTalkDeliveryStatus {
|
||||
|
||||
@ -15,11 +15,14 @@ export interface InventoryTaskPreviewPayload {
|
||||
asset_type_ids?: number[]
|
||||
storage_location_id?: number | null
|
||||
storage_location_ids?: number[]
|
||||
person_id?: number | null
|
||||
person_ids?: number[]
|
||||
}
|
||||
|
||||
export interface CreateInventoryTaskPayload extends InventoryTaskPreviewPayload {
|
||||
title: string
|
||||
description?: string
|
||||
deadline_at?: string
|
||||
}
|
||||
|
||||
export const previewInventoryTask = async (data: InventoryTaskPreviewPayload) => {
|
||||
|
||||
@ -74,6 +74,19 @@ export const confirmPublicInventoryParticipant = async (token: string, participa
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const confirmCurrentInventoryParticipant = async (token: string) => {
|
||||
const response = await publicInventoryApi.post<
|
||||
ApiResponse<{
|
||||
task_id: number
|
||||
task_title: string
|
||||
participant_id: number
|
||||
participant_name: string
|
||||
participant_nickname: string
|
||||
}>
|
||||
>(`/tasks/${token}/participants/confirm-me`)
|
||||
return response.data
|
||||
}
|
||||
|
||||
export const getPublicInventoryMe = async () => {
|
||||
const response = await publicInventoryApi.get<ApiResponse<PublicInventorySessionData>>('/me')
|
||||
return response.data
|
||||
|
||||
@ -256,12 +256,21 @@ export interface InventoryScopeEntity {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface InventoryScopePerson {
|
||||
id: number
|
||||
name: string
|
||||
real_name: string
|
||||
department_name?: string | null
|
||||
dingtalk_ready: boolean
|
||||
}
|
||||
|
||||
export interface InventoryTaskScopeSnapshot {
|
||||
scope_department: InventoryScopeEntity | null
|
||||
scope_asset_type: InventoryScopeEntity | null
|
||||
scope_departments: InventoryScopeEntity[]
|
||||
scope_asset_types: InventoryScopeEntity[]
|
||||
scope_storage_locations: InventoryScopeEntity[]
|
||||
scope_people: InventoryScopePerson[]
|
||||
participants_count: number
|
||||
items_count: number
|
||||
unassigned_count: number
|
||||
@ -285,6 +294,7 @@ export interface InventoryPreviewParticipantSample {
|
||||
person_name: string
|
||||
person_nickname: string
|
||||
person_source_userid: string | null
|
||||
dingtalk_ready?: boolean
|
||||
department_id: number | null
|
||||
department_name: string | null
|
||||
}
|
||||
@ -314,6 +324,7 @@ export interface InventoryTaskPreview {
|
||||
scope_departments: InventoryScopeEntity[]
|
||||
scope_asset_types: InventoryScopeEntity[]
|
||||
scope_storage_locations: InventoryScopeEntity[]
|
||||
scope_people: InventoryScopePerson[]
|
||||
participants_count: number
|
||||
items_count: number
|
||||
unassigned_count: number
|
||||
@ -328,6 +339,7 @@ export interface InventoryTaskListItem {
|
||||
title: string
|
||||
description?: string | null
|
||||
status: InventoryTaskStatus | string
|
||||
identity_mode?: 'legacy' | 'dingtalk' | string
|
||||
public_token: string
|
||||
scope_department_id?: number | null
|
||||
scope_department_name?: string | null
|
||||
@ -340,6 +352,7 @@ export interface InventoryTaskListItem {
|
||||
unassigned_count: number
|
||||
created_by: number
|
||||
created_by_name?: string | null
|
||||
deadline_at?: string | null
|
||||
closed_at?: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@ -347,6 +360,7 @@ export interface InventoryTaskListItem {
|
||||
scope_departments?: InventoryScopeEntity[]
|
||||
scope_asset_types?: InventoryScopeEntity[]
|
||||
scope_storage_locations?: InventoryScopeEntity[]
|
||||
scope_people?: InventoryScopePerson[]
|
||||
}
|
||||
|
||||
export interface InventoryTaskParticipant {
|
||||
@ -356,6 +370,7 @@ export interface InventoryTaskParticipant {
|
||||
person_name: string
|
||||
person_nickname: string
|
||||
person_source_userid?: string | null
|
||||
dingtalk_ready?: boolean
|
||||
department_id: number | null
|
||||
department_name: string | null
|
||||
status: InventoryParticipantStatus | string
|
||||
@ -436,6 +451,7 @@ export interface PublicInventoryTaskInfo {
|
||||
title: string
|
||||
description?: string | null
|
||||
status: InventoryTaskStatus | string
|
||||
identity_mode?: 'legacy' | 'dingtalk' | string
|
||||
scope_department_name?: string | null
|
||||
scope_asset_type_name?: string | null
|
||||
participants_count: number
|
||||
@ -443,12 +459,14 @@ export interface PublicInventoryTaskInfo {
|
||||
completed_count: number
|
||||
exception_count: number
|
||||
unassigned_count: number
|
||||
deadline_at?: string | null
|
||||
created_at: string
|
||||
closed_at?: string | null
|
||||
scope_snapshot?: InventoryTaskScopeSnapshot | null
|
||||
scope_departments?: InventoryScopeEntity[]
|
||||
scope_asset_types?: InventoryScopeEntity[]
|
||||
scope_storage_locations?: InventoryScopeEntity[]
|
||||
scope_people?: InventoryScopePerson[]
|
||||
}
|
||||
|
||||
export interface PublicInventoryParticipantOption {
|
||||
@ -465,6 +483,7 @@ export interface PublicInventorySessionTask {
|
||||
id: number
|
||||
title: string
|
||||
status: InventoryTaskStatus | string
|
||||
identity_mode?: 'legacy' | 'dingtalk' | string
|
||||
scope_department_name?: string | null
|
||||
scope_asset_type_name?: string | null
|
||||
scope_snapshot?: InventoryTaskScopeSnapshot | null
|
||||
@ -474,6 +493,7 @@ export interface PublicInventorySessionTask {
|
||||
completed_count: number
|
||||
exception_count: number
|
||||
items_count: number
|
||||
deadline_at?: string | null
|
||||
closed_at?: string | null
|
||||
}
|
||||
|
||||
|
||||
@ -49,6 +49,26 @@
|
||||
>
|
||||
{{ loading ? '登录中...' : '进入系统' }}
|
||||
</button>
|
||||
|
||||
<template v-if="dingtalkAvailable">
|
||||
<div class="flex items-center gap-3 py-1" aria-hidden="true">
|
||||
<span class="h-px flex-1 bg-border"></span>
|
||||
<span class="text-xs text-text-tertiary">或</span>
|
||||
<span class="h-px flex-1 bg-border"></span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn-secondary w-full justify-center"
|
||||
:disabled="loading"
|
||||
@click="handleDingTalkLogin"
|
||||
>
|
||||
使用钉钉登录
|
||||
</button>
|
||||
<p class="text-center text-xs leading-5 text-text-tertiary">
|
||||
在职员工无需单独申请普通账号,管理员、行政和 IT 仍按系统角色授权。
|
||||
</p>
|
||||
</template>
|
||||
</form>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
@ -83,6 +103,7 @@ import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import keycloak from '@/auth/keycloak'
|
||||
import api from '@/api'
|
||||
import { getDingTalkPublicConfig } from '@/api/dingtalk'
|
||||
import { useSiteStore } from '@/stores/site'
|
||||
import { resolveAuthMode } from '@/utils/authMode'
|
||||
|
||||
@ -92,6 +113,7 @@ const loading = ref(false)
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const errorText = ref('')
|
||||
const dingtalkAvailable = ref(false)
|
||||
const siteStore = useSiteStore()
|
||||
|
||||
const authMode = resolveAuthMode((import.meta.env as any).VITE_AUTH_MODE)
|
||||
@ -137,7 +159,22 @@ const handleLocalLogin = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const handleDingTalkLogin = async () => {
|
||||
await router.push({
|
||||
path: '/dingtalk-entry',
|
||||
query: { redirect: '/dashboard' }
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
siteStore.fetchSiteConfig()
|
||||
if (!isLocalAuth.value) return
|
||||
|
||||
try {
|
||||
const response = await getDingTalkPublicConfig('/dashboard')
|
||||
dingtalkAvailable.value = Boolean(response.data?.configured && response.data?.loginEntryUrl)
|
||||
} catch {
|
||||
dingtalkAvailable.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -17,7 +17,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { getDingTalkPublicConfig } from '@/api/dingtalk'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const SAFE_PATH_PREFIXES = ['/dashboard', '/equipment', '/workflows/disposals', '/maintenance', '/my-assets', '/notifications']
|
||||
const SAFE_PATH_PREFIXES = ['/dashboard', '/equipment', '/workflows/disposals', '/maintenance', '/my-assets', '/notifications', '/inventory/task']
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
@ -18,6 +18,7 @@ const ACTIONS_BY_ROLE: Record<UserRole, DashboardRoleAction[]> = {
|
||||
{ id: 'borrow', label: '资产借出', description: '扫码选择使用者并完成出库', to: '/borrow/scan' },
|
||||
{ id: 'return', label: '资产归还', description: '扫码归还资产及随借配件', to: '/return/scan' },
|
||||
{ id: 'transfer', label: '调拨与闲置', description: '在资产管理中选择资产后调拨', to: '/equipment' },
|
||||
{ id: 'inventory', label: '发起盘点', description: '按人员或组织派发手机盘点任务', to: '/inventory' },
|
||||
{ id: 'maintenance', label: '维修管理', description: '登记、完成或取消维修', to: '/maintenance' },
|
||||
{ id: 'disposal-apply', label: '发起报废', description: '在资产详情中选择整机及配件', to: '/equipment' }
|
||||
],
|
||||
|
||||
@ -59,6 +59,11 @@
|
||||
placeholder="可选,给盘点负责人留一句简短说明"
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<label class="field-label">截止时间</label>
|
||||
<input v-model="form.deadline_at" type="datetime-local" class="input" />
|
||||
<p class="text-xs leading-5 text-text-tertiary">通知中会明确显示,便于员工及时完成。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@ -70,12 +75,70 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="panel space-y-4">
|
||||
<div class="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-text-primary">指定盘点人员(可选)</p>
|
||||
<p class="mt-1 text-xs leading-5 text-text-secondary">
|
||||
留空时覆盖当前资产范围内的全部责任人;指定后只纳入这些人员当前持有的资产。
|
||||
</p>
|
||||
</div>
|
||||
<span class="badge badge-neutral">已选 {{ form.person_ids.length }} 人</span>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedPeople.length" class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="person in selectedPeople"
|
||||
:key="person.id"
|
||||
type="button"
|
||||
class="badge badge-neutral"
|
||||
:aria-label="`移除 ${person.nickname}`"
|
||||
@click="togglePerson(person)"
|
||||
>
|
||||
{{ person.nickname }} · {{ person.real_name }} ×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="field-stack">
|
||||
<label class="field-label">搜索花名、姓名或工号</label>
|
||||
<input v-model.trim="personKeyword" type="text" class="input" placeholder="输入关键词选择人员" />
|
||||
</div>
|
||||
|
||||
<div v-if="personLoading" class="empty-state">正在搜索人员...</div>
|
||||
<div v-else class="grid max-h-64 gap-2 overflow-auto sm:grid-cols-2 xl:grid-cols-3">
|
||||
<button
|
||||
v-for="person in personOptions"
|
||||
:key="person.id"
|
||||
type="button"
|
||||
class="touch-option text-left"
|
||||
:class="{ 'is-active': form.person_ids.includes(person.id) }"
|
||||
@click="togglePerson(person)"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div class="min-w-0">
|
||||
<p class="truncate text-sm font-medium text-text-primary">{{ person.nickname }} · {{ person.real_name }}</p>
|
||||
<p class="mt-1 truncate text-xs text-text-secondary">
|
||||
{{ person.main_department?.name || person.department || '未归属部门' }}
|
||||
<span v-if="person.job_number"> · {{ person.job_number }}</span>
|
||||
</p>
|
||||
</div>
|
||||
<span class="badge" :class="person.dingtalk_ready ? 'badge-success' : 'badge-warning'">
|
||||
{{ person.dingtalk_ready ? '钉钉可达' : '无法通知' }}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
<p v-if="personOptions.length === 0" class="py-6 text-center text-sm text-text-tertiary sm:col-span-2 xl:col-span-3">
|
||||
没有匹配的在职人员。
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="grid gap-6 xl:grid-cols-3">
|
||||
<section class="space-y-3">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-sm font-semibold text-text-primary">业务部门</p>
|
||||
<p class="mt-1 text-xs text-text-tertiary">{{ selectedDepartmentLabel }}</p>
|
||||
<p class="mt-1 text-xs text-text-tertiary">{{ selectedDepartmentLabel }},自动包含下级部门</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
@ -251,7 +314,7 @@
|
||||
<div class="panel">
|
||||
<p class="mobile-card__label">无人归属资产</p>
|
||||
<p class="mt-1 text-lg font-semibold text-text-primary">{{ formatNumber(preview.unassigned_count) }}</p>
|
||||
<p class="mt-1 text-xs text-text-secondary">这部分会单列给管理员处理,其他资产管理员也可直接代扫</p>
|
||||
<p class="mt-1 text-xs text-text-secondary">这部分会单列给管理人员处理,管理人员也可直接代扫</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -360,12 +423,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { getAssetTypeOptions } from '@/api/assetType'
|
||||
import { getDepartmentOptions } from '@/api/department'
|
||||
import { createInventoryTask, previewInventoryTask } from '@/api/inventory'
|
||||
import { getStorageLocationOptions } from '@/api/storageLocation'
|
||||
import { getPersonOptions, type PersonOption } from '@/api/person'
|
||||
import type { AssetType, Department, InventoryTaskPreview, StorageLocation } from '@/types'
|
||||
import { formatNumber } from '@/utils/format'
|
||||
import { getInventoryPublicAssetLabel, isInventoryPublicAssetPerson } from './inventoryPublicAsset'
|
||||
@ -376,17 +440,31 @@ const router = useRouter()
|
||||
const departments = ref<Department[]>([])
|
||||
const assetTypes = ref<AssetType[]>([])
|
||||
const storageLocations = ref<StorageLocation[]>([])
|
||||
const personOptions = ref<PersonOption[]>([])
|
||||
const selectedPeople = ref<PersonOption[]>([])
|
||||
const personKeyword = ref('')
|
||||
const personLoading = ref(false)
|
||||
const preview = ref<InventoryTaskPreview | null>(null)
|
||||
const previewLoading = ref(false)
|
||||
const creating = ref(false)
|
||||
const previewDirty = ref(true)
|
||||
|
||||
const toLocalDateTimeInput = (date: Date) => {
|
||||
const pad = (value: number) => String(value).padStart(2, '0')
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`
|
||||
}
|
||||
|
||||
const defaultDeadline = new Date(Date.now() + 3 * 24 * 60 * 60 * 1000)
|
||||
defaultDeadline.setSeconds(0, 0)
|
||||
|
||||
const form = reactive({
|
||||
title: '',
|
||||
description: '',
|
||||
deadline_at: toLocalDateTimeInput(defaultDeadline),
|
||||
department_ids: [] as number[],
|
||||
asset_type_ids: [] as number[],
|
||||
storage_location_ids: [] as number[]
|
||||
storage_location_ids: [] as number[],
|
||||
person_ids: [] as number[]
|
||||
})
|
||||
|
||||
const selectedDepartments = computed(() =>
|
||||
@ -399,13 +477,14 @@ const selectedStorageLocations = computed(() =>
|
||||
storageLocations.value.filter((location) => form.storage_location_ids.includes(location.id))
|
||||
)
|
||||
|
||||
const currentScopeLabel = computed(() =>
|
||||
buildInventoryScopeLabel({
|
||||
const currentScopeLabel = computed(() => {
|
||||
const scope = buildInventoryScopeLabel({
|
||||
scope_departments: selectedDepartments.value,
|
||||
scope_asset_types: selectedAssetTypes.value,
|
||||
scope_storage_locations: selectedStorageLocations.value
|
||||
})
|
||||
)
|
||||
return form.person_ids.length > 0 ? `${scope} · 指定 ${form.person_ids.length} 人` : scope
|
||||
})
|
||||
const selectedDepartmentLabel = computed(() =>
|
||||
form.department_ids.length > 0 ? `已选 ${form.department_ids.length} 个业务部门` : '留空表示全部业务部门'
|
||||
)
|
||||
@ -420,7 +499,8 @@ const canCreate = computed(() => form.title.length > 0 && !creating.value)
|
||||
const buildPreviewPayload = () => ({
|
||||
department_ids: [...form.department_ids],
|
||||
asset_type_ids: [...form.asset_type_ids],
|
||||
storage_location_ids: [...form.storage_location_ids]
|
||||
storage_location_ids: [...form.storage_location_ids],
|
||||
person_ids: [...form.person_ids]
|
||||
})
|
||||
|
||||
const toggleSelection = (
|
||||
@ -439,6 +519,38 @@ const clearSelection = (key: 'department_ids' | 'asset_type_ids' | 'storage_loca
|
||||
form[key] = [] as never
|
||||
}
|
||||
|
||||
const togglePerson = (person: PersonOption) => {
|
||||
if (form.person_ids.includes(person.id)) {
|
||||
form.person_ids = form.person_ids.filter((id) => id !== person.id)
|
||||
selectedPeople.value = selectedPeople.value.filter((item) => item.id !== person.id)
|
||||
return
|
||||
}
|
||||
|
||||
form.person_ids = [...form.person_ids, person.id].sort((left, right) => left - right)
|
||||
selectedPeople.value = [...selectedPeople.value, person]
|
||||
.sort((left, right) => left.nickname.localeCompare(right.nickname, 'zh-CN'))
|
||||
}
|
||||
|
||||
let personSearchTimer: number | null = null
|
||||
|
||||
const searchPeople = async () => {
|
||||
personLoading.value = true
|
||||
try {
|
||||
const response = await getPersonOptions({
|
||||
page: 1,
|
||||
pageSize: 30,
|
||||
keyword: personKeyword.value,
|
||||
status: 'active'
|
||||
})
|
||||
personOptions.value = response.data?.list || []
|
||||
} catch (error: any) {
|
||||
personOptions.value = []
|
||||
alert(error?.message || '搜索盘点人员失败')
|
||||
} finally {
|
||||
personLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadOptions = async () => {
|
||||
try {
|
||||
const [departmentResponse, assetTypeResponse, storageLocationResponse] = await Promise.all([
|
||||
@ -500,6 +612,7 @@ const handleSubmit = async () => {
|
||||
const response = await createInventoryTask({
|
||||
title: form.title.trim(),
|
||||
description: form.description.trim() || undefined,
|
||||
deadline_at: form.deadline_at ? new Date(form.deadline_at).toISOString() : undefined,
|
||||
...buildPreviewPayload()
|
||||
})
|
||||
alert('盘点任务已创建')
|
||||
@ -512,14 +625,26 @@ const handleSubmit = async () => {
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [form.department_ids.join(','), form.asset_type_ids.join(','), form.storage_location_ids.join(',')],
|
||||
() => [form.department_ids.join(','), form.asset_type_ids.join(','), form.storage_location_ids.join(','), form.person_ids.join(',')],
|
||||
() => {
|
||||
previewDirty.value = true
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => personKeyword.value,
|
||||
() => {
|
||||
if (personSearchTimer) window.clearTimeout(personSearchTimer)
|
||||
personSearchTimer = window.setTimeout(searchPeople, 250)
|
||||
}
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadOptions()
|
||||
await Promise.all([loadOptions(), searchPeople()])
|
||||
handlePreview()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (personSearchTimer) window.clearTimeout(personSearchTimer)
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -40,7 +40,7 @@
|
||||
<p class="page-meta__label">任务状态</p>
|
||||
<p class="page-meta__value">{{ getTaskStatusText(task.status) }}</p>
|
||||
<p class="page-meta__subvalue">
|
||||
{{ task.closed_at ? `关闭于 ${formatDateTime(task.closed_at)}` : `创建于 ${formatDateTime(task.created_at)}` }}
|
||||
{{ task.closed_at ? `关闭于 ${formatDateTime(task.closed_at)}` : task.deadline_at ? `截止 ${formatDateTime(task.deadline_at)}` : `创建于 ${formatDateTime(task.created_at)}` }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="page-meta">
|
||||
@ -56,7 +56,7 @@
|
||||
<div class="page-meta">
|
||||
<p class="page-meta__label">无人归属资产</p>
|
||||
<p class="page-meta__value">{{ formatNumber(unassignedItems.length) }}</p>
|
||||
<p class="page-meta__subvalue">这部分会单列处理,其他责任人资产管理员也可直接代扫</p>
|
||||
<p class="page-meta__subvalue">这部分会单列处理,管理人员也可直接代扫</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@ -78,6 +78,10 @@
|
||||
<p class="field-label">创建人</p>
|
||||
<p class="text-sm text-text-primary">{{ task.created_by_name || `用户 ${task.created_by}` }}</p>
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<p class="field-label">截止时间</p>
|
||||
<p class="text-sm text-text-primary">{{ task.deadline_at ? formatDateTime(task.deadline_at) : '未设置' }}</p>
|
||||
</div>
|
||||
<div class="field-stack">
|
||||
<p class="field-label">手机盘点地址</p>
|
||||
<div class="flex flex-col gap-2 sm:flex-row">
|
||||
@ -143,8 +147,8 @@
|
||||
<section v-if="task.status === 'active'" class="card">
|
||||
<div class="list-header">
|
||||
<div>
|
||||
<h3 class="section-title">管理员代扫</h3>
|
||||
<p class="mt-1 text-sm text-text-secondary">管理员可直接替任意责任人完成扫码盘点,也能顺手处理无人归属资产。</p>
|
||||
<h3 class="section-title">管理人员代扫</h3>
|
||||
<p class="mt-1 text-sm text-text-secondary">管理员、行政或 IT 可替责任人完成扫码盘点,也能处理无人归属资产。</p>
|
||||
</div>
|
||||
<span class="badge badge-warning">全任务通用</span>
|
||||
</div>
|
||||
@ -526,7 +530,7 @@
|
||||
|
||||
<CameraScannerModal
|
||||
v-model="showAdminScanner"
|
||||
title="管理员代扫"
|
||||
title="管理人员代扫"
|
||||
description="扫码后将直接替责任人完成本次盘点。"
|
||||
manual-placeholder="请输入资产编码"
|
||||
@detected="handleAdminScannerDetected"
|
||||
@ -716,7 +720,7 @@ const resolveActionSummary = (action: InventoryTaskAction) => {
|
||||
task_closed: '关闭了盘点任务',
|
||||
participant_confirmed: '盘点人完成身份确认',
|
||||
item_checked: '确认了一件资产',
|
||||
item_checked_by_admin: '管理员代扫了一件资产',
|
||||
item_checked_by_admin: '管理人员代扫了一件资产',
|
||||
item_exception_reported: '上报了一件异常资产',
|
||||
item_reassigned: '重新分配了一件无人归属资产'
|
||||
}
|
||||
@ -735,8 +739,8 @@ const resolveActionDetail = (action: InventoryTaskAction) => {
|
||||
return payload?.equipment_code ? `资产编码:${payload.equipment_code}` : '资产已被确认。'
|
||||
case 'item_checked_by_admin':
|
||||
return payload?.equipment_code
|
||||
? `管理员代扫资产编码:${payload.equipment_code}${payload?.is_unassigned ? '(原为无人归属)' : ''}`
|
||||
: '管理员已代为完成扫码盘点。'
|
||||
? `管理人员代扫资产编码:${payload.equipment_code}${payload?.is_unassigned ? '(原为无人归属)' : ''}`
|
||||
: '管理人员已代为完成扫码盘点。'
|
||||
case 'item_exception_reported':
|
||||
return payload
|
||||
? `${getExceptionText(payload.exception_type)}${payload.exception_note ? `,备注:${payload.exception_note}` : ''}`
|
||||
@ -859,7 +863,7 @@ const runAdminScan = async (equipmentCode: string) => {
|
||||
showAdminScanner.value = false
|
||||
await loadTask()
|
||||
} catch (error: any) {
|
||||
alert(error?.message || '管理员扫码盘点失败')
|
||||
alert(error?.message || '管理人员扫码盘点失败')
|
||||
} finally {
|
||||
adminScanning.value = false
|
||||
}
|
||||
|
||||
@ -85,6 +85,10 @@
|
||||
<p class="mobile-card__label">创建时间</p>
|
||||
<p class="mobile-card__value">{{ formatDateTime(task.created_at) }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="mobile-card__label">截止时间</p>
|
||||
<p class="mobile-card__value">{{ task.deadline_at ? formatDateTime(task.deadline_at) : '未设置' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 grid gap-2 sm:grid-cols-2">
|
||||
@ -164,6 +168,7 @@
|
||||
<td>
|
||||
<p class="text-sm text-text-primary">{{ task.created_by_name || `用户 ${task.created_by}` }}</p>
|
||||
<p class="mt-1 text-xs text-text-secondary">{{ formatDateTime(task.created_at) }}</p>
|
||||
<p v-if="task.deadline_at" class="mt-1 text-xs text-status-warning">截止 {{ formatDateTime(task.deadline_at) }}</p>
|
||||
<p v-if="task.closed_at" class="mt-1 text-xs text-text-tertiary">结束于 {{ formatDateTime(task.closed_at) }}</p>
|
||||
</td>
|
||||
<td>
|
||||
|
||||
@ -8,6 +8,7 @@
|
||||
<h2 class="page-title">{{ displayedTask?.title || '手机盘点' }}</h2>
|
||||
<p class="mt-2 text-xs text-text-secondary">
|
||||
{{ displayedTask?.description || '确认身份后盘点资产' }}
|
||||
<span v-if="displayedTask?.deadline_at"> · 截止 {{ formatDateTime(displayedTask.deadline_at) }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@ -48,13 +49,34 @@
|
||||
<template v-else-if="task">
|
||||
<section v-if="!session" class="card">
|
||||
<div class="list-header">
|
||||
<h3 class="section-title">确认身份</h3>
|
||||
<h3 class="section-title">{{ usesBoundIdentity ? '钉钉身份确认' : '确认身份' }}</h3>
|
||||
<span class="badge" :class="isTaskClosed ? 'badge-neutral' : 'badge-success'">
|
||||
{{ isTaskClosed ? '任务已关闭,仅可查看结果' : '任务进行中' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div v-if="usesBoundIdentity" class="panel">
|
||||
<p class="text-sm font-medium text-text-primary">
|
||||
{{ automaticIdentityError || '正在按当前钉钉账号匹配本人盘点清单...' }}
|
||||
</p>
|
||||
<p class="mt-2 text-xs leading-5 text-text-secondary">
|
||||
系统只允许进入本人任务,不能切换成其他员工。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="requiresDingTalkIdentity" class="panel space-y-4">
|
||||
<div>
|
||||
<p class="text-sm font-medium text-text-primary">请使用本人钉钉身份进入</p>
|
||||
<p class="mt-2 text-xs leading-5 text-text-secondary">
|
||||
新盘点任务不再允许通过共享链接手工选择姓名,避免误选或代替他人提交。
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" class="btn-primary w-full" @click="startDingTalkIdentityLogin">
|
||||
使用钉钉登录并进入本人清单
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div class="field-stack">
|
||||
<label class="field-label">搜索姓名</label>
|
||||
<input
|
||||
@ -135,7 +157,7 @@
|
||||
>
|
||||
{{ showManualEntry ? '收起' : '手动输入' }}
|
||||
</button>
|
||||
<button type="button" class="btn-secondary w-full text-sm sm:w-auto" @click="switchIdentity">
|
||||
<button v-if="!usesBoundIdentity && !requiresDingTalkIdentity" type="button" class="btn-secondary w-full text-sm sm:w-auto" @click="switchIdentity">
|
||||
切换
|
||||
</button>
|
||||
</div>
|
||||
@ -417,6 +439,7 @@ import { useRoute } from 'vue-router'
|
||||
import CameraScannerModal from '@/components/CameraScannerModal.vue'
|
||||
import {
|
||||
confirmPublicInventoryParticipant,
|
||||
confirmCurrentInventoryParticipant,
|
||||
getPublicInventoryItems,
|
||||
getPublicInventoryMe,
|
||||
getPublicInventoryTask,
|
||||
@ -424,6 +447,7 @@ import {
|
||||
submitPublicInventoryException,
|
||||
submitPublicInventoryScan
|
||||
} from '@/api/publicInventory'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import type {
|
||||
InventoryExceptionType,
|
||||
InventoryTaskItem,
|
||||
@ -439,6 +463,7 @@ type ItemFilter = 'all' | 'pending' | 'checked' | 'exception'
|
||||
type FeedbackType = 'success' | 'error' | 'info'
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const task = ref<PublicInventoryTaskInfo | null>(null)
|
||||
const session = ref<PublicInventorySessionData | null>(null)
|
||||
@ -454,6 +479,7 @@ const lastHandledItem = ref<InventoryTaskItem | null>(null)
|
||||
const loadingTask = ref(false)
|
||||
const searchingParticipants = ref(false)
|
||||
const confirmingParticipant = ref(false)
|
||||
const automaticIdentityError = ref('')
|
||||
const exceptionSubmitting = ref(false)
|
||||
const feedback = ref<{ type: FeedbackType; text: string } | null>(null)
|
||||
const participantKeyword = ref('')
|
||||
@ -477,6 +503,8 @@ const exceptionForm = reactive<{
|
||||
let participantSearchTimer: number | null = null
|
||||
|
||||
const taskToken = computed(() => String(route.params.token || ''))
|
||||
const usesBoundIdentity = computed(() => Boolean(authStore.isAuthenticated && authStore.user?.person_id))
|
||||
const requiresDingTalkIdentity = computed(() => task.value?.identity_mode === 'dingtalk')
|
||||
const displayedTask = computed(() => {
|
||||
if (!task.value) return null
|
||||
if (!session.value || session.value.task.id !== task.value.id) return task.value
|
||||
@ -629,12 +657,24 @@ const bootstrap = async () => {
|
||||
participants.value = []
|
||||
selectedParticipantId.value = null
|
||||
lastHandledItem.value = null
|
||||
automaticIdentityError.value = ''
|
||||
|
||||
try {
|
||||
await loadTaskInfo()
|
||||
const hasSession = await loadSessionAndItems()
|
||||
if (!hasSession) {
|
||||
await searchParticipantsNow('')
|
||||
if (usesBoundIdentity.value) {
|
||||
try {
|
||||
await confirmCurrentInventoryParticipant(taskToken.value)
|
||||
const confirmed = await loadSessionAndItems()
|
||||
if (!confirmed) throw new Error('钉钉身份已确认,但盘点会话建立失败')
|
||||
setFeedback('success', '已按当前钉钉身份进入本人盘点清单')
|
||||
} catch (error: any) {
|
||||
automaticIdentityError.value = error?.message || '当前钉钉账号不在本次盘点范围内'
|
||||
}
|
||||
} else if (!requiresDingTalkIdentity.value) {
|
||||
await searchParticipantsNow('')
|
||||
}
|
||||
}
|
||||
} catch (error: any) {
|
||||
setFeedback('error', error?.message || '获取盘点任务失败')
|
||||
@ -643,6 +683,11 @@ const bootstrap = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const startDingTalkIdentityLogin = () => {
|
||||
const redirect = `/inventory/task/${encodeURIComponent(taskToken.value)}`
|
||||
window.location.assign(`/dingtalk-entry?redirect=${encodeURIComponent(redirect)}`)
|
||||
}
|
||||
|
||||
const confirmIdentity = async () => {
|
||||
if (!selectedParticipantId.value) {
|
||||
setFeedback('error', '请先选择你的身份')
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
import type { InventoryScopeEntity } from '@/types'
|
||||
import type { InventoryScopeEntity, InventoryScopePerson } from '@/types'
|
||||
|
||||
type InventoryScopeLike = {
|
||||
scope_departments?: InventoryScopeEntity[] | null
|
||||
scope_asset_types?: InventoryScopeEntity[] | null
|
||||
scope_storage_locations?: InventoryScopeEntity[] | null
|
||||
scope_people?: InventoryScopePerson[] | null
|
||||
scope_department_name?: string | null
|
||||
scope_asset_type_name?: string | null
|
||||
}
|
||||
@ -53,6 +54,10 @@ export const buildInventoryScopeLabel = (scope: InventoryScopeLike) => {
|
||||
formatScopeSegment(
|
||||
'地区',
|
||||
normalizeScopeEntities(scope.scope_storage_locations)
|
||||
),
|
||||
formatScopeSegment(
|
||||
'人员',
|
||||
normalizeScopeEntities(scope.scope_people)
|
||||
)
|
||||
].filter(Boolean)
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user