Final version

This commit is contained in:
cyrteL
2025-08-22 19:26:43 +03:00
committed by GitHub
parent b4c1a2caa9
commit 558cd96cdf
31 changed files with 6181 additions and 1632 deletions

31
server/env.email.example Normal file
View File

@ -0,0 +1,31 @@
# Email Configuration
# Скопируйте этот файл в .env и настройте параметры для вашего SMTP-сервера
# SMTP Server Configuration
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your-email@gmail.com
SMTP_PASS=your-app-password
SMTP_FROM=your-email@gmail.com
# Для Gmail используйте App Password вместо обычного пароля
# Инструкция: https://support.google.com/accounts/answer/185833
# Альтернативные SMTP-серверы:
# Yandex: smtp.yandex.ru:587
# Mail.ru: smtp.mail.ru:587
# Outlook: smtp-mail.outlook.com:587
# Пример для Yandex:
# SMTP_HOST=smtp.yandex.ru
# SMTP_PORT=587
# SMTP_USER=your-email@yandex.ru
# SMTP_PASS=your-password
# SMTP_FROM=your-email@yandex.ru
# Пример для Mail.ru:
# SMTP_HOST=smtp.mail.ru
# SMTP_PORT=587
# SMTP_USER=your-email@mail.ru
# SMTP_PASS=your-password
# SMTP_FROM=your-email@mail.ru

View File

@ -0,0 +1,30 @@
# Production Environment Configuration
NODE_ENV=production
PORT=3001
# Database Configuration
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=warehouse_user
DB_PASSWORD=CHANGE_THIS_PASSWORD_IN_PRODUCTION
DB_NAME=warehouse_manager
# Security Configuration
JWT_SECRET=CHANGE_THIS_JWT_SECRET_IN_PRODUCTION_USE_STRONG_RANDOM_STRING
JWT_EXPIRES_IN=8h
# CORS Configuration
CORS_ORIGIN=https://your-domain.com
CORS_CREDENTIALS=true
# Rate Limiting
RATE_LIMIT_WINDOW_MS=900000
RATE_LIMIT_MAX_REQUESTS=100
# Logging
LOG_LEVEL=info
LOG_FILE=logs/app.log
# Performance
CONNECTION_LIMIT=20
QUEUE_LIMIT=0

33
server/package.json Normal file
View File

@ -0,0 +1,33 @@
{
"name": "warehouse-manager-backend",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "src/server.js",
"scripts": {
"dev": "nodemon src/server.js",
"start": "node src/server.js",
"start:prod": "NODE_ENV=production node src/server.js",
"migrate": "node scripts/migrate.js",
"seed": "node scripts/seed.js",
"setup": "npm run migrate && npm run seed",
"setup:prod": "NODE_ENV=production npm run setup",
"test": "echo \"No tests specified\" && exit 0",
"lint": "echo \"No linter configured\" && exit 0"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^16.4.5",
"express": "^4.19.2",
"nodemailer": "^6.9.7",
"helmet": "^7.1.0",
"joi": "^17.13.3",
"jsonwebtoken": "^9.0.2",
"mysql2": "^3.11.0",
"pino": "^9.3.2"
},
"devDependencies": {
"nodemon": "^3.1.4"
}
}

26
server/scripts/migrate.js Normal file
View File

@ -0,0 +1,26 @@
import fs from 'fs';
import path from 'path';
import url from 'url';
import pool from '../src/config/db.js';
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
async function run() {
const schemaPath = path.join(__dirname, '..', 'sql', 'schema.sql');
const sql = fs.readFileSync(schemaPath, 'utf8');
const statements = sql.split(/;\s*\n/).filter(s => s.trim());
const conn = await pool.getConnection();
try {
for (const stmt of statements) {
await conn.query(stmt);
}
console.log('Migration completed');
} finally {
conn.release();
process.exit(0);
}
}
run().catch(err => { console.error(err); process.exit(1); });

26
server/scripts/seed.js Normal file
View File

@ -0,0 +1,26 @@
import fs from 'fs';
import path from 'path';
import url from 'url';
import pool from '../src/config/db.js';
const __dirname = path.dirname(url.fileURLToPath(import.meta.url));
async function run() {
const seedPath = path.join(__dirname, '..', 'sql', 'seed.sql');
const sql = fs.readFileSync(seedPath, 'utf8');
const statements = sql.split(/;\s*\n/).filter(s => s.trim());
const conn = await pool.getConnection();
try {
for (const stmt of statements) {
await conn.query(stmt);
}
console.log('Seed completed');
} finally {
conn.release();
process.exit(0);
}
}
run().catch(err => { console.error(err); process.exit(1); });

83
server/sql/schema.sql Normal file
View File

@ -0,0 +1,83 @@
-- MySQL schema for warehouse manager
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
password_hash VARCHAR(255) NULL,
name VARCHAR(128) NOT NULL,
email VARCHAR(128) NOT NULL,
department VARCHAR(128) DEFAULT NULL,
position VARCHAR(128) DEFAULT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS roles (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(32) NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS permissions (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(32) NOT NULL UNIQUE
);
CREATE TABLE IF NOT EXISTS user_roles (
user_id INT NOT NULL,
role_id INT NOT NULL,
PRIMARY KEY(user_id, role_id),
FOREIGN KEY(user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY(role_id) REFERENCES roles(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS role_permissions (
role_id INT NOT NULL,
permission_id INT NOT NULL,
PRIMARY KEY(role_id, permission_id),
FOREIGN KEY(role_id) REFERENCES roles(id) ON DELETE CASCADE,
FOREIGN KEY(permission_id) REFERENCES permissions(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS categories (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(128) NOT NULL,
description TEXT,
icon VARCHAR(64) DEFAULT 'fas fa-tag',
color VARCHAR(16) DEFAULT '#2c5aa0',
active TINYINT(1) DEFAULT 1,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS items (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
category_id INT NULL,
price DECIMAL(12,2) NOT NULL DEFAULT 0,
quantity INT NOT NULL DEFAULT 0,
description TEXT,
barcode VARCHAR(128),
min_quantity INT NOT NULL DEFAULT 0,
location VARCHAR(255),
supplier VARCHAR(255),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY(category_id) REFERENCES categories(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS operations (
id INT AUTO_INCREMENT PRIMARY KEY,
type ENUM('incoming','outgoing') NOT NULL,
item_id INT NOT NULL,
quantity INT NOT NULL,
employee_id INT NOT NULL,
status ENUM('completed','pending','cancelled') DEFAULT 'completed',
notes TEXT,
supplier VARCHAR(255),
recipient VARCHAR(255),
date TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY(item_id) REFERENCES items(id) ON DELETE CASCADE,
FOREIGN KEY(employee_id) REFERENCES users(id) ON DELETE RESTRICT
);

43
server/sql/seed.sql Normal file
View File

@ -0,0 +1,43 @@
-- Seed roles and permissions
INSERT IGNORE INTO roles (id, name) VALUES
(1,'admin'),(2,'manager'),(3,'operator'),(4,'viewer');
INSERT IGNORE INTO permissions (id, name) VALUES
(1,'read'),(2,'write'),(3,'delete'),(4,'admin');
INSERT IGNORE INTO role_permissions (role_id, permission_id) VALUES
(1,1),(1,2),(1,3),(1,4), -- admin: all
(2,1),(2,2), -- manager: read, write
(3,1), -- operator: read
(4,1); -- viewer: read
-- Users (CHANGE THESE PASSWORDS IN PRODUCTION!)
-- Default passwords: admin123, manager123, operator123, viewer123
-- Use bcrypt or similar for production hashing
INSERT INTO users (id, username, password_hash, name, email, department, position) VALUES
(1,'admin','admin123','Администратор','admin@company.com','IT','Системный администратор'),
(2,'manager','manager123','Менеджер склада','manager@company.com','Склад','Менеджер склада'),
(3,'operator','operator123','Оператор','operator@company.com','Склад','Оператор склада'),
(4,'viewer','viewer123','Наблюдатель','viewer@company.com','Бухгалтерия','Бухгалтер')
ON DUPLICATE KEY UPDATE name=VALUES(name);
INSERT IGNORE INTO user_roles (user_id, role_id) VALUES
(1,1), (2,2), (3,3), (4,4);
-- Categories
INSERT INTO categories (id, name, description, icon, color, active) VALUES
(1,'Электроника','Техника и гаджеты','fas fa-laptop','#2c5aa0',1),
(2,'Инструменты','Строительные и слесарные инструменты','fas fa-tools','#1a7f37',1),
(3,'Офис','Канцелярия и офисные товары','fas fa-briefcase','#8a2be2',1)
ON DUPLICATE KEY UPDATE name=VALUES(name);
-- Items
INSERT INTO items (name, category_id, price, quantity, description, barcode, min_quantity, location, supplier) VALUES
('Ноутбук Lenovo', 1, 65000, 10, '15.6" Ryzen 5', 'LN-12345', 2, 'Стеллаж A1', 'Lenovo'),
('Шуруповерт Bosch', 2, 8500, 25, 'Аккумуляторный 18В', 'BS-98765', 5, 'Стеллаж B2', 'Bosch'),
('Бумага A4', 3, 350, 200, 'Пачка 500 листов', 'A4-11111', 50, 'Стеллаж C3', 'Svetocopy');
-- Sample operations (today)
INSERT INTO operations (type, item_id, quantity, employee_id, status, notes, supplier)
VALUES ('incoming', 1, 5, 1, 'completed', 'Поступление партии', 'Lenovo');

18
server/src/config/db.js Normal file
View File

@ -0,0 +1,18 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const pool = mysql.createPool({
host: process.env.DB_HOST || '127.0.0.1',
port: Number(process.env.DB_PORT || 3306),
user: process.env.DB_USER || 'root',
password: process.env.DB_PASSWORD || '',
database: process.env.DB_NAME || 'warehouse_manager',
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
});
export default pool;

47
server/src/routes/auth.js Normal file
View File

@ -0,0 +1,47 @@
import { Router } from 'express';
import jwt from 'jsonwebtoken';
import pool from '../config/db.js';
const router = Router();
router.post('/login', async (req, res, next) => {
try {
const { username, password } = req.body;
if (!username || !password) return res.status(400).json({ error: 'Missing credentials' });
const [rows] = await pool.query(
'SELECT id, username, name, email, department, position, password_hash FROM users WHERE username = ? LIMIT 1',
[username]
);
if (!rows.length) return res.status(401).json({ error: 'Invalid credentials' });
const user = rows[0];
// NOTE: For simplicity, compare plain text if password_hash is null; otherwise use MySQL PASSWORD() or bcrypt in seed
const isValid = !user.password_hash ? password === 'admin123' : password === user.password_hash;
if (!isValid) return res.status(401).json({ error: 'Invalid credentials' });
const [roleRows] = await pool.query(
'SELECT r.name as role FROM user_roles ur JOIN roles r ON r.id = ur.role_id WHERE ur.user_id = ?',
[user.id]
);
const [permRows] = await pool.query(
'SELECT p.name as permission FROM role_permissions rp JOIN permissions p ON p.id = rp.permission_id WHERE rp.role_id IN (SELECT role_id FROM user_roles WHERE user_id = ?)',
[user.id]
);
const payload = {
id: user.id,
username: user.username,
name: user.name,
email: user.email,
department: user.department,
position: user.position,
roles: roleRows.map(r => r.role),
permissions: permRows.map(p => p.permission)
};
const token = jwt.sign(payload, process.env.JWT_SECRET || 'dev_secret', { expiresIn: '8h' });
res.json({ token, user: payload });
} catch (e) { next(e); }
});
export default router;

View File

@ -0,0 +1,46 @@
import { Router } from 'express';
import pool from '../config/db.js';
const router = Router();
router.get('/', async (req, res, next) => {
try {
const [rows] = await pool.query('SELECT c.*, (SELECT COUNT(*) FROM items i WHERE i.category_id = c.id) AS itemCount FROM categories c');
res.json(rows);
} catch (e) { next(e); }
});
router.post('/', async (req, res, next) => {
try {
const { name, description, icon, color, active } = req.body;
const [result] = await pool.query(
'INSERT INTO categories (name, description, icon, color, active) VALUES (?, ?, ?, ?, ?)',
[name, description || '', icon || 'fas fa-tag', color || '#2c5aa0', active ? 1 : 0]
);
const [rows] = await pool.query('SELECT * FROM categories WHERE id = ?', [result.insertId]);
res.status(201).json(rows[0]);
} catch (e) { next(e); }
});
router.put('/:id', async (req, res, next) => {
try {
const { name, description, icon, color, active } = req.body;
await pool.query(
'UPDATE categories SET name=?, description=?, icon=?, color=?, active=?, updated_at=NOW() WHERE id = ?',
[name, description || '', icon || 'fas fa-tag', color || '#2c5aa0', active ? 1 : 0, req.params.id]
);
const [rows] = await pool.query('SELECT * FROM categories WHERE id = ?', [req.params.id]);
res.json(rows[0]);
} catch (e) { next(e); }
});
router.delete('/:id', async (req, res, next) => {
try {
await pool.query('DELETE FROM items WHERE category_id = ?', [req.params.id]);
await pool.query('DELETE FROM categories WHERE id = ?', [req.params.id]);
res.json({ success: true });
} catch (e) { next(e); }
});
export default router;

View File

@ -0,0 +1,54 @@
import { Router } from 'express';
import pool from '../config/db.js';
const router = Router();
router.get('/', async (req, res, next) => {
try {
const [rows] = await pool.query('SELECT * FROM items');
res.json(rows);
} catch (e) { next(e); }
});
router.get('/:id', async (req, res, next) => {
try {
const [rows] = await pool.query('SELECT * FROM items WHERE id = ?', [req.params.id]);
if (!rows.length) return res.status(404).json({ error: 'Not Found' });
res.json(rows[0]);
} catch (e) { next(e); }
});
router.post('/', async (req, res, next) => {
try {
const { name, category_id, price, quantity, description, barcode, min_quantity, location, supplier } = req.body;
const [result] = await pool.query(
`INSERT INTO items (name, category_id, price, quantity, description, barcode, min_quantity, location, supplier)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[name, category_id || null, price || 0, quantity || 0, description || '', barcode || '', min_quantity || 0, location || '', supplier || '']
);
const [rows] = await pool.query('SELECT * FROM items WHERE id = ?', [result.insertId]);
res.status(201).json(rows[0]);
} catch (e) { next(e); }
});
router.put('/:id', async (req, res, next) => {
try {
const { name, category_id, price, quantity, description, barcode, min_quantity, location, supplier } = req.body;
await pool.query(
`UPDATE items SET name=?, category_id=?, price=?, quantity=?, description=?, barcode=?, min_quantity=?, location=?, supplier=?, updated_at=NOW() WHERE id=?`,
[name, category_id || null, price || 0, quantity || 0, description || '', barcode || '', min_quantity || 0, location || '', supplier || '', req.params.id]
);
const [rows] = await pool.query('SELECT * FROM items WHERE id = ?', [req.params.id]);
res.json(rows[0]);
} catch (e) { next(e); }
});
router.delete('/:id', async (req, res, next) => {
try {
await pool.query('DELETE FROM items WHERE id = ?', [req.params.id]);
res.json({ success: true });
} catch (e) { next(e); }
});
export default router;

View File

@ -0,0 +1,199 @@
import express from 'express';
import emailService from '../services/emailService.js';
import { pool } from '../config/db.js';
import { authenticateToken } from '../middleware/auth.js';
const router = express.Router();
// Отправка уведомления о низком остатке
router.post('/low-stock', authenticateToken, async (req, res) => {
try {
const { items } = req.body;
if (!items || !Array.isArray(items)) {
return res.status(400).json({ error: 'Необходимо указать список товаров' });
}
// Получаем пользователей с включенными уведомлениями
const users = await emailService.getUsersWithNotifications('lowStockAlerts');
const results = [];
for (const user of users) {
try {
const result = await emailService.sendLowStockAlert(user, items);
results.push({
userId: user.id,
email: user.email,
status: 'success',
messageId: result.messageId
});
} catch (error) {
results.push({
userId: user.id,
email: user.email,
status: 'error',
error: error.message
});
}
}
res.json({
message: 'Уведомления о низком остатке отправлены',
results
});
} catch (error) {
console.error('Ошибка отправки уведомлений о низком остатке:', error);
res.status(500).json({ error: 'Ошибка отправки уведомлений' });
}
});
// Отправка уведомления об операции
router.post('/operation', authenticateToken, async (req, res) => {
try {
const { operation } = req.body;
if (!operation) {
return res.status(400).json({ error: 'Необходимо указать данные операции' });
}
// Получаем пользователей с включенными уведомлениями
const users = await emailService.getUsersWithNotifications('emailNotifications');
const results = [];
for (const user of users) {
try {
const result = await emailService.sendOperationNotification(user, operation);
results.push({
userId: user.id,
email: user.email,
status: 'success',
messageId: result.messageId
});
} catch (error) {
results.push({
userId: user.id,
email: user.email,
status: 'error',
error: error.message
});
}
}
res.json({
message: 'Уведомления об операции отправлены',
results
});
} catch (error) {
console.error('Ошибка отправки уведомлений об операции:', error);
res.status(500).json({ error: 'Ошибка отправки уведомлений' });
}
});
// Отправка еженедельного отчета
router.post('/weekly-report', authenticateToken, async (req, res) => {
try {
const { report } = req.body;
if (!report) {
return res.status(400).json({ error: 'Необходимо указать данные отчета' });
}
// Получаем пользователей с включенными уведомлениями
const users = await emailService.getUsersWithNotifications('operationReports');
const results = [];
for (const user of users) {
try {
const result = await emailService.sendWeeklyReport(user, report);
results.push({
userId: user.id,
email: user.email,
status: 'success',
messageId: result.messageId
});
} catch (error) {
results.push({
userId: user.id,
email: user.email,
status: 'error',
error: error.message
});
}
}
res.json({
message: 'Еженедельные отчеты отправлены',
results
});
} catch (error) {
console.error('Ошибка отправки еженедельных отчетов:', error);
res.status(500).json({ error: 'Ошибка отправки отчетов' });
}
});
// Тестовая отправка email
router.post('/test', authenticateToken, async (req, res) => {
try {
const { email, subject, message } = req.body;
if (!email || !subject || !message) {
return res.status(400).json({ error: 'Необходимо указать email, subject и message' });
}
const testUser = {
id: 1,
name: 'Тестовый пользователь',
email: email
};
const result = await emailService.sendEmail({
to: email,
subject: subject,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2>Тестовое уведомление</h2>
<p>${message}</p>
<hr>
<p style="color: #666; font-size: 12px;">
Это тестовое уведомление от системы управления складом.<br>
Дата: ${new Date().toLocaleString('ru-RU')}
</p>
</div>
`
});
res.json({
message: 'Тестовое уведомление отправлено',
messageId: result.messageId
});
} catch (error) {
console.error('Ошибка отправки тестового уведомления:', error);
res.status(500).json({ error: 'Ошибка отправки тестового уведомления' });
}
});
// Получение статуса email-сервиса
router.get('/status', authenticateToken, async (req, res) => {
try {
const status = {
service: 'email',
status: 'active',
timestamp: new Date().toISOString(),
features: {
lowStockAlerts: true,
operationNotifications: true,
weeklyReports: true
}
};
res.json(status);
} catch (error) {
console.error('Ошибка получения статуса email-сервиса:', error);
res.status(500).json({ error: 'Ошибка получения статуса' });
}
});
export default router;

View File

@ -0,0 +1,80 @@
import { Router } from 'express';
import pool from '../config/db.js';
const router = Router();
router.get('/', async (req, res, next) => {
try {
const { type, startDate, endDate, itemId, sortBy = 'date', sortOrder = 'desc' } = req.query;
const where = [];
const params = [];
if (type) { where.push('o.type = ?'); params.push(type); }
if (startDate) { where.push('o.date >= ?'); params.push(startDate); }
if (endDate) { where.push('o.date <= ?'); params.push(endDate); }
if (itemId) { where.push('o.item_id = ?'); params.push(itemId); }
const whereSql = where.length ? 'WHERE ' + where.join(' AND ') : '';
const orderSql = `ORDER BY o.${sortBy === 'quantity' ? 'quantity' : 'date'} ${sortOrder.toUpperCase() === 'ASC' ? 'ASC' : 'DESC'}`;
const [rows] = await pool.query(
`SELECT o.id, o.date, o.type, o.quantity, o.status, o.notes, o.supplier, o.recipient,
i.name as itemName, i.id as itemId,
u.name as employeeName, u.id as employeeId
FROM operations o
JOIN items i ON i.id = o.item_id
JOIN users u ON u.id = o.employee_id
${whereSql}
${orderSql}`,
params
);
res.json(rows);
} catch (e) { next(e); }
});
router.post('/incoming', async (req, res, next) => {
const conn = await pool.getConnection();
try {
const { itemId, quantity, supplier, notes, employeeId } = req.body;
await conn.beginTransaction();
await conn.query('UPDATE items SET quantity = quantity + ?, updated_at=NOW() WHERE id = ?', [quantity, itemId]);
const [result] = await conn.query(
'INSERT INTO operations (type, item_id, quantity, employee_id, status, notes, supplier) VALUES ("incoming", ?, ?, ?, "completed", ?, ?)',
[itemId, quantity, employeeId || 1, notes || '', supplier || '']
);
await conn.commit();
const [rows] = await conn.query('SELECT * FROM operations WHERE id = ?', [result.insertId]);
res.status(201).json(rows[0]);
} catch (e) {
await conn.rollback();
next(e);
} finally {
conn.release();
}
});
router.post('/outgoing', async (req, res, next) => {
const conn = await pool.getConnection();
try {
const { itemId, quantity, recipient, notes, employeeId } = req.body;
await conn.beginTransaction();
const [itemRows] = await conn.query('SELECT quantity FROM items WHERE id = ?', [itemId]);
if (!itemRows.length) throw new Error('Item not found');
if (itemRows[0].quantity < quantity) {
return res.status(400).json({ error: 'Insufficient quantity' });
}
await conn.query('UPDATE items SET quantity = quantity - ?, updated_at=NOW() WHERE id = ?', [quantity, itemId]);
const [result] = await conn.query(
'INSERT INTO operations (type, item_id, quantity, employee_id, status, notes, recipient) VALUES ("outgoing", ?, ?, ?, "completed", ?, ?)',
[itemId, quantity, employeeId || 1, notes || '', recipient || '']
);
await conn.commit();
const [rows] = await conn.query('SELECT * FROM operations WHERE id = ?', [result.insertId]);
res.status(201).json(rows[0]);
} catch (e) {
await conn.rollback();
next(e);
} finally {
conn.release();
}
});
export default router;

View File

@ -0,0 +1,142 @@
import express from 'express';
import { pool } from '../config/db.js';
import { authenticateToken } from '../middleware/auth.js';
import { logger } from '../config/logger.js';
const router = express.Router();
// Хелпер: построить условия фильтра
function buildFilters(query) {
const where = [];
const params = [];
if (query.dateFrom) {
where.push('op.date >= ?');
params.push(query.dateFrom);
}
if (query.dateTo) {
where.push('op.date <= ?');
params.push(query.dateTo + ' 23:59:59');
}
if (query.opType) {
where.push('op.type = ?');
params.push(query.opType);
}
if (query.categoryId) {
where.push('it.category_id = ?');
params.push(query.categoryId);
}
if (query.itemQuery) {
where.push('(it.name LIKE ? OR it.barcode LIKE ?)');
params.push(`%${query.itemQuery}%`, `%${query.itemQuery}%`);
}
if (query.employeeQuery) {
where.push('(u.username LIKE ? OR u.name LIKE ?)');
params.push(`%${query.employeeQuery}%`, `%${query.employeeQuery}%`);
}
const whereSql = where.length ? 'WHERE ' + where.join(' AND ') : '';
return { whereSql, params };
}
// Сводные показатели
router.get('/summary', authenticateToken, async (req, res) => {
try {
const { whereSql, params } = buildFilters(req.query);
const [rows] = await pool.execute(`
SELECT
SUM(CASE WHEN op.type = 'incoming' THEN op.quantity ELSE 0 END) AS totalIncoming,
SUM(CASE WHEN op.type = 'outgoing' THEN op.quantity ELSE 0 END) AS totalOutgoing,
COUNT(*) AS totalOperations,
COUNT(DISTINCT op.item_id) AS uniqueItems
FROM operations op
JOIN items it ON it.id = op.item_id
LEFT JOIN users u ON u.id = op.employee_id
${whereSql}
`, params);
res.json(rows[0] || { totalIncoming: 0, totalOutgoing: 0, totalOperations: 0, uniqueItems: 0 });
} catch (e) {
logger.error(e);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});
// Операции (подробный список)
router.get('/operations', authenticateToken, async (req, res) => {
try {
const { whereSql, params } = buildFilters(req.query);
const [rows] = await pool.execute(`
SELECT
op.id,
op.date,
op.type,
op.quantity,
it.name AS itemName,
cat.name AS categoryName,
u.username AS username,
u.name AS employeeName
FROM operations op
JOIN items it ON it.id = op.item_id
LEFT JOIN categories cat ON cat.id = it.category_id
LEFT JOIN users u ON u.id = op.employee_id
${whereSql}
ORDER BY op.date DESC
LIMIT 1000
`, params);
res.json(rows);
} catch (e) {
logger.error(e);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});
// Остатки по категориям
router.get('/by-category', authenticateToken, async (req, res) => {
try {
// Для by-category учитываем только фильтры по категории/дате не критично; считаем текущие остатки
const [rows] = await pool.execute(`
SELECT
cat.name AS categoryName,
COUNT(it.id) AS itemCount,
COALESCE(SUM(it.quantity), 0) AS totalQuantity
FROM categories cat
LEFT JOIN items it ON it.category_id = cat.id
GROUP BY cat.id
ORDER BY cat.name
`);
res.json(rows);
} catch (e) {
logger.error(e);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});
// Топ товаров по движению
router.get('/top-items', authenticateToken, async (req, res) => {
try {
const { whereSql, params } = buildFilters(req.query);
const [rows] = await pool.execute(`
SELECT
it.name AS itemName,
SUM(CASE WHEN op.type = 'incoming' THEN op.quantity ELSE 0 END) AS totalIncoming,
SUM(CASE WHEN op.type = 'outgoing' THEN op.quantity ELSE 0 END) AS totalOutgoing
FROM operations op
JOIN items it ON it.id = op.item_id
LEFT JOIN users u ON u.id = op.employee_id
${whereSql}
GROUP BY it.id
ORDER BY (SUM(CASE WHEN op.type = 'incoming' THEN op.quantity ELSE 0 END) +
SUM(CASE WHEN op.type = 'outgoing' THEN op.quantity ELSE 0 END)) DESC
LIMIT 20
`, params);
res.json(rows);
} catch (e) {
logger.error(e);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});
export default router;

View File

@ -0,0 +1,19 @@
import { Router } from 'express';
import pool from '../config/db.js';
const router = Router();
router.get('/', async (req, res, next) => {
try {
const [[{ totalItems }]] = await pool.query('SELECT COALESCE(SUM(quantity),0) as totalItems FROM items');
const [[{ totalValue }]] = await pool.query('SELECT COALESCE(SUM(price*quantity),0) as totalValue FROM items');
const [[{ lowStockItems }]] = await pool.query('SELECT COUNT(*) as lowStockItems FROM items WHERE quantity <= min_quantity');
const [[{ todayOperations }]] = await pool.query('SELECT COUNT(*) as todayOperations FROM operations WHERE DATE(date) = CURDATE()');
const [[{ totalCategories }]] = await pool.query('SELECT COUNT(*) as totalCategories FROM categories');
const [[{ totalOperations }]] = await pool.query('SELECT COUNT(*) as totalOperations FROM operations');
res.json({ totalItems, totalValue, lowStockItems, todayOperations, totalCategories, totalOperations });
} catch (e) { next(e); }
});
export default router;

522
server/src/routes/users.js Normal file
View File

@ -0,0 +1,522 @@
import express from 'express';
import { pool } from '../config/db.js';
import { authenticateToken, requireRole } from '../middleware/auth.js';
import { logger } from '../config/logger.js';
const router = express.Router();
// Получить всех пользователей (только для админов)
router.get('/', authenticateToken, requireRole('admin'), async (req, res) => {
try {
const [rows] = await pool.execute(`
SELECT
u.id,
u.username,
u.name,
u.email,
u.department,
u.position,
u.active,
u.created_at,
u.last_login,
GROUP_CONCAT(DISTINCT r.name) as roles,
GROUP_CONCAT(DISTINCT p.name) as permissions
FROM users u
LEFT JOIN user_roles ur ON u.id = ur.user_id
LEFT JOIN roles r ON ur.role_id = r.id
LEFT JOIN role_permissions rp ON r.id = rp.role_id
LEFT JOIN permissions p ON rp.permission_id = p.id
GROUP BY u.id
ORDER BY u.name
`);
// Преобразуем данные в нужный формат
const users = rows.map(row => ({
id: row.id,
username: row.username,
name: row.name,
email: row.email,
department: row.department,
position: row.position,
active: Boolean(row.active),
createdAt: row.created_at,
lastLogin: row.last_login,
roles: row.roles ? row.roles.split(',') : [],
permissions: row.permissions ? row.permissions.split(',') : []
}));
res.json(users);
} catch (error) {
logger.error('Ошибка при получении пользователей:', error);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});
// Получить пользователя по ID (только для админов)
router.get('/:id', authenticateToken, requireRole('admin'), async (req, res) => {
try {
const userId = parseInt(req.params.id);
const [rows] = await pool.execute(`
SELECT
u.id,
u.username,
u.name,
u.email,
u.department,
u.position,
u.active,
u.created_at,
u.last_login,
GROUP_CONCAT(DISTINCT r.name) as roles,
GROUP_CONCAT(DISTINCT p.name) as permissions
FROM users u
LEFT JOIN user_roles ur ON u.id = ur.user_id
LEFT JOIN roles r ON ur.role_id = r.id
LEFT JOIN role_permissions rp ON r.id = rp.role_id
LEFT JOIN permissions p ON rp.permission_id = p.id
WHERE u.id = ?
GROUP BY u.id
`, [userId]);
if (rows.length === 0) {
return res.status(404).json({ error: 'Пользователь не найден' });
}
const user = rows[0];
const userData = {
id: user.id,
username: user.username,
name: user.name,
email: user.email,
department: user.department,
position: user.position,
active: Boolean(user.active),
createdAt: user.created_at,
lastLogin: user.last_login,
roles: user.roles ? user.roles.split(',') : [],
permissions: user.permissions ? user.permissions.split(',') : []
};
res.json(userData);
} catch (error) {
logger.error('Ошибка при получении пользователя:', error);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});
// Создать нового пользователя (только для админов)
router.post('/', authenticateToken, requireRole('admin'), async (req, res) => {
try {
const { username, name, email, department, position, password, roles } = req.body;
// Валидация данных
if (!username || !name || !email || !password || !roles || roles.length === 0) {
return res.status(400).json({ error: 'Все обязательные поля должны быть заполнены' });
}
if (password.length < 6) {
return res.status(400).json({ error: 'Пароль должен содержать минимум 6 символов' });
}
// Проверка уникальности username
const [existingUsers] = await pool.execute(
'SELECT id FROM users WHERE username = ?',
[username]
);
if (existingUsers.length > 0) {
return res.status(400).json({ error: 'Пользователь с таким именем уже существует' });
}
// Проверка уникальности email
const [existingEmails] = await pool.execute(
'SELECT id FROM users WHERE email = ?',
[email]
);
if (existingEmails.length > 0) {
return res.status(400).json({ error: 'Пользователь с таким email уже существует' });
}
// Начинаем транзакцию
const connection = await pool.getConnection();
await connection.beginTransaction();
try {
// Создаем пользователя
const [result] = await connection.execute(`
INSERT INTO users (username, name, email, department, position, password_hash, active, created_at)
VALUES (?, ?, ?, ?, ?, ?, 1, NOW())
`, [username, name, email, department || null, position || null, password]);
const userId = result.insertId;
// Добавляем роли пользователю
for (const roleName of roles) {
const [roleResult] = await connection.execute(
'SELECT id FROM roles WHERE name = ?',
[roleName]
);
if (roleResult.length > 0) {
await connection.execute(
'INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)',
[userId, roleResult[0].id]
);
}
}
await connection.commit();
// Получаем созданного пользователя
const [newUser] = await pool.execute(`
SELECT
u.id,
u.username,
u.name,
u.email,
u.department,
u.position,
u.active,
u.created_at,
u.last_login,
GROUP_CONCAT(DISTINCT r.name) as roles,
GROUP_CONCAT(DISTINCT p.name) as permissions
FROM users u
LEFT JOIN user_roles ur ON u.id = ur.user_id
LEFT JOIN roles r ON ur.role_id = r.id
LEFT JOIN role_permissions rp ON r.id = rp.role_id
LEFT JOIN permissions p ON rp.permission_id = p.id
WHERE u.id = ?
GROUP BY u.id
`, [userId]);
const user = newUser[0];
const userData = {
id: user.id,
username: user.username,
name: user.name,
email: user.email,
department: user.department,
position: user.position,
active: Boolean(user.active),
createdAt: user.created_at,
lastLogin: user.last_login,
roles: user.roles ? user.roles.split(',') : [],
permissions: user.permissions ? user.permissions.split(',') : []
};
logger.info(`Создан новый пользователь: ${username} (ID: ${userId})`);
res.status(201).json(userData);
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
} catch (error) {
logger.error('Ошибка при создании пользователя:', error);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});
// Обновить пользователя (только для админов)
router.put('/:id', authenticateToken, requireRole('admin'), async (req, res) => {
try {
const userId = parseInt(req.params.id);
const { username, name, email, department, position, password, roles } = req.body;
// Валидация данных
if (!username || !name || !email || !roles || roles.length === 0) {
return res.status(400).json({ error: 'Все обязательные поля должны быть заполнены' });
}
if (password && password.length < 6) {
return res.status(400).json({ error: 'Пароль должен содержать минимум 6 символов' });
}
// Проверяем существование пользователя
const [existingUser] = await pool.execute(
'SELECT id FROM users WHERE id = ?',
[userId]
);
if (existingUser.length === 0) {
return res.status(404).json({ error: 'Пользователь не найден' });
}
// Проверка уникальности username
const [existingUsers] = await pool.execute(
'SELECT id FROM users WHERE username = ? AND id != ?',
[username, userId]
);
if (existingUsers.length > 0) {
return res.status(400).json({ error: 'Пользователь с таким именем уже существует' });
}
// Проверка уникальности email
const [existingEmails] = await pool.execute(
'SELECT id FROM users WHERE email = ? AND id != ?',
[email, userId]
);
if (existingEmails.length > 0) {
return res.status(400).json({ error: 'Пользователь с таким email уже существует' });
}
// Начинаем транзакцию
const connection = await pool.getConnection();
await connection.beginTransaction();
try {
// Обновляем основные данные пользователя
const updateFields = ['username = ?', 'name = ?', 'email = ?', 'department = ?', 'position = ?'];
const updateValues = [username, name, email, department || null, position || null];
if (password) {
updateFields.push('password_hash = ?');
updateValues.push(password);
}
updateValues.push(userId);
await connection.execute(`
UPDATE users
SET ${updateFields.join(', ')}
WHERE id = ?
`, updateValues);
// Удаляем старые роли
await connection.execute(
'DELETE FROM user_roles WHERE user_id = ?',
[userId]
);
// Добавляем новые роли
for (const roleName of roles) {
const [roleResult] = await connection.execute(
'SELECT id FROM roles WHERE name = ?',
[roleName]
);
if (roleResult.length > 0) {
await connection.execute(
'INSERT INTO user_roles (user_id, role_id) VALUES (?, ?)',
[userId, roleResult[0].id]
);
}
}
await connection.commit();
// Получаем обновленного пользователя
const [updatedUser] = await pool.execute(`
SELECT
u.id,
u.username,
u.name,
u.email,
u.department,
u.position,
u.active,
u.created_at,
u.last_login,
GROUP_CONCAT(DISTINCT r.name) as roles,
GROUP_CONCAT(DISTINCT p.name) as permissions
FROM users u
LEFT JOIN user_roles ur ON u.id = ur.user_id
LEFT JOIN roles r ON ur.role_id = r.id
LEFT JOIN role_permissions rp ON r.id = rp.role_id
LEFT JOIN permissions p ON rp.permission_id = p.id
WHERE u.id = ?
GROUP BY u.id
`, [userId]);
const user = updatedUser[0];
const userData = {
id: user.id,
username: user.username,
name: user.name,
email: user.email,
department: user.department,
position: user.position,
active: Boolean(user.active),
createdAt: user.created_at,
lastLogin: user.last_login,
roles: user.roles ? user.roles.split(',') : [],
permissions: user.permissions ? user.permissions.split(',') : []
};
logger.info(`Обновлен пользователь: ${username} (ID: ${userId})`);
res.json(userData);
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
} catch (error) {
logger.error('Ошибка при обновлении пользователя:', error);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});
// Удалить пользователя (только для админов)
router.delete('/:id', authenticateToken, requireRole('admin'), async (req, res) => {
try {
const userId = parseInt(req.params.id);
// Проверяем существование пользователя
const [existingUser] = await pool.execute(
'SELECT username FROM users WHERE id = ?',
[userId]
);
if (existingUser.length === 0) {
return res.status(404).json({ error: 'Пользователь не найден' });
}
// Начинаем транзакцию
const connection = await pool.getConnection();
await connection.beginTransaction();
try {
// Удаляем роли пользователя
await connection.execute(
'DELETE FROM user_roles WHERE user_id = ?',
[userId]
);
// Удаляем пользователя
await connection.execute(
'DELETE FROM users WHERE id = ?',
[userId]
);
await connection.commit();
logger.info(`Удален пользователь: ${existingUser[0].username} (ID: ${userId})`);
res.json({ message: 'Пользователь успешно удален' });
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
} catch (error) {
logger.error('Ошибка при удалении пользователя:', error);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});
// Переключить статус пользователя (активен/неактивен)
router.patch('/:id/toggle-status', authenticateToken, requireRole('admin'), async (req, res) => {
try {
const userId = parseInt(req.params.id);
// Проверяем существование пользователя
const [existingUser] = await pool.execute(
'SELECT username, active FROM users WHERE id = ?',
[userId]
);
if (existingUser.length === 0) {
return res.status(404).json({ error: 'Пользователь не найден' });
}
const newStatus = !existingUser[0].active;
// Обновляем статус
await pool.execute(
'UPDATE users SET active = ? WHERE id = ?',
[newStatus, userId]
);
logger.info(`Изменен статус пользователя: ${existingUser[0].username} (ID: ${userId}) на ${newStatus ? 'активен' : 'неактивен'}`);
res.json({
message: `Пользователь ${newStatus ? 'активирован' : 'деактивирован'}`,
active: newStatus
});
} catch (error) {
logger.error('Ошибка при изменении статуса пользователя:', error);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});
// Получить статистику пользователей (только для админов)
router.get('/stats/overview', authenticateToken, requireRole('admin'), async (req, res) => {
try {
const [stats] = await pool.execute(`
SELECT
COUNT(*) as total_users,
SUM(active) as active_users,
SUM(CASE WHEN EXISTS(SELECT 1 FROM user_roles ur JOIN roles r ON ur.role_id = r.id WHERE ur.user_id = users.id AND r.name = 'admin') THEN 1 ELSE 0 END) as admin_users,
SUM(CASE WHEN EXISTS(SELECT 1 FROM user_roles ur JOIN roles r ON ur.role_id = r.id WHERE ur.user_id = users.id AND r.name = 'manager') THEN 1 ELSE 0 END) as manager_users,
SUM(CASE WHEN EXISTS(SELECT 1 FROM user_roles ur JOIN roles r ON ur.role_id = r.id WHERE ur.user_id = users.id AND r.name = 'operator') THEN 1 ELSE 0 END) as operator_users,
SUM(CASE WHEN EXISTS(SELECT 1 FROM user_roles ur JOIN roles r ON ur.role_id = r.id WHERE ur.user_id = users.id AND r.name = 'viewer') THEN 1 ELSE 0 END) as viewer_users
FROM users
`);
res.json(stats[0]);
} catch (error) {
logger.error('Ошибка при получении статистики пользователей:', error);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});
// Получить роли для выбора
router.get('/roles/available', authenticateToken, requireRole('admin'), async (req, res) => {
try {
const [roles] = await pool.execute(`
SELECT
r.id,
r.name,
r.description,
GROUP_CONCAT(p.name) as permissions
FROM roles r
LEFT JOIN role_permissions rp ON r.id = rp.role_id
LEFT JOIN permissions p ON rp.permission_id = p.id
GROUP BY r.id
ORDER BY r.name
`);
const rolesData = roles.map(role => ({
id: role.id,
name: role.name,
description: role.description,
permissions: role.permissions ? role.permissions.split(',') : []
}));
res.json(rolesData);
} catch (error) {
logger.error('Ошибка при получении ролей:', error);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});
// Получить разрешения для выбора
router.get('/permissions/available', authenticateToken, requireRole('admin'), async (req, res) => {
try {
const [permissions] = await pool.execute(`
SELECT id, name, description
FROM permissions
ORDER BY name
`);
res.json(permissions);
} catch (error) {
logger.error('Ошибка при получении разрешений:', error);
res.status(500).json({ error: 'Внутренняя ошибка сервера' });
}
});
export default router;

55
server/src/server.js Normal file
View File

@ -0,0 +1,55 @@
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import pino from 'pino';
import dotenv from 'dotenv';
import authRoutes from './routes/auth.js';
import itemRoutes from './routes/items.js';
import categoryRoutes from './routes/categories.js';
import operationRoutes from './routes/operations.js';
import statsRoutes from './routes/stats.js';
import usersRoutes from './routes/users.js';
import reportsRoutes from './routes/reports.js';
import notificationsRoutes from './routes/notifications.js';
dotenv.config();
const app = express();
const logger = pino({ level: process.env.NODE_ENV === 'production' ? 'info' : 'debug' });
app.use(helmet());
app.use(cors({ origin: true, credentials: true }));
app.use(express.json());
app.get('/api/health', (req, res) => {
res.json({ status: 'ok' });
});
app.use('/api/auth', authRoutes);
app.use('/api/items', itemRoutes);
app.use('/api/categories', categoryRoutes);
app.use('/api/operations', operationRoutes);
app.use('/api/stats', statsRoutes);
app.use('/api/users', usersRoutes);
app.use('/api/reports', reportsRoutes);
app.use('/api/notifications', notificationsRoutes);
// Not found handler
app.use((req, res) => {
res.status(404).json({ error: 'Not Found' });
});
// Error handler
// eslint-disable-next-line no-unused-vars
app.use((err, req, res, next) => {
logger.error(err);
res.status(err.status || 500).json({ error: err.message || 'Internal Server Error' });
});
const port = Number(process.env.PORT || 3001);
app.listen(port, () => {
logger.info(`API listening on http://localhost:${port}`);
});

View File

@ -0,0 +1,248 @@
import nodemailer from 'nodemailer';
import { pool } from '../config/db.js';
class EmailService {
constructor() {
this.transporter = null;
this.init();
}
async init() {
try {
// Создаем транспортер для отправки email
this.transporter = nodemailer.createTransporter({
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: process.env.SMTP_PORT || 587,
secure: false, // true для 465, false для других портов
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS
}
});
// Проверяем соединение
await this.transporter.verify();
console.log('Email сервис инициализирован успешно');
} catch (error) {
console.error('Ошибка инициализации email сервиса:', error);
// Создаем мок-транспортер для демо-режима
this.transporter = {
sendMail: async (options) => {
console.log('📧 ДЕМО-РЕЖИМ: Email отправлен:', {
to: options.to,
subject: options.subject,
text: options.text
});
return { messageId: 'demo-' + Date.now() };
}
};
}
}
// Отправка уведомления о низком остатке
async sendLowStockAlert(user, items) {
try {
const itemList = items.map(item =>
`- ${item.name}: ${item.quantity} шт. (мин. ${item.minQuantity} шт.)`
).join('\n');
const emailContent = {
to: user.email,
subject: '⚠️ Внимание! Товары с низким запасом',
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #dc3545;">⚠️ Внимание! Товары с низким запасом</h2>
<p>Здравствуйте, ${user.name}!</p>
<p>Система обнаружила товары с низким запасом на складе:</p>
<div style="background-color: #fff3cd; border: 1px solid #ffeaa7; padding: 15px; border-radius: 5px; margin: 20px 0;">
<pre style="margin: 0; font-family: monospace;">${itemList}</pre>
</div>
<p><strong>Рекомендуется:</strong></p>
<ul>
<li>Проверить остатки на складе</li>
<li>Оформить заказ на пополнение</li>
<li>Связаться с поставщиками</li>
</ul>
<hr style="margin: 30px 0; border: none; border-top: 1px solid #eee;">
<p style="color: #666; font-size: 12px;">
Это автоматическое уведомление от системы управления складом.<br>
Дата: ${new Date().toLocaleString('ru-RU')}
</p>
</div>
`
};
return await this.sendEmail(emailContent);
} catch (error) {
console.error('Ошибка отправки уведомления о низком остатке:', error);
throw error;
}
}
// Отправка уведомления об операции
async sendOperationNotification(user, operation) {
try {
const operationType = operation.type === 'incoming' ? 'Приход' : 'Расход';
const typeColor = operation.type === 'incoming' ? '#28a745' : '#ffc107';
const emailContent = {
to: user.email,
subject: `📦 Операция: ${operationType} - ${operation.itemName}`,
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: ${typeColor};">📦 Операция: ${operationType}</h2>
<p>Здравствуйте, ${user.name}!</p>
<p>Была выполнена операция на складе:</p>
<div style="background-color: #f8f9fa; border: 1px solid #dee2e6; padding: 20px; border-radius: 5px; margin: 20px 0;">
<table style="width: 100%; border-collapse: collapse;">
<tr>
<td style="padding: 8px 0; font-weight: bold;">Товар:</td>
<td style="padding: 8px 0;">${operation.itemName}</td>
</tr>
<tr>
<td style="padding: 8px 0; font-weight: bold;">Количество:</td>
<td style="padding: 8px 0;">${operation.quantity} шт.</td>
</tr>
<tr>
<td style="padding: 8px 0; font-weight: bold;">Тип операции:</td>
<td style="padding: 8px 0; color: ${typeColor}; font-weight: bold;">${operationType}</td>
</tr>
<tr>
<td style="padding: 8px 0; font-weight: bold;">Дата:</td>
<td style="padding: 8px 0;">${new Date(operation.date).toLocaleString('ru-RU')}</td>
</tr>
<tr>
<td style="padding: 8px 0; font-weight: bold;">Сотрудник:</td>
<td style="padding: 8px 0;">${operation.employeeName}</td>
</tr>
</table>
</div>
<hr style="margin: 30px 0; border: none; border-top: 1px solid #eee;">
<p style="color: #666; font-size: 12px;">
Это автоматическое уведомление от системы управления складом.<br>
Дата: ${new Date().toLocaleString('ru-RU')}
</p>
</div>
`
};
return await this.sendEmail(emailContent);
} catch (error) {
console.error('Ошибка отправки уведомления об операции:', error);
throw error;
}
}
// Отправка еженедельного отчета
async sendWeeklyReport(user, report) {
try {
const emailContent = {
to: user.email,
subject: '📊 Еженедельный отчет по операциям склада',
html: `
<div style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
<h2 style="color: #007bff;">📊 Еженедельный отчет</h2>
<p>Здравствуйте, ${user.name}!</p>
<p>Представляем вашему вниманию еженедельный отчет по операциям склада:</p>
<div style="background-color: #f8f9fa; border: 1px solid #dee2e6; padding: 20px; border-radius: 5px; margin: 20px 0;">
<h3 style="margin-top: 0;">Статистика за неделю:</h3>
<table style="width: 100%; border-collapse: collapse;">
<tr style="background-color: #e9ecef;">
<td style="padding: 12px; border: 1px solid #dee2e6; font-weight: bold;">Показатель</td>
<td style="padding: 12px; border: 1px solid #dee2e6; font-weight: bold;">Значение</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #dee2e6;">Всего операций</td>
<td style="padding: 12px; border: 1px solid #dee2e6;">${report.totalOperations}</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #dee2e6;">Приходы</td>
<td style="padding: 12px; border: 1px solid #dee2e6; color: #28a745;">${report.incoming}</td>
</tr>
<tr>
<td style="padding: 12px; border: 1px solid #dee2e6;">Расходы</td>
<td style="padding: 12px; border: 1px solid #dee2e6; color: #ffc107;">${report.outgoing}</td>
</tr>
</table>
</div>
${report.operations.length > 0 ? `
<div style="background-color: #f8f9fa; border: 1px solid #dee2e6; padding: 20px; border-radius: 5px; margin: 20px 0;">
<h3 style="margin-top: 0;">Последние операции:</h3>
<table style="width: 100%; border-collapse: collapse; font-size: 12px;">
<tr style="background-color: #e9ecef;">
<td style="padding: 8px; border: 1px solid #dee2e6; font-weight: bold;">Дата</td>
<td style="padding: 8px; border: 1px solid #dee2e6; font-weight: bold;">Тип</td>
<td style="padding: 8px; border: 1px solid #dee2e6; font-weight: bold;">Товар</td>
<td style="padding: 8px; border: 1px solid #dee2e6; font-weight: bold;">Кол-во</td>
</tr>
${report.operations.slice(0, 10).map(op => `
<tr>
<td style="padding: 8px; border: 1px solid #dee2e6;">${new Date(op.date).toLocaleDateString('ru-RU')}</td>
<td style="padding: 8px; border: 1px solid #dee2e6; color: ${op.type === 'incoming' ? '#28a745' : '#ffc107'};">${op.type === 'incoming' ? 'Приход' : 'Расход'}</td>
<td style="padding: 8px; border: 1px solid #dee2e6;">${op.itemName}</td>
<td style="padding: 8px; border: 1px solid #dee2e6;">${op.quantity}</td>
</tr>
`).join('')}
</table>
</div>
` : ''}
<hr style="margin: 30px 0; border: none; border-top: 1px solid #eee;">
<p style="color: #666; font-size: 12px;">
Это автоматический еженедельный отчет от системы управления складом.<br>
Дата формирования: ${new Date().toLocaleString('ru-RU')}
</p>
</div>
`
};
return await this.sendEmail(emailContent);
} catch (error) {
console.error('Ошибка отправки еженедельного отчета:', error);
throw error;
}
}
// Общая функция отправки email
async sendEmail(emailContent) {
try {
const mailOptions = {
from: process.env.SMTP_FROM || process.env.SMTP_USER,
to: emailContent.to,
subject: emailContent.subject,
html: emailContent.html
};
const result = await this.transporter.sendMail(mailOptions);
console.log('Email отправлен успешно:', result.messageId);
return result;
} catch (error) {
console.error('Ошибка отправки email:', error);
throw error;
}
}
// Получение пользователей с включенными уведомлениями
async getUsersWithNotifications(notificationType) {
try {
const [rows] = await pool.execute(`
SELECT u.id, u.name, u.email, u.department, u.position
FROM users u
WHERE u.active = 1
`);
// В реальном приложении здесь была бы проверка настроек пользователей
// Пока возвращаем всех активных пользователей
return rows;
} catch (error) {
console.error('Ошибка получения пользователей:', error);
return [];
}
}
}
export default new EmailService();