diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0831a6d --- /dev/null +++ b/.env.example @@ -0,0 +1,20 @@ +MYSQL_ROOT_PASSWORD=change-me-local-password +MYSQL_DATABASE=equipment_management +SESSION_SECRET=change-me-session-secret +AUTH_MODE=local +PORT=3000 +VITE_API_BASE_URL=/api +VITE_AUTH_MODE=local +SQLSERVER_HOST= +SQLSERVER_PORT=1433 +SQLSERVER_DATABASE= +SQLSERVER_USER= +SQLSERVER_PASSWORD= +SQLSERVER_ENCRYPT=false +SQLSERVER_TRUST_CERT=true +HR_SYNC_ENABLED=false +HR_SYNC_SCHEDULE=03:00 +HR_SYNC_ALLOWED_STATUS_VALUES=3 +HR_SYNC_INACTIVE_STATUS_VALUES=5 +HR_SYNC_EXCLUDED_DEPARTMENTS=供应商,快递公司,外部交流 +HR_SYNC_EXCLUDED_NAME_KEYWORDS= diff --git a/.gitignore b/.gitignore index 9168751..4b8827c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,7 @@ hr_sync_cookie.txt +.env +.env.* +!.env.example backend/node_modules/ backend/dist/ frontend/node_modules/ diff --git a/README.md b/README.md index 541552d..a63897d 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,12 @@ ## 本地开发 +先复制环境变量模板: + +```bash +cp .env.example .env +``` + 分别安装依赖: ```bash @@ -61,7 +67,8 @@ cd /opt/equipment-management 在当前这台 Windows 开发机上,推荐直接使用: ```powershell -$env:DEPLOY_PASSWORD="jsjj8888" +$env:DEPLOY_HOST="<服务器地址>" +$env:DEPLOY_PASSWORD="<部署密码>" .\scripts\publish-to-server.ps1 ``` @@ -112,11 +119,11 @@ POST /api/sync/hr/run 建议配置的环境变量: ```bash -SQLSERVER_HOST=218.93.191.16 +SQLSERVER_HOST= SQLSERVER_PORT=1433 -SQLSERVER_DATABASE=JSDataMiddlePlatform -SQLSERVER_USER=nanting -SQLSERVER_PASSWORD=*** +SQLSERVER_DATABASE= +SQLSERVER_USER= +SQLSERVER_PASSWORD= SQLSERVER_ENCRYPT=false SQLSERVER_TRUST_CERT=true HR_SYNC_ENABLED=false @@ -137,9 +144,9 @@ HR_SYNC_EXCLUDED_NAME_KEYWORDS= ## 当前线上资源 -- 前端:`http://1.13.22.133` -- 后端:`http://1.13.22.133:3000/api` -- 健康检查:`http://1.13.22.133:3000/api/health` +- 前端:`http://` +- 后端:`http://:3000/api` +- 健康检查:`http://:3000/api/health` ## 注意事项 diff --git a/backend/src/controllers/batchController.ts b/backend/src/controllers/batchController.ts index 83f0a2f..c10ade0 100644 --- a/backend/src/controllers/batchController.ts +++ b/backend/src/controllers/batchController.ts @@ -275,8 +275,8 @@ export const batchTransfer = async (req: Request, res: Response) => { } const department = await prisma.department.findUnique({ where: { id: Number(department_id) } }) - if (!department) { - return errorResponse(res, '目标部门不存在') + if (!department || department.status !== 'active') { + return errorResponse(res, '目标部门不存在或已停用') } const results: any[] = [] diff --git a/backend/src/controllers/disposalController.ts b/backend/src/controllers/disposalController.ts index 190832f..5e8d831 100644 --- a/backend/src/controllers/disposalController.ts +++ b/backend/src/controllers/disposalController.ts @@ -2,6 +2,7 @@ import { Request, Response } from 'express' import prisma from '../utils/prisma' import { successResponse, errorResponse } from '../utils/response' import { logOperation } from './operationLogController' +import { ensureNoActiveBorrowRecords } from '../utils/equipmentGuards' // 创建报废申请 export const createDisposal = async (req: Request, res: Response) => { @@ -21,28 +22,35 @@ export const createDisposal = async (req: Request, res: Response) => { return errorResponse(res, '资产不存在', 404) } - if (equipment.status === '已借出') { - return errorResponse(res, '资产已借出,无法申请报废') - } if (equipment.status === '待报废' || equipment.status === '已报废') { return errorResponse(res, '资产已在报废流程中') } - const disposal = await prisma.disposalRecord.create({ - data: { - equipment_id: equipment.id, - equipment_code: equipment.equipment_code, - disposal_reason, - disposal_method, - estimated_value: Number(estimated_value) || 0, - applied_by: userId, - notes - } - }) + try { + await ensureNoActiveBorrowRecords(equipment.id, '资产存在未归还记录,无法申请报废') + } catch (error: any) { + return errorResponse(res, error.message || '资产借用状态校验失败', 422) + } - await prisma.equipment.update({ - where: { id: equipment.id }, - data: { status: '待报废' } + const disposal = await prisma.$transaction(async (tx) => { + const created = await tx.disposalRecord.create({ + data: { + equipment_id: equipment.id, + equipment_code: equipment.equipment_code, + disposal_reason, + disposal_method, + estimated_value: Number(estimated_value) || 0, + applied_by: userId, + notes + } + }) + + await tx.equipment.update({ + where: { id: equipment.id }, + data: { status: '待报废' } + }) + + return created }) // 记录操作日志 @@ -148,21 +156,24 @@ export const approveDisposal = async (req: Request, res: Response) => { return errorResponse(res, '该申请已处理') } - if (approved) { - await prisma.disposalRecord.update({ - where: { id: Number(id) }, - data: { status: 'approved', approved_by: userId, approved_at: new Date() } - }) - } else { - await prisma.disposalRecord.update({ + await prisma.$transaction(async (tx) => { + if (approved) { + await tx.disposalRecord.update({ + where: { id: Number(id) }, + data: { status: 'approved', approved_by: userId, approved_at: new Date() } + }) + return + } + + await tx.disposalRecord.update({ where: { id: Number(id) }, data: { status: 'rejected', approved_by: userId, approved_at: new Date(), notes: reject_reason } }) - await prisma.equipment.update({ + await tx.equipment.update({ where: { id: disposal.equipment_id }, data: { status: '可用' } }) - } + }) // 记录操作日志 const username = res.locals.user?.username || res.locals.user?.name || 'unknown' @@ -200,19 +211,21 @@ export const completeDisposal = async (req: Request, res: Response) => { return errorResponse(res, '只有已审批的申请才能完成处置') } - await prisma.disposalRecord.update({ - where: { id: Number(id) }, - data: { - status: 'completed', - actual_value: actual_value !== undefined ? Number(actual_value) : disposal.estimated_value, - completed_at: new Date(), - notes: notes || disposal.notes - } - }) + await prisma.$transaction(async (tx) => { + await tx.disposalRecord.update({ + where: { id: Number(id) }, + data: { + status: 'completed', + actual_value: actual_value !== undefined ? Number(actual_value) : disposal.estimated_value, + completed_at: new Date(), + notes: notes || disposal.notes + } + }) - await prisma.equipment.update({ - where: { id: disposal.equipment_id }, - data: { status: '已报废' } + await tx.equipment.update({ + where: { id: disposal.equipment_id }, + data: { status: '已报废' } + }) }) // 记录操作日志 diff --git a/backend/src/controllers/maintenanceController.ts b/backend/src/controllers/maintenanceController.ts index bd3c074..9da5c39 100644 --- a/backend/src/controllers/maintenanceController.ts +++ b/backend/src/controllers/maintenanceController.ts @@ -2,6 +2,7 @@ import { Request, Response } from 'express' import prisma from '../utils/prisma' import { successResponse, errorResponse } from '../utils/response' import { logOperation } from './operationLogController' +import { ensureNoActiveBorrowRecords } from '../utils/equipmentGuards' // 创建维修记录 export const createMaintenance = async (req: Request, res: Response) => { @@ -21,9 +22,6 @@ export const createMaintenance = async (req: Request, res: Response) => { return errorResponse(res, '资产不存在', 404) } - if (equipment.status === '已借出') { - return errorResponse(res, '资产已借出,无法送修') - } if (equipment.status === '维护中') { return errorResponse(res, '资产已在维修中') } @@ -31,21 +29,31 @@ export const createMaintenance = async (req: Request, res: Response) => { return errorResponse(res, '资产已报废,无法送修') } - const maintenance = await prisma.maintenanceRecord.create({ - data: { - equipment_id: equipment.id, - equipment_code: equipment.equipment_code, - issue_description, - repair_vendor, - estimated_cost: Number(estimated_cost) || 0, - expected_end_date: expected_end_date ? new Date(expected_end_date) : null, - created_by: userId - } - }) + try { + await ensureNoActiveBorrowRecords(equipment.id, '资产存在未归还记录,无法送修') + } catch (error: any) { + return errorResponse(res, error.message || '资产借用状态校验失败', 422) + } - await prisma.equipment.update({ - where: { id: equipment.id }, - data: { status: '维护中' } + const maintenance = await prisma.$transaction(async (tx) => { + const created = await tx.maintenanceRecord.create({ + data: { + equipment_id: equipment.id, + equipment_code: equipment.equipment_code, + issue_description, + repair_vendor, + estimated_cost: Number(estimated_cost) || 0, + expected_end_date: expected_end_date ? new Date(expected_end_date) : null, + created_by: userId + } + }) + + await tx.equipment.update({ + where: { id: equipment.id }, + data: { status: '维护中' } + }) + + return created }) // 记录操作日志 @@ -177,19 +185,21 @@ export const completeMaintenance = async (req: Request, res: Response) => { return errorResponse(res, '只能完成进行中的维修记录') } - await prisma.maintenanceRecord.update({ - where: { id: Number(id) }, - data: { - status: 'completed', - actual_cost: actual_cost !== undefined ? Number(actual_cost) : maintenance.estimated_cost, - actual_end_date: new Date(), - repair_result - } - }) + await prisma.$transaction(async (tx) => { + await tx.maintenanceRecord.update({ + where: { id: Number(id) }, + data: { + status: 'completed', + actual_cost: actual_cost !== undefined ? Number(actual_cost) : maintenance.estimated_cost, + actual_end_date: new Date(), + repair_result + } + }) - await prisma.equipment.update({ - where: { id: maintenance.equipment_id }, - data: { status: '可用' } + await tx.equipment.update({ + where: { id: maintenance.equipment_id }, + data: { status: '可用' } + }) }) // 记录操作日志 @@ -229,14 +239,16 @@ export const cancelMaintenance = async (req: Request, res: Response) => { return errorResponse(res, '只能取消进行中的维修记录') } - await prisma.maintenanceRecord.update({ - where: { id: Number(id) }, - data: { status: 'cancelled', repair_result: reason || '已取消' } - }) + await prisma.$transaction(async (tx) => { + await tx.maintenanceRecord.update({ + where: { id: Number(id) }, + data: { status: 'cancelled', repair_result: reason || '已取消' } + }) - await prisma.equipment.update({ - where: { id: maintenance.equipment_id }, - data: { status: '可用' } + await tx.equipment.update({ + where: { id: maintenance.equipment_id }, + data: { status: '可用' } + }) }) // 记录操作日志 diff --git a/backend/src/controllers/mountController.ts b/backend/src/controllers/mountController.ts index 9410a55..d817856 100644 --- a/backend/src/controllers/mountController.ts +++ b/backend/src/controllers/mountController.ts @@ -1,9 +1,9 @@ import { Request, Response } from 'express' +import { Decimal } from '@prisma/client/runtime/library' import prisma from '../utils/prisma' import { successResponse, errorResponse } from '../utils/response' -import { Decimal } from '@prisma/client/runtime/library' +import { ensureNoActiveBorrowRecords } from '../utils/equipmentGuards' -// 计算父资产含配件总价值 async function calculateTotalValueWithChildren(parentId: number): Promise { const parent = await prisma.equipment.findUnique({ where: { id: parentId }, @@ -24,7 +24,6 @@ async function calculateTotalValueWithChildren(parentId: number): Promise { const totalValue = await calculateTotalValueWithChildren(parentId) await prisma.equipment.update({ @@ -33,31 +32,72 @@ async function updateParentTotalValue(parentId: number): Promise { }) } -// 挂载子资产到父资产 +const ensureMountableEquipment = async ( + equipment: { id: number; status: string; remaining_quantity: number }, + label: string +) => { + if (equipment.status !== '可用' || equipment.remaining_quantity <= 0) { + throw new Error(`${label}当前状态不可挂载`) + } + + await ensureNoActiveBorrowRecords(equipment.id, `${label}存在未归还记录,当前不可挂载`) +} + +const ensureNoMountCycle = async (parentId: number, childId: number) => { + if (parentId === childId) { + throw new Error('父资产和子资产不能是同一条记录') + } + + let current = await prisma.equipment.findUnique({ + where: { id: parentId }, + select: { parent_id: true } + }) + + while (current?.parent_id) { + if (current.parent_id === childId) { + throw new Error('不能把资产挂载到自己的下级节点上') + } + + current = await prisma.equipment.findUnique({ + where: { id: current.parent_id }, + select: { parent_id: true } + }) + } +} + export const mountChild = async (req: Request, res: Response) => { try { - const { parentId } = req.params + const numericParentId = Number(req.params.parentId) const { child_id, notes } = req.body as { child_id: number; notes?: string } const userId = Number(res.locals.user?.id) + if (!userId) { return errorResponse(res, '用户未登录', 401) } - // 验证父资产存在 + try { + await ensureNoMountCycle(numericParentId, child_id) + } catch (error: any) { + return errorResponse(res, error.message || '挂载关系校验失败', 422) + } + const parent = await prisma.equipment.findUnique({ - where: { id: Number(parentId) }, + where: { id: numericParentId }, include: { assetType: true } }) if (!parent) { return errorResponse(res, '父资产不存在', 404) } - - // 验证父资产类型支持挂载 if (!parent.assetType?.can_be_parent) { return errorResponse(res, '该资产类型不支持挂载配件', 400) } - // 验证子资产存在 + try { + await ensureMountableEquipment(parent, '父资产') + } catch (error: any) { + return errorResponse(res, error.message || '父资产状态校验失败', 422) + } + const child = await prisma.equipment.findUnique({ where: { id: child_id }, include: { assetType: true } @@ -65,13 +105,16 @@ export const mountChild = async (req: Request, res: Response) => { if (!child) { return errorResponse(res, '子资产不存在', 404) } - - // 验证子资产类型支持被挂载 if (!child.assetType?.can_be_child) { return errorResponse(res, '该资产类型不能作为配件挂载', 400) } - // 验证子资产未被挂载 + try { + await ensureMountableEquipment(child, '子资产') + } catch (error: any) { + return errorResponse(res, error.message || '子资产状态校验失败', 422) + } + if (child.parent_id) { const currentParent = await prisma.equipment.findUnique({ where: { id: child.parent_id } @@ -79,22 +122,19 @@ export const mountChild = async (req: Request, res: Response) => { return errorResponse(res, `该配件已挂载到 ${currentParent?.equipment_name || '其他资产'}`, 400) } - // 执行挂载操作 await prisma.$transaction(async (tx) => { - // 更新子资产 await tx.equipment.update({ where: { id: child_id }, data: { - parent_id: Number(parentId), + parent_id: numericParentId, mount_status: 'mounted' } }) - // 记录挂载历史 await tx.mountHistory.create({ data: { child_id, - parent_id: Number(parentId), + parent_id: numericParentId, operation_type: 'mount', operated_by: userId, notes @@ -102,12 +142,10 @@ export const mountChild = async (req: Request, res: Response) => { }) }) - // 更新父资产含配件总价值 - await updateParentTotalValue(Number(parentId)) + await updateParentTotalValue(numericParentId) - // 获取更新后的数据 const updatedParent = await prisma.equipment.findUnique({ - where: { id: Number(parentId) }, + where: { id: numericParentId }, include: { children: { select: { @@ -129,30 +167,41 @@ export const mountChild = async (req: Request, res: Response) => { } } -// 从父资产卸载子资产 export const unmountChild = async (req: Request, res: Response) => { try { - const { parentId } = req.params + const numericParentId = Number(req.params.parentId) const { child_id, notes } = req.body as { child_id: number; notes?: string } const userId = Number(res.locals.user?.id) + if (!userId) { return errorResponse(res, '用户未登录', 401) } - // 验证子资产存在且挂载在该父资产上 + const parent = await prisma.equipment.findUnique({ + where: { id: numericParentId } + }) + if (!parent) { + return errorResponse(res, '父资产不存在', 404) + } + const child = await prisma.equipment.findUnique({ where: { id: child_id } }) if (!child) { return errorResponse(res, '子资产不存在', 404) } - if (child.parent_id !== Number(parentId)) { + if (child.parent_id !== numericParentId) { return errorResponse(res, '该配件未挂载到此资产', 400) } - // 执行卸载操作 + try { + await ensureMountableEquipment(parent, '父资产') + await ensureMountableEquipment(child, '子资产') + } catch (error: any) { + return errorResponse(res, error.message || '卸载状态校验失败', 422) + } + await prisma.$transaction(async (tx) => { - // 更新子资产 await tx.equipment.update({ where: { id: child_id }, data: { @@ -161,11 +210,10 @@ export const unmountChild = async (req: Request, res: Response) => { } }) - // 记录卸载历史 await tx.mountHistory.create({ data: { child_id, - parent_id: Number(parentId), + parent_id: numericParentId, operation_type: 'unmount', operated_by: userId, notes @@ -173,8 +221,7 @@ export const unmountChild = async (req: Request, res: Response) => { }) }) - // 更新父资产含配件总价值 - await updateParentTotalValue(Number(parentId)) + await updateParentTotalValue(numericParentId) successResponse(res, null, '卸载成功') } catch (error: any) { @@ -182,29 +229,54 @@ export const unmountChild = async (req: Request, res: Response) => { } } -// 更换配件(卸载旧配件 + 挂载新配件) export const replaceChild = async (req: Request, res: Response) => { try { - const { parentId } = req.params + const numericParentId = Number(req.params.parentId) const { old_child_id, new_child_id, notes } = req.body as { old_child_id: number new_child_id: number notes?: string } const userId = Number(res.locals.user?.id) + if (!userId) { return errorResponse(res, '用户未登录', 401) } + if (old_child_id === new_child_id) { + return errorResponse(res, '旧配件和新配件不能相同', 422) + } + + try { + await ensureNoMountCycle(numericParentId, new_child_id) + } catch (error: any) { + return errorResponse(res, error.message || '更换关系校验失败', 422) + } - // 验证旧配件挂载在该父资产上 const oldChild = await prisma.equipment.findUnique({ where: { id: old_child_id } }) - if (!oldChild || oldChild.parent_id !== Number(parentId)) { + if (!oldChild || oldChild.parent_id !== numericParentId) { return errorResponse(res, '旧配件未挂载到此资产', 400) } - // 验证新配件存在且未被挂载 + const parent = await prisma.equipment.findUnique({ + where: { id: numericParentId }, + include: { assetType: true } + }) + if (!parent) { + return errorResponse(res, '父资产不存在', 404) + } + if (!parent.assetType?.can_be_parent) { + return errorResponse(res, '该资产类型不支持挂载配件', 400) + } + + try { + await ensureMountableEquipment(parent, '父资产') + await ensureMountableEquipment(oldChild, '旧配件') + } catch (error: any) { + return errorResponse(res, error.message || '挂载状态校验失败', 422) + } + const newChild = await prisma.equipment.findUnique({ where: { id: new_child_id }, include: { assetType: true } @@ -219,9 +291,13 @@ export const replaceChild = async (req: Request, res: Response) => { return errorResponse(res, '新配件已挂载到其他资产', 400) } - // 在事务中执行更换操作 + try { + await ensureMountableEquipment(newChild, '新配件') + } catch (error: any) { + return errorResponse(res, error.message || '新配件状态校验失败', 422) + } + await prisma.$transaction(async (tx) => { - // 卸载旧配件 await tx.equipment.update({ where: { id: old_child_id }, data: { @@ -230,24 +306,22 @@ export const replaceChild = async (req: Request, res: Response) => { } }) - // 挂载新配件 await tx.equipment.update({ where: { id: new_child_id }, data: { - parent_id: Number(parentId), + parent_id: numericParentId, mount_status: 'mounted' } }) - // 记录更换历史 await tx.mountHistory.create({ data: { child_id: old_child_id, - parent_id: Number(parentId), + parent_id: numericParentId, operation_type: 'replace', operated_by: userId, notes, - old_parent_id: Number(parentId), + old_parent_id: numericParentId, new_parent_id: null } }) @@ -255,18 +329,17 @@ export const replaceChild = async (req: Request, res: Response) => { await tx.mountHistory.create({ data: { child_id: new_child_id, - parent_id: Number(parentId), + parent_id: numericParentId, operation_type: 'replace', operated_by: userId, notes, old_parent_id: null, - new_parent_id: Number(parentId) + new_parent_id: numericParentId } }) }) - // 更新父资产含配件总价值 - await updateParentTotalValue(Number(parentId)) + await updateParentTotalValue(numericParentId) successResponse(res, null, '更换成功') } catch (error: any) { @@ -274,7 +347,6 @@ export const replaceChild = async (req: Request, res: Response) => { } } -// 获取父资产的子资产列表 export const getChildren = async (req: Request, res: Response) => { try { const { parentId } = req.params @@ -307,17 +379,20 @@ export const getChildren = async (req: Request, res: Response) => { new Decimal(0) ) - successResponse(res, { - children: parent.children, - total_children_value: Number(totalChildrenValue), - total_value_with_children: Number(parent.total_value_with_children) - }, '获取成功') + successResponse( + res, + { + children: parent.children, + total_children_value: Number(totalChildrenValue), + total_value_with_children: Number(parent.total_value_with_children) + }, + '获取成功' + ) } catch (error: any) { errorResponse(res, error.message || '获取子资产列表失败') } } -// 获取资产的挂载历史 export const getMountHistory = async (req: Request, res: Response) => { try { const { id } = req.params @@ -348,18 +423,16 @@ export const getMountHistory = async (req: Request, res: Response) => { orderBy: { operated_at: 'desc' } }) - // 补充父资产信息 const historyWithParent = await Promise.all( - history.map(async (h) => { + history.map(async (item) => { let parentInfo = null - if (h.parent_id) { - const parent = await prisma.equipment.findUnique({ - where: { id: h.parent_id }, + if (item.parent_id) { + parentInfo = await prisma.equipment.findUnique({ + where: { id: item.parent_id }, select: { id: true, equipment_code: true, equipment_name: true } }) - parentInfo = parent } - return { ...h, parent: parentInfo } + return { ...item, parent: parentInfo } }) ) @@ -369,23 +442,27 @@ export const getMountHistory = async (req: Request, res: Response) => { } } -// 获取可挂载的子资产列表(未挂载的配件类型资产) export const getAvailableChildren = async (req: Request, res: Response) => { try { const { keyword, equipment_type } = req.query - // 获取可作为子资产的类型 const childTypes = await prisma.assetType.findMany({ where: { can_be_child: true, status: 'active' }, select: { name: true } }) - const childTypeNames = childTypes.map(t => t.name) + const childTypeNames = childTypes.map((item) => item.name) const where: any = { parent_id: null, mount_status: { in: ['none', 'unmounted'] }, equipment_type: { in: childTypeNames }, - status: '可用' + status: '可用', + remaining_quantity: { gt: 0 }, + borrow_records: { + none: { + status: '已借出' + } + } } if (keyword) { diff --git a/backend/src/controllers/statisticsController.ts b/backend/src/controllers/statisticsController.ts index c0fbe78..dfd91af 100644 --- a/backend/src/controllers/statisticsController.ts +++ b/backend/src/controllers/statisticsController.ts @@ -1,14 +1,78 @@ import { Request, Response } from 'express' +import { Prisma } from '@prisma/client' import prisma from '../utils/prisma' import { successResponse, errorResponse } from '../utils/response' +const toNumber = (value: unknown) => { + if (value === null || value === undefined) return 0 + const parsed = Number(value) + return Number.isNaN(parsed) ? 0 : parsed +} + +const buildEquipmentWhere = (equipmentType?: string, startDate?: string, endDate?: string) => { + const where: Prisma.EquipmentWhereInput = {} + + if (equipmentType) { + where.equipment_type = equipmentType + } + + if (startDate || endDate) { + where.created_at = {} + if (startDate) { + where.created_at.gte = new Date(startDate) + } + if (endDate) { + const endOfDay = new Date(endDate) + endOfDay.setHours(23, 59, 59, 999) + where.created_at.lte = endOfDay + } + } + + return where +} + // 获取首页统计数据 export const getDashboardStats = async (req: Request, res: Response) => { try { const startOfToday = new Date(new Date().setHours(0, 0, 0, 0)) - const [equipmentList, todayBorrowCount, todayReturnCount] = await Promise.all([ - prisma.equipment.findMany(), + const [ + equipmentTotals, + maintenanceTotals, + inventoryByType, + todayBorrowCount, + todayReturnCount, + todayBorrowValueRows, + todayReturnValueRows + ] = await Promise.all([ + prisma.equipment.aggregate({ + _sum: { + total_quantity: true, + remaining_quantity: true, + total_value: true, + borrowed_value: true, + available_value: true + } + }), + prisma.equipment.aggregate({ + where: { status: '维护中' }, + _sum: { + total_quantity: true, + available_value: true + } + }), + prisma.equipment.groupBy({ + by: ['equipment_type'], + _sum: { + total_quantity: true, + remaining_quantity: true + }, + orderBy: { + _sum: { + total_quantity: 'desc' + } + } + }), prisma.borrowRecord.count({ where: { borrow_time: { @@ -22,92 +86,34 @@ export const getDashboardStats = async (req: Request, res: Response) => { gte: startOfToday } } - }) + }), + prisma.$queryRaw>(Prisma.sql` + SELECT COALESCE(SUM(e.unit_price), 0) AS total + FROM borrow_record br + INNER JOIN equipment e ON e.id = br.equipment_id + WHERE br.borrow_time >= ${startOfToday} + `), + prisma.$queryRaw>(Prisma.sql` + SELECT COALESCE(SUM(e.unit_price), 0) AS total + FROM borrow_record br + INNER JOIN equipment e ON e.id = br.equipment_id + WHERE br.return_time >= ${startOfToday} + `) ]) - const totalEquipmentQuantity = equipmentList.reduce((sum, eq) => sum + (eq.total_quantity || 0), 0) - const availableEquipmentQuantity = equipmentList.reduce( - (sum, eq) => sum + (eq.remaining_quantity || 0), + const totalEquipmentQuantity = toNumber(equipmentTotals._sum.total_quantity) + const availableEquipmentQuantity = toNumber(equipmentTotals._sum.remaining_quantity) + const borrowedEquipmentQuantity = Math.max( + totalEquipmentQuantity - availableEquipmentQuantity, 0 ) - const borrowedEquipmentQuantity = equipmentList.reduce( - (sum, eq) => sum + Math.max((eq.total_quantity || 0) - (eq.remaining_quantity || 0), 0), - 0 - ) - const maintenanceEquipmentQuantity = equipmentList - .filter((eq) => eq.status === '维护中') - .reduce((sum, eq) => sum + (eq.total_quantity || 0), 0) - - const inventoryByTypeMap = equipmentList.reduce((acc, eq) => { - const type = eq.equipment_type || '其他' - if (!acc[type]) { - acc[type] = { - equipment_type: type, - total_quantity: 0, - available_quantity: 0, - borrowed_quantity: 0 - } - } - const availableQuantity = eq.remaining_quantity || 0 - const totalQuantity = eq.total_quantity || 0 - acc[type].total_quantity += totalQuantity - acc[type].available_quantity += availableQuantity - acc[type].borrowed_quantity += Math.max(totalQuantity - availableQuantity, 0) - return acc - }, {} as Record< - string, - { - equipment_type: string - total_quantity: number - available_quantity: number - borrowed_quantity: number - } - >) - - const inventoryByType = Object.values(inventoryByTypeMap).filter( - (item) => item.total_quantity > 0 - ) - - const totalValue = equipmentList.reduce((sum, eq) => sum + Number(eq.total_value), 0) - const borrowedValue = equipmentList.reduce((sum, eq) => sum + Number(eq.borrowed_value), 0) - const availableValue = equipmentList.reduce((sum, eq) => sum + Number(eq.available_value), 0) - const maintenanceValue = equipmentList - .filter(eq => eq.status === '维护中') - .reduce((sum, eq) => sum + Number(eq.available_value), 0) - - // 获取今日借用/归还价值 - const todayBorrowRecords = await prisma.borrowRecord.findMany({ - where: { - borrow_time: { - gte: startOfToday - } - }, - include: { - equipment: true - } - }) - - const todayBorrowValue = todayBorrowRecords.reduce( - (sum, record) => sum + Number(record.equipment.unit_price), - 0 - ) - - const todayReturnRecords = await prisma.borrowRecord.findMany({ - where: { - return_time: { - gte: startOfToday - } - }, - include: { - equipment: true - } - }) - - const todayReturnValue = todayReturnRecords.reduce( - (sum, record) => sum + Number(record.equipment.unit_price), - 0 - ) - + const maintenanceEquipmentQuantity = toNumber(maintenanceTotals._sum.total_quantity) + const totalValue = toNumber(equipmentTotals._sum.total_value) + const borrowedValue = toNumber(equipmentTotals._sum.borrowed_value) + const availableValue = toNumber(equipmentTotals._sum.available_value) + const maintenanceValue = toNumber(maintenanceTotals._sum.available_value) + const todayBorrowValue = toNumber(todayBorrowValueRows[0]?.total) + const todayReturnValue = toNumber(todayReturnValueRows[0]?.total) const usageRate = totalEquipmentQuantity > 0 ? (borrowedEquipmentQuantity / totalEquipmentQuantity) * 100 : 0 const valueUsageRate = totalValue > 0 ? (borrowedValue / totalValue) * 100 : 0 @@ -128,6 +134,17 @@ export const getDashboardStats = async (req: Request, res: Response) => { today_borrow_value: todayBorrowValue, today_return_value: todayReturnValue, inventory_by_type: inventoryByType + .map((item) => { + const totalQuantity = toNumber(item._sum.total_quantity) + const availableQuantity = toNumber(item._sum.remaining_quantity) + return { + equipment_type: item.equipment_type || '其他', + total_quantity: totalQuantity, + available_quantity: availableQuantity, + borrowed_quantity: Math.max(totalQuantity - availableQuantity, 0) + } + }) + .filter((item) => item.total_quantity > 0) }, '获取成功') } catch (error: any) { errorResponse(res, error.message || '获取统计数据失败') @@ -138,70 +155,80 @@ export const getDashboardStats = async (req: Request, res: Response) => { export const getValueStatistics = async (req: Request, res: Response) => { try { const { equipment_type, start_date, end_date } = req.query - - const where: any = {} - - if (equipment_type) { - where.equipment_type = equipment_type - } - - if (start_date || end_date) { - where.created_at = {} - if (start_date) { - where.created_at.gte = new Date(start_date as string) - } - if (end_date) { - where.created_at.lte = new Date(end_date as string) - } - } - - const equipmentList = await prisma.equipment.findMany({ where }) - - const totalValue = equipmentList.reduce((sum, eq) => sum + Number(eq.total_value), 0) - const borrowedValue = equipmentList.reduce((sum, eq) => sum + Number(eq.borrowed_value), 0) - const availableValue = equipmentList.reduce((sum, eq) => sum + Number(eq.available_value), 0) - const maintenanceValue = equipmentList - .filter(eq => eq.status === '维护中') - .reduce((sum, eq) => sum + Number(eq.available_value), 0) - - // 按资产类型统计,避免沿用旧项目中的固定类型名单 - const valueByTypeMap = equipmentList.reduce((acc, eq) => { - const type = eq.equipment_type || '未分类' - if (!acc[type]) { - acc[type] = { - equipment_type: type, - total_value: 0, - borrowed_value: 0, - available_value: 0 + + const where = buildEquipmentWhere( + typeof equipment_type === 'string' ? equipment_type : undefined, + typeof start_date === 'string' ? start_date : undefined, + typeof end_date === 'string' ? end_date : undefined + ) + + const [summary, maintenanceSummary, valueByTypeGroups, topEquipmentRows] = await Promise.all([ + prisma.equipment.aggregate({ + where, + _sum: { + total_value: true, + borrowed_value: true, + available_value: true } - } + }), + prisma.equipment.aggregate({ + where: { + ...where, + status: '维护中' + }, + _sum: { + available_value: true + } + }), + prisma.equipment.groupBy({ + by: ['equipment_type'], + where, + _sum: { + total_value: true, + borrowed_value: true, + available_value: true + }, + orderBy: { + _sum: { + total_value: 'desc' + } + } + }), + prisma.equipment.findMany({ + where, + select: { + equipment_name: true, + unit_price: true, + total_value: true, + borrowed_value: true, + available_value: true + }, + orderBy: { + total_value: 'desc' + }, + take: 10 + }) + ]) - acc[type].total_value += Number(eq.total_value) - acc[type].borrowed_value += Number(eq.borrowed_value) - acc[type].available_value += Number(eq.available_value) - return acc - }, {} as Record) - - const valueByType = Object.values(valueByTypeMap) - .filter((item) => item.total_value > 0) - .sort((a, b) => b.total_value - a.total_value) - - // 价值最高的资产(Top 10) - const topEquipment = equipmentList - .map(eq => ({ - equipment_name: eq.equipment_name, - unit_price: Number(eq.unit_price), - total_value: Number(eq.total_value), - borrowed_value: Number(eq.borrowed_value), - available_value: Number(eq.available_value) + const totalValue = toNumber(summary._sum.total_value) + const borrowedValue = toNumber(summary._sum.borrowed_value) + const availableValue = toNumber(summary._sum.available_value) + const maintenanceValue = toNumber(maintenanceSummary._sum.available_value) + const valueByType = valueByTypeGroups + .map((item) => ({ + equipment_type: item.equipment_type || '未分类', + total_value: toNumber(item._sum.total_value), + borrowed_value: toNumber(item._sum.borrowed_value), + available_value: toNumber(item._sum.available_value) })) - .sort((a, b) => b.total_value - a.total_value) - .slice(0, 10) + .filter((item) => item.total_value > 0) + const topEquipment = topEquipmentRows.map((eq) => ({ + equipment_name: eq.equipment_name, + unit_price: toNumber(eq.unit_price), + total_value: toNumber(eq.total_value), + borrowed_value: toNumber(eq.borrowed_value), + available_value: toNumber(eq.available_value) + })) successResponse(res, { total_value: totalValue, diff --git a/backend/src/index.ts b/backend/src/index.ts index fe53bff..9be79c1 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -75,7 +75,7 @@ app.use('/api/batch-import', batchImportRoutes) app.use('/api/mount', protect(), mountRoutes) app.use('/api/disposal', protect(), disposalRoutes) app.use('/api/maintenance', protect(), maintenanceRoutes) -app.use('/api/batch', protect(), batchRoutes) +app.use('/api/batch', protect(), requireAdmin(), batchRoutes) app.use('/api/export', protect(), exportRoutes) app.use('/api/search', protect(), searchRoutes) app.use('/api/sync', protect(), requireAdmin(), syncRoutes) diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index 4a9de4d..c17dd0c 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -1,5 +1,6 @@ import type { Request, Response, NextFunction, RequestHandler } from 'express' import { keycloak } from '../utils/keycloak' +import { resolveOrProvisionAuthUser } from '../utils/authIdentity' const isLocalAuth = () => process.env.AUTH_MODE === 'local' @@ -19,17 +20,23 @@ export function protect(): RequestHandler { const keycloakMiddleware = keycloak.protect() return function keycloakWrapper(req: Request, res: Response, next: NextFunction) { - keycloakMiddleware(req, res, (err?: any) => { + keycloakMiddleware(req, res, async (err?: any) => { if (err) { return next(err) } const tokenContent = (req as any).kauth?.grant?.access_token?.content as Record | undefined if (tokenContent) { - res.locals.user = { - id: tokenContent.sub, - username: tokenContent.preferred_username, - name: tokenContent.name || tokenContent.preferred_username, - roles: tokenContent.realm_access?.roles || [] + try { + const resolvedUser = await resolveOrProvisionAuthUser({ + username: tokenContent.preferred_username, + name: tokenContent.name || tokenContent.preferred_username, + email: tokenContent.email, + roles: tokenContent.realm_access?.roles || [] + }) + + res.locals.user = resolvedUser + } catch (resolveError) { + return next(resolveError) } } next() diff --git a/backend/src/utils/authIdentity.ts b/backend/src/utils/authIdentity.ts new file mode 100644 index 0000000..2977985 --- /dev/null +++ b/backend/src/utils/authIdentity.ts @@ -0,0 +1,97 @@ +import bcrypt from 'bcryptjs' +import prisma from './prisma' + +type AuthIdentityInput = { + username?: string + name?: string + email?: string + roles?: string[] +} + +type AuthIdentityUser = { + id: number + username: string + name: string + email?: string | null + role: string + roles: string[] + status: string +} + +const SHADOW_PASSWORD_ROUNDS = 10 + +const buildShadowPassword = async (username: string) => { + const seed = `keycloak-shadow:${username}:${Date.now()}:${Math.random()}` + return bcrypt.hash(seed, SHADOW_PASSWORD_ROUNDS) +} + +export const resolveOrProvisionAuthUser = async ( + identity: AuthIdentityInput +): Promise => { + const username = identity.username?.trim() + + if (!username) { + throw new Error('认证信息缺少用户名') + } + + const existing = await prisma.user.findUnique({ + where: { username } + }) + + if (existing) { + return { + id: existing.id, + username: existing.username, + name: existing.name, + email: existing.email, + role: existing.role, + roles: identity.roles || [existing.role], + status: existing.status + } + } + + try { + const created = await prisma.user.create({ + data: { + username, + password: await buildShadowPassword(username), + name: identity.name?.trim() || username, + email: identity.email?.trim() || null, + role: identity.roles?.includes('admin') ? 'admin' : 'user', + status: 'active' + } + }) + + return { + id: created.id, + username: created.username, + name: created.name, + email: created.email, + role: created.role, + roles: identity.roles || [created.role], + status: created.status + } + } catch (error: any) { + if (error?.code !== 'P2002') { + throw error + } + + const conflicted = await prisma.user.findUnique({ + where: { username } + }) + + if (!conflicted) { + throw error + } + + return { + id: conflicted.id, + username: conflicted.username, + name: conflicted.name, + email: conflicted.email, + role: conflicted.role, + roles: identity.roles || [conflicted.role], + status: conflicted.status + } + } +} diff --git a/backend/src/utils/equipmentGuards.ts b/backend/src/utils/equipmentGuards.ts new file mode 100644 index 0000000..945b199 --- /dev/null +++ b/backend/src/utils/equipmentGuards.ts @@ -0,0 +1,20 @@ +import prisma from './prisma' + +export const countActiveBorrowRecords = async (equipmentId: number) => { + return prisma.borrowRecord.count({ + where: { + equipment_id: equipmentId, + status: '已借出' + } + }) +} + +export const ensureNoActiveBorrowRecords = async ( + equipmentId: number, + message = '资产存在未归还记录,当前不可操作' +) => { + const activeBorrowCount = await countActiveBorrowRecords(equipmentId) + if (activeBorrowCount > 0) { + throw new Error(message) + } +} diff --git a/deploy.sh b/deploy.sh index aa2fb0b..8d95bbc 100644 --- a/deploy.sh +++ b/deploy.sh @@ -80,8 +80,9 @@ curl -fsS http://localhost:3000/api/health || echo "后端服务启动中..." echo "" echo "==========================================" echo " 部署完成" -echo " 固定资产前端: http://1.13.22.133" -echo " 固定资产后端: http://1.13.22.133:3000/api" -echo " 摄影设备前端: http://1.13.22.133:8081" -echo " 摄影设备后端: http://1.13.22.133:3001" +SERVER_HOST="${SERVER_HOST:-}" +echo " 固定资产前端: http://$SERVER_HOST" +echo " 固定资产后端: http://$SERVER_HOST:3000/api" +echo " 摄影设备前端: http://$SERVER_HOST:8081" +echo " 摄影设备后端: http://$SERVER_HOST:3001" echo "==========================================" diff --git a/docker-compose.yml b/docker-compose.yml index 3d56dcf..65cf245 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,8 +7,8 @@ services: container_name: equipment-mysql restart: always environment: - MYSQL_ROOT_PASSWORD: jsjj8888 - MYSQL_DATABASE: equipment_management + MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-change-me-local-password} + MYSQL_DATABASE: ${MYSQL_DATABASE:-equipment_management} MYSQL_CHARSET: utf8mb4 MYSQL_COLLATION: utf8mb4_unicode_ci ports: @@ -17,7 +17,7 @@ services: - mysql_data:/var/lib/mysql command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci healthcheck: - test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-pjsjj8888"] + test: ["CMD-SHELL", "mysqladmin ping -h localhost -u root -p\"$MYSQL_ROOT_PASSWORD\""] interval: 10s timeout: 5s retries: 5 @@ -32,10 +32,10 @@ services: ports: - "3000:3000" environment: - DATABASE_URL: mysql://root:jsjj8888@mysql:3306/equipment_management?charset=utf8mb4 - SESSION_SECRET: equipment-management-secret-prod - AUTH_MODE: local - PORT: 3000 + DATABASE_URL: mysql://root:${MYSQL_ROOT_PASSWORD:-change-me-local-password}@mysql:3306/${MYSQL_DATABASE:-equipment_management}?charset=utf8mb4 + SESSION_SECRET: ${SESSION_SECRET:-change-me-session-secret} + AUTH_MODE: ${AUTH_MODE:-local} + PORT: ${PORT:-3000} SQLSERVER_HOST: ${SQLSERVER_HOST:-} SQLSERVER_PORT: ${SQLSERVER_PORT:-1433} SQLSERVER_DATABASE: ${SQLSERVER_DATABASE:-} @@ -61,8 +61,8 @@ services: context: ./frontend dockerfile: Dockerfile args: - VITE_API_BASE_URL: /api - VITE_AUTH_MODE: local + VITE_API_BASE_URL: ${VITE_API_BASE_URL:-/api} + VITE_AUTH_MODE: ${VITE_AUTH_MODE:-local} container_name: equipment-frontend restart: always ports: diff --git a/frontend/src/components/GlobalSearch.vue b/frontend/src/components/GlobalSearch.vue index e8bedf6..b0fcdd5 100644 --- a/frontend/src/components/GlobalSearch.vue +++ b/frontend/src/components/GlobalSearch.vue @@ -1,12 +1,12 @@