From c29751059e9b458f5f0ea5f2a29e31dca4b60c55 Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Fri, 17 Jul 2026 14:51:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=85=A8=E9=87=8F=E5=BC=80=E6=94=BE?= =?UTF-8?q?=E9=92=89=E9=92=89=E7=99=BB=E5=BD=95=E5=B9=B6=E5=8D=87=E7=BA=A7?= =?UTF-8?q?=E8=B5=84=E4=BA=A7=E7=9B=98=E7=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../migration.sql | 11 + backend/prisma/schema.prisma | 3 + .../dingtalkController.broker-login.test.ts | 11 +- .../dingtalkController.redirect.test.ts | 4 + .../personController.privacy.test.ts | 1 + backend/src/controllers/dingtalkController.ts | 10 +- .../src/controllers/inventoryController.ts | 16 +- backend/src/controllers/personController.ts | 17 +- .../controllers/publicInventoryController.ts | 76 ++++- backend/src/routes/publicInventory.ts | 3 + .../dingtalkUserProvisionService.test.ts | 137 ++++++++ .../__tests__/inventoryIdentity.test.ts | 136 ++++++++ .../services/dingtalkBrokerLoginService.ts | 11 + .../services/dingtalkUserProvisionService.ts | 95 ++++++ backend/src/services/inventoryService.ts | 294 +++++++++++++++++- .../inventoryDingTalkMigration.test.ts | 20 ++ .../utils/__tests__/inventoryScope.test.ts | 21 +- .../src/utils/__tests__/permissions.test.ts | 3 +- backend/src/utils/dingtalkRedirect.ts | 1 + backend/src/utils/inventoryScope.ts | 7 +- backend/src/utils/permissions.ts | 1 + frontend/src/api/dingtalk.ts | 1 + frontend/src/api/inventory.ts | 3 + frontend/src/api/publicInventory.ts | 13 + frontend/src/types/index.ts | 20 ++ frontend/src/views/Login.vue | 39 ++- frontend/src/views/auth/DingTalkEntry.vue | 2 +- .../views/dashboard/dashboardRoleConfig.ts | 1 + .../src/views/inventory/InventoryCreate.vue | 145 ++++++++- .../src/views/inventory/InventoryDetail.vue | 22 +- .../src/views/inventory/InventoryList.vue | 5 + .../views/inventory/PublicInventoryTask.vue | 53 +++- .../views/inventory/inventoryScopeSummary.ts | 7 +- 33 files changed, 1123 insertions(+), 66 deletions(-) create mode 100644 backend/prisma/migrations/20260717_inventory_dingtalk_dispatch/migration.sql create mode 100644 backend/src/services/__tests__/dingtalkUserProvisionService.test.ts create mode 100644 backend/src/services/__tests__/inventoryIdentity.test.ts create mode 100644 backend/src/services/dingtalkUserProvisionService.ts create mode 100644 backend/src/utils/__tests__/inventoryDingTalkMigration.test.ts diff --git a/backend/prisma/migrations/20260717_inventory_dingtalk_dispatch/migration.sql b/backend/prisma/migrations/20260717_inventory_dingtalk_dispatch/migration.sql new file mode 100644 index 0000000..98da495 --- /dev/null +++ b/backend/prisma/migrations/20260717_inventory_dingtalk_dispatch/migration.sql @@ -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`); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 65dae77..c7ca10d 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -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") } diff --git a/backend/src/controllers/__tests__/dingtalkController.broker-login.test.ts b/backend/src/controllers/__tests__/dingtalkController.broker-login.test.ts index 663abd8..59c4ce5 100644 --- a/backend/src/controllers/__tests__/dingtalkController.broker-login.test.ts +++ b/backend/src/controllers/__tests__/dingtalkController.broker-login.test.ts @@ -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 () => { diff --git a/backend/src/controllers/__tests__/dingtalkController.redirect.test.ts b/backend/src/controllers/__tests__/dingtalkController.redirect.test.ts index fc2e458..2dac908 100644 --- a/backend/src/controllers/__tests__/dingtalkController.redirect.test.ts +++ b/backend/src/controllers/__tests__/dingtalkController.redirect.test.ts @@ -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('钉钉免登拒绝外链、协议相对地址和非白名单路径', () => { diff --git a/backend/src/controllers/__tests__/personController.privacy.test.ts b/backend/src/controllers/__tests__/personController.privacy.test.ts index 7c20271..836134e 100644 --- a/backend/src/controllers/__tests__/personController.privacy.test.ts +++ b/backend/src/controllers/__tests__/personController.privacy.test.ts @@ -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) diff --git a/backend/src/controllers/dingtalkController.ts b/backend/src/controllers/dingtalkController.ts index 602c69c..7a35b39 100644 --- a/backend/src/controllers/dingtalkController.ts +++ b/backend/src/controllers/dingtalkController.ts @@ -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) { diff --git a/backend/src/controllers/inventoryController.ts b/backend/src/controllers/inventoryController.ts index 0ef2dd4..2b43a7a 100644 --- a/backend/src/controllers/inventoryController.ts +++ b/backend/src/controllers/inventoryController.ts @@ -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, diff --git a/backend/src/controllers/personController.ts b/backend/src/controllers/personController.ts index 706a2f4..277578c 100644 --- a/backend/src/controllers/personController.ts +++ b/backend/src/controllers/personController.ts @@ -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, diff --git a/backend/src/controllers/publicInventoryController.ts b/backend/src/controllers/publicInventoryController.ts index 9d249ae..a6af82f 100644 --- a/backend/src/controllers/publicInventoryController.ts +++ b/backend/src/controllers/publicInventoryController.ts @@ -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 diff --git a/backend/src/routes/publicInventory.ts b/backend/src/routes/publicInventory.ts index 20ac36f..86c1201 100644 --- a/backend/src/routes/publicInventory.ts +++ b/backend/src/routes/publicInventory.ts @@ -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) diff --git a/backend/src/services/__tests__/dingtalkUserProvisionService.test.ts b/backend/src/services/__tests__/dingtalkUserProvisionService.test.ts new file mode 100644 index 0000000..6203117 --- /dev/null +++ b/backend/src/services/__tests__/dingtalkUserProvisionService.test.ts @@ -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) +}) diff --git a/backend/src/services/__tests__/inventoryIdentity.test.ts b/backend/src/services/__tests__/inventoryIdentity.test.ts new file mode 100644 index 0000000..cb10996 --- /dev/null +++ b/backend/src/services/__tests__/inventoryIdentity.test.ts @@ -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 + } +}) diff --git a/backend/src/services/dingtalkBrokerLoginService.ts b/backend/src/services/dingtalkBrokerLoginService.ts index ed0431d..019bc0b 100644 --- a/backend/src/services/dingtalkBrokerLoginService.ts +++ b/backend/src/services/dingtalkBrokerLoginService.ts @@ -1,6 +1,8 @@ import prisma from '../utils/prisma' +import { provisionDingTalkLoginUser } from './dingtalkUserProvisionService' type UserLookupClient = Pick +type LoginProvisionClient = Pick 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) +} diff --git a/backend/src/services/dingtalkUserProvisionService.ts b/backend/src/services/dingtalkUserProvisionService.ts new file mode 100644 index 0000000..b71efe2 --- /dev/null +++ b/backend/src/services/dingtalkUserProvisionService.ts @@ -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 | 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) +} diff --git a/backend/src/services/inventoryService.ts b/backend/src/services/inventoryService.ts index 31664ad..a1fe6e2 100644 --- a/backend/src/services/inventoryService.ts +++ b/backend/src/services/inventoryService.ts @@ -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 = { + 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() + 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 => { 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({ diff --git a/backend/src/utils/__tests__/inventoryDingTalkMigration.test.ts b/backend/src/utils/__tests__/inventoryDingTalkMigration.test.ts new file mode 100644 index 0000000..1e99d2c --- /dev/null +++ b/backend/src/utils/__tests__/inventoryDingTalkMigration.test.ts @@ -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) +}) diff --git a/backend/src/utils/__tests__/inventoryScope.test.ts b/backend/src/utils/__tests__/inventoryScope.test.ts index 3df8ec0..2f1f9b9 100644 --- a/backend/src/utils/__tests__/inventoryScope.test.ts +++ b/backend/src/utils/__tests__/inventoryScope.test.ts @@ -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] } ) }) diff --git a/backend/src/utils/__tests__/permissions.test.ts b/backend/src/utils/__tests__/permissions.test.ts index 1d60299..2a56874 100644 --- a/backend/src/utils/__tests__/permissions.test.ts +++ b/backend/src/utils/__tests__/permissions.test.ts @@ -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)) }) diff --git a/backend/src/utils/dingtalkRedirect.ts b/backend/src/utils/dingtalkRedirect.ts index 8339d60..a9b4246 100644 --- a/backend/src/utils/dingtalkRedirect.ts +++ b/backend/src/utils/dingtalkRedirect.ts @@ -2,6 +2,7 @@ const allowedRedirectPrefixes = [ '/workflows/disposals', '/maintenance', '/my-assets', + '/inventory/task', '/equipment', '/notifications', '/dashboard' diff --git a/backend/src/utils/inventoryScope.ts b/backend/src/utils/inventoryScope.ts index 369467f..92bdb87 100644 --- a/backend/src/utils/inventoryScope.ts +++ b/backend/src/utils/inventoryScope.ts @@ -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 = ( diff --git a/backend/src/utils/permissions.ts b/backend/src/utils/permissions.ts index 24372b5..a3743a3 100644 --- a/backend/src/utils/permissions.ts +++ b/backend/src/utils/permissions.ts @@ -51,6 +51,7 @@ const ROLE_PERMISSIONS: Record = { PERMISSIONS.ASSETS_MOUNT, PERMISSIONS.ASSETS_PRINT_LABELS, PERMISSIONS.DISPOSALS_APPLY, + PERMISSIONS.INVENTORY_MANAGE, PERMISSIONS.NOTIFICATIONS_VIEW, PERMISSIONS.REPORTS_VIEW ], diff --git a/frontend/src/api/dingtalk.ts b/frontend/src/api/dingtalk.ts index b2b7788..08d27be 100644 --- a/frontend/src/api/dingtalk.ts +++ b/frontend/src/api/dingtalk.ts @@ -5,6 +5,7 @@ export interface DingTalkPublicConfig { configured: boolean mode: 'notification_center' loginEntryUrl?: string + rolloutMode?: 'disabled' | 'test' | 'full' } export interface DingTalkDeliveryStatus { diff --git a/frontend/src/api/inventory.ts b/frontend/src/api/inventory.ts index 318f5d0..883ae40 100644 --- a/frontend/src/api/inventory.ts +++ b/frontend/src/api/inventory.ts @@ -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) => { diff --git a/frontend/src/api/publicInventory.ts b/frontend/src/api/publicInventory.ts index 781e0b2..c2b3d53 100644 --- a/frontend/src/api/publicInventory.ts +++ b/frontend/src/api/publicInventory.ts @@ -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>('/me') return response.data diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index e008ec8..4b31662 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -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 } diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index 817e104..99aaa6d 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -49,6 +49,26 @@ > {{ loading ? '登录中...' : '进入系统' }} + +
@@ -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 + } }) diff --git a/frontend/src/views/auth/DingTalkEntry.vue b/frontend/src/views/auth/DingTalkEntry.vue index f6aa91c..6303a19 100644 --- a/frontend/src/views/auth/DingTalkEntry.vue +++ b/frontend/src/views/auth/DingTalkEntry.vue @@ -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() diff --git a/frontend/src/views/dashboard/dashboardRoleConfig.ts b/frontend/src/views/dashboard/dashboardRoleConfig.ts index 1379599..7dc61b2 100644 --- a/frontend/src/views/dashboard/dashboardRoleConfig.ts +++ b/frontend/src/views/dashboard/dashboardRoleConfig.ts @@ -18,6 +18,7 @@ const ACTIONS_BY_ROLE: Record = { { 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' } ], diff --git a/frontend/src/views/inventory/InventoryCreate.vue b/frontend/src/views/inventory/InventoryCreate.vue index 97e4a17..d8e08af 100644 --- a/frontend/src/views/inventory/InventoryCreate.vue +++ b/frontend/src/views/inventory/InventoryCreate.vue @@ -59,6 +59,11 @@ placeholder="可选,给盘点负责人留一句简短说明" >
+
+ + +

通知中会明确显示,便于员工及时完成。

+
@@ -70,12 +75,70 @@ +
+
+
+

指定盘点人员(可选)

+

+ 留空时覆盖当前资产范围内的全部责任人;指定后只纳入这些人员当前持有的资产。 +

+
+ 已选 {{ form.person_ids.length }} 人 +
+ +
+ +
+ +
+ + +
+ +
正在搜索人员...
+
+ +

+ 没有匹配的在职人员。 +

+
+
+

业务部门

-

{{ selectedDepartmentLabel }}

+

{{ selectedDepartmentLabel }},自动包含下级部门

@@ -360,12 +423,13 @@ diff --git a/frontend/src/views/inventory/InventoryDetail.vue b/frontend/src/views/inventory/InventoryDetail.vue index 94cd546..a56b2ec 100644 --- a/frontend/src/views/inventory/InventoryDetail.vue +++ b/frontend/src/views/inventory/InventoryDetail.vue @@ -40,7 +40,7 @@

任务状态

{{ getTaskStatusText(task.status) }}

- {{ 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)}` }}

