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);