fix #7

Merged
romtuck merged 1 commits from dd into main 2026-06-08 20:14:10 +03:00
6 changed files with 201 additions and 24 deletions

View File

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

View File

@ -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
? '<span class="badge ok">отправлено</span>'
: receipt.emailSent === false
@ -113,6 +143,7 @@ function renderReceipts() {
const statusBadge = receipt.status === "cancelled"
? '<span class="badge bad">аннулирован</span>'
: '<span class="badge ok">действует</span>';
const clientTypeLabel = normalizeClientType(receipt.clientType) === "legal" ? "юр, 6%" : "физ, 4%";
const link = receipt.printLink
? `<a href="${receipt.printLink}" target="_blank" rel="noreferrer">открыть</a>`
: "";
@ -123,16 +154,19 @@ function renderReceipts() {
<td>${receipt.email || '<span class="muted">неизвестно</span>'}</td>
<td>
<div>${receipt.receiptId || '<span class="muted">нет ID</span>'}</div>
<div class="muted">${clientTypeLabel}</div>
<div class="muted">${(receipt.items || []).map(item => item.name || item.title).filter(Boolean).join(", ")}</div>
</td>
<td>${formatMoney(amount)} </td>
<td>${formatMoney(grossAmount)} </td>
<td>${formatMoney(taxAmount)} </td>
<td>${formatMoney(netAmount)} </td>
<td>${statusBadge}</td>
<td>${emailBadge}</td>
<td><span class="badge">${source}</span></td>
<td>${link}</td>
</tr>
`;
}).join("") || '<tr><td colspan="8" class="muted">Чеков пока нет</td></tr>';
}).join("") || '<tr><td colspan="10" class="muted">Чеков пока нет</td></tr>';
}
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 ? "Чек создан и отправлен" : "Чек создан, но письмо не ушло");

View File

@ -42,7 +42,9 @@
<div class="metrics">
<div><span id="metric-count">0</span><small>чеков</small></div>
<div><span id="metric-total">0.00</span><small>рублей</small></div>
<div><span id="metric-gross">0.00</span><small>грязными</small></div>
<div><span id="metric-tax">0.00</span><small>налог</small></div>
<div><span id="metric-net">0.00</span><small>чистыми</small></div>
<div><span id="metric-errors">0</span><small>ошибок</small></div>
</div>
@ -53,7 +55,9 @@
<th>Дата</th>
<th>Пользователь</th>
<th>Чек</th>
<th>Сумма</th>
<th>Грязными</th>
<th>Налог</th>
<th>Чистыми</th>
<th>Статус</th>
<th>Письмо</th>
<th>Источник</th>
@ -71,13 +75,22 @@
Email пользователя
<input name="email" type="email" required placeholder="client@example.com">
</label>
<label>
Тип клиента
<select name="clientType" id="client-type">
<option value="individual">Физлицо, 4%</option>
<option value="legal">Юрлицо, 6%</option>
</select>
</label>
<div class="items-head">
<strong>Позиции</strong>
<button id="add-item" type="button">Добавить позицию</button>
</div>
<div id="items-list" class="items-list"></div>
<div class="form-footer">
<span>Итого: <strong id="create-total">0.00</strong></span>
<span>Грязными: <strong id="create-total">0.00</strong></span>
<span>Налог: <strong id="create-tax">0.00</strong></span>
<span>Чистыми: <strong id="create-net">0.00</strong></span>
<button type="submit">Создать чек</button>
</div>
</form>

View File

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

View File

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

View File

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