fix #9
14
README.MD
14
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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
|
||||
@ -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": "Нет доступа"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
124
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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user