diff --git a/README.MD b/README.MD index 5578b1f..827c52b 100644 --- a/README.MD +++ b/README.MD @@ -165,6 +165,20 @@ GET /api/v1/receipts?limit=50&offset=0&status=created&clientType=individual&sear X-Api-Key: strong_api_password ``` +Журнал можно фильтровать по периоду через `dateFrom` и `dateTo`: + +```http +GET /api/v1/receipts?dateFrom=2026-06-01&dateTo=2026-06-30 +X-Api-Key: strong_api_password +``` + +Экспортировать все чеки за период в CSV без пагинации: + +```http +GET /api/v1/receipts/export?dateFrom=2026-06-01&dateTo=2026-06-30 +X-Api-Key: strong_api_password +``` + Получить один чек: ```http diff --git a/public/app.js b/public/app.js index d555137..f0bbaf5 100644 --- a/public/app.js +++ b/public/app.js @@ -36,6 +36,30 @@ async function api(path, options = {}) { return data; } +async function downloadFile(path) { + const response = await fetch(path, { + headers: headers() + }); + + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.error || `HTTP ${response.status}`); + } + + const blob = await response.blob(); + const disposition = response.headers.get("Content-Disposition") || ""; + const match = /filename="?([^"]+)"?/i.exec(disposition); + const filename = match?.[1] || "receipts.csv"; + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + document.body.append(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +} + function saveToken(auth) { state.token = auth.token; state.tokenExpiresAt = auth.expiresAt; @@ -116,9 +140,26 @@ function receiptText(receipt) { return `${receipt.email || ""} ${receipt.receiptId || ""} ${itemText}`.toLowerCase(); } +function receiptTime(receipt) { + return new Date(receipt.createdAt || receipt.operationTime || receipt.raw?.operationTime || "").getTime(); +} + +function receiptInSelectedPeriod(receipt) { + const from = $("#receipt-date-from").value; + const to = $("#receipt-date-to").value; + const time = receiptTime(receipt); + + if ((from || to) && Number.isNaN(time)) return false; + if (from && time < new Date(`${from}T00:00:00.000Z`).getTime()) return false; + if (to && time > new Date(`${to}T23:59:59.999Z`).getTime()) return false; + return true; +} + function renderReceipts() { const query = $("#receipt-search").value.trim().toLowerCase(); - const filtered = state.receipts.filter(receipt => receiptText(receipt).includes(query)); + const filtered = state.receipts.filter(receipt => + receiptText(receipt).includes(query) && receiptInSelectedPeriod(receipt) + ); const activeReceipts = filtered.filter(receipt => receipt.status !== "cancelled"); const grossTotal = activeReceipts.reduce((sum, receipt) => sum + receiptGross(receipt), 0); const taxTotal = activeReceipts.reduce((sum, receipt) => sum + receiptTax(receipt), 0); @@ -270,7 +311,24 @@ function bindEvents() { }); $("#receipt-search").addEventListener("input", renderReceipts); + $("#receipt-date-from").addEventListener("input", renderReceipts); + $("#receipt-date-to").addEventListener("input", renderReceipts); $("#reload-receipts").addEventListener("click", () => loadReceipts().then(() => toast("Чеки обновлены")).catch(err => toast(err.message))); + $("#export-receipts").addEventListener("click", async () => { + try { + const params = new URLSearchParams(); + const search = $("#receipt-search").value.trim(); + const dateFrom = $("#receipt-date-from").value; + const dateTo = $("#receipt-date-to").value; + if (search) params.set("search", search); + if (dateFrom) params.set("dateFrom", dateFrom); + if (dateTo) params.set("dateTo", dateTo); + await downloadFile(`/admin/api/receipts/export?${params.toString()}`); + toast("Экспорт скачан"); + } catch (err) { + toast(err.message); + } + }); $("#sync-fns").addEventListener("click", async () => { try { const month = $("#sync-month").value; diff --git a/public/index.html b/public/index.html index c42b6e8..00cbbd4 100644 --- a/public/index.html +++ b/public/index.html @@ -35,8 +35,11 @@ + + + diff --git a/public/openapi.json b/public/openapi.json index d1118c3..4214372 100644 --- a/public/openapi.json +++ b/public/openapi.json @@ -679,6 +679,24 @@ "type": "string" }, "description": "Поиск по email, ID чека и позициям" + }, + { + "name": "dateFrom", + "in": "query", + "schema": { + "type": "string", + "format": "date" + }, + "description": "Начало периода включительно, YYYY-MM-DD или ISO date-time" + }, + { + "name": "dateTo", + "in": "query", + "schema": { + "type": "string", + "format": "date" + }, + "description": "Конец периода включительно, YYYY-MM-DD или ISO date-time" } ], "responses": { @@ -1072,6 +1090,174 @@ } } } + }, + "/api/v1/receipts/export": { + "get": { + "tags": [ + "Receipts" + ], + "summary": "Экспортировать чеки в CSV", + "description": "Возвращает все чеки, подходящие под фильтры search, status, clientType, dateFrom и dateTo, без пагинации.", + "security": [ + { + "AdminBearer": [] + }, + { + "AdminPassword": [] + } + ], + "parameters": [ + { + "name": "status", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "created", + "cancelled" + ] + } + }, + { + "name": "clientType", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "individual", + "legal" + ] + } + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Поиск по email, ID чека и позициям" + }, + { + "name": "dateFrom", + "in": "query", + "schema": { + "type": "string", + "format": "date" + }, + "description": "Начало периода включительно, YYYY-MM-DD или ISO date-time" + }, + { + "name": "dateTo", + "in": "query", + "schema": { + "type": "string", + "format": "date" + }, + "description": "Конец периода включительно, YYYY-MM-DD или ISO date-time" + } + ], + "responses": { + "200": { + "description": "CSV-файл с чеками", + "content": { + "text/csv": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Неверный период экспорта" + }, + "401": { + "description": "Нет доступа" + } + } + } + }, + "/admin/api/receipts/export": { + "get": { + "tags": [ + "Admin" + ], + "summary": "Экспортировать чеки в CSV для админ-панели", + "security": [ + { + "AdminBearer": [] + } + ], + "parameters": [ + { + "name": "status", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "created", + "cancelled" + ] + } + }, + { + "name": "clientType", + "in": "query", + "schema": { + "type": "string", + "enum": [ + "individual", + "legal" + ] + } + }, + { + "name": "search", + "in": "query", + "schema": { + "type": "string" + }, + "description": "Поиск по email, ID чека и позициям" + }, + { + "name": "dateFrom", + "in": "query", + "schema": { + "type": "string", + "format": "date" + }, + "description": "Начало периода включительно, YYYY-MM-DD или ISO date-time" + }, + { + "name": "dateTo", + "in": "query", + "schema": { + "type": "string", + "format": "date" + }, + "description": "Конец периода включительно, YYYY-MM-DD или ISO date-time" + } + ], + "responses": { + "200": { + "description": "CSV-файл с чеками", + "content": { + "text/csv": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "400": { + "description": "Неверный период экспорта" + }, + "401": { + "description": "Нет доступа" + } + } + } } } } diff --git a/public/styles.css b/public/styles.css index 90e68b0..30e5ba6 100644 --- a/public/styles.css +++ b/public/styles.css @@ -156,6 +156,10 @@ main { max-width: 170px; } +.date-input { + max-width: 150px; +} + .metrics { display: grid; grid-template-columns: repeat(5, minmax(120px, 1fr)); diff --git a/server.js b/server.js index 9101bd2..16a604f 100644 --- a/server.js +++ b/server.js @@ -127,6 +127,16 @@ function receiptAmount(receipt) { return Number(receipt.grossAmount ?? receipt.amount ?? receipt.totalAmount ?? 0) || 0; } +function receiptDateValue(receipt) { + return receipt.createdAt || + receipt.operationTime || + receipt.requestTime || + receipt.raw?.operationTime || + receipt.raw?.requestTime || + receipt.raw?.createdAt || + ""; +} + function withReceiptFinancials(receipt) { const clientType = normalizeClientType( receipt.clientType || @@ -602,10 +612,18 @@ function filterReceipts(receipts, query = {}) { const status = String(query.status || "").trim().toLowerCase(); const clientType = String(query.clientType || "").trim().toLowerCase(); const search = String(query.search || "").trim().toLowerCase(); + const dateFrom = parseDateBoundary(query.dateFrom || query.from, false); + const dateTo = parseDateBoundary(query.dateTo || query.to, true); return receipts.filter(receipt => { if (status && String(receipt.status || "").toLowerCase() !== status) return false; if (clientType && normalizeClientType(receipt.clientType) !== normalizeClientType(clientType)) return false; + if (dateFrom || dateTo) { + const receiptTime = new Date(receiptDateValue(receipt)).getTime(); + if (Number.isNaN(receiptTime)) return false; + if (dateFrom && receiptTime < dateFrom.getTime()) return false; + if (dateTo && receiptTime > dateTo.getTime()) return false; + } if (!search) return true; const itemText = (receipt.items || []) @@ -616,6 +634,30 @@ function filterReceipts(receipts, query = {}) { }); } +function parseDateBoundary(value, endOfDay = false) { + const text = String(value || "").trim(); + if (!text) return null; + + const dateOnly = /^(\d{4})-(\d{2})-(\d{2})$/.exec(text); + const date = dateOnly + ? new Date(Date.UTC( + Number(dateOnly[1]), + Number(dateOnly[2]) - 1, + Number(dateOnly[3]), + endOfDay ? 23 : 0, + endOfDay ? 59 : 0, + endOfDay ? 59 : 0, + endOfDay ? 999 : 0 + )) + : new Date(text); + + if (Number.isNaN(date.getTime())) { + throw new Error("Период должен быть в формате YYYY-MM-DD или ISO date-time"); + } + + return date; +} + function paginate(items, query = {}) { const limit = Math.min(Math.max(Number(query.limit) || 50, 1), 200); const offset = Math.max(Number(query.offset) || 0, 0); @@ -628,6 +670,53 @@ function paginate(items, query = {}) { }; } +function csvCell(value) { + if (value === null || value === undefined) return ""; + const text = Array.isArray(value) ? value.join("; ") : String(value); + const protectedText = /^[=+\-@\t\r]/.test(text) ? `'${text}` : text; + return `"${protectedText.replace(/"/g, '""')}"`; +} + +function receiptsToCsv(receipts) { + const columns = [ + ["createdAt", "Дата"], + ["receiptId", "ID чека"], + ["email", "Email"], + ["clientType", "Тип клиента"], + ["grossAmount", "Грязными"], + ["taxRate", "Ставка налога"], + ["taxAmount", "Налог"], + ["netAmount", "Чистыми"], + ["status", "Статус"], + ["emailSent", "Письмо отправлено"], + ["source", "Источник"], + ["items", "Позиции"], + ["printLink", "Ссылка"] + ]; + + const rows = receipts.map(receipt => columns.map(([key]) => { + if (key === "createdAt") return csvCell(receiptDateValue(receipt)); + if (key === "source") return csvCell(receipt.syncedFromFns ? "fns" : "local"); + if (key === "items") { + return csvCell((receipt.items || []) + .map(item => [item.id, item.name || item.title, item.price, item.quantity].filter(Boolean).join(" ")) + .filter(Boolean)); + } + return csvCell(receipt[key]); + }).join(",")); + + return [ + columns.map(([, label]) => csvCell(label)).join(","), + ...rows + ].join("\n"); +} + +function exportFileName(query = {}) { + const from = String(query.dateFrom || query.from || "all").replace(/[^\dA-Za-z_-]/g, "-"); + const to = String(query.dateTo || query.to || "all").replace(/[^\dA-Za-z_-]/g, "-"); + return `receipts-${from}-${to}.csv`; +} + function isCancelledFnsIncome(item) { const values = [ item.status, @@ -995,8 +1084,28 @@ app.get("/admin/api/receipts", requireAdmin, async (req, res) => { }); }); +app.get("/admin/api/receipts/export", requireAdmin, async (req, res) => { + try { + const receipts = filterReceipts(await readReceipts(), req.query); + const csv = receiptsToCsv(receipts); + + res.setHeader("Content-Type", "text/csv; charset=utf-8"); + res.setHeader("Content-Disposition", `attachment; filename="${exportFileName(req.query)}"`); + res.send(`\uFEFF${csv}`); + } catch (err) { + res.status(400).json({ error: err.message || "Неверный период экспорта" }); + } +}); + app.get("/api/v1/receipts", requireApiAccess, async (req, res) => { - const receipts = filterReceipts(await readReceipts(), req.query); + let receipts; + + try { + receipts = filterReceipts(await readReceipts(), req.query); + } catch (err) { + return res.status(400).json({ error: err.message || "Неверный период фильтрации" }); + } + const page = paginate(receipts, req.query); res.json({ @@ -1010,6 +1119,19 @@ app.get("/api/v1/receipts", requireApiAccess, async (req, res) => { }); }); +app.get("/api/v1/receipts/export", requireApiAccess, async (req, res) => { + try { + const receipts = filterReceipts(await readReceipts(), req.query); + const csv = receiptsToCsv(receipts); + + res.setHeader("Content-Type", "text/csv; charset=utf-8"); + res.setHeader("Content-Disposition", `attachment; filename="${exportFileName(req.query)}"`); + res.send(`\uFEFF${csv}`); + } catch (err) { + res.status(400).json({ error: err.message || "Неверный период экспорта" }); + } +}); + app.get("/api/v1/receipts/:receiptId", requireApiAccess, async (req, res) => { const receipts = await readReceipts(); const receipt = receipts.find(item => item.receiptId === req.params.receiptId);