From 57d1cb87186ec426d31b0032c01ee8b7e7873201 Mon Sep 17 00:00:00 2001 From: romantarkin Date: Mon, 8 Jun 2026 22:13:48 +0500 Subject: [PATCH] fix --- README.MD | 2 ++ public/app.js | 52 +++++++++++++++++++++++---- public/index.html | 19 ++++++++-- public/openapi.json | 63 +++++++++++++++++++++++++++++++++ public/styles.css | 3 +- server.js | 86 +++++++++++++++++++++++++++++++++++++-------- 6 files changed, 201 insertions(+), 24 deletions(-) diff --git a/README.MD b/README.MD index d7eb8e8..68af3e2 100644 --- a/README.MD +++ b/README.MD @@ -137,3 +137,5 @@ Content-Type: application/json Если чек создан в ФНС, но письмо клиенту не отправилось, API вернет `success: true`, `receiptCreated: true`, `emailSent: false`, `receiptId`, `printLink` и `technicalError`. Ошибка отправки сохранится в `error.json`. Все успешно созданные чеки сохраняются в Redis, чтобы в UI была связь между чеком, email пользователя и позициями заказа. Синхронизация с ФНС выполняется за выбранный месяц, подтягивает страницы по 50 записей до конца месяца и помечает аннулированные чеки как `cancelled`. Аннулированные чеки показываются в списке, но не учитываются в количестве действующих чеков и итоговой сумме. + +В UI и API по каждому чеку рассчитываются суммы `grossAmount` (грязными), `taxAmount` (налог) и `netAmount` (чистыми). Для физлиц используется ставка 4%, для юрлиц 6%. diff --git a/public/app.js b/public/app.js index 44694b8..d555137 100644 --- a/public/app.js +++ b/public/app.js @@ -85,6 +85,30 @@ function formatMoney(value) { }); } +function normalizeClientType(value) { + return value === "legal" ? "legal" : "individual"; +} + +function taxRateForClientType(clientType) { + return normalizeClientType(clientType) === "legal" ? 0.06 : 0.04; +} + +function receiptGross(receipt) { + return Number(receipt.grossAmount ?? receipt.amount ?? receipt.totalAmount ?? 0) || 0; +} + +function receiptTaxRate(receipt) { + return Number(receipt.taxRate ?? taxRateForClientType(receipt.clientType)); +} + +function receiptTax(receipt) { + return Number(receipt.taxAmount ?? (receiptGross(receipt) * receiptTaxRate(receipt))) || 0; +} + +function receiptNet(receipt) { + return Number(receipt.netAmount ?? (receiptGross(receipt) - receiptTax(receipt))) || 0; +} + function receiptText(receipt) { const itemText = (receipt.items || []) .map(item => `${item.id || ""} ${item.name || item.title || ""}`) @@ -96,14 +120,20 @@ function renderReceipts() { const query = $("#receipt-search").value.trim().toLowerCase(); const filtered = state.receipts.filter(receipt => receiptText(receipt).includes(query)); const activeReceipts = filtered.filter(receipt => receipt.status !== "cancelled"); - const total = activeReceipts.reduce((sum, receipt) => sum + (Number(receipt.amount) || Number(receipt.totalAmount) || 0), 0); + const grossTotal = activeReceipts.reduce((sum, receipt) => sum + receiptGross(receipt), 0); + const taxTotal = activeReceipts.reduce((sum, receipt) => sum + receiptTax(receipt), 0); + const netTotal = activeReceipts.reduce((sum, receipt) => sum + receiptNet(receipt), 0); $("#metric-count").textContent = activeReceipts.length; - $("#metric-total").textContent = formatMoney(total); + $("#metric-gross").textContent = formatMoney(grossTotal); + $("#metric-tax").textContent = formatMoney(taxTotal); + $("#metric-net").textContent = formatMoney(netTotal); $("#metric-errors").textContent = state.errors.length; $("#receipts-body").innerHTML = filtered.map(receipt => { - const amount = receipt.amount || receipt.totalAmount || 0; + const grossAmount = receiptGross(receipt); + const taxAmount = receiptTax(receipt); + const netAmount = receiptNet(receipt); const emailBadge = receipt.emailSent === true ? 'отправлено' : receipt.emailSent === false @@ -113,6 +143,7 @@ function renderReceipts() { const statusBadge = receipt.status === "cancelled" ? 'аннулирован' : 'действует'; + const clientTypeLabel = normalizeClientType(receipt.clientType) === "legal" ? "юр, 6%" : "физ, 4%"; const link = receipt.printLink ? `открыть` : ""; @@ -123,16 +154,19 @@ function renderReceipts() { ${receipt.email || 'неизвестно'}
${receipt.receiptId || 'нет ID'}
+
${clientTypeLabel}
${(receipt.items || []).map(item => item.name || item.title).filter(Boolean).join(", ")}
- ${formatMoney(amount)} ₽ + ${formatMoney(grossAmount)} ₽ + ${formatMoney(taxAmount)} ₽ + ${formatMoney(netAmount)} ₽ ${statusBadge} ${emailBadge} ${source} ${link} `; - }).join("") || 'Чеков пока нет'; + }).join("") || 'Чеков пока нет'; } function renderSettings() { @@ -203,7 +237,11 @@ function currentItems() { function updateCreateTotal() { const total = currentItems().reduce((sum, item) => sum + item.price * item.quantity, 0); + const tax = total * taxRateForClientType($("#client-type").value); + const net = total - tax; $("#create-total").textContent = formatMoney(total); + $("#create-tax").textContent = formatMoney(tax); + $("#create-net").textContent = formatMoney(net); } function showView(name) { @@ -248,9 +286,11 @@ function bindEvents() { }); $("#add-item").addEventListener("click", () => addItemRow()); + $("#client-type").addEventListener("change", updateCreateTotal); $("#create-form").addEventListener("submit", async event => { event.preventDefault(); const email = new FormData(event.currentTarget).get("email"); + const clientType = new FormData(event.currentTarget).get("clientType"); const items = currentItems(); if (!items.length) { toast("Добавьте хотя бы одну позицию с ценой"); @@ -259,7 +299,7 @@ function bindEvents() { try { const result = await api("/api/v1/create-receipt", { method: "POST", - body: JSON.stringify({ email, items }) + body: JSON.stringify({ email, clientType, items }) }); await loadReceipts(); toast(result.emailSent ? "Чек создан и отправлен" : "Чек создан, но письмо не ушло"); diff --git a/public/index.html b/public/index.html index c884463..c42b6e8 100644 --- a/public/index.html +++ b/public/index.html @@ -42,7 +42,9 @@
0чеков
-
0.00рублей
+
0.00грязными
+
0.00налог
+
0.00чистыми
0ошибок
@@ -53,7 +55,9 @@ Дата Пользователь Чек - Сумма + Грязными + Налог + Чистыми Статус Письмо Источник @@ -71,13 +75,22 @@ Email пользователя +
Позиции
diff --git a/public/openapi.json b/public/openapi.json index bdb2c26..b4b50d5 100644 --- a/public/openapi.json +++ b/public/openapi.json @@ -110,6 +110,15 @@ "items": { "$ref": "#/components/schemas/Item" } + }, + "clientType": { + "type": "string", + "enum": [ + "individual", + "legal" + ], + "default": "individual", + "description": "individual = физлицо 4%, legal = юрлицо 6%" } } }, @@ -138,6 +147,33 @@ "technicalError": { "type": "object", "additionalProperties": true + }, + "clientType": { + "type": "string", + "enum": [ + "individual", + "legal" + ], + "description": "individual = физлицо 4%, legal = юрлицо 6%" + }, + "grossAmount": { + "type": "number", + "description": "Сумма грязными, до налога" + }, + "taxRate": { + "type": "number", + "examples": [ + 0.04, + 0.06 + ] + }, + "taxAmount": { + "type": "number", + "description": "Сумма налога" + }, + "netAmount": { + "type": "number", + "description": "Сумма чистыми, после налога" } } }, @@ -181,6 +217,33 @@ }, "cancelled": { "type": "boolean" + }, + "clientType": { + "type": "string", + "enum": [ + "individual", + "legal" + ], + "description": "individual = физлицо 4%, legal = юрлицо 6%" + }, + "grossAmount": { + "type": "number", + "description": "Сумма грязными, до налога" + }, + "taxRate": { + "type": "number", + "examples": [ + 0.04, + 0.06 + ] + }, + "taxAmount": { + "type": "number", + "description": "Сумма налога" + }, + "netAmount": { + "type": "number", + "description": "Сумма чистыми, после налога" } }, "additionalProperties": true diff --git a/public/styles.css b/public/styles.css index 945b0d3..90e68b0 100644 --- a/public/styles.css +++ b/public/styles.css @@ -158,7 +158,7 @@ main { .metrics { display: grid; - grid-template-columns: repeat(3, minmax(120px, 1fr)); + grid-template-columns: repeat(5, minmax(120px, 1fr)); gap: 12px; margin-bottom: 14px; } @@ -280,6 +280,7 @@ td a { .form-footer { justify-content: space-between; + flex-wrap: wrap; } .settings-head { diff --git a/server.js b/server.js index ccc2fef..0da2a11 100644 --- a/server.js +++ b/server.js @@ -102,6 +102,53 @@ function getRedisKey(name) { return `${getConfig("REDIS_KEY_PREFIX", "fns-receipt-service")}:${name}`; } +function normalizeClientType(value) { + const normalized = String(value || "").toLowerCase(); + if ( + normalized === "legal" || + normalized === "juridical" || + normalized === "company" || + normalized === "organization" || + normalized.includes("legal") || + normalized.includes("юр") || + normalized.includes("from_legal") + ) { + return "legal"; + } + + return "individual"; +} + +function taxRateForClientType(clientType) { + return normalizeClientType(clientType) === "legal" ? 0.06 : 0.04; +} + +function receiptAmount(receipt) { + return Number(receipt.grossAmount ?? receipt.amount ?? receipt.totalAmount ?? 0) || 0; +} + +function withReceiptFinancials(receipt) { + const clientType = normalizeClientType( + receipt.clientType || + receipt.raw?.client?.incomeType || + receipt.raw?.incomeInfo?.client?.incomeType + ); + const grossAmount = Number(receiptAmount(receipt).toFixed(2)); + const taxRate = taxRateForClientType(clientType); + const taxAmount = Number((grossAmount * taxRate).toFixed(2)); + const netAmount = Number((grossAmount - taxAmount).toFixed(2)); + + return { + ...receipt, + clientType, + grossAmount, + taxRate, + taxAmount, + netAmount, + amount: grossAmount + }; +} + function createTransporter() { const smtpTimeoutMs = getSmtpTimeoutMs(); @@ -217,7 +264,7 @@ async function readReceipts() { try { const client = await getRedisClient(); - if (!client) return await fallbackReceipts(); + if (!client) return (await fallbackReceipts()).map(withReceiptFinancials); const value = await withTimeout( client.get(getRedisKey("receipts")), @@ -230,19 +277,19 @@ async function readReceipts() { if (localReceipts.length > 0) { await writeReceipts(localReceipts); } - return localReceipts; + return localReceipts.map(withReceiptFinancials); } const parsed = JSON.parse(value); - return Array.isArray(parsed) ? parsed : []; + return Array.isArray(parsed) ? parsed.map(withReceiptFinancials) : []; } catch (err) { console.error("Не удалось прочитать чеки из Redis, используется локальный файл:", err.message || err); - return await fallbackReceipts(); + return (await fallbackReceipts()).map(withReceiptFinancials); } } async function writeReceipts(receipts) { - const normalizedReceipts = Array.isArray(receipts) ? receipts : []; + const normalizedReceipts = Array.isArray(receipts) ? receipts.map(withReceiptFinancials) : []; try { const client = await getRedisClient(); @@ -485,14 +532,15 @@ async function saveReceipt(receiptData) { createdAt: receiptData.createdAt || new Date().toISOString(), updatedAt: new Date().toISOString() }; + const receiptWithFinancials = withReceiptFinancials(normalizedReceipt); if (existingIndex >= 0) { nextReceipts[existingIndex] = { ...nextReceipts[existingIndex], - ...normalizedReceipt + ...receiptWithFinancials }; } else { - nextReceipts.unshift(normalizedReceipt); + nextReceipts.unshift(receiptWithFinancials); } await writeReceipts(nextReceipts); @@ -563,12 +611,15 @@ 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); + const clientType = normalizeClientType(item.client?.incomeType || item.incomeInfo?.client?.incomeType || item.incomeType); - return { + return withReceiptFinancials({ source: "fns", receiptId, email: "", amount, + grossAmount: amount, + clientType, status: cancelled ? "cancelled" : "created", cancelled, emailSent: null, @@ -576,7 +627,7 @@ function normalizeFnsIncome(item) { printLink: receiptId ? receiptPrintLink(receiptId) : "", items: item.services || [], raw: item - }; + }); } function monthRange(month) { @@ -655,6 +706,7 @@ async function syncFnsReceipts({ month } = {}) { ...existingReceipt, ...fnsReceipt, email: existingReceipt.email || fnsReceipt.email, + clientType: existingReceipt.clientType || fnsReceipt.clientType, emailSent: existingReceipt.emailSent ?? fnsReceipt.emailSent, items: existingReceipt.items?.length ? existingReceipt.items : fnsReceipt.items, fns: fnsReceipt.raw, @@ -670,14 +722,15 @@ async function syncFnsReceipts({ month } = {}) { } } - merged.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0)); - await writeReceipts(merged); + const receiptsWithFinancials = merged.map(withReceiptFinancials); + receiptsWithFinancials.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0)); + await writeReceipts(receiptsWithFinancials); return { synced: fnsReceipts.items.length, activeSynced: fnsReceipts.items.filter(item => item.status !== "cancelled").length, cancelledSynced: fnsReceipts.items.filter(item => item.status === "cancelled").length, - total: merged.length, + total: receiptsWithFinancials.length, month: targetMonth, from: fnsReceipts.from, to: fnsReceipts.to, @@ -908,6 +961,7 @@ app.get("/admin/api/fns-user", requireAdmin, async (req, res) => { app.post("/api/v1/create-receipt", async (req, res) => { try { const { email, items } = req.body; + const clientType = normalizeClientType(req.body?.clientType); if (!hasReceiptAccess(req)) { return res.status(401).json({ error: "Unauthorized" }); @@ -1019,6 +1073,7 @@ ${getConfig("APPNAME")} email, items, amount: total, + clientType, printLink, status: "created", emailSent: false, @@ -1032,7 +1087,8 @@ ${getConfig("APPNAME")} receiptId, printLink, warning: "Чек создан в ФНС, но письмо клиенту не отправлено. Данные сохранены в error.json.", - technicalError + technicalError, + ...withReceiptFinancials({ amount: total, clientType }) }); } @@ -1041,6 +1097,7 @@ ${getConfig("APPNAME")} email, items, amount: total, + clientType, printLink, status: "created", emailSent: true @@ -1051,7 +1108,8 @@ ${getConfig("APPNAME")} receiptCreated: true, emailSent: true, receiptId, - printLink + printLink, + ...withReceiptFinancials({ amount: total, clientType }) }); } catch (err) { -- 2.45.2