fix #6
12
.env.example
12
.env.example
@ -18,6 +18,18 @@ SMTP_MAIL_FROM=noreply@example.com # Email отправителя
|
||||
API_PASS=your_secure_password_here # Пароль для доступа к API (используйте сложный!)
|
||||
JWT_SECRET=your_long_random_jwt_secret # Секрет подписи JWT для админ-панели
|
||||
ADMIN_SESSION_HOURS=12 # Срок действия JWT-сессии админа в часах
|
||||
|
||||
# === Redis хранилище чеков ===
|
||||
# Можно указать REDIS_URL или отдельные параметры ниже.
|
||||
REDIS_URL= # Например redis://default:password@host:6379/0
|
||||
REDIS_HOST=127.0.0.1 # Redis host
|
||||
REDIS_PORT=6379 # Redis port
|
||||
REDIS_USER=default # Redis user
|
||||
REDIS_PASS=redis_password # Redis password
|
||||
REDIS_DB=0 # Redis database number
|
||||
REDIS_KEY_PREFIX=fns-receipt-service # Prefix for Redis keys
|
||||
REDIS_TIMEOUT_MS=5000 # Redis operation timeout
|
||||
|
||||
PORT=3000 # Порт, на котором будет работать сервер
|
||||
HOST=0.0.0.0 # Хост для облачного деплоя
|
||||
FNS_TIMEOUT_MS=30000 # Таймаут создания чека в ФНС
|
||||
|
||||
17
README.MD
17
README.MD
@ -64,6 +64,13 @@ SMTP_MAIL_FROM=noreply@example.com
|
||||
API_PASS=strong_api_password
|
||||
JWT_SECRET=long_random_jwt_secret
|
||||
ADMIN_SESSION_HOURS=12
|
||||
REDIS_HOST=127.0.0.1
|
||||
REDIS_PORT=6379
|
||||
REDIS_USER=default
|
||||
REDIS_PASS=redis_password
|
||||
REDIS_DB=0
|
||||
REDIS_KEY_PREFIX=fns-receipt-service
|
||||
REDIS_TIMEOUT_MS=5000
|
||||
PORT=3000
|
||||
HOST=0.0.0.0
|
||||
FNS_TIMEOUT_MS=30000
|
||||
@ -76,6 +83,14 @@ SMTP_TIMEOUT_MS=15000
|
||||
|
||||
Настройки, измененные через UI, сохраняются в `data/config.json` и перекрывают значения из `.env`. Сетевые параметры `PORT` и `HOST` применятся после перезапуска процесса.
|
||||
|
||||
Чеки сохраняются в Redis, если задан `REDIS_URL` или `REDIS_HOST`. Основной ключ:
|
||||
|
||||
```text
|
||||
fns-receipt-service:receipts
|
||||
```
|
||||
|
||||
Префикс можно изменить через `REDIS_KEY_PREFIX`. Если Redis не настроен или временно недоступен, сервис использует локальный fallback `data/receipts.json`.
|
||||
|
||||
## Timeweb Cloud
|
||||
|
||||
Что заполнить при Docker-деплое:
|
||||
@ -121,4 +136,4 @@ Content-Type: application/json
|
||||
|
||||
Если чек создан в ФНС, но письмо клиенту не отправилось, API вернет `success: true`, `receiptCreated: true`, `emailSent: false`, `receiptId`, `printLink` и `technicalError`. Ошибка отправки сохранится в `error.json`.
|
||||
|
||||
Все успешно созданные чеки сохраняются в локальный журнал `data/receipts.json`, чтобы в UI была связь между чеком, email пользователя и позициями заказа. Синхронизация с ФНС подтягивает последние чеки из кабинета, но для старых чеков, созданных не этим сервисом, email пользователя может быть неизвестен.
|
||||
Все успешно созданные чеки сохраняются в Redis, чтобы в UI была связь между чеком, email пользователя и позициями заказа. Синхронизация с ФНС выполняется за выбранный месяц, подтягивает страницы по 50 записей до конца месяца и помечает аннулированные чеки как `cancelled`. Аннулированные чеки показываются в списке, но не учитываются в количестве действующих чеков и итоговой сумме.
|
||||
|
||||
100
package-lock.json
generated
100
package-lock.json
generated
@ -12,12 +12,85 @@
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.2.1",
|
||||
"lknpd-nalog-api": "^1.0.1",
|
||||
"nodemailer": "^7.0.13"
|
||||
"nodemailer": "^7.0.13",
|
||||
"redis": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/bloom": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-6.0.0.tgz",
|
||||
"integrity": "sha512-P0n5NkV9IIdT6nYXOfMHG83sho8pE7Nay7yw27wOGVLv4DthgvzebpGz6m7VuMTizeJmw3LPw2Xek5wFUhGpVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/client": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/client/-/client-6.0.0.tgz",
|
||||
"integrity": "sha512-NS4iIT25r24sAjNQ2nSRdCW5jPJoV0rxkBee27oTeR+RXaOu89cjIsrww5rPBaYVGVdL1QCx9uz9141gZiSKdQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cluster-key-slot": "1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@node-rs/xxhash": "^1.1.0",
|
||||
"@opentelemetry/api": ">=1 <2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@node-rs/xxhash": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/json": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/json/-/json-6.0.0.tgz",
|
||||
"integrity": "sha512-F+eqFfgPcy57Zs1KW7UtLnBtRk6lxAUIoe7dyZerpm6e+ssYXG/dWJrbrHFYs0b7tt6QBtYpVuukBuM9XqhUAg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/search": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/search/-/search-6.0.0.tgz",
|
||||
"integrity": "sha512-VHuCJ2W0YWFixGZh/l//8JiyOsD4gN+NhjdRAGIoUe0UQ4mtq1NyY2ZJ973XT+vYhaU21XdK8r8oNrd5n7wbzQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@redis/time-series": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-6.0.0.tgz",
|
||||
"integrity": "sha512-QWhkYsg+3lhBrBf+cbzybtV8LQcSrk7iXIgTaGU+pHNFTkql7TpVRE24ROS6M2ybVIV6O/zxTqfxgxxYiqyw0Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@redis/client": "^6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
@ -93,6 +166,15 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/cluster-key-slot": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
|
||||
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
|
||||
@ -687,6 +769,22 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/redis": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redis/-/redis-6.0.0.tgz",
|
||||
"integrity": "sha512-n9Thfc39OXleEoPT2k5gwKsqY+HfCww3YS71ofcr9KKbkn89bpjU9dToIlD+JRdM3/GYQkwMtVgTxLyed+LptQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@redis/bloom": "6.0.0",
|
||||
"@redis/client": "6.0.0",
|
||||
"@redis/json": "6.0.0",
|
||||
"@redis/search": "6.0.0",
|
||||
"@redis/time-series": "6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 20.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/router": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz",
|
||||
|
||||
@ -17,6 +17,7 @@
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.2.1",
|
||||
"lknpd-nalog-api": "^1.0.1",
|
||||
"nodemailer": "^7.0.13"
|
||||
"nodemailer": "^7.0.13",
|
||||
"redis": "^6.0.0"
|
||||
}
|
||||
}
|
||||
|
||||
@ -95,9 +95,10 @@ function receiptText(receipt) {
|
||||
function renderReceipts() {
|
||||
const query = $("#receipt-search").value.trim().toLowerCase();
|
||||
const filtered = state.receipts.filter(receipt => receiptText(receipt).includes(query));
|
||||
const total = filtered.reduce((sum, receipt) => sum + (Number(receipt.amount) || Number(receipt.totalAmount) || 0), 0);
|
||||
const activeReceipts = filtered.filter(receipt => receipt.status !== "cancelled");
|
||||
const total = activeReceipts.reduce((sum, receipt) => sum + (Number(receipt.amount) || Number(receipt.totalAmount) || 0), 0);
|
||||
|
||||
$("#metric-count").textContent = filtered.length;
|
||||
$("#metric-count").textContent = activeReceipts.length;
|
||||
$("#metric-total").textContent = formatMoney(total);
|
||||
$("#metric-errors").textContent = state.errors.length;
|
||||
|
||||
@ -109,6 +110,9 @@ function renderReceipts() {
|
||||
? '<span class="badge bad">ошибка</span>'
|
||||
: '<span class="badge warn">нет данных</span>';
|
||||
const source = receipt.syncedFromFns ? "ФНС" : "локально";
|
||||
const statusBadge = receipt.status === "cancelled"
|
||||
? '<span class="badge bad">аннулирован</span>'
|
||||
: '<span class="badge ok">действует</span>';
|
||||
const link = receipt.printLink
|
||||
? `<a href="${receipt.printLink}" target="_blank" rel="noreferrer">открыть</a>`
|
||||
: "";
|
||||
@ -122,12 +126,13 @@ function renderReceipts() {
|
||||
<div class="muted">${(receipt.items || []).map(item => item.name || item.title).filter(Boolean).join(", ")}</div>
|
||||
</td>
|
||||
<td>${formatMoney(amount)} ₽</td>
|
||||
<td>${statusBadge}</td>
|
||||
<td>${emailBadge}</td>
|
||||
<td><span class="badge">${source}</span></td>
|
||||
<td>${link}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join("") || '<tr><td colspan="7" class="muted">Чеков пока нет</td></tr>';
|
||||
}).join("") || '<tr><td colspan="8" class="muted">Чеков пока нет</td></tr>';
|
||||
}
|
||||
|
||||
function renderSettings() {
|
||||
@ -230,9 +235,13 @@ function bindEvents() {
|
||||
$("#reload-receipts").addEventListener("click", () => loadReceipts().then(() => toast("Чеки обновлены")).catch(err => toast(err.message)));
|
||||
$("#sync-fns").addEventListener("click", async () => {
|
||||
try {
|
||||
const result = await api("/admin/api/receipts/sync", { method: "POST", body: "{}" });
|
||||
const month = $("#sync-month").value;
|
||||
const result = await api("/admin/api/receipts/sync", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ month })
|
||||
});
|
||||
await loadReceipts();
|
||||
toast(`Синхронизировано из ФНС: ${result.synced}`);
|
||||
toast(`ФНС ${result.month}: действует ${result.activeSynced}, аннулировано ${result.cancelledSynced}`);
|
||||
} catch (err) {
|
||||
toast(err.message);
|
||||
}
|
||||
@ -301,4 +310,5 @@ function bindEvents() {
|
||||
|
||||
bindEvents();
|
||||
addItemRow();
|
||||
$("#sync-month").value = new Date().toISOString().slice(0, 7);
|
||||
bootstrap().catch(err => toast(err.message));
|
||||
|
||||
@ -35,6 +35,7 @@
|
||||
<div class="search">
|
||||
<input id="receipt-search" type="search" placeholder="Поиск по email, ID чека или позиции">
|
||||
</div>
|
||||
<input id="sync-month" class="month-input" type="month" aria-label="Месяц синхронизации">
|
||||
<button id="reload-receipts" type="button">Обновить</button>
|
||||
<button id="sync-fns" type="button">Синхронизировать с ФНС</button>
|
||||
</div>
|
||||
@ -53,6 +54,7 @@
|
||||
<th>Пользователь</th>
|
||||
<th>Чек</th>
|
||||
<th>Сумма</th>
|
||||
<th>Статус</th>
|
||||
<th>Письмо</th>
|
||||
<th>Источник</th>
|
||||
<th></th>
|
||||
|
||||
@ -157,7 +157,11 @@
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"created",
|
||||
"cancelled"
|
||||
]
|
||||
},
|
||||
"emailSent": {
|
||||
"type": [
|
||||
@ -174,6 +178,9 @@
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Item"
|
||||
}
|
||||
},
|
||||
"cancelled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"additionalProperties": true
|
||||
@ -242,6 +249,57 @@
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SyncReceiptsRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"month": {
|
||||
"type": "string",
|
||||
"pattern": "^\\d{4}-\\d{2}$",
|
||||
"examples": [
|
||||
"2026-06"
|
||||
],
|
||||
"description": "Месяц выгрузки из ФНС в формате YYYY-MM"
|
||||
}
|
||||
}
|
||||
},
|
||||
"SyncReceiptsResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"synced": {
|
||||
"type": "number"
|
||||
},
|
||||
"activeSynced": {
|
||||
"type": "number"
|
||||
},
|
||||
"cancelledSynced": {
|
||||
"type": "number"
|
||||
},
|
||||
"total": {
|
||||
"type": "number"
|
||||
},
|
||||
"month": {
|
||||
"type": "string"
|
||||
},
|
||||
"from": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"to": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"storage": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"redis",
|
||||
"file"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -451,6 +509,21 @@
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"redis",
|
||||
"file"
|
||||
]
|
||||
},
|
||||
"receiptsKey": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -525,6 +598,13 @@
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
}
|
||||
},
|
||||
"storage": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"redis",
|
||||
"file"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -539,7 +619,7 @@
|
||||
"tags": [
|
||||
"Admin"
|
||||
],
|
||||
"summary": "Синхронизировать последние чеки из ФНС",
|
||||
"summary": "Синхронизировать чеки из ФНС за выбранный месяц",
|
||||
"security": [
|
||||
{
|
||||
"AdminBearer": []
|
||||
@ -551,18 +631,7 @@
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"synced": {
|
||||
"type": "number"
|
||||
},
|
||||
"total": {
|
||||
"type": "number"
|
||||
}
|
||||
}
|
||||
"$ref": "#/components/schemas/SyncReceiptsResponse"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -570,6 +639,16 @@
|
||||
"500": {
|
||||
"description": "Ошибка синхронизации"
|
||||
}
|
||||
},
|
||||
"requestBody": {
|
||||
"required": false,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SyncReceiptsRequest"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -152,6 +152,10 @@ main {
|
||||
min-width: 220px;
|
||||
}
|
||||
|
||||
.month-input {
|
||||
max-width: 170px;
|
||||
}
|
||||
|
||||
.metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(120px, 1fr));
|
||||
|
||||
256
server.js
256
server.js
@ -6,6 +6,7 @@ import pkg from "lknpd-nalog-api";
|
||||
import fs from "fs/promises";
|
||||
import path from "path";
|
||||
import crypto from "crypto";
|
||||
import { createClient } from "redis";
|
||||
import { fileURLToPath } from "url";
|
||||
|
||||
dotenv.config();
|
||||
@ -38,16 +39,26 @@ const CONFIG_KEYS = [
|
||||
"API_PASS",
|
||||
"JWT_SECRET",
|
||||
"ADMIN_SESSION_HOURS",
|
||||
"REDIS_URL",
|
||||
"REDIS_HOST",
|
||||
"REDIS_PORT",
|
||||
"REDIS_USER",
|
||||
"REDIS_PASS",
|
||||
"REDIS_DB",
|
||||
"REDIS_KEY_PREFIX",
|
||||
"REDIS_TIMEOUT_MS",
|
||||
"PORT",
|
||||
"HOST",
|
||||
"FNS_TIMEOUT_MS",
|
||||
"SMTP_TIMEOUT_MS"
|
||||
];
|
||||
const SECRET_KEYS = new Set(["PASSWORD", "SMTP_PASS", "API_PASS", "JWT_SECRET"]);
|
||||
const SECRET_KEYS = new Set(["PASSWORD", "SMTP_PASS", "API_PASS", "JWT_SECRET", "REDIS_URL", "REDIS_PASS"]);
|
||||
|
||||
let nalogApi;
|
||||
let runtimeConfig = {};
|
||||
let transporter;
|
||||
let redisClient;
|
||||
let redisConfigSignature;
|
||||
|
||||
function getConfig(key, fallback = "") {
|
||||
return runtimeConfig[key] ?? process.env[key] ?? fallback;
|
||||
@ -83,6 +94,14 @@ function getAdminSessionSeconds() {
|
||||
return Math.max(1, getNumberConfig("ADMIN_SESSION_HOURS", 12)) * 60 * 60;
|
||||
}
|
||||
|
||||
function getRedisTimeoutMs() {
|
||||
return getNumberConfig("REDIS_TIMEOUT_MS", 5000);
|
||||
}
|
||||
|
||||
function getRedisKey(name) {
|
||||
return `${getConfig("REDIS_KEY_PREFIX", "fns-receipt-service")}:${name}`;
|
||||
}
|
||||
|
||||
function createTransporter() {
|
||||
const smtpTimeoutMs = getSmtpTimeoutMs();
|
||||
|
||||
@ -122,6 +141,126 @@ async function loadRuntimeConfig() {
|
||||
transporter = createTransporter();
|
||||
}
|
||||
|
||||
function hasRedisConfig() {
|
||||
return Boolean(getConfig("REDIS_URL") || getConfig("REDIS_HOST"));
|
||||
}
|
||||
|
||||
function currentRedisConfigSignature() {
|
||||
return JSON.stringify({
|
||||
url: getConfig("REDIS_URL"),
|
||||
host: getConfig("REDIS_HOST"),
|
||||
port: getConfig("REDIS_PORT"),
|
||||
user: getConfig("REDIS_USER"),
|
||||
pass: getConfig("REDIS_PASS"),
|
||||
db: getConfig("REDIS_DB")
|
||||
});
|
||||
}
|
||||
|
||||
async function closeRedisClient() {
|
||||
if (!redisClient) return;
|
||||
|
||||
try {
|
||||
if (redisClient.isOpen) {
|
||||
await redisClient.quit();
|
||||
}
|
||||
} catch (err) {
|
||||
try {
|
||||
await redisClient.disconnect();
|
||||
} catch (disconnectErr) {
|
||||
}
|
||||
} finally {
|
||||
redisClient = undefined;
|
||||
redisConfigSignature = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function getRedisClient() {
|
||||
if (!hasRedisConfig()) return null;
|
||||
|
||||
const signature = currentRedisConfigSignature();
|
||||
if (redisClient && redisConfigSignature === signature && redisClient.isOpen) {
|
||||
return redisClient;
|
||||
}
|
||||
|
||||
await closeRedisClient();
|
||||
|
||||
const options = getConfig("REDIS_URL")
|
||||
? { url: getConfig("REDIS_URL") }
|
||||
: {
|
||||
username: getConfig("REDIS_USER", "default"),
|
||||
password: getConfig("REDIS_PASS"),
|
||||
database: getNumberConfig("REDIS_DB", 0),
|
||||
socket: {
|
||||
host: getConfig("REDIS_HOST"),
|
||||
port: getNumberConfig("REDIS_PORT", 6379),
|
||||
connectTimeout: getRedisTimeoutMs()
|
||||
}
|
||||
};
|
||||
|
||||
const client = createClient(options);
|
||||
client.on("error", err => {
|
||||
console.error("Redis error:", err.message || err);
|
||||
});
|
||||
|
||||
await withTimeout(client.connect(), getRedisTimeoutMs(), "Redis connect");
|
||||
redisClient = client;
|
||||
redisConfigSignature = signature;
|
||||
|
||||
return redisClient;
|
||||
}
|
||||
|
||||
async function readReceipts() {
|
||||
const fallbackReceipts = async () => {
|
||||
const data = await readJsonFile(RECEIPTS_FILE, []);
|
||||
return Array.isArray(data) ? data : [];
|
||||
};
|
||||
|
||||
try {
|
||||
const client = await getRedisClient();
|
||||
if (!client) return await fallbackReceipts();
|
||||
|
||||
const value = await withTimeout(
|
||||
client.get(getRedisKey("receipts")),
|
||||
getRedisTimeoutMs(),
|
||||
"Redis receipts read"
|
||||
);
|
||||
|
||||
if (!value) {
|
||||
const localReceipts = await fallbackReceipts();
|
||||
if (localReceipts.length > 0) {
|
||||
await writeReceipts(localReceipts);
|
||||
}
|
||||
return localReceipts;
|
||||
}
|
||||
|
||||
const parsed = JSON.parse(value);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (err) {
|
||||
console.error("Не удалось прочитать чеки из Redis, используется локальный файл:", err.message || err);
|
||||
return await fallbackReceipts();
|
||||
}
|
||||
}
|
||||
|
||||
async function writeReceipts(receipts) {
|
||||
const normalizedReceipts = Array.isArray(receipts) ? receipts : [];
|
||||
|
||||
try {
|
||||
const client = await getRedisClient();
|
||||
if (client) {
|
||||
await withTimeout(
|
||||
client.set(getRedisKey("receipts"), JSON.stringify(normalizedReceipts)),
|
||||
getRedisTimeoutMs(),
|
||||
"Redis receipts write"
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Не удалось записать чеки в Redis, используется локальный файл:", err.message || err);
|
||||
}
|
||||
|
||||
await writeJsonFile(RECEIPTS_FILE, normalizedReceipts);
|
||||
}
|
||||
|
||||
function getNalogApi() {
|
||||
if (!getConfig("INN") || !getConfig("PASSWORD")) {
|
||||
throw new Error("INN and PASSWORD environment variables are required");
|
||||
@ -339,8 +478,7 @@ async function saveToErrorFile(errorData) {
|
||||
}
|
||||
|
||||
async function saveReceipt(receiptData) {
|
||||
const receipts = await readJsonFile(RECEIPTS_FILE, []);
|
||||
const nextReceipts = Array.isArray(receipts) ? receipts : [];
|
||||
const nextReceipts = await readReceipts();
|
||||
const existingIndex = nextReceipts.findIndex(item => item.receiptId === receiptData.receiptId);
|
||||
const normalizedReceipt = {
|
||||
...receiptData,
|
||||
@ -357,7 +495,7 @@ async function saveReceipt(receiptData) {
|
||||
nextReceipts.unshift(normalizedReceipt);
|
||||
}
|
||||
|
||||
await writeJsonFile(RECEIPTS_FILE, nextReceipts);
|
||||
await writeReceipts(nextReceipts);
|
||||
}
|
||||
|
||||
function requireAdmin(req, res, next) {
|
||||
@ -396,15 +534,43 @@ function receiptPrintLink(receiptId) {
|
||||
return `https://lknpd.nalog.ru/api/v1/receipt/${getConfig("INN")}/${receiptId}/print`;
|
||||
}
|
||||
|
||||
function isCancelledFnsIncome(item) {
|
||||
const values = [
|
||||
item.status,
|
||||
item.state,
|
||||
item.operationType,
|
||||
item.incomeType,
|
||||
item.cancellationInfo?.status,
|
||||
item.incomeInfo?.status
|
||||
].filter(Boolean).map(value => String(value).toLowerCase());
|
||||
|
||||
return Boolean(
|
||||
item.cancelTime ||
|
||||
item.cancelled ||
|
||||
item.isCancelled ||
|
||||
item.cancellationInfo ||
|
||||
item.incomeInfo?.cancelTime ||
|
||||
values.some(value =>
|
||||
value.includes("cancel") ||
|
||||
value.includes("аннул") ||
|
||||
value.includes("сторн") ||
|
||||
value.includes("void")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeFnsIncome(item) {
|
||||
const receiptId = item.receiptUuid || item.uuid || item.approvedReceiptUuid || item.receiptId || item.id;
|
||||
const amount = Number(item.totalAmount || item.amount || item.incomeInfo?.totalAmount || 0);
|
||||
const cancelled = isCancelledFnsIncome(item);
|
||||
|
||||
return {
|
||||
source: "fns",
|
||||
receiptId,
|
||||
email: "",
|
||||
amount,
|
||||
status: item.cancelTime || item.cancellationInfo ? "cancelled" : "created",
|
||||
status: cancelled ? "cancelled" : "created",
|
||||
cancelled,
|
||||
emailSent: null,
|
||||
createdAt: item.operationTime || item.requestTime || item.createdAt || null,
|
||||
printLink: receiptId ? receiptPrintLink(receiptId) : "",
|
||||
@ -413,12 +579,28 @@ function normalizeFnsIncome(item) {
|
||||
};
|
||||
}
|
||||
|
||||
async function getFnsReceipts({ offset = 0, limit = 50 } = {}) {
|
||||
function monthRange(month) {
|
||||
if (!/^\d{4}-\d{2}$/.test(String(month || ""))) {
|
||||
throw new Error("Месяц должен быть в формате YYYY-MM");
|
||||
}
|
||||
|
||||
const [year, monthNumber] = month.split("-").map(Number);
|
||||
const from = new Date(Date.UTC(year, monthNumber - 1, 1, 0, 0, 0, 0));
|
||||
const to = new Date(Date.UTC(year, monthNumber, 0, 23, 59, 59, 999));
|
||||
|
||||
return { from, to };
|
||||
}
|
||||
|
||||
async function getFnsReceipts({ offset = 0, limit = 50, from, to } = {}) {
|
||||
const params = new URLSearchParams({
|
||||
offset: String(offset),
|
||||
limit: String(Math.min(Number(limit) || 50, 50)),
|
||||
sortBy: "operation_time:desc"
|
||||
});
|
||||
|
||||
if (from) params.set("from", from.toISOString());
|
||||
if (to) params.set("to", to.toISOString());
|
||||
|
||||
const data = await withTimeout(
|
||||
getNalogApi().callMethod(`incomes?${params.toString()}`),
|
||||
getFnsTimeoutMs(),
|
||||
@ -433,18 +615,48 @@ async function getFnsReceipts({ offset = 0, limit = 50 } = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
async function syncFnsReceipts() {
|
||||
const fnsReceipts = await getFnsReceipts();
|
||||
const localReceipts = await readJsonFile(RECEIPTS_FILE, []);
|
||||
const merged = Array.isArray(localReceipts) ? [...localReceipts] : [];
|
||||
async function getFnsReceiptsForMonth(month) {
|
||||
const range = monthRange(month);
|
||||
const limit = 50;
|
||||
const maxPages = 100;
|
||||
const allItems = [];
|
||||
let total = 0;
|
||||
|
||||
for (let page = 0; page < maxPages; page++) {
|
||||
const offset = page * limit;
|
||||
const result = await getFnsReceipts({ offset, limit, ...range });
|
||||
total = Number(result.total) || total;
|
||||
allItems.push(...result.items);
|
||||
|
||||
if (result.items.length < limit) break;
|
||||
if (total && allItems.length >= total) break;
|
||||
}
|
||||
|
||||
return {
|
||||
items: allItems,
|
||||
total,
|
||||
month,
|
||||
from: range.from.toISOString(),
|
||||
to: range.to.toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
async function syncFnsReceipts({ month } = {}) {
|
||||
const targetMonth = month || new Date().toISOString().slice(0, 7);
|
||||
const fnsReceipts = await getFnsReceiptsForMonth(targetMonth);
|
||||
const merged = await readReceipts();
|
||||
|
||||
for (const fnsReceipt of fnsReceipts.items) {
|
||||
if (!fnsReceipt.receiptId) continue;
|
||||
const existingIndex = merged.findIndex(item => item.receiptId === fnsReceipt.receiptId);
|
||||
if (existingIndex >= 0) {
|
||||
const existingReceipt = merged[existingIndex];
|
||||
merged[existingIndex] = {
|
||||
...existingReceipt,
|
||||
...fnsReceipt,
|
||||
...merged[existingIndex],
|
||||
email: existingReceipt.email || fnsReceipt.email,
|
||||
emailSent: existingReceipt.emailSent ?? fnsReceipt.emailSent,
|
||||
items: existingReceipt.items?.length ? existingReceipt.items : fnsReceipt.items,
|
||||
fns: fnsReceipt.raw,
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
@ -459,11 +671,17 @@ async function syncFnsReceipts() {
|
||||
}
|
||||
|
||||
merged.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0));
|
||||
await writeJsonFile(RECEIPTS_FILE, merged);
|
||||
await writeReceipts(merged);
|
||||
|
||||
return {
|
||||
synced: fnsReceipts.items.length,
|
||||
total: merged.length
|
||||
activeSynced: fnsReceipts.items.filter(item => item.status !== "cancelled").length,
|
||||
cancelledSynced: fnsReceipts.items.filter(item => item.status === "cancelled").length,
|
||||
total: merged.length,
|
||||
month: targetMonth,
|
||||
from: fnsReceipts.from,
|
||||
to: fnsReceipts.to,
|
||||
storage: hasRedisConfig() ? "redis" : "file"
|
||||
};
|
||||
}
|
||||
|
||||
@ -620,6 +838,10 @@ app.get("/admin/api/config", requireAdmin, (req, res) => {
|
||||
config: CONFIG_FILE,
|
||||
receipts: RECEIPTS_FILE,
|
||||
errors: ERROR_FILE
|
||||
},
|
||||
storage: {
|
||||
type: hasRedisConfig() ? "redis" : "file",
|
||||
receiptsKey: getRedisKey("receipts")
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -642,24 +864,26 @@ app.put("/admin/api/config", requireAdmin, async (req, res) => {
|
||||
runtimeConfig = nextConfig;
|
||||
nalogApi = undefined;
|
||||
transporter = createTransporter();
|
||||
await closeRedisClient();
|
||||
await writeJsonFile(CONFIG_FILE, runtimeConfig);
|
||||
|
||||
res.json({ success: true, config: publicConfig() });
|
||||
});
|
||||
|
||||
app.get("/admin/api/receipts", requireAdmin, async (req, res) => {
|
||||
const receipts = await readJsonFile(RECEIPTS_FILE, []);
|
||||
const receipts = await readReceipts();
|
||||
const errors = await readJsonFile(ERROR_FILE, []);
|
||||
|
||||
res.json({
|
||||
receipts: Array.isArray(receipts) ? receipts : [],
|
||||
errors: Array.isArray(errors) ? errors : []
|
||||
errors: Array.isArray(errors) ? errors : [],
|
||||
storage: hasRedisConfig() ? "redis" : "file"
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/admin/api/receipts/sync", requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const result = await syncFnsReceipts();
|
||||
const result = await syncFnsReceipts({ month: req.body?.month });
|
||||
res.json({ success: true, ...result });
|
||||
} catch (err) {
|
||||
res.status(500).json({
|
||||
|
||||
Loading…
Reference in New Issue
Block a user