From 1d11fc017a257955540f171fe21bccf8bf16095f Mon Sep 17 00:00:00 2001 From: romantarkin Date: Mon, 8 Jun 2026 21:00:54 +0500 Subject: [PATCH] fix --- .env.example | 12 +++ README.MD | 17 ++- package-lock.json | 100 ++++++++++++++++- package.json | 3 +- public/app.js | 20 +++- public/index.html | 2 + public/openapi.json | 107 +++++++++++++++--- public/styles.css | 4 + server.js | 256 +++++++++++++++++++++++++++++++++++++++++--- 9 files changed, 483 insertions(+), 38 deletions(-) diff --git a/.env.example b/.env.example index 53553d8..b8f6d5b 100644 --- a/.env.example +++ b/.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 # Таймаут создания чека в ФНС diff --git a/README.MD b/README.MD index 5baee0b..d7eb8e8 100644 --- a/README.MD +++ b/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`. Аннулированные чеки показываются в списке, но не учитываются в количестве действующих чеков и итоговой сумме. diff --git a/package-lock.json b/package-lock.json index ed07d80..f1a4a65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index f5a753f..237fe71 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/public/app.js b/public/app.js index d078267..44694b8 100644 --- a/public/app.js +++ b/public/app.js @@ -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() { ? 'ошибка' : 'нет данных'; const source = receipt.syncedFromFns ? "ФНС" : "локально"; + const statusBadge = receipt.status === "cancelled" + ? 'аннулирован' + : 'действует'; const link = receipt.printLink ? `открыть` : ""; @@ -122,12 +126,13 @@ function renderReceipts() {
${(receipt.items || []).map(item => item.name || item.title).filter(Boolean).join(", ")}
${formatMoney(amount)} ₽ + ${statusBadge} ${emailBadge} ${source} ${link} `; - }).join("") || 'Чеков пока нет'; + }).join("") || 'Чеков пока нет'; } 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)); diff --git a/public/index.html b/public/index.html index dad9a51..c884463 100644 --- a/public/index.html +++ b/public/index.html @@ -35,6 +35,7 @@ + @@ -53,6 +54,7 @@ Пользователь Чек Сумма + Статус Письмо Источник diff --git a/public/openapi.json b/public/openapi.json index 82b8473..bdb2c26 100644 --- a/public/openapi.json +++ b/public/openapi.json @@ -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" + } + } + } } } }, diff --git a/public/styles.css b/public/styles.css index 5ea7bb4..945b0d3 100644 --- a/public/styles.css +++ b/public/styles.css @@ -152,6 +152,10 @@ main { min-width: 220px; } +.month-input { + max-width: 170px; +} + .metrics { display: grid; grid-template-columns: repeat(3, minmax(120px, 1fr)); diff --git a/server.js b/server.js index d58d044..ccc2fef 100644 --- a/server.js +++ b/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({ -- 2.45.2