fix #9

Merged
romtuck merged 1 commits from dd into main 2026-06-12 20:58:31 +03:00
6 changed files with 389 additions and 2 deletions

View File

@ -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

View File

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

View File

@ -35,8 +35,11 @@
<div class="search">
<input id="receipt-search" type="search" placeholder="Поиск по email, ID чека или позиции">
</div>
<input id="receipt-date-from" class="date-input" type="date" aria-label="Начало периода">
<input id="receipt-date-to" class="date-input" type="date" aria-label="Конец периода">
<input id="sync-month" class="month-input" type="month" aria-label="Месяц синхронизации">
<button id="reload-receipts" type="button">Обновить</button>
<button id="export-receipts" type="button">Экспорт CSV</button>
<button id="sync-fns" type="button">Синхронизировать с ФНС</button>
</div>

View File

@ -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": "Нет доступа"
}
}
}
}
}
}

View File

@ -156,6 +156,10 @@ main {
max-width: 170px;
}
.date-input {
max-width: 150px;
}
.metrics {
display: grid;
grid-template-columns: repeat(5, minmax(120px, 1fr));

124
server.js
View File

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