@@ -56,7 +56,7 @@

无人归属资产

{{ formatNumber(unassignedItems.length) }}

-

这部分会单列处理,其他责任人资产管理员也可直接代扫

+

这部分会单列处理,管理人员也可直接代扫

@@ -78,6 +78,10 @@

创建人

{{ task.created_by_name || `用户 ${task.created_by}` }}

+
+

截止时间

+

{{ task.deadline_at ? formatDateTime(task.deadline_at) : '未设置' }}

+

手机盘点地址

@@ -143,8 +147,8 @@
-

管理员代扫

-

管理员可直接替任意责任人完成扫码盘点,也能顺手处理无人归属资产。

+

管理人员代扫

+

管理员、行政或 IT 可替责任人完成扫码盘点,也能处理无人归属资产。

全任务通用
@@ -526,7 +530,7 @@ { 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 } diff --git a/frontend/src/views/inventory/InventoryList.vue b/frontend/src/views/inventory/InventoryList.vue index fab77f7..5148170 100644 --- a/frontend/src/views/inventory/InventoryList.vue +++ b/frontend/src/views/inventory/InventoryList.vue @@ -85,6 +85,10 @@

创建时间

{{ formatDateTime(task.created_at) }}

+
+

截止时间

+

{{ task.deadline_at ? formatDateTime(task.deadline_at) : '未设置' }}

+
@@ -164,6 +168,7 @@

{{ task.created_by_name || `用户 ${task.created_by}` }}

{{ formatDateTime(task.created_at) }}

+

截止 {{ formatDateTime(task.deadline_at) }}

结束于 {{ formatDateTime(task.closed_at) }}

diff --git a/frontend/src/views/inventory/PublicInventoryTask.vue b/frontend/src/views/inventory/PublicInventoryTask.vue index 73546d5..59c9430 100644 --- a/frontend/src/views/inventory/PublicInventoryTask.vue +++ b/frontend/src/views/inventory/PublicInventoryTask.vue @@ -8,6 +8,7 @@

{{ displayedTask?.title || '手机盘点' }}

{{ displayedTask?.description || '确认身份后盘点资产' }} + · 截止 {{ formatDateTime(displayedTask.deadline_at) }}

@@ -48,13 +49,34 @@