This commit is contained in:
2026-10-29 16:51:26 +03:00
parent 70a20665ae
commit 4b2f0903b1
51 changed files with 6540 additions and 1212 deletions
Binary file not shown.
+8 -1
View File
@@ -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
View File
@@ -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"
+75
View File
@@ -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;
+27
View File
@@ -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);
+87
View File
@@ -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",
+1
View File
@@ -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.
+37
View File
@@ -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 };
+502
View File
@@ -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;
+154 -19
View File
@@ -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
View File
@@ -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);
// ============================================
// 🚀 ЗАПУСК СЕРВЕРА
// ============================================
+116 -36
View File
@@ -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