food
This commit is contained in:
Binary file not shown.
+8
-1
@@ -9,4 +9,11 @@ LOG_FILE_CARDS=cards.log
|
||||
|
||||
LOG_DIR=../../logs/
|
||||
LOG_FILE=app.log
|
||||
DOCX_PATH=../../../frontend/public/handbook.docx
|
||||
DOCX_PATH=../../../frontend/public/handbook.docx
|
||||
|
||||
# Питание (rasch_content — локальная БД с users/content/food)
|
||||
FOOD_DB_HOST=localhost
|
||||
FOOD_DB_PORT=3306
|
||||
FOOD_DB_USER=root
|
||||
FOOD_DB_PASSWORD=Vova-2003.
|
||||
FOOD_DB_NAME=rasch_content
|
||||
+32
-2
@@ -12,6 +12,34 @@
|
||||
"testMode": true,
|
||||
"mockData": {
|
||||
"employees": [
|
||||
{
|
||||
"id": "1635578914",
|
||||
"cardNumber": "1635578914",
|
||||
"tnom": "22906",
|
||||
"famaly": "Пецевич",
|
||||
"ima": "Анатолий",
|
||||
"otch": "Анатольевич",
|
||||
"profession": "Инженер-электроник",
|
||||
"category": "Высшая",
|
||||
"departmentName": "УАМИТ",
|
||||
"departmentCode": "550",
|
||||
"organization": "Филиал «Завод Химволокно» ОАО «Гродно Азот»",
|
||||
"identNumber": "123456789"
|
||||
},
|
||||
{
|
||||
"id": "1649045490",
|
||||
"cardNumber": "1649045490",
|
||||
"tnom": "23959",
|
||||
"famaly": "Григорян",
|
||||
"ima": "Владимир",
|
||||
"otch": "Гагикович",
|
||||
"profession": "Инженер-программист сектора АСУТП",
|
||||
"category": "Высшая",
|
||||
"departmentName": "УАМИТ",
|
||||
"departmentCode": "550",
|
||||
"organization": "Филиал «Завод Химволокно» ОАО «Гродно Азот»",
|
||||
"identNumber": "123456789"
|
||||
},
|
||||
{
|
||||
"id": 1,
|
||||
"cardNumber": "1001",
|
||||
@@ -19,7 +47,7 @@
|
||||
"famaly": "Петров",
|
||||
"ima": "Петр",
|
||||
"otch": "Петрович",
|
||||
"profession": "Главный инженер",
|
||||
"profession": "электромонтер",
|
||||
"category": "Высшая",
|
||||
"departmentName": "Технический отдел",
|
||||
"departmentCode": "101",
|
||||
@@ -47,7 +75,7 @@
|
||||
"famaly": "Иванов",
|
||||
"ima": "Иван",
|
||||
"otch": "Иванович",
|
||||
"profession": "Разработчик",
|
||||
"profession": "Инженер-программист",
|
||||
"category": "2",
|
||||
"departmentName": "IT-отдел",
|
||||
"departmentCode": "303",
|
||||
@@ -56,6 +84,8 @@
|
||||
}
|
||||
],
|
||||
"payslips": [
|
||||
"001.pdf",
|
||||
"001.pdf",
|
||||
"001.pdf",
|
||||
"002.pdf",
|
||||
"003.pdf"
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
-- ============================================================
|
||||
-- Модуль питания: категории, блюда, счета, заказы
|
||||
-- ============================================================
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- ---------- Компания-оператор питания ----------
|
||||
CREATE TABLE IF NOT EXISTS food_companies (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ---------- Категории (Горячее, Напитки, Закуски, Суп...) ----------
|
||||
CREATE TABLE IF NOT EXISTS food_categories (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
emoji VARCHAR(16) DEFAULT NULL,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ---------- Блюда ----------
|
||||
CREATE TABLE IF NOT EXISTS food_items (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
category_id INT NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
description VARCHAR(500) DEFAULT NULL,
|
||||
price DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
image_url VARCHAR(500) DEFAULT NULL,
|
||||
weight_grams INT DEFAULT NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
KEY idx_food_items_cat (category_id),
|
||||
CONSTRAINT fk_item_cat FOREIGN KEY (category_id)
|
||||
REFERENCES food_categories(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ---------- Счета питания сотрудников (по табельному) ----------
|
||||
CREATE TABLE IF NOT EXISTS food_accounts (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
tnom VARCHAR(20) NOT NULL UNIQUE,
|
||||
balance DECIMAL(10,2) NOT NULL DEFAULT 0,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ---------- Заказы ----------
|
||||
CREATE TABLE IF NOT EXISTS food_orders (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
tnom VARCHAR(20) NOT NULL,
|
||||
total_amount DECIMAL(10,2) NOT NULL,
|
||||
status ENUM('new','accepted','done','cancelled')
|
||||
NOT NULL DEFAULT 'new',
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
ON UPDATE CURRENT_TIMESTAMP,
|
||||
KEY idx_food_orders_status (status),
|
||||
KEY idx_food_orders_tnom (tnom),
|
||||
KEY idx_food_orders_date (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
|
||||
-- ---------- Позиции заказа ----------
|
||||
CREATE TABLE IF NOT EXISTS food_order_items (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
order_id INT NOT NULL,
|
||||
item_id INT NOT NULL,
|
||||
item_name VARCHAR(255) NOT NULL, -- фиксируем имя на момент заказа
|
||||
unit_price DECIMAL(10,2) NOT NULL, -- и цену
|
||||
quantity INT NOT NULL,
|
||||
subtotal DECIMAL(10,2) NOT NULL,
|
||||
KEY idx_order_items_order (order_id),
|
||||
KEY idx_order_items_item (item_id),
|
||||
CONSTRAINT fk_oi_order FOREIGN KEY (order_id)
|
||||
REFERENCES food_orders(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||
@@ -0,0 +1,27 @@
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
INSERT IGNORE INTO food_companies (id, name) VALUES (1, 'АзотСервис');
|
||||
|
||||
INSERT IGNORE INTO food_categories (id, name, emoji, sort_order) VALUES
|
||||
(1, 'Горячее', '🍲', 10),
|
||||
(2, 'Супы', '🥣', 20),
|
||||
(3, 'Закуски', '🥪', 30),
|
||||
(4, 'Напитки', '🥤', 40),
|
||||
(5, 'Десерты', '🍰', 50);
|
||||
|
||||
INSERT IGNORE INTO food_items (id, category_id, name, description, price, image_url, weight_grams, sort_order) VALUES
|
||||
(1, 1, 'Котлета', 'Куриная котлета с маслом', 6.50, '/uploads/food/kiev.jpg', 250, 10),
|
||||
(2, 1, 'Плов с говядиной', 'Рассольный плов', 7.20, '/uploads/food/plov.jpg', 300, 20),
|
||||
(3, 2, 'Борщ', 'С говядиной и сметаной', 4.50, '/uploads/food/borsch.jpg', 350, 10),
|
||||
(4, 2, 'Куриный суп', 'С домашней лапшой', 3.80, '/uploads/food/soup.jpg', 350, 20),
|
||||
(5, 3, 'Бутерброд с сыром', 'Тост, сыр, ветчина', 2.90, '/uploads/food/sandwich.jpg',150, 10),
|
||||
(6, 4, 'Чай чёрный', 'С сахаром', 1.20, '/uploads/food/tea.jpg', 250, 10),
|
||||
(7, 4, 'Кофе американо', 'Эспрессо с водой', 2.00, '/uploads/food/coffee.jpg', 200, 20),
|
||||
(8, 4, 'Сок апельсиновый', 'Свежевыжатый', 2.50, '/uploads/food/juice.jpg', 250, 30),
|
||||
(9, 5, 'Пирожок с вишней', 'Выпечка', 1.80, '/uploads/food/pie.jpg', 100, 10);
|
||||
|
||||
-- Тестовые счета (можно удалить на проде)
|
||||
INSERT IGNORE INTO food_accounts (tnom, balance) VALUES
|
||||
('001', 50.00),
|
||||
('002', 32.50),
|
||||
('003', 100.00);
|
||||
Generated
+87
@@ -15,6 +15,7 @@
|
||||
"express-rate-limit": "^8.6.2",
|
||||
"helmet": "^8.3.0",
|
||||
"mammoth": "^1.9.1",
|
||||
"multer": "^2.5.0",
|
||||
"mysql2": "^3.14.2",
|
||||
"node-cron": "^4.2.1",
|
||||
"odata-client": "^0.8.0",
|
||||
@@ -152,6 +153,12 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/append-field": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
|
||||
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "1.0.10",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
|
||||
@@ -288,6 +295,17 @@
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/busboy": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
|
||||
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
|
||||
"dependencies": {
|
||||
"streamsearch": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
@@ -1446,6 +1464,67 @@
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.5.0",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.5.0.tgz",
|
||||
"integrity": "sha512-BsqOzYGVq49JcW2IzoSAxeKYRevb+dGGPPWleOR8Lrlk4igwxu1/9pwSXlByhCb7MVZDrNJFC0IBP5/O5nLYRw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"type-is": "^1.6.18"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.16.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/multer/node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mysql2": {
|
||||
"version": "3.23.2",
|
||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.23.2.tgz",
|
||||
@@ -2054,6 +2133,14 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/streamsearch": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
|
||||
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
"express-rate-limit": "^8.6.2",
|
||||
"helmet": "^8.3.0",
|
||||
"mammoth": "^1.9.1",
|
||||
"multer": "^2.5.0",
|
||||
"mysql2": "^3.14.2",
|
||||
"node-cron": "^4.2.1",
|
||||
"odata-client": "^0.8.0",
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,37 @@
|
||||
// ============================================
|
||||
// 🍔 Отдельное подключение к БД питания
|
||||
// (rasch_content — там таблицы food_*)
|
||||
// ============================================
|
||||
const mysql = require('mysql2/promise');
|
||||
const path = require('path');
|
||||
const { logger } = require('./logger');
|
||||
|
||||
require('dotenv').config({ path: path.join(__dirname, '../../config.env') });
|
||||
|
||||
const pool = mysql.createPool({
|
||||
host: process.env.FOOD_DB_HOST || 'localhost',
|
||||
port: Number(process.env.FOOD_DB_PORT) || 3306,
|
||||
user: process.env.FOOD_DB_USER || 'root',
|
||||
password: process.env.FOOD_DB_PASSWORD || '',
|
||||
database: process.env.FOOD_DB_NAME || 'rasch_content',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 5,
|
||||
queueLimit: 0,
|
||||
charset: 'utf8mb4',
|
||||
});
|
||||
|
||||
async function query(sql, params = []) {
|
||||
const [rows] = await pool.execute(sql, params);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function queryOne(sql, params = []) {
|
||||
const rows = await query(sql, params);
|
||||
return rows.length > 0 ? rows[0] : null;
|
||||
}
|
||||
|
||||
logger.info(
|
||||
`[db_food] pool → ${process.env.FOOD_DB_HOST || 'localhost'}:${process.env.FOOD_DB_PORT || 3306}/${process.env.FOOD_DB_NAME || 'rasch_content'}`
|
||||
);
|
||||
|
||||
module.exports = { pool, query, queryOne };
|
||||
@@ -0,0 +1,502 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { query, queryOne } = require('../config/db_food');
|
||||
const { logger } = require('../config/logger');
|
||||
const multer = require('multer');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
// ============================================
|
||||
// 🖼️ НАСТРОЙКА ЗАГРУЗКИ КАРТИНОК (MULTER)
|
||||
// ============================================
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
const dir = path.join(__dirname, '../../uploads/food');
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
cb(null, dir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
cb(null, Date.now() + path.extname(file.originalname));
|
||||
}
|
||||
});
|
||||
const upload = multer({ storage });
|
||||
|
||||
// ============================================
|
||||
// 📋 ПОЛУЧЕНИЕ МЕНЮ (ДЛЯ КИОСКА И АДМИНКИ)
|
||||
// ============================================
|
||||
router.get('/menu', async (req, res) => {
|
||||
try {
|
||||
const categories = await query(
|
||||
`SELECT id, name, emoji, sort_order
|
||||
FROM food_categories
|
||||
WHERE is_active = 1
|
||||
ORDER BY sort_order, name`
|
||||
);
|
||||
|
||||
const items = await query(
|
||||
`SELECT id, category_id, name, description, price, image_url, weight_grams
|
||||
FROM food_items
|
||||
WHERE is_active = 1
|
||||
ORDER BY category_id, sort_order, name`
|
||||
);
|
||||
|
||||
const company = await queryOne(
|
||||
`SELECT name FROM food_companies WHERE is_active = 1 LIMIT 1`
|
||||
);
|
||||
|
||||
const byCat = {};
|
||||
items.forEach((it) => {
|
||||
(byCat[it.category_id] ||= []).push({
|
||||
id: it.id,
|
||||
name: it.name,
|
||||
description: it.description,
|
||||
price: Number(it.price),
|
||||
imageUrl: it.image_url,
|
||||
weightGrams: it.weight_grams,
|
||||
});
|
||||
});
|
||||
|
||||
res.json({
|
||||
company: company?.name || 'АзотСервис',
|
||||
categories: categories.map((c) => ({
|
||||
id: c.id,
|
||||
name: c.name,
|
||||
emoji: c.emoji,
|
||||
items: byCat[c.id] || [],
|
||||
})),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('[food/menu]', { error: err.message });
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 💰 БАЛАНС ПОЛЬЗОВАТЕЛЯ
|
||||
// ============================================
|
||||
router.get('/balance/:tnom', async (req, res) => {
|
||||
try {
|
||||
const { tnom } = req.params;
|
||||
const row = await queryOne(
|
||||
`SELECT balance FROM food_accounts WHERE tnom = ?`,
|
||||
[tnom]
|
||||
);
|
||||
res.json({ tnom, balance: row ? Number(row.balance) : 0 });
|
||||
} catch (err) {
|
||||
logger.error('[food/balance]', { error: err.message });
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 🛒 СОЗДАНИЕ ЗАКАЗА
|
||||
// ============================================
|
||||
router.post('/orders', async (req, res) => {
|
||||
const { tnom, items } = req.body || {};
|
||||
|
||||
if (!tnom || !Array.isArray(items) || items.length === 0) {
|
||||
return res.status(400).json({ error: 'tnom и items обязательны' });
|
||||
}
|
||||
|
||||
const ids = [...new Set(items.map((i) => Number(i.itemId)))];
|
||||
if (ids.length === 0 || ids.some((n) => !Number.isFinite(n))) {
|
||||
return res.status(400).json({ error: 'Некорректные itemId' });
|
||||
}
|
||||
|
||||
try {
|
||||
const placeholders = ids.map(() => '?').join(',');
|
||||
const rows = await query(
|
||||
`SELECT id, name, price FROM food_items
|
||||
WHERE id IN (${placeholders}) AND is_active = 1`,
|
||||
ids
|
||||
);
|
||||
|
||||
const byId = Object.fromEntries(rows.map((r) => [r.id, r]));
|
||||
|
||||
let total = 0;
|
||||
const orderItems = [];
|
||||
for (const it of items) {
|
||||
const dbItem = byId[Number(it.itemId)];
|
||||
const qty = Number(it.quantity);
|
||||
if (!dbItem || !Number.isInteger(qty) || qty <= 0) continue;
|
||||
|
||||
const unitPrice = Number(dbItem.price);
|
||||
const subtotal = +(unitPrice * qty).toFixed(2);
|
||||
total += subtotal;
|
||||
|
||||
orderItems.push({
|
||||
itemId: dbItem.id,
|
||||
itemName: dbItem.name,
|
||||
unitPrice,
|
||||
quantity: qty,
|
||||
subtotal,
|
||||
});
|
||||
}
|
||||
|
||||
if (orderItems.length === 0) {
|
||||
return res.status(400).json({ error: 'Пустой заказ' });
|
||||
}
|
||||
|
||||
total = +total.toFixed(2);
|
||||
|
||||
const acc = await queryOne(
|
||||
`SELECT balance FROM food_accounts WHERE tnom = ?`,
|
||||
[tnom]
|
||||
);
|
||||
const balance = acc ? Number(acc.balance) : 0;
|
||||
if (acc && balance < total) {
|
||||
return res.status(409).json({
|
||||
error: 'Недостаточно средств на счёте питания',
|
||||
balance,
|
||||
total,
|
||||
});
|
||||
}
|
||||
|
||||
const orderId = await (async () => {
|
||||
const { pool } = require('../config/db_food');
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
|
||||
const [orderResult] = await conn.execute(
|
||||
`INSERT INTO food_orders (tnom, total_amount) VALUES (?, ?)`,
|
||||
[tnom, total]
|
||||
);
|
||||
const newId = orderResult.insertId;
|
||||
|
||||
for (const oi of orderItems) {
|
||||
await conn.execute(
|
||||
`INSERT INTO food_order_items
|
||||
(order_id, item_id, item_name, unit_price, quantity, subtotal)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[newId, oi.itemId, oi.itemName, oi.unitPrice, oi.quantity, oi.subtotal]
|
||||
);
|
||||
}
|
||||
|
||||
if (acc) {
|
||||
await conn.execute(
|
||||
`UPDATE food_accounts SET balance = balance - ? WHERE tnom = ?`,
|
||||
[total, tnom]
|
||||
);
|
||||
}
|
||||
|
||||
await conn.commit();
|
||||
return newId;
|
||||
} catch (e) {
|
||||
await conn.rollback();
|
||||
throw e;
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
})();
|
||||
|
||||
logger.info(`[food/orders] Создан заказ #${orderId} для ${tnom}, сумма ${total}`);
|
||||
res.json({
|
||||
success: true,
|
||||
orderId,
|
||||
total,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('[food/orders]', { error: err.message });
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 📦 ПОЛУЧЕНИЕ СПИСКА ЗАКАЗОВ (ДЛЯ МОДЕРАТОРА)
|
||||
// ============================================
|
||||
router.get('/orders', async (req, res) => {
|
||||
try {
|
||||
const { status, tnom, date } = req.query;
|
||||
const where = [];
|
||||
const params = [];
|
||||
|
||||
if (status && status !== 'all') { where.push('o.status = ?'); params.push(status); }
|
||||
if (tnom) { where.push('o.tnom = ?'); params.push(tnom); }
|
||||
if (date) { where.push('DATE(o.created_at) = ?'); params.push(date); }
|
||||
|
||||
const whereSql = where.length ? `WHERE ${where.join(' AND ')}` : '';
|
||||
|
||||
const orders = await query(
|
||||
`SELECT o.id, o.tnom, o.total_amount, o.status, o.created_at
|
||||
FROM food_orders o
|
||||
${whereSql}
|
||||
ORDER BY o.created_at DESC
|
||||
LIMIT 200`,
|
||||
params
|
||||
);
|
||||
|
||||
if (orders.length > 0) {
|
||||
const ids = orders.map((o) => o.id);
|
||||
const ph = ids.map(() => '?').join(',');
|
||||
const items = await query(
|
||||
`SELECT order_id, item_id, item_name, unit_price, quantity, subtotal
|
||||
FROM food_order_items
|
||||
WHERE order_id IN (${ph})`,
|
||||
ids
|
||||
);
|
||||
const grouped = {};
|
||||
items.forEach((it) => (grouped[it.order_id] ||= []).push(it));
|
||||
|
||||
return res.json(
|
||||
orders.map((o) => ({
|
||||
id: o.id,
|
||||
tnom: o.tnom,
|
||||
employee: 'Сотрудник (таб. ' + o.tnom + ')',
|
||||
totalAmount: Number(o.total_amount),
|
||||
status: o.status,
|
||||
createdAt: o.created_at,
|
||||
items: (grouped[o.id] || []).map((it) => ({
|
||||
itemId: it.item_id,
|
||||
name: it.item_name,
|
||||
unitPrice: Number(it.unit_price),
|
||||
quantity: it.quantity,
|
||||
subtotal: Number(it.subtotal),
|
||||
})),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
res.json([]);
|
||||
} catch (err) {
|
||||
logger.error('[food/orders GET]', { error: err.message });
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 🔄 СМЕНА СТАТУСА ЗАКАЗА (С ВОЗВРАТОМ СРЕДСТВ ПРИ ОТМЕНЕ)
|
||||
// ============================================
|
||||
router.patch('/orders/:id/status', async (req, res) => {
|
||||
const { status } = req.body || {};
|
||||
const allowed = ['new', 'accepted', 'done', 'cancelled'];
|
||||
|
||||
if (!allowed.includes(status)) {
|
||||
return res.status(400).json({ error: 'Некорректный статус' });
|
||||
}
|
||||
|
||||
try {
|
||||
// 1. Получаем текущий заказ
|
||||
const order = await queryOne(
|
||||
`SELECT id, tnom, total_amount, status FROM food_orders WHERE id = ?`,
|
||||
[req.params.id]
|
||||
);
|
||||
|
||||
if (!order) {
|
||||
return res.status(404).json({ error: 'Заказ не найден' });
|
||||
}
|
||||
|
||||
// 2. Если заказ уже отменён — блокируем изменения
|
||||
if (order.status === 'cancelled') {
|
||||
return res.status(400).json({ error: 'Заказ уже отменён. Изменение статуса недоступно.' });
|
||||
}
|
||||
|
||||
// 3. Если новый статус — cancelled, возвращаем деньги и меняем статус
|
||||
if (status === 'cancelled') {
|
||||
const { pool } = require('../config/db_food');
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
|
||||
// Меняем статус
|
||||
await conn.execute(
|
||||
`UPDATE food_orders SET status = 'cancelled' WHERE id = ?`,
|
||||
[req.params.id]
|
||||
);
|
||||
|
||||
// Возвращаем деньги на баланс
|
||||
await conn.execute(
|
||||
`UPDATE food_accounts SET balance = balance + ? WHERE tnom = ?`,
|
||||
[order.total_amount, order.tnom]
|
||||
);
|
||||
|
||||
await conn.commit();
|
||||
logger.info(`[food/orders] Заказ #${req.params.id} отменён, возвращено ${order.total_amount} BYN`);
|
||||
return res.json({ success: true, refunded: true, refund: Number(order.total_amount) });
|
||||
} catch (e) {
|
||||
await conn.rollback();
|
||||
throw e;
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Любой другой статус (new/accepted/done) — просто меняем
|
||||
await query(`UPDATE food_orders SET status = ? WHERE id = ?`, [status, req.params.id]);
|
||||
res.json({ success: true, refunded: false });
|
||||
|
||||
} catch (err) {
|
||||
logger.error('[food/orders PATCH]', { error: err.message });
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 🛠️ CRUD ДЛЯ МОДЕРАТОРА (Управление меню)
|
||||
// ============================================
|
||||
|
||||
// Добавить блюдо
|
||||
router.post('/items', upload.single('image'), async (req, res) => {
|
||||
try {
|
||||
const { category_id, name, description, price, weight_grams } = req.body;
|
||||
const image_url = req.file ? `/uploads/food/${req.file.filename}` : null;
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO food_items (category_id, name, description, price, image_url, weight_grams)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[category_id, name, description, price, image_url, weight_grams || null]
|
||||
);
|
||||
res.json({ success: true, id: result.insertId });
|
||||
} catch (err) {
|
||||
logger.error('[food/items POST]', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Редактировать блюдо
|
||||
router.put('/items/:id', upload.single('image'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
const { category_id, name, description, price, weight_grams, current_image_url } = req.body;
|
||||
|
||||
let image_url = current_image_url;
|
||||
if (req.file) {
|
||||
image_url = `/uploads/food/${req.file.filename}`;
|
||||
}
|
||||
|
||||
if (image_url === 'null' || image_url === 'undefined') {
|
||||
image_url = null;
|
||||
}
|
||||
|
||||
await query(
|
||||
`UPDATE food_items SET category_id=?, name=?, description=?, price=?, image_url=?, weight_grams=?
|
||||
WHERE id=?`,
|
||||
[category_id, name, description, price, image_url, weight_grams || null, id]
|
||||
);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
logger.error('[food/items PUT]', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Удалить блюдо
|
||||
router.delete('/items/:id', async (req, res) => {
|
||||
try {
|
||||
await query(`DELETE FROM food_items WHERE id = ?`, [req.params.id]);
|
||||
res.json({ success: true });
|
||||
} catch (err) {
|
||||
logger.error('[food/items DELETE]', err);
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 📦 МОИ ЗАКАЗЫ (ДЛЯ ПОЛЬЗОВАТЕЛЯ)
|
||||
// ============================================
|
||||
router.get('/my-orders/:tnom', async (req, res) => {
|
||||
try {
|
||||
const { tnom } = req.params;
|
||||
|
||||
const orders = await query(
|
||||
`SELECT id, total_amount, status, created_at
|
||||
FROM food_orders
|
||||
WHERE tnom = ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 50`,
|
||||
[tnom]
|
||||
);
|
||||
|
||||
if (orders.length === 0) return res.json([]);
|
||||
|
||||
// Получаем позиции для этих заказов
|
||||
const ids = orders.map((o) => o.id);
|
||||
const ph = ids.map(() => '?').join(',');
|
||||
const items = await query(
|
||||
`SELECT order_id, item_name, unit_price, quantity, subtotal
|
||||
FROM food_order_items
|
||||
WHERE order_id IN (${ph})`,
|
||||
ids
|
||||
);
|
||||
|
||||
const grouped = {};
|
||||
items.forEach((it) => (grouped[it.order_id] ||= []).push(it));
|
||||
|
||||
const result = orders.map((o) => ({
|
||||
id: o.id,
|
||||
totalAmount: Number(o.total_amount),
|
||||
status: o.status,
|
||||
createdAt: o.created_at,
|
||||
items: (grouped[o.id] || []).map((it) => ({
|
||||
name: it.item_name,
|
||||
unitPrice: Number(it.unit_price),
|
||||
quantity: it.quantity,
|
||||
subtotal: Number(it.subtotal),
|
||||
})),
|
||||
}));
|
||||
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
logger.error('[food/my-orders]', { error: err.message });
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// ❌ ОТМЕНА ЗАКАЗА ПОЛЬЗОВАТЕЛЕМ (ТОЛЬКО СТАТУС 'new')
|
||||
// ============================================
|
||||
router.patch('/my-orders/:id/cancel', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { tnom } = req.body; // Проверяем, что отменяет владелец
|
||||
|
||||
if (!tnom) return res.status(400).json({ error: 'Не указан табельный номер' });
|
||||
|
||||
try {
|
||||
// Получаем информацию о заказе
|
||||
const order = await queryOne(
|
||||
`SELECT id, status, total_amount FROM food_orders WHERE id = ? AND tnom = ?`,
|
||||
[id, tnom]
|
||||
);
|
||||
|
||||
if (!order) {
|
||||
return res.status(404).json({ error: 'Заказ не найден' });
|
||||
}
|
||||
|
||||
if (order.status !== 'new') {
|
||||
return res.status(400).json({ error: 'Отменить можно только новые заказы' });
|
||||
}
|
||||
|
||||
// Транзакция: смена статуса + возврат денег
|
||||
const { pool } = require('../config/db_food');
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
await conn.beginTransaction();
|
||||
|
||||
// 1. Меняем статус
|
||||
await conn.execute(
|
||||
`UPDATE food_orders SET status = 'cancelled' WHERE id = ?`,
|
||||
[id]
|
||||
);
|
||||
|
||||
// 2. Возвращаем деньги
|
||||
await conn.execute(
|
||||
`UPDATE food_accounts SET balance = balance + ? WHERE tnom = ?`,
|
||||
[order.total_amount, tnom]
|
||||
);
|
||||
|
||||
await conn.commit();
|
||||
res.json({ success: true, refund: Number(order.total_amount) });
|
||||
} catch (e) {
|
||||
await conn.rollback();
|
||||
throw e;
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[food/my-orders cancel]', { error: err.message });
|
||||
res.status(500).json({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -1,6 +1,6 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const fs = require('fs');
|
||||
const { logger } = require('../config/logger');
|
||||
const { parsePayslipExcel } = require('../services/excelParser');
|
||||
const { generatePayslipForm } = require('../services/formGenerator');
|
||||
@@ -9,7 +9,7 @@ const router = express.Router();
|
||||
|
||||
/**
|
||||
* GET /api/payslip/form/:tnom
|
||||
* Получить HTML-форму расчетного листка
|
||||
* Получить HTML-форму расчетного листка (для предпросмотра)
|
||||
*/
|
||||
router.get('/form/:tnom', async (req, res) => {
|
||||
try {
|
||||
@@ -18,9 +18,8 @@ router.get('/form/:tnom', async (req, res) => {
|
||||
|
||||
logger.info(`[PayslipForm] Запрос формы для ${tnom}, период: ${period || 'не указан'}`);
|
||||
|
||||
// 1. Находим Excel-файл
|
||||
const configPath = path.join(__dirname, '../../config.json');
|
||||
const config = JSON.parse(await fs.readFile(configPath, 'utf8'));
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
|
||||
let payslipBasePath = config.payslipsPath || './payslips';
|
||||
if (!path.isAbsolute(payslipBasePath)) {
|
||||
@@ -36,10 +35,7 @@ router.get('/form/:tnom', async (req, res) => {
|
||||
|
||||
logger.info(`[PayslipForm] Путь к Excel: ${excelPath}`);
|
||||
|
||||
// 2. Проверяем существование файла
|
||||
try {
|
||||
await fs.access(excelPath);
|
||||
} catch (err) {
|
||||
if (!fs.existsSync(excelPath)) {
|
||||
logger.warn(`[PayslipForm] Excel не найден: ${excelPath}`);
|
||||
return res.status(404).json({
|
||||
message: 'Excel-файл расчетного листка не найден',
|
||||
@@ -47,7 +43,6 @@ router.get('/form/:tnom', async (req, res) => {
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Парсим Excel
|
||||
const data = parsePayslipExcel(excelPath);
|
||||
logger.info(`[PayslipForm] Данные извлечены:`, {
|
||||
fullName: data.fullName,
|
||||
@@ -56,10 +51,7 @@ router.get('/form/:tnom', async (req, res) => {
|
||||
totalPaid: data.totalPaid
|
||||
});
|
||||
|
||||
// 4. Генерируем HTML-форму
|
||||
const html = generatePayslipForm(data);
|
||||
|
||||
// 5. Отправляем HTML
|
||||
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
res.send(html);
|
||||
|
||||
@@ -72,6 +64,154 @@ router.get('/form/:tnom', async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/payslip/data/:tnom
|
||||
* Получить структурированные данные из Excel (для предпросмотра и печати)
|
||||
*/
|
||||
router.get('/data/:tnom', async (req, res) => {
|
||||
try {
|
||||
const { tnom } = req.params;
|
||||
const { period } = req.query;
|
||||
|
||||
if (!tnom || !period) {
|
||||
return res.status(400).json({
|
||||
error: 'Не указан табельный номер или период'
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`[PayslipData] Запрос данных для ${tnom}, период: ${period}`);
|
||||
|
||||
const configPath = path.join(__dirname, '../../config.json');
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
|
||||
let payslipBasePath = config.payslipsPath || './payslips';
|
||||
if (!path.isAbsolute(payslipBasePath)) {
|
||||
payslipBasePath = path.join(__dirname, '../..', payslipBasePath);
|
||||
}
|
||||
|
||||
const excelPath = path.join(payslipBasePath, period, `${tnom}.xlsx`);
|
||||
|
||||
logger.info(`[PayslipData] Путь к Excel: ${excelPath}`);
|
||||
|
||||
if (!fs.existsSync(excelPath)) {
|
||||
logger.warn(`[PayslipData] Файл не найден: ${excelPath}`);
|
||||
return res.status(404).json({
|
||||
error: `Файл не найден: ${tnom}.xlsx в папке ${period}`
|
||||
});
|
||||
}
|
||||
|
||||
const data = parsePayslipExcel(excelPath);
|
||||
data.period = period;
|
||||
|
||||
logger.info(`[PayslipData] Данные извлечены:`, {
|
||||
fullName: data.fullName,
|
||||
tabNumber: data.tabNumber,
|
||||
totalAccrued: data.totalAccrued,
|
||||
totalPaid: data.totalPaid
|
||||
});
|
||||
|
||||
res.json(data);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[PayslipData] Ошибка:', error.message);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/payslip/url/:tnom
|
||||
* Проверка наличия PDF файла
|
||||
*/
|
||||
router.get('/url/:tnom', async (req, res) => {
|
||||
try {
|
||||
const { tnom } = req.params;
|
||||
const { period } = req.query;
|
||||
|
||||
if (!tnom || !period) {
|
||||
return res.status(400).json({
|
||||
error: 'Не указан табельный номер или период'
|
||||
});
|
||||
}
|
||||
|
||||
const configPath = path.join(__dirname, '../../config.json');
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
|
||||
let payslipBasePath = config.payslipsPath || './payslips';
|
||||
if (!path.isAbsolute(payslipBasePath)) {
|
||||
payslipBasePath = path.join(__dirname, '../..', payslipBasePath);
|
||||
}
|
||||
|
||||
const pdfPath = path.join(payslipBasePath, period, `${tnom}.pdf`);
|
||||
const exists = fs.existsSync(pdfPath);
|
||||
|
||||
logger.info(`[PayslipURL] Проверка PDF для ${tnom}, период: ${period}, существует: ${exists}`);
|
||||
|
||||
res.json({
|
||||
exists: exists,
|
||||
url: exists ? `/payslips/${period}/${tnom}.pdf` : null
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[PayslipURL] Ошибка:', error.message);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/payslip/file/:fileName
|
||||
* Отдача PDF файла для предпросмотра (НОВЫЙ ЭНДПОИНТ)
|
||||
*/
|
||||
router.get('/file/:fileName', async (req, res) => {
|
||||
try {
|
||||
const { fileName } = req.params;
|
||||
|
||||
const configPath = path.join(__dirname, '../../config.json');
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
|
||||
let payslipBasePath = config.payslipsPath || './payslips';
|
||||
if (!path.isAbsolute(payslipBasePath)) {
|
||||
payslipBasePath = path.join(__dirname, '../..', payslipBasePath);
|
||||
}
|
||||
|
||||
// Ищем файл в папке payslips и подпапках
|
||||
let filePath = null;
|
||||
|
||||
// Сначала ищем в корне
|
||||
const rootPath = path.join(payslipBasePath, fileName);
|
||||
if (fs.existsSync(rootPath)) {
|
||||
filePath = rootPath;
|
||||
} else {
|
||||
// Ищем в подпапках (периодах)
|
||||
try {
|
||||
const subDirs = fs.readdirSync(payslipBasePath);
|
||||
for (const subDir of subDirs) {
|
||||
const subPath = path.join(payslipBasePath, subDir, fileName);
|
||||
if (fs.existsSync(subPath)) {
|
||||
filePath = subPath;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('[PayslipFile] Не удалось прочитать папку:', err.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (!filePath) {
|
||||
logger.warn(`[PayslipFile] Файл не найден: ${fileName}`);
|
||||
return res.status(404).json({ error: 'Файл не найден' });
|
||||
}
|
||||
|
||||
logger.info(`[PayslipFile] Отдача файла: ${filePath}`);
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', 'inline; filename="' + fileName + '"');
|
||||
res.sendFile(filePath);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('[PayslipFile] Ошибка:', error.message);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/payslip/form-data/:tnom
|
||||
* Получить данные из Excel в JSON (для отладки)
|
||||
@@ -81,9 +221,8 @@ router.get('/form-data/:tnom', async (req, res) => {
|
||||
const { tnom } = req.params;
|
||||
const { period } = req.query;
|
||||
|
||||
// Находим Excel-файл
|
||||
const configPath = path.join(__dirname, '../../config.json');
|
||||
const config = JSON.parse(await fs.readFile(configPath, 'utf8'));
|
||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
|
||||
let payslipBasePath = config.payslipsPath || './payslips';
|
||||
if (!path.isAbsolute(payslipBasePath)) {
|
||||
@@ -97,14 +236,10 @@ router.get('/form-data/:tnom', async (req, res) => {
|
||||
excelPath = path.join(payslipBasePath, `${tnom}.xlsx`);
|
||||
}
|
||||
|
||||
// Проверяем существование
|
||||
try {
|
||||
await fs.access(excelPath);
|
||||
} catch (err) {
|
||||
if (!fs.existsSync(excelPath)) {
|
||||
return res.status(404).json({ message: 'Excel не найден' });
|
||||
}
|
||||
|
||||
// Парсим и возвращаем JSON
|
||||
const data = parsePayslipExcel(excelPath);
|
||||
res.json(data);
|
||||
|
||||
|
||||
+20
-17
@@ -7,6 +7,7 @@ const { connectDB } = require('./config/db');
|
||||
const { logger } = require('./config/logger');
|
||||
const handbookRoutes = require('./routes/handbook');
|
||||
const employeeRoutes = require('./routes/employee');
|
||||
const foodRoutes = require('./routes/food'); // ← НОВАЯ
|
||||
const helmet = require('helmet');
|
||||
const rateLimit = require('express-rate-limit');
|
||||
const XLSX = require('xlsx');
|
||||
@@ -72,11 +73,16 @@ async function connectToNetworkShare(config) {
|
||||
const app = express();
|
||||
|
||||
app.use(cors());
|
||||
// ✅ Раздаем статические файлы (картинки питания, слайды и т.д.)
|
||||
app.use('/uploads', express.static(path.join(__dirname, '../uploads')));
|
||||
|
||||
app.use(express.json());
|
||||
|
||||
app.use(function(req, res, next) {
|
||||
if (req.method === 'POST' || req.method === 'PUT') {
|
||||
if (req.path.startsWith('/api/food')) {
|
||||
return next(); // Пропускаем FormData для питания
|
||||
}
|
||||
if (!req.is('application/json')) {
|
||||
return res.status(415).json({ message: 'Only JSON allowed' });
|
||||
}
|
||||
@@ -96,8 +102,8 @@ app.use(helmet({
|
||||
// Другие необходимые директивы
|
||||
scriptSrc: ["'self'", "'unsafe-inline'"],
|
||||
styleSrc: ["'self'", "'unsafe-inline'"],
|
||||
imgSrc: ["'self'", "data:", "blob:"],
|
||||
connectSrc: ["'self'"],
|
||||
imgSrc: ["'self'", "data:", "blob:"],
|
||||
connectSrc: ["'self'"],
|
||||
fontSrc: ["'self'"],
|
||||
objectSrc: ["'self'", "blob:"],
|
||||
mediaSrc: ["'self'"],
|
||||
@@ -117,6 +123,7 @@ app.use('/api/', limiter);
|
||||
|
||||
app.use('/api/handbook', handbookRoutes);
|
||||
app.use('/api/employee', employeeRoutes);
|
||||
app.use('/api/food', foodRoutes); // ← НОВАЯ
|
||||
|
||||
// ============================================
|
||||
// 🧪 ТЕСТОВЫЙ ЭНДПОИНТ
|
||||
@@ -401,7 +408,7 @@ async function getPayslipFilePath(tnom, period = null) {
|
||||
app.get('/api/payslip/check/:tnom', async function(req, res) {
|
||||
try {
|
||||
var tnom = req.params.tnom;
|
||||
var period = req.query.period || null; // ✅ Берем period из query-параметров
|
||||
var period = req.query.period || null;
|
||||
|
||||
console.log('==========================================');
|
||||
console.log(`📁 [PAYSLIP CHECK] Табельный номер: ${tnom}, Период: ${period || 'не указан'}`);
|
||||
@@ -430,7 +437,7 @@ app.get('/api/payslip/check/:tnom', async function(req, res) {
|
||||
app.get('/api/payslip/url/:tnom', async function(req, res) {
|
||||
try {
|
||||
var tnom = req.params.tnom;
|
||||
var period = req.query.period || null; // ✅ Берем period из query-параметров
|
||||
var period = req.query.period || null;
|
||||
|
||||
console.log('==========================================');
|
||||
console.log(`📁 [PAYSLIP URL] Табельный номер: ${tnom}, Период: ${period || 'не указан'}`);
|
||||
@@ -443,10 +450,9 @@ app.get('/api/payslip/url/:tnom', async function(req, res) {
|
||||
console.log('==========================================');
|
||||
|
||||
if (exists) {
|
||||
// ✅ Формируем URL для получения файла
|
||||
let fileUrl = `/api/payslip/tnom/${tnom}`;
|
||||
if (period) {
|
||||
fileUrl += `?period=${period}`; // ✅ Добавляем period в query
|
||||
fileUrl += `?period=${period}`;
|
||||
}
|
||||
logger.info(`[PAYSLIP URL] Файл найден для ${tnom} (период: ${period || 'корень'})`);
|
||||
res.json({
|
||||
@@ -476,7 +482,7 @@ app.get('/api/payslip/url/:tnom', async function(req, res) {
|
||||
app.get('/api/payslip/tnom/:tnom', async function(req, res) {
|
||||
try {
|
||||
var tnom = req.params.tnom;
|
||||
var period = req.query.period || null; // ✅ Берем period из query-параметров
|
||||
var period = req.query.period || null;
|
||||
|
||||
console.log('==========================================');
|
||||
console.log(`📁 [PAYSLIP] Запрос к /api/payslip/tnom/${tnom}, Период: ${period || 'не указан'}`);
|
||||
@@ -484,7 +490,6 @@ app.get('/api/payslip/tnom/:tnom', async function(req, res) {
|
||||
const filePath = await getPayslipFilePath(tnom, period);
|
||||
console.log(`📁 Полный путь к файлу: ${filePath}`);
|
||||
|
||||
// ✅ ЯВНО проверяем существование файла
|
||||
try {
|
||||
await fsPromises.access(filePath);
|
||||
console.log('✅ Файл найден, начинаем отправку...');
|
||||
@@ -499,11 +504,9 @@ app.get('/api/payslip/tnom/:tnom', async function(req, res) {
|
||||
});
|
||||
}
|
||||
|
||||
// ✅ Добавляем правильные заголовки для PDF
|
||||
res.setHeader('Content-Type', 'application/pdf');
|
||||
res.setHeader('Content-Disposition', 'inline; filename="' + tnom + '.pdf"');
|
||||
|
||||
// ✅ Отправляем файл с обработкой ошибок
|
||||
res.sendFile(filePath, function(err) {
|
||||
if (err) {
|
||||
console.error('❌ Ошибка при отправке файла:', err);
|
||||
@@ -524,6 +527,13 @@ app.get('/api/payslip/tnom/:tnom', async function(req, res) {
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 📄 РАСЧЕТНЫЙ ЛИСТОК (ФОРМА ДЛЯ ПЕЧАТИ) - НОВЫЙ МАРШРУТ
|
||||
// ============================================
|
||||
|
||||
const payslipFormRoutes = require('./routes/payslipForm');
|
||||
app.use('/api/payslip', payslipFormRoutes);
|
||||
|
||||
// ============================================
|
||||
// 🗄️ СТАРЫЕ ЭНДПОИНТЫ РАСЧЕТНЫХ ЛИСТКОВ (для обратной совместимости)
|
||||
// ============================================
|
||||
@@ -769,13 +779,6 @@ app.get('/api/time', function(req, res) {
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================
|
||||
// 📄 РАСЧЕТНЫЙ ЛИСТОК (ФОРМА ДЛЯ ПЕЧАТИ)
|
||||
// ============================================
|
||||
|
||||
const payslipFormRoutes = require('./routes/payslipForm');
|
||||
app.use('/api/payslip', payslipFormRoutes);
|
||||
|
||||
// ============================================
|
||||
// 🚀 ЗАПУСК СЕРВЕРА
|
||||
// ============================================
|
||||
|
||||
@@ -5,6 +5,10 @@ const { logger } = require('../config/logger');
|
||||
* @param {Object} data - данные из Excel
|
||||
* @returns {string} HTML-код формы
|
||||
*/
|
||||
// ============================================
|
||||
// 📄 ГЕНЕРАЦИЯ HTML-ФОРМЫ ДЛЯ ТЕРМОПРИНТЕРА
|
||||
// ============================================
|
||||
|
||||
function generatePayslipForm(data) {
|
||||
try {
|
||||
logger.info('[FormGenerator] Генерация HTML-формы');
|
||||
@@ -18,111 +22,187 @@ function generatePayslipForm(data) {
|
||||
const monthNum = monthNames[data.month] || '00';
|
||||
const period = `${monthNum}${data.year || '2026'}`;
|
||||
|
||||
// Формируем HTML для термопринтера (80мм ширина)
|
||||
// ✅ Формируем HTML с улучшенной печатью
|
||||
let html = `
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Расчетный листок</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
/* ✅ ГЛОБАЛЬНЫЕ ПРАВИЛА ДЛЯ ПЕЧАТИ */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
width: 80mm;
|
||||
margin: 0 auto;
|
||||
padding: 8px 4px;
|
||||
padding: 2mm 2mm;
|
||||
font-family: 'Courier New', Courier, monospace;
|
||||
font-size: 11px;
|
||||
font-size: 9pt;
|
||||
color: #000;
|
||||
background: #fff;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
/* ✅ ПРАВИЛА ДЛЯ ПЕЧАТИ - ПРИНУДИТЕЛЬНОЕ ОТОБРАЖЕНИЕ */
|
||||
@media print {
|
||||
body {
|
||||
width: 80mm;
|
||||
margin: 0;
|
||||
padding: 1mm 1.5mm;
|
||||
font-size: 8.5pt;
|
||||
background: #fff;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
/* ✅ ЗАПРЕЩАЕМ РАЗРЫВЫ ВНУТРИ БЛОКОВ */
|
||||
.header, .sub-header, .section-title, .total-row, .footer {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
/* ✅ ПРИНУДИТЕЛЬНО РАЗМЕРЫ */
|
||||
.item-row {
|
||||
font-size: 7.5pt !important;
|
||||
padding: 0.5px 0 !important;
|
||||
}
|
||||
|
||||
.total-row {
|
||||
font-size: 9.5pt !important;
|
||||
padding: 2px 0 !important;
|
||||
}
|
||||
|
||||
.header {
|
||||
font-size: 11pt !important;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 9pt !important;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
font-size: 7.5pt !important;
|
||||
}
|
||||
|
||||
.footer {
|
||||
font-size: 6.5pt !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ✅ СТИЛИ ДЛЯ ЭКРАНА (ПРЕДПРОСМОТР) */
|
||||
.header {
|
||||
text-align: center;
|
||||
font-size: 14px;
|
||||
font-size: 11pt;
|
||||
font-weight: bold;
|
||||
padding-bottom: 4px;
|
||||
padding-bottom: 3px;
|
||||
border-bottom: 1px dashed #000;
|
||||
margin-bottom: 4px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.sub-header {
|
||||
text-align: center;
|
||||
font-size: 12px;
|
||||
padding-bottom: 4px;
|
||||
font-size: 9pt;
|
||||
padding-bottom: 3px;
|
||||
border-bottom: 1px dashed #000;
|
||||
margin-bottom: 4px;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 2px 0;
|
||||
font-size: 11px;
|
||||
padding: 1px 0;
|
||||
font-size: 8.5pt;
|
||||
}
|
||||
|
||||
.row-label {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.row-value {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.divider {
|
||||
border-top: 1px dashed #000;
|
||||
margin: 4px 0;
|
||||
padding-top: 4px;
|
||||
margin: 3px 0;
|
||||
padding-top: 3px;
|
||||
}
|
||||
|
||||
.total-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 3px 0;
|
||||
padding: 2px 0;
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
font-size: 9.5pt;
|
||||
border-top: 1px solid #000;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-weight: bold;
|
||||
font-size: 12px;
|
||||
padding: 4px 0 2px 0;
|
||||
font-size: 9pt;
|
||||
padding: 3px 0 2px 0;
|
||||
border-bottom: 1px dotted #000;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.item-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 1px 0;
|
||||
font-size: 10px;
|
||||
padding-left: 4px;
|
||||
padding: 0.5px 0;
|
||||
font-size: 7.5pt;
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
.item-desc {
|
||||
flex: 1;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.item-amount {
|
||||
text-align: right;
|
||||
min-width: 50px;
|
||||
min-width: 40px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
text-align: center;
|
||||
font-size: 9px;
|
||||
font-size: 6.5pt;
|
||||
color: #666;
|
||||
border-top: 1px dashed #000;
|
||||
margin-top: 6px;
|
||||
padding-top: 4px;
|
||||
margin-top: 4px;
|
||||
padding-top: 3px;
|
||||
}
|
||||
|
||||
.bank-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 1px 0;
|
||||
font-size: 10px;
|
||||
padding: 0.5px 0;
|
||||
font-size: 7.5pt;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 1px 0;
|
||||
font-size: 10px;
|
||||
padding: 0.5px 0;
|
||||
font-size: 7.5pt;
|
||||
}
|
||||
@media print {
|
||||
body { width: 100%; }
|
||||
|
||||
/* ✅ ДЛЯ ПРЕДПРОСМОТРА В ПРИЛОЖЕНИИ */
|
||||
@media screen {
|
||||
body {
|
||||
max-width: 80mm;
|
||||
padding: 3mm;
|
||||
border: 1px solid #ddd;
|
||||
margin: 10px auto;
|
||||
box-shadow: 0 0 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -145,11 +225,11 @@ function generatePayslipForm(data) {
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span>Подразделение:</span>
|
||||
<span style="font-size:9px;max-width:120px;overflow:hidden;">${data.department || ''}</span>
|
||||
<span style="font-size:7pt;max-width:100px;overflow:hidden;">${data.department || ''}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span>Должность:</span>
|
||||
<span style="font-size:9px;max-width:120px;overflow:hidden;">${data.position || ''}</span>
|
||||
<span style="font-size:7pt;max-width:100px;overflow:hidden;">${data.position || ''}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span>Отработано:</span>
|
||||
@@ -189,7 +269,7 @@ function generatePayslipForm(data) {
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- Итоги -->
|
||||
<div class="total-row" style="border-top:2px solid #000;font-size:14px;">
|
||||
<div class="total-row" style="border-top:2px solid #000;font-size:12pt;padding:3px 0;">
|
||||
<span>💰 К ВЫПЛАТЕ:</span>
|
||||
<span>${data.totalPaid.toFixed(2)}</span>
|
||||
</div>
|
||||
@@ -216,9 +296,9 @@ function generatePayslipForm(data) {
|
||||
<!-- Лицевой счет -->
|
||||
${data.accountNumber ? `
|
||||
<div class="divider"></div>
|
||||
<div class="info-row" style="font-size:9px;color:#666;">
|
||||
<div class="info-row" style="font-size:7pt;color:#666;">
|
||||
<span>Лицевой счет:</span>
|
||||
<span style="font-size:8px;">${data.accountNumber}</span>
|
||||
<span style="font-size:6pt;">${data.accountNumber}</span>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 113 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Reference in New Issue
Block a user