diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b65066c --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# Порт сервера +PORT=4000 + +# База данных +DATABASE_URL="postgresql://postgres:postgres@localhost:5432/warehouse" + +# JWT секрет +JWT_SECRET="your-secret-key" + +# Код для регистрации админа +ADMIN_CODE="admin123" + +# Настройки CORS (опционально) +CORS_ORIGIN="http://localhost:3000" \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..475c848 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# Зависимости +/node_modules +/dist + +# Env файлы +.env +.env.* +!.env.example + +# Логи +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Prisma +/prisma/*.db +/prisma/migrations/* +!/prisma/migrations/.gitkeep + +# Временные файлы +*.log +*.pid +*.seed +*.pid.lock + +# Тестирование +/coverage + +# Production +/build \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..812128a --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM node:18-alpine + +WORKDIR /app + +COPY package*.json ./ +RUN npm install + +COPY . . + +# Генерируем Prisma Client +RUN npx prisma generate + +EXPOSE 4000 + +CMD ["npm", "run", "dev"] \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..17e055e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,41 @@ +version: '3.8' + +services: + backend: + build: + context: . + dockerfile: Dockerfile + ports: + - "4000:4000" + volumes: + - .:/app + - /app/node_modules + environment: + - NODE_ENV=development + - DATABASE_URL=postgresql://postgres:postgres@db:5432/warehouse + - JWT_SECRET=your-secret-key + - ADMIN_CODE=admin123 + depends_on: + - db + networks: + - warehouse-network + + db: + image: postgres:14-alpine + ports: + - "5432:5432" + environment: + - POSTGRES_USER=postgres + - POSTGRES_PASSWORD=postgres + - POSTGRES_DB=warehouse + volumes: + - postgres_data:/var/lib/postgresql/data + networks: + - warehouse-network + +volumes: + postgres_data: + +networks: + warehouse-network: + name: warehouse-network \ No newline at end of file diff --git a/nodemon.json b/nodemon.json new file mode 100644 index 0000000..3382a2a --- /dev/null +++ b/nodemon.json @@ -0,0 +1,6 @@ +{ + "watch": ["src"], + "ext": ".ts,.js", + "ignore": [], + "exec": "ts-node ./src/app.ts" +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..6ec3fd2 --- /dev/null +++ b/package.json @@ -0,0 +1,38 @@ +{ + "name": "warehouse-management-backend", + "version": "1.0.0", + "private": true, + "dependencies": { + "@prisma/client": "^5.5.2", + "@types/axios": "^0.9.36", + "axios": "^1.7.9", + "bcrypt": "^5.1.1", + "cors": "^2.8.5", + "dotenv": "^16.3.1", + "exceljs": "^4.4.0", + "express": "^4.18.2", + "jsonwebtoken": "^9.0.2", + "node-schedule": "^2.1.1", + "pdfkit": "^0.14.0" + }, + "devDependencies": { + "@types/bcrypt": "^5.0.2", + "@types/cors": "^2.8.16", + "@types/express": "^4.17.21", + "@types/jsonwebtoken": "^9.0.5", + "@types/node": "^20.17.16", + "@types/node-schedule": "^2.1.3", + "@types/pdfkit": "^0.13.8", + "nodemon": "^3.0.1", + "prisma": "^5.5.2", + "ts-node": "^10.9.1", + "typescript": "^5.2.2" + }, + "scripts": { + "dev": "nodemon", + "build": "tsc", + "start": "node dist/app.js", + "prisma:generate": "prisma generate", + "prisma:migrate": "prisma migrate deploy" + } +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..d6c7c41 --- /dev/null +++ b/prisma/schema.prisma @@ -0,0 +1,139 @@ +generator client { + provider = "prisma-client-js" +} + +datasource db { + provider = "postgresql" + url = env("DATABASE_URL") +} + +model Location { + id Int @id @default(autoincrement()) + name String @unique + description String? + categories Category[] + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@map("locations") +} + +model Category { + id Int @id @default(autoincrement()) + name String + location Location @relation(fields: [location_id], references: [id]) + location_id Int + items Item[] + boxes Box[] + userCategoryAccess UserCategoryAccess[] + created_at DateTime @default(now()) + + @@map("categories") +} + +model Item { + id Int @id @default(autoincrement()) + name String + code String? + description String? + quantity Int @default(0) + category Category @relation(fields: [category_id], references: [id]) + category_id Int + box_items BoxItem[] + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + + @@map("items") +} + +model Box { + id Int @id @default(autoincrement()) + name String + category Category @relation(fields: [category_id], references: [id]) + category_id Int + items BoxItem[] + created_at DateTime @default(now()) + + @@map("boxes") +} + +model BoxItem { + id Int @id @default(autoincrement()) + box Box @relation(fields: [box_id], references: [id]) + box_id Int + item Item @relation(fields: [item_id], references: [id]) + item_id Int + quantity Int + created_at DateTime @default(now()) + + @@map("box_items") +} + +model Role { + id Int @id @default(autoincrement()) + name String @unique + description String? + users User[] + permissions RolePermission[] + created_at DateTime @default(now()) + + @@map("roles") +} + +model Permission { + id Int @id @default(autoincrement()) + name String @unique + description String? + roles RolePermission[] + created_at DateTime @default(now()) + + @@map("permissions") +} + +model RolePermission { + id Int @id @default(autoincrement()) + role Role @relation(fields: [role_id], references: [id]) + role_id Int + permission Permission @relation(fields: [permission_id], references: [id]) + permission_id Int + created_at DateTime @default(now()) + + @@unique([role_id, permission_id]) + @@map("role_permissions") +} + +model User { + id Int @id @default(autoincrement()) + username String @unique + password_hash String + role Role @relation(fields: [role_id], references: [id]) + role_id Int + is_active Boolean @default(true) + last_login DateTime? + categoryAccess UserCategoryAccess[] + created_at DateTime @default(now()) + + @@map("users") +} + +model UserCategoryAccess { + id Int @id @default(autoincrement()) + user User @relation(fields: [user_id], references: [id]) + user_id Int + category Category @relation(fields: [category_id], references: [id]) + category_id Int + created_at DateTime @default(now()) + + @@map("user_category_access") +} + +model YearEndTransfer { + id Int @id @default(autoincrement()) + year Int + completed_at DateTime + status String + error_message String? + created_at DateTime @default(now()) + + @@map("year_end_transfers") +} diff --git a/src/app.ts b/src/app.ts new file mode 100644 index 0000000..e0f45ae --- /dev/null +++ b/src/app.ts @@ -0,0 +1,70 @@ +import express from 'express'; +import cors from 'cors'; +import path from 'path'; +import { PrismaClient } from '@prisma/client'; +import YearEndService from './services/YearEndService'; + +// Импорт маршрутов +import authRoutes from './routes/auth.routes'; +import adminRoutes from './routes/admin.routes'; +import inventoryRoutes from './routes/inventory.routes'; +import reportRoutes from './routes/report.routes'; +import yearEndRoutes from './routes/yearEnd.routes'; + +const app = express(); +const prisma = new PrismaClient(); +const port = process.env.PORT || 4000; + +// Middleware +app.use(cors({ + origin: 'http://localhost:3000', // Разрешаем запросы с фронта + credentials: true, // Разрешаем куки и авторизацию + methods: 'GET,POST,PUT,DELETE,OPTIONS', // Разрешенные методы + allowedHeaders: 'Content-Type,Authorization' // Разрешенные заголовки +})); +app.options('*', (req, res) => { + res.header('Access-Control-Allow-Origin', 'http://localhost:3000'); + res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); + res.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); + res.header('Access-Control-Allow-Credentials', 'true'); + res.sendStatus(200); +}); + +app.use(express.json()); + +// API маршруты +app.use('/api/auth', authRoutes); +app.use('/api/admin', adminRoutes); +app.use('/api/inventory', inventoryRoutes); +app.use('/api/reports', reportRoutes); +app.use('/api/year-end', yearEndRoutes); + +// Раздача статических файлов в production +if (process.env.NODE_ENV === 'production') { + app.use(express.static(path.join(__dirname, '../../frontend/dist'))); + + app.get('*', (req, res) => { + res.sendFile(path.join(__dirname, '../../frontend/dist/index.html')); + }); +} + +// Инициализация планировщика переноса остатков +YearEndService.initScheduler(); + +// Обработка ошибок +app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => { + console.error(err.stack); + res.status(500).json({ message: 'Что-то пошло не так!' }); +}); + +// Запуск сервера +app.listen(port, () => { + console.log(`Server is running on port ${port}`); +}); + +// Graceful shutdown +process.on('SIGTERM', async () => { + console.log('SIGTERM received. Closing HTTP server and database connections...'); + await prisma.$disconnect(); + process.exit(0); +}); \ No newline at end of file diff --git a/src/controllers/admin.controller.ts b/src/controllers/admin.controller.ts new file mode 100644 index 0000000..6bd72e1 --- /dev/null +++ b/src/controllers/admin.controller.ts @@ -0,0 +1,378 @@ +import { Request, Response } from 'express'; +import bcrypt from 'bcrypt'; +import { prisma } from '../utils/prisma'; + +export class AdminController { + static async getUsers(req: Request, res: Response) { + try { + const users = await prisma.user.findMany({ + include: { + role: true, + categoryAccess: { + include: { + category: true + } + } + } + }); + + const formattedUsers = users.map(user => ({ + id: user.id, + username: user.username, + role: user.role.name, + isActive: user.is_active, + lastLogin: user.last_login, + categories: user.categoryAccess.map(access => access.category.name) + })); + + res.json(formattedUsers); + } catch (error) { + console.error('Error getting users:', error); + res.status(500).json({ message: 'Ошибка сервера' }); + } + } + + static async createUser(req: Request, res: Response) { + try { + const { username, password, roleId, categoryIds } = req.body; + + const existingUser = await prisma.user.findUnique({ + where: { username } + }); + + if (existingUser) { + return res.status(400).json({ message: 'Пользователь уже существует' }); + } + + const passwordHash = await bcrypt.hash(password, 10); + + const user = await prisma.user.create({ + data: { + username, + password_hash: passwordHash, + role_id: roleId, + is_active: true, + categoryAccess: { + create: categoryIds.map((categoryId: number) => ({ + category_id: categoryId + })) + } + }, + include: { + role: true, + categoryAccess: { + include: { + category: true + } + } + } + }); + + const formattedUser = { + id: user.id, + username: user.username, + role: user.role.name, + isActive: user.is_active, + lastLogin: user.last_login, + categories: user.categoryAccess.map(access => access.category.name) + }; + + res.json(formattedUser); + } catch (error) { + console.error('Error creating user:', error); + res.status(500).json({ message: 'Ошибка сервера' }); + } + } + + static async updateUser(req: Request, res: Response) { + try { + const { id } = req.params; + const { password, roleId, isActive, categoryIds } = req.body; + + const updateData: any = { + role_id: roleId, + is_active: isActive + }; + + if (password) { + updateData.password_hash = await bcrypt.hash(password, 10); + } + + const user = await prisma.user.update({ + where: { id: Number(id) }, + data: { + ...updateData, + categoryAccess: { + deleteMany: {}, // Удаляем текущие доступы + create: categoryIds.map((categoryId: number) => ({ + category_id: categoryId + })) + } + }, + include: { + role: true, + categoryAccess: { + include: { + category: true + } + } + } + }); + + const formattedUser = { + id: user.id, + username: user.username, + role: user.role.name, + isActive: user.is_active, + lastLogin: user.last_login, + categories: user.categoryAccess.map(access => access.category.name) + }; + + res.json({ message: 'Пользователь успешно обновлен', user: formattedUser }); + } catch (error) { + console.error('Error updating user:', error); + res.status(500).json({ message: 'Ошибка сервера' }); + } + } + + static async deleteUser(req: Request, res: Response) { + try { + const { id } = req.params; + + await prisma.user.delete({ + where: { id: Number(id) } + }); + + res.json({ message: 'Пользователь успешно удален' }); + } catch (error) { + console.error('Error deleting user:', error); + res.status(500).json({ message: 'Ошибка сервера' }); + } + } + + static async getRoles(req: Request, res: Response) { + try { + const roles = await prisma.role.findMany({ + include: { + permissions: { + include: { + permission: true + } + } + } + }); + res.json(roles); + } catch (error) { + console.error('Error fetching roles:', error); + res.status(500).json({ message: 'Ошибка сервера' }); + } + } + + static async getPermissions(req: Request, res: Response) { + try { + const permissions = await prisma.permission.findMany(); + res.json(permissions); + } catch (error) { + console.error('Error fetching permissions:', error); + res.status(500).json({ message: 'Ошибка сервера' }); + } + } + + static async createRole(req: Request, res: Response) { + try { + const { name, description, permissions } = req.body; + + const existingRole = await prisma.role.findUnique({ + where: { name } + }); + + if (existingRole) { + return res.status(400).json({ message: 'Роль с таким названием уже существует' }); + } + + const role = await prisma.role.create({ + data: { + name, + description, + permissions: { + create: permissions.map((permissionId: number) => ({ + permission_id: permissionId + })) + } + }, + include: { + permissions: { + include: { + permission: true + } + } + } + }); + + res.json(role); + } catch (error) { + console.error('Error creating role:', error); + res.status(500).json({ message: 'Ошибка сервера' }); + } + } + + static async updateRole(req: Request, res: Response) { + try { + const { id } = req.params; + const { name, description, permissions } = req.body; + + const role = await prisma.role.findUnique({ + where: { id: Number(id) } + }); + + if (role?.name === 'admin') { + return res.status(403).json({ message: 'Роль администратора нельзя изменить' }); + } + + await prisma.rolePermission.deleteMany({ + where: { role_id: Number(id) } + }); + + const updatedRole = await prisma.role.update({ + where: { id: Number(id) }, + data: { + name, + description, + permissions: { + create: permissions.map((permissionId: number) => ({ + permission_id: permissionId + })) + } + }, + include: { + permissions: { + include: { + permission: true + } + } + } + }); + + res.json(updatedRole); + } catch (error) { + console.error('Error updating role:', error); + res.status(500).json({ message: 'Ошибка сервера' }); + } + } + + static async deleteRole(req: Request, res: Response) { + try { + const { id } = req.params; + + const role = await prisma.role.findUnique({ + where: { id: Number(id) } + }); + + if (role?.name === 'admin') { + return res.status(403).json({ message: 'Роль администратора нельзя удалить' }); + } + + await prisma.role.delete({ + where: { id: Number(id) } + }); + + res.json({ message: 'Роль успешно удалена' }); + } catch (error) { + console.error('Error deleting role:', error); + res.status(500).json({ message: 'Ошибка сервера' }); + } + } + + static async getLocations(req: Request, res: Response) { + try { + const locations = await prisma.location.findMany(); + res.json(locations); + } catch (error) { + console.error('Error fetching locations:', error); + res.status(500).json({ message: 'Ошибка сервера' }); + } + } + + static async getCategories(req: Request, res: Response) { + try { + const categories = await prisma.category.findMany({ + include: { + location: true + } + }); + res.json(categories); + } catch (error) { + console.error('Error fetching categories:', error); + res.status(500).json({ message: 'Ошибка сервера' }); + } + } + + static async createCategory(req: Request, res: Response) { + try { + const { name, location_id } = req.body; + + const category = await prisma.category.create({ + data: { + name, + location_id + }, + include: { + location: true + } + }); + + res.json(category); + } catch (error) { + console.error('Error creating category:', error); + res.status(500).json({ message: 'Ошибка сервера' }); + } + } + + static async updateCategory(req: Request, res: Response) { + try { + const { id } = req.params; + const { name, location_id } = req.body; + + const category = await prisma.category.update({ + where: { id: Number(id) }, + data: { + name, + location_id + }, + include: { + location: true + } + }); + + res.json(category); + } catch (error) { + console.error('Error updating category:', error); + res.status(500).json({ message: 'Ошибка сервера' }); + } + } + + static async deleteCategory(req: Request, res: Response) { + try { + const { id } = req.params; + + const itemsCount = await prisma.item.count({ + where: { category_id: Number(id) } + }); + + if (itemsCount > 0) { + return res.status(400).json({ + message: 'Невозможно удалить категорию, содержащую товары' + }); + } + + await prisma.category.delete({ + where: { id: Number(id) } + }); + + res.json({ message: 'Категория успешно удалена' }); + } catch (error) { + console.error('Error deleting category:', error); + res.status(500).json({ message: 'Ошибка сервера' }); + } + } +} diff --git a/src/controllers/auth.controller.ts b/src/controllers/auth.controller.ts new file mode 100644 index 0000000..68b08fc --- /dev/null +++ b/src/controllers/auth.controller.ts @@ -0,0 +1,149 @@ +import { Request, Response } from 'express'; +import { prisma } from '../utils/prisma'; +import bcrypt from 'bcrypt'; +import jwt from 'jsonwebtoken'; + +class AuthController { + /** + * Регистрация нового пользователя (доступ только по adminCode) + */ + static async register(req: Request, res: Response) { + try { + const { username, password, adminCode } = req.body; + + // Проверка наличия данных + if (!username || !password || !adminCode) { + return res.status(400).json({ message: 'Необходимо указать имя пользователя, пароль и код доступа' }); + } + + // Проверяем adminCode + if (adminCode !== process.env.ADMIN_CODE) { + return res.status(403).json({ message: 'Неверный код доступа' }); + } + + // Проверяем, существует ли пользователь + const existingUser = await prisma.user.findUnique({ where: { username } }); + if (existingUser) { + return res.status(400).json({ message: 'Пользователь с таким именем уже существует' }); + } + + // Ищем роль "user" (по умолчанию) + const userRole = await prisma.role.findFirst({ where: { name: 'user' } }); + if (!userRole) return res.status(500).json({ message: 'Роль пользователя не найдена' }); + + // Хешируем пароль + const hashedPassword = await bcrypt.hash(password, 10); + + // Создаем пользователя + const user = await prisma.user.create({ + data: { + username, + password_hash: hashedPassword, + role_id: userRole.id, + is_active: true, + }, + include: { + role: true, + }, + }); + + // Генерируем JWT токен + const token = jwt.sign( + { id: user.id, username: user.username, role: user.role.name }, + process.env.JWT_SECRET as string, + { expiresIn: '24h' } + ); + + res.status(201).json({ + message: 'Пользователь успешно зарегистрирован', + token, + user: { + id: user.id, + username: user.username, + role: user.role.name, + }, + }); + } catch (error) { + console.error('Ошибка при регистрации:', error); + res.status(500).json({ message: 'Ошибка сервера' }); + } + } + + /** + * Авторизация пользователя (логин) + */ + static async login(req: Request, res: Response) { + try { + const { username, password } = req.body; + + if (!username || !password) { + return res.status(400).json({ message: 'Необходимо указать имя пользователя и пароль' }); + } + + // Поиск пользователя + const user = await prisma.user.findUnique({ + where: { username }, + include: { role: true }, + }); + + if (!user) { + return res.status(401).json({ message: 'Неверное имя пользователя или пароль' }); + } + + // Проверка активности + if (!user.is_active) { + return res.status(403).json({ message: 'Учетная запись деактивирована' }); + } + + // Проверка пароля + const isValidPassword = await bcrypt.compare(password, user.password_hash); + if (!isValidPassword) { + return res.status(401).json({ message: 'Неверное имя пользователя или пароль' }); + } + + // Обновляем last_login + await prisma.user.update({ + where: { id: user.id }, + data: { last_login: new Date() }, + }); + + // Генерация токена + const token = jwt.sign( + { id: user.id, username: user.username, role: user.role.name }, + process.env.JWT_SECRET as string, + { expiresIn: '24h' } + ); + + res.json({ + token, + user: { + id: user.id, + username: user.username, + role: user.role.name, + }, + }); + } catch (error) { + console.error('Ошибка при входе:', error); + res.status(500).json({ message: 'Ошибка сервера' }); + } + } + + /** + * Валидация JWT токена + */ + static async validateToken(req: Request, res: Response) { + try { + const token = req.headers.authorization?.split(' ')[1]; + if (!token) { + return res.status(401).json({ message: 'Токен не предоставлен' }); + } + const secretKey: string = process.env.JWT_SECRET as string; + const decoded = jwt.verify(token, secretKey); + res.json({ valid: true, user: decoded }); + } catch (error) { + res.status(401).json({ valid: false, message: 'Недействительный токен' }); + } + } +} + +export default AuthController; diff --git a/src/controllers/inventory.controller.ts b/src/controllers/inventory.controller.ts new file mode 100644 index 0000000..88ab93e --- /dev/null +++ b/src/controllers/inventory.controller.ts @@ -0,0 +1,299 @@ +import { Request, Response } from 'express'; +import { prisma } from '../utils/prisma'; +import { Prisma } from '@prisma/client'; + +// Типы для расширенных моделей с включенными связями +type ItemWithRelations = Prisma.ItemGetPayload<{ + include: { + category: true; + box_items: { + include: { + box: true; + }; + }; + }; +}>; + +type BoxWithRelations = Prisma.BoxGetPayload<{ + include: { + category: true; + items: { + include: { + item: true; + }; + }; + }; +}>; + +// Типы для входящих данных +interface CreateItemData { + name: string; + code?: string; + description?: string; + categoryId: number; + quantity?: number; +} + +interface UpdateItemData { + name?: string; + code?: string; + description?: string; + quantity?: number; +} + +interface BoxItemInput { + id: number; + quantity: number; +} + +interface CreateBoxData { + name: string; + categoryId: number; + items: BoxItemInput[]; +} + +interface UpdateBoxData { + name?: string; + items: BoxItemInput[]; +} + +class InventoryController { + // Получение товаров категории + static async getCategoryItems(req: Request, res: Response) { + try { + const categoryId = parseInt(req.params.categoryId, 10); + if (isNaN(categoryId)) { + return res.status(400).json({ message: 'Некорректный идентификатор категории' }); + } + + const items: ItemWithRelations[] = await prisma.item.findMany({ + where: { category_id: categoryId }, + include: { + category: true, + box_items: { include: { box: true } }, + }, + }); + + res.json( + items.map((item) => ({ + id: item.id, + name: item.name, + code: item.code, + description: item.description, + quantity: item.quantity, + category: item.category?.name || 'Без категории', + boxes: item.box_items.map((bi) => ({ + id: bi.box.id, + name: bi.box.name, + quantity: bi.quantity, + })), + })) + ); + } catch (error) { + console.error('Ошибка при получении товаров категории:', error); + res.status(500).json({ message: 'Ошибка сервера' }); + } + } + + // Создание нового товара + static async createItem(req: Request<{}, {}, CreateItemData>, res: Response) { + try { + const { name, code, description, categoryId, quantity = 0 } = req.body; + + if (!name || !categoryId) { + return res.status(400).json({ message: 'Необходимо указать название и категорию' }); + } + + const category_id = parseInt(String(categoryId), 10); + if (isNaN(category_id)) { + return res.status(400).json({ message: 'Некорректный идентификатор категории' }); + } + + const item = await prisma.item.create({ + data: { name, code, description, quantity, category_id }, + include: { category: true }, + }); + + res.json(item); + } catch (error) { + console.error('Ошибка при создании товара:', error); + res.status(500).json({ message: 'Ошибка сервера' }); + } + } + + // Обновление товара + static async updateItem(req: Request<{ id: string }, {}, UpdateItemData>, res: Response) { + try { + const id = parseInt(req.params.id, 10); + if (isNaN(id)) { + return res.status(400).json({ message: 'Некорректный идентификатор товара' }); + } + + const item: ItemWithRelations = await prisma.item.update({ + where: { id }, + data: req.body, + include: { + category: true, + box_items: { include: { box: true } }, + }, + }); + + res.json(item); + } catch (error) { + if (error instanceof Prisma.PrismaClientKnownRequestError) { + if (error.code === 'P2025') { + return res.status(404).json({ message: 'Товар не найден' }); + } + } + console.error('Ошибка при обновлении товара:', error); + res.status(500).json({ message: 'Ошибка сервера' }); + } + } + + // Удаление товара + static async deleteItem(req: Request, res: Response) { + try { + const id = parseInt(req.params.id, 10); + if (isNaN(id)) { + return res.status(400).json({ message: 'Некорректный идентификатор товара' }); + } + + const boxItems = await prisma.boxItem.findMany({ where: { item_id: id } }); + + if (boxItems.length > 0) { + return res.status(400).json({ message: 'Невозможно удалить товар, который находится в коробках' }); + } + + await prisma.item.delete({ where: { id } }); + + res.json({ message: 'Товар успешно удален' }); + } catch (error) { + console.error('Ошибка при удалении товара:', error); + res.status(500).json({ message: 'Ошибка сервера' }); + } + } + + // Получение коробок категории + static async getCategoryBoxes(req: Request, res: Response) { + try { + const categoryId = parseInt(req.params.categoryId, 10); + if (isNaN(categoryId)) { + return res.status(400).json({ message: 'Некорректный идентификатор категории' }); + } + + const boxes: BoxWithRelations[] = await prisma.box.findMany({ + where: { category_id: categoryId }, + include: { + category: true, + items: { include: { item: true } }, + }, + }); + + res.json( + boxes.map(box => ({ + id: box.id, + name: box.name, + category: box.category?.name, + items: box.items.map(bi => ({ + id: bi.item.id, + name: bi.item.name, + quantity: bi.quantity, + })), + })) + ); + } catch (error) { + console.error('Ошибка при получении коробок категории:', error); + res.status(500).json({ message: 'Ошибка сервера' }); + } + } + + // Создание новой коробки + static async createBox(req: Request<{}, {}, CreateBoxData>, res: Response) { + try { + const { name, categoryId, items } = req.body; + + if (!name || !categoryId || !Array.isArray(items)) { + return res.status(400).json({ message: 'Некорректные входные данные' }); + } + + const box: BoxWithRelations = await prisma.box.create({ + data: { + name, + category_id: categoryId, + items: { + create: items.map((item: BoxItemInput) => ({ + item_id: item.id, + quantity: item.quantity, + })), + }, + }, + include: { + category: true, + items: { include: { item: true } }, + }, + }); + + res.json(box); + } catch (error) { + console.error('Ошибка при создании коробки:', error); + res.status(500).json({ message: 'Ошибка сервера' }); + } + } + + // Обновление коробки + static async updateBox(req: Request<{ id: string }, {}, UpdateBoxData>, res: Response) { + try { + const id = parseInt(req.params.id, 10); + if (isNaN(id)) { + return res.status(400).json({ message: 'Некорректный идентификатор коробки' }); + } + + const { name, items } = req.body; + + const box = await prisma.$transaction(async (prisma) => { + await prisma.boxItem.deleteMany({ where: { box_id: id } }); + + return prisma.box.update({ + where: { id }, + data: { + name, + items: { + create: items.map((item: BoxItemInput) => ({ + item_id: item.id, + quantity: item.quantity, + })), + }, + }, + include: { + category: true, + items: { include: { item: true } }, + }, + }); + }); + + res.json(box); + } catch (error) { + console.error('Ошибка при обновлении коробки:', error); + res.status(500).json({ message: 'Ошибка сервера' }); + } + } + + // Удаление коробки + static async deleteBox(req: Request, res: Response) { + try { + const id = parseInt(req.params.id, 10); + if (isNaN(id)) { + return res.status(400).json({ message: 'Некорректный идентификатор коробки' }); + } + + await prisma.box.delete({ where: { id } }); + + res.json({ message: 'Коробка успешно удалена' }); + } catch (error) { + console.error('Ошибка при удалении коробки:', error); + res.status(500).json({ message: 'Ошибка сервера' }); + } + } +} + +export default InventoryController; diff --git a/src/controllers/location.controller.ts b/src/controllers/location.controller.ts new file mode 100644 index 0000000..26b493d --- /dev/null +++ b/src/controllers/location.controller.ts @@ -0,0 +1,123 @@ +import { Request, Response } from 'express'; +import { prisma } from '../utils/prisma'; + +class LocationController { + static async getAll(req: Request, res: Response) { + try { + const locations = await prisma.location.findMany({ + orderBy: { name: 'asc' } + }); + res.json(locations); + } catch (error) { + console.error('Ошибка при получении локаций:', error); + res.status(500).json({ message: 'Ошибка сервера' }); + } + } + + static async create(req: Request, res: Response) { + try { + const { name, description } = req.body; + + if (!name) { + return res.status(400).json({ message: 'Название локации обязательно' }); + } + + // Проверяем уникальность имени + const existingLocation = await prisma.location.findFirst({ + where: { name } + }); + + if (existingLocation) { + return res.status(400).json({ message: 'Локация с таким названием уже существует' }); + } + + const location = await prisma.location.create({ + data: { + name, + description + } + }); + + res.status(201).json(location); + } catch (error) { + console.error('Ошибка при создании локации:', error); + res.status(500).json({ message: 'Ошибка сервера' }); + } + } + + static async update(req: Request, res: Response) { + try { + const { id } = req.params; + const { name, description } = req.body; + + if (!name) { + return res.status(400).json({ message: 'Название локации обязательно' }); + } + + // Проверяем существование локации + const existingLocation = await prisma.location.findUnique({ + where: { id: Number(id) } + }); + + if (!existingLocation) { + return res.status(404).json({ message: 'Локация не найдена' }); + } + + // Проверяем уникальность нового имени + const duplicateName = await prisma.location.findFirst({ + where: { + name, + NOT: { id: Number(id) } + } + }); + + if (duplicateName) { + return res.status(400).json({ message: 'Локация с таким названием уже существует' }); + } + + const location = await prisma.location.update({ + where: { id: Number(id) }, + data: { name, description } + }); + + res.json(location); + } catch (error) { + console.error('Ошибка при обновлении локации:', error); + res.status(500).json({ message: 'Ошибка сервера' }); + } + } + + static async delete(req: Request, res: Response) { + try { + const { id } = req.params; + + // Проверяем существование локации + const location = await prisma.location.findUnique({ + where: { id: Number(id) }, + include: { categories: true } + }); + + if (!location) { + return res.status(404).json({ message: 'Локация не найдена' }); + } + + // Проверяем, есть ли связанные категории + if (location.categories.length > 0) { + return res.status(400).json({ + message: 'Невозможно удалить локацию, содержащую категории' + }); + } + + await prisma.location.delete({ + where: { id: Number(id) } + }); + + res.json({ message: 'Локация успешно удалена' }); + } catch (error) { + console.error('Ошибка при удалении локации:', error); + res.status(500).json({ message: 'Ошибка сервера' }); + } + } +} + +export default LocationController; \ No newline at end of file diff --git a/src/controllers/report.controller.ts b/src/controllers/report.controller.ts new file mode 100644 index 0000000..6e49700 --- /dev/null +++ b/src/controllers/report.controller.ts @@ -0,0 +1,124 @@ +import { Request, Response } from 'express'; +import ReportService from '../services/ReportService'; +import { prisma } from '../utils/prisma'; + +class ReportController { + static async generateInventoryReport(req: Request, res: Response) { + try { + const { categoryId, format = 'excel' } = req.query; + const parsedCategoryId = categoryId ? parseInt(categoryId as string, 10) : undefined; + + if (categoryId && isNaN(parsedCategoryId!)) { + return res.status(400).json({ message: 'Некорректный идентификатор категории' }); + } + + let buffer: Buffer; + if (format === 'excel') { + buffer = await ReportService.generateInventoryReport(parsedCategoryId); + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.setHeader('Content-Disposition', 'attachment; filename=inventory-report.xlsx'); + } else if (format === 'pdf') { + const items = await prisma.item.findMany({ + // Используем parsedCategoryId вместо categoryId + where: parsedCategoryId !== undefined ? { category_id: parsedCategoryId } : undefined, + include: { + category: { + include: { + location: true + } + }, + box_items: { + include: { + box: true + } + } + } + }); + buffer = await ReportService.generatePDFReport(items, 'inventory'); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', 'attachment; filename=inventory-report.pdf'); + } else { + return res.status(400).json({ message: 'Неподдерживаемый формат отчета' }); + } + + res.send(buffer); + } catch (error) { + console.error('Ошибка при генерации отчета по инвентарю:', error); + res.status(500).json({ message: 'Ошибка сервера' }); + } + } + + static async generateBoxReport(req: Request, res: Response) { + try { + const { categoryId, format = 'excel' } = req.query; + + if (!categoryId) { + return res.status(400).json({ message: 'Необходимо указать категорию' }); + } + + const parsedCategoryId = parseInt(categoryId as string, 10); + if (isNaN(parsedCategoryId)) { + return res.status(400).json({ message: 'Некорректный идентификатор категории' }); + } + + let buffer: Buffer; + if (format === 'excel') { + buffer = await ReportService.generateBoxReport(parsedCategoryId); + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.setHeader('Content-Disposition', 'attachment; filename=box-report.xlsx'); + } else if (format === 'pdf') { + const boxes = await prisma.box.findMany({ + where: { category_id: parsedCategoryId }, + include: { + category: { + include: { + location: true + } + }, + items: { + include: { + item: true + } + } + } + }); + buffer = await ReportService.generatePDFReport(boxes, 'boxes'); + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Disposition', 'attachment; filename=box-report.pdf'); + } else { + return res.status(400).json({ message: 'Неподдерживаемый формат отчета' }); + } + + res.send(buffer); + } catch (error) { + console.error('Ошибка при генерации отчета по коробкам:', error); + res.status(500).json({ message: 'Ошибка сервера' }); + } + } + + static async generateYearEndReport(req: Request, res: Response) { + try { + const { year, format = 'excel' } = req.query; + + if (!year) { + return res.status(400).json({ message: 'Необходимо указать год' }); + } + + const parsedYear = parseInt(year as string, 10); + if (isNaN(parsedYear)) { + return res.status(400).json({ message: 'Некорректный формат года' }); + } + + const buffer = await ReportService.generateYearEndReport(parsedYear); + + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.setHeader('Content-Disposition', `attachment; filename=year-end-report-${parsedYear}.xlsx`); + res.send(buffer); + } catch (error) { + console.error('Ошибка при генерации годового отчета:', error); + res.status(500).json({ message: 'Ошибка сервера' }); + } + } +} + +export default ReportController; diff --git a/src/controllers/yearEnd.controller.ts b/src/controllers/yearEnd.controller.ts new file mode 100644 index 0000000..aeb7949 --- /dev/null +++ b/src/controllers/yearEnd.controller.ts @@ -0,0 +1,26 @@ +import { Request, Response } from 'express'; +import YearEndService from '../services/YearEndService'; + +class YearEndController { + static async initiateTransfer(req: Request, res: Response) { + try { + await YearEndService.transferRemainders(); + res.json({ message: 'Перенос остатков успешно выполнен' }); + } catch (error) { + console.error('Error initiating transfer:', error); + res.status(500).json({ message: 'Ошибка при переносе остатков' }); + } + } + + static async getTransferStatus(req: Request, res: Response) { + try { + const status = await YearEndService.getLastTransferStatus(); + res.json(status); + } catch (error) { + console.error('Error getting transfer status:', error); + res.status(500).json({ message: 'Ошибка при получении статуса переноса' }); + } + } +} + +export default YearEndController; \ No newline at end of file diff --git a/src/middleware/auth.middleware.ts b/src/middleware/auth.middleware.ts new file mode 100644 index 0000000..9bed016 --- /dev/null +++ b/src/middleware/auth.middleware.ts @@ -0,0 +1,31 @@ +import { Request, Response, NextFunction } from 'express'; +import jwt from 'jsonwebtoken'; + +export const authMiddleware = (req: Request, res: Response, next: NextFunction) => { + try { + const token = req.headers.authorization?.split(' ')[1]; + if (!token) { + return res.status(401).json({ message: 'Токен не предоставлен' }); + } + const secretKey: string = process.env.JWT_SECRET as string; + const decoded = jwt.verify(token, secretKey); + (req as any).user = decoded; + next(); + } catch (error) { + return res.status(401).json({ message: 'Недействительный токен' }); + } +}; + +export const roleMiddleware = (roles: string[]) => { + return (req: Request, res: Response, next: NextFunction) => { + try { + const userRole = (req as any).user.role; + if (!roles.includes(userRole)) { + return res.status(403).json({ message: 'Доступ запрещен' }); + } + next(); + } catch (error) { + return res.status(403).json({ message: 'Доступ запрещен' }); + } + }; +}; \ No newline at end of file diff --git a/src/routes/admin.routes.ts b/src/routes/admin.routes.ts new file mode 100644 index 0000000..a0b4215 --- /dev/null +++ b/src/routes/admin.routes.ts @@ -0,0 +1,38 @@ +import { Router } from 'express'; +import { AdminController } from '../controllers/admin.controller'; +import { authMiddleware, roleMiddleware } from '../middleware/auth.middleware'; +import LocationController from '../controllers/location.controller'; + +const router = Router(); + +router.use(authMiddleware); +router.use(roleMiddleware(['admin'])); + +// Маршруты для управления пользователями +router.get('/users', AdminController.getUsers); +router.post('/users', AdminController.createUser); +router.put('/users/:id', AdminController.updateUser); +router.delete('/users/:id', AdminController.deleteUser); + +// Маршруты для управления ролями +router.get('/roles', AdminController.getRoles); +router.post('/roles', AdminController.createRole); +router.put('/roles/:id', AdminController.updateRole); +router.delete('/roles/:id', AdminController.deleteRole); + +// Маршруты для получения разрешений +router.get('/permissions', AdminController.getPermissions); + +// Маршруты для управления локациями +router.get('/locations', LocationController.getAll); +router.post('/locations', LocationController.create); +router.put('/locations/:id', LocationController.update); +router.delete('/locations/:id', LocationController.delete); + +// Маршруты для управления категориями +router.get('/categories', AdminController.getCategories); +router.post('/categories', AdminController.createCategory); +router.put('/categories/:id', AdminController.updateCategory); +router.delete('/categories/:id', AdminController.deleteCategory); + +export default router; \ No newline at end of file diff --git a/src/routes/auth.routes.ts b/src/routes/auth.routes.ts new file mode 100644 index 0000000..a542513 --- /dev/null +++ b/src/routes/auth.routes.ts @@ -0,0 +1,10 @@ +import { Router } from 'express'; +import AuthController from '../controllers/auth.controller'; + +const router = Router(); + +router.post('/login', AuthController.login); +router.post('/register', AuthController.register); +router.post('/validate', AuthController.validateToken); + +export default router; \ No newline at end of file diff --git a/src/routes/inventory.routes.ts b/src/routes/inventory.routes.ts new file mode 100644 index 0000000..6421a8e --- /dev/null +++ b/src/routes/inventory.routes.ts @@ -0,0 +1,21 @@ +import { Router } from 'express'; +import InventoryController from '../controllers/inventory.controller'; +import { authMiddleware } from '../middleware/auth.middleware'; + +const router = Router(); + +router.use(authMiddleware); + +// Маршруты для товаров +router.get('/categories/:categoryId/items', InventoryController.getCategoryItems); +router.post('/items', InventoryController.createItem); +router.put('/items/:id', InventoryController.updateItem); +router.delete('/items/:id', InventoryController.deleteItem); + +// Маршруты для коробок +router.get('/categories/:categoryId/boxes', InventoryController.getCategoryBoxes); +router.post('/boxes', InventoryController.createBox); +router.put('/boxes/:id', InventoryController.updateBox); +router.delete('/boxes/:id', InventoryController.deleteBox); + +export default router; \ No newline at end of file diff --git a/src/routes/report.routes.ts b/src/routes/report.routes.ts new file mode 100644 index 0000000..42b2a8a --- /dev/null +++ b/src/routes/report.routes.ts @@ -0,0 +1,10 @@ +import { Router } from 'express'; +import { authMiddleware } from '../middleware/auth.middleware'; + +const router = Router(); + +router.use(authMiddleware); + +// Здесь будут маршруты отчетов + +export default router; \ No newline at end of file diff --git a/src/routes/yearEnd.routes.ts b/src/routes/yearEnd.routes.ts new file mode 100644 index 0000000..ceffe2c --- /dev/null +++ b/src/routes/yearEnd.routes.ts @@ -0,0 +1,15 @@ +import { Router } from 'express'; +import YearEndController from '../controllers/yearEnd.controller'; +import { authMiddleware, roleMiddleware } from '../middleware/auth.middleware'; + +const router = Router(); + +router.use(authMiddleware); +router.use(roleMiddleware(['admin'])); // Только админ может управлять переносом остатков + +router.post('/transfer', YearEndController.initiateTransfer); +router.get('/status', YearEndController.getTransferStatus); + +// Здесь будут маршруты переноса остатков + +export default router; \ No newline at end of file diff --git a/src/services/ReportService.ts b/src/services/ReportService.ts new file mode 100644 index 0000000..94980c1 --- /dev/null +++ b/src/services/ReportService.ts @@ -0,0 +1,369 @@ +import { prisma } from '../utils/prisma'; +import { Prisma } from '@prisma/client'; +import ExcelJS from 'exceljs'; +import PDFDocument from 'pdfkit'; +import { Buffer } from 'buffer'; + +type ItemWithRelations = Prisma.ItemGetPayload<{ + include: { + category: { + include: { + location: true; + }; + }; + box_items: { + include: { + box: true; + }; + }; + }; +}>; + +type BoxWithRelations = Prisma.BoxGetPayload<{ + include: { + category: { + include: { + location: true; + }; + }; + items: { + include: { + item: true; + }; + }; + }; +}>; + +class ReportService { + static async generateInventoryReport(categoryId?: number): Promise { + try { + const workbook = new ExcelJS.Workbook(); + const worksheet = workbook.addWorksheet('Инвентарь'); + + // Настройка заголовков + worksheet.columns = [ + { header: 'ID', key: 'id', width: 10 }, + { header: 'Название', key: 'name', width: 30 }, + { header: 'Код', key: 'code', width: 15 }, + { header: 'Описание', key: 'description', width: 40 }, + { header: 'Количество', key: 'quantity', width: 15 }, + { header: 'Категория', key: 'category', width: 20 }, + { header: 'Коробки', key: 'boxes', width: 40 }, + ]; + + // Получение данных + const items: ItemWithRelations[] = await prisma.item.findMany({ + where: categoryId ? { category_id: categoryId } : undefined, + include: { + category: { + include: { + location: true, + }, + }, + box_items: { + include: { + box: true, + }, + }, + }, + }); + + // Заполнение данными + items.forEach((item) => { + worksheet.addRow({ + id: item.id, + name: item.name, + code: item.code || '', + description: item.description || '', + quantity: item.quantity, + category: item.category?.name || 'Без категории', + boxes: item.box_items + .map((bi) => `${bi.box.name} (${bi.quantity})`) + .join(', '), + }); + }); + + // Стилизация + const headerRow = worksheet.getRow(1); + headerRow.font = { bold: true }; + headerRow.alignment = { vertical: 'middle', horizontal: 'center' }; + + // Возвращаем буфер + return await workbook.xlsx.writeBuffer() as Buffer; + } catch (error) { + console.error('Ошибка при генерации отчета по инвентарю:', error); + throw error; + } + } + + static async generateBoxReport(categoryId: number): Promise { // Исправленный тип + try { + const workbook = new ExcelJS.Workbook(); + const worksheet = workbook.addWorksheet('Коробки'); + + // Настройка заголовков + worksheet.columns = [ + { header: 'ID коробки', key: 'boxId', width: 10 }, + { header: 'Название коробки', key: 'boxName', width: 30 }, + { header: 'Категория', key: 'category', width: 20 }, + { header: 'Товар', key: 'itemName', width: 30 }, + { header: 'Код товара', key: 'itemCode', width: 15 }, + { header: 'Количество', key: 'quantity', width: 15 }, + ]; + + // Получение данных + const boxes: BoxWithRelations[] = await prisma.box.findMany({ + where: { category_id: categoryId }, + include: { + category: { + include: { + location: true, + }, + }, + items: { + include: { + item: true, + }, + }, + }, + }); + + // Заполнение данными + boxes.forEach((box) => { + box.items.forEach((boxItem) => { + worksheet.addRow({ + boxId: box.id, + boxName: box.name, + category: box.category?.name || 'Без категории', + itemName: boxItem.item.name, + itemCode: boxItem.item.code || '', + quantity: boxItem.quantity, + }); + }); + }); + + // Стилизация + const headerRow = worksheet.getRow(1); + headerRow.font = { bold: true }; + headerRow.alignment = { vertical: 'middle', horizontal: 'center' }; + + // Возвращаем буфер + return await workbook.xlsx.writeBuffer() as Buffer; + } catch (error) { + console.error('Ошибка при генерации отчета по коробкам:', error); + throw error; + } + } + + static async generateYearEndReport(year: number): Promise { + try { + const workbook = new ExcelJS.Workbook(); + const worksheet = workbook.addWorksheet(`Остатки ${year}`); + + // Настройка заголовков + worksheet.columns = [ + { header: 'Категория', key: 'category', width: 30 }, + { header: 'Товар', key: 'name', width: 30 }, + { header: 'Код', key: 'code', width: 15 }, + { header: 'Описание', key: 'description', width: 40 }, + { header: 'Количество', key: 'quantity', width: 15 }, + ]; + + // Получение данных + const items: ItemWithRelations[] = await prisma.item.findMany({ + where: { + category: { + name: { + startsWith: `${year} Остатки`, + }, + }, + }, + include: { + category: { + include: { + location: true, + }, + }, + box_items: { + include: { + box: true, + }, + }, + }, + }); + + // Заполнение данными + items.forEach((item) => { + worksheet.addRow({ + category: item.category?.name || 'Без категории', + name: item.name, + code: item.code || '', + description: item.description || '', + quantity: item.quantity, + }); + }); + + // Стилизация + const headerRow = worksheet.getRow(1); + headerRow.font = { bold: true }; + headerRow.alignment = { vertical: 'middle', horizontal: 'center' }; + + // Возвращаем буфер + return await workbook.xlsx.writeBuffer() as Buffer; + } catch (error) { + console.error('Ошибка при генерации годового отчета:', error); + throw error; + } + } + + static async generateExcelReport( + data: ItemWithRelations[] | BoxWithRelations[], + type: 'inventory' | 'boxes' + ): Promise { + const workbook = new ExcelJS.Workbook(); + const worksheet = workbook.addWorksheet('Report'); + + if (type === 'inventory') { + // Настройка колонок для инвентарного отчета + worksheet.columns = [ + { header: 'Локация', key: 'location', width: 15 }, + { header: 'Категория', key: 'category', width: 20 }, + { header: 'Код', key: 'code', width: 15 }, + { header: 'Название', key: 'name', width: 30 }, + { header: 'Количество', key: 'quantity', width: 12 }, + ]; + + // Заполнение данными + (data as ItemWithRelations[]).forEach((item) => { + worksheet.addRow({ + location: item.category?.location?.name || 'Без локации', + category: item.category?.name || 'Без категории', + code: item.code || '', + name: item.name, + quantity: item.quantity, + }); + }); + } else { + // Настройка колонок для отчета по коробкам + worksheet.columns = [ + { header: 'Локация', key: 'location', width: 15 }, + { header: 'Категория', key: 'category', width: 20 }, + { header: 'Коробка', key: 'box', width: 20 }, + { header: 'Товар', key: 'item', width: 30 }, + { header: 'Количество', key: 'quantity', width: 12 }, + ]; + + // Заполнение данными + (data as BoxWithRelations[]).forEach((box) => { + box.items.forEach((item) => { + worksheet.addRow({ + location: box.category?.location?.name || 'Без локации', + category: box.category?.name || 'Без категории', + box: box.name, + item: item.item.name, + quantity: item.quantity, + }); + }); + }); + } + + // Стилизация заголовков + const headerRow = worksheet.getRow(1); + headerRow.font = { bold: true }; + headerRow.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFE0E0E0' }, + }; + headerRow.alignment = { vertical: 'middle', horizontal: 'center' }; + + return await workbook.xlsx.writeBuffer() as Buffer; + } + + static async generatePDFReport( + data: ItemWithRelations[] | BoxWithRelations[], + type: 'inventory' | 'boxes' + ): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + const doc = new PDFDocument({ margin: 50 }); + + doc.on('data', (chunk) => chunks.push(chunk)); + doc.on('end', () => resolve(Buffer.concat(chunks))); + doc.on('error', reject); + + // Добавляем заголовок + doc + .fontSize(16) + .text( + type === 'inventory' ? 'Инвентарный отчет' : 'Отчет по коробкам', + { align: 'center' } + ); + doc.moveDown(); + + if (type === 'inventory') { + // Группируем данные по локациям и категориям + const grouped = (data as ItemWithRelations[]).reduce( + (acc, item) => { + const locationName = item.category?.location?.name || 'Без локации'; + const categoryName = item.category?.name || 'Без категории'; + + if (!acc[locationName]) { + acc[locationName] = {}; + } + if (!acc[locationName][categoryName]) { + acc[locationName][categoryName] = []; + } + + acc[locationName][categoryName].push(item); + return acc; + }, + {} as Record> + ); + + // Выводим данные + Object.entries(grouped).forEach(([location, categories]) => { + doc.fontSize(14).text(location, { underline: true }); + doc.moveDown(0.5); + + Object.entries(categories).forEach(([category, items]) => { + doc.font('Helvetica-Bold').fontSize(12).text(category); + doc.moveDown(0.5); + + items.forEach((item) => { + doc.fontSize(10).text( + `${item.code || 'Без кода'} - ${item.name}: ${item.quantity} шт.`, + { indent: 20 } + ); + }); + doc.moveDown(); + }); + doc.moveDown(); + }); + } else { + // Вывод данных по коробкам + (data as BoxWithRelations[]).forEach((box) => { + doc + .fontSize(12) + .text( + `${box.category?.location?.name || 'Без локации'} - ${ + box.category?.name || 'Без категории' + }`, + { underline: true } + ); + doc.fontSize(11).text(`Коробка: ${box.name}`); + doc.moveDown(0.5); + + box.items.forEach((item) => { + doc.fontSize(10).text(`${item.item.name}: ${item.quantity} шт.`, { indent: 20 }); + }); + doc.moveDown(); + }); + } + + doc.end(); + }); + } +} + +export default ReportService; diff --git a/src/services/YearEndService.ts b/src/services/YearEndService.ts new file mode 100644 index 0000000..0f76d8d --- /dev/null +++ b/src/services/YearEndService.ts @@ -0,0 +1,103 @@ +import { PrismaClient } from '@prisma/client'; +import { scheduleJob } from 'node-schedule'; + +const prisma = new PrismaClient(); + +class YearEndService { + // Запускаем планировщик для автоматического переноса + static initScheduler() { + // Запускаем задачу в последний день года в 23:59 + scheduleJob('59 23 31 12 *', async () => { + try { + await YearEndService.transferRemainders(); + } catch (error) { + console.error('Error in year-end transfer:', error); + } + }); + } + + // Метод для ручного запуска переноса остатков + static async transferRemainders() { + const currentYear = new Date().getFullYear(); + const nextYear = currentYear + 1; + + try { + await prisma.$transaction(async (prisma) => { + // Получаем все локации + const locations = await prisma.location.findMany(); + + for (const location of locations) { + // Получаем все категории для локации + const categories = await prisma.category.findMany({ + where: { location_id: location.id } + }); + + for (const category of categories) { + // Создаем новую категорию для следующего года + const newCategory = await prisma.category.create({ + data: { + name: `${nextYear} Остатки - ${category.name}`, + location_id: location.id + } + }); + + // Получаем все товары из текущей категории + const items = await prisma.item.findMany({ + where: { category_id: category.id } + }); + + // Переносим товары с остатками в новую категорию + for (const item of items) { + if (item.quantity > 0) { + await prisma.item.create({ + data: { + name: item.name, + code: item.code, + description: `Перенесено из ${currentYear} года - ${item.description || ''}`, + quantity: item.quantity, + category_id: newCategory.id + } + }); + + // Обнуляем количество в старой записи + await prisma.item.update({ + where: { id: item.id }, + data: { quantity: 0 } + }); + } + } + } + } + + // Создаем запись в журнале переносов + await prisma.yearEndTransfer.create({ + data: { + year: currentYear, + completed_at: new Date(), + status: 'completed' + } + }); + }); + + console.log(`Year-end transfer for ${currentYear} completed successfully`); + } catch (error) { + console.error('Error during year-end transfer:', error); + throw error; + } + } + + // Метод для проверки статуса последнего переноса + static async getLastTransferStatus() { + try { + const lastTransfer = await prisma.yearEndTransfer.findFirst({ + orderBy: { completed_at: 'desc' } + }); + return lastTransfer; + } catch (error) { + console.error('Error getting last transfer status:', error); + throw error; + } + } +} + +export default YearEndService; \ No newline at end of file diff --git a/src/utils/prisma.ts b/src/utils/prisma.ts new file mode 100644 index 0000000..7f133f3 --- /dev/null +++ b/src/utils/prisma.ts @@ -0,0 +1,3 @@ +import { PrismaClient } from '@prisma/client'; + +export const prisma = new PrismaClient(); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..107e556 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "types": ["node"] // Добавлено + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] + } + \ No newline at end of file