Merge pull request 'fix' (#7) from dd into main

Reviewed-on: #7
This commit is contained in:
romtuck 2026-06-08 20:14:10 +03:00
commit 0c46a389f6
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`. Если чек создан в ФНС, но письмо клиенту не отправилось, API вернет `success: true`, `receiptCreated: true`, `emailSent: false`, `receiptId`, `printLink` и `technicalError`. Ошибка отправки сохранится в `error.json`.
Все успешно созданные чеки сохраняются в Redis, чтобы в UI была связь между чеком, email пользователя и позициями заказа. Синхронизация с ФНС выполняется за выбранный месяц, подтягивает страницы по 50 записей до конца месяца и помечает аннулированные чеки как `cancelled`. Аннулированные чеки показываются в списке, но не учитываются в количестве действующих чеков и итоговой сумме. Все успешно созданные чеки сохраняются в 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) { function receiptText(receipt) {
const itemText = (receipt.items || []) const itemText = (receipt.items || [])
.map(item => `${item.id || ""} ${item.name || item.title || ""}`) .map(item => `${item.id || ""} ${item.name || item.title || ""}`)
@ -96,14 +120,20 @@ function renderReceipts() {
const query = $("#receipt-search").value.trim().toLowerCase(); 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));
const activeReceipts = filtered.filter(receipt => receipt.status !== "cancelled"); 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-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; $("#metric-errors").textContent = state.errors.length;
$("#receipts-body").innerHTML = filtered.map(receipt => { $("#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 const emailBadge = receipt.emailSent === true
? '<span class="badge ok">отправлено</span>' ? '<span class="badge ok">отправлено</span>'
: receipt.emailSent === false : receipt.emailSent === false
@ -113,6 +143,7 @@ function renderReceipts() {
const statusBadge = receipt.status === "cancelled" const statusBadge = receipt.status === "cancelled"
? '<span class="badge bad">аннулирован</span>' ? '<span class="badge bad">аннулирован</span>'
: '<span class="badge ok">действует</span>'; : '<span class="badge ok">действует</span>';
const clientTypeLabel = normalizeClientType(receipt.clientType) === "legal" ? "юр, 6%" : "физ, 4%";
const link = receipt.printLink const link = receipt.printLink
? `<a href="${receipt.printLink}" target="_blank" rel="noreferrer">открыть</a>` ? `<a href="${receipt.printLink}" target="_blank" rel="noreferrer">открыть</a>`
: ""; : "";
@ -123,16 +154,19 @@ function renderReceipts() {
<td>${receipt.email || '<span class="muted">неизвестно</span>'}</td> <td>${receipt.email || '<span class="muted">неизвестно</span>'}</td>
<td> <td>
<div>${receipt.receiptId || '<span class="muted">нет ID</span>'}</div> <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> <div class="muted">${(receipt.items || []).map(item => item.name || item.title).filter(Boolean).join(", ")}</div>
</td> </td>
<td>${formatMoney(amount)} </td> <td>${formatMoney(grossAmount)} </td>
<td>${formatMoney(taxAmount)} </td>
<td>${formatMoney(netAmount)} </td>
<td>${statusBadge}</td> <td>${statusBadge}</td>
<td>${emailBadge}</td> <td>${emailBadge}</td>
<td><span class="badge">${source}</span></td> <td><span class="badge">${source}</span></td>
<td>${link}</td> <td>${link}</td>
</tr> </tr>
`; `;
}).join("") || '<tr><td colspan="8" class="muted">Чеков пока нет</td></tr>'; }).join("") || '<tr><td colspan="10" class="muted">Чеков пока нет</td></tr>';
} }
function renderSettings() { function renderSettings() {
@ -203,7 +237,11 @@ function currentItems() {
function updateCreateTotal() { function updateCreateTotal() {
const total = currentItems().reduce((sum, item) => sum + item.price * item.quantity, 0); 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-total").textContent = formatMoney(total);
$("#create-tax").textContent = formatMoney(tax);
$("#create-net").textContent = formatMoney(net);
} }
function showView(name) { function showView(name) {
@ -248,9 +286,11 @@ function bindEvents() {
}); });
$("#add-item").addEventListener("click", () => addItemRow()); $("#add-item").addEventListener("click", () => addItemRow());
$("#client-type").addEventListener("change", updateCreateTotal);
$("#create-form").addEventListener("submit", async event => { $("#create-form").addEventListener("submit", async event => {
event.preventDefault(); event.preventDefault();
const email = new FormData(event.currentTarget).get("email"); const email = new FormData(event.currentTarget).get("email");
const clientType = new FormData(event.currentTarget).get("clientType");
const items = currentItems(); const items = currentItems();
if (!items.length) { if (!items.length) {
toast("Добавьте хотя бы одну позицию с ценой"); toast("Добавьте хотя бы одну позицию с ценой");
@ -259,7 +299,7 @@ function bindEvents() {
try { try {
const result = await api("/api/v1/create-receipt", { const result = await api("/api/v1/create-receipt", {
method: "POST", method: "POST",
body: JSON.stringify({ email, items }) body: JSON.stringify({ email, clientType, items })
}); });
await loadReceipts(); await loadReceipts();
toast(result.emailSent ? "Чек создан и отправлен" : "Чек создан, но письмо не ушло"); toast(result.emailSent ? "Чек создан и отправлен" : "Чек создан, но письмо не ушло");

View File

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

View File

@ -110,6 +110,15 @@
"items": { "items": {
"$ref": "#/components/schemas/Item" "$ref": "#/components/schemas/Item"
} }
},
"clientType": {
"type": "string",
"enum": [
"individual",
"legal"
],
"default": "individual",
"description": "individual = физлицо 4%, legal = юрлицо 6%"
} }
} }
}, },
@ -138,6 +147,33 @@
"technicalError": { "technicalError": {
"type": "object", "type": "object",
"additionalProperties": true "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": { "cancelled": {
"type": "boolean" "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 "additionalProperties": true

View File

@ -158,7 +158,7 @@ main {
.metrics { .metrics {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(120px, 1fr)); grid-template-columns: repeat(5, minmax(120px, 1fr));
gap: 12px; gap: 12px;
margin-bottom: 14px; margin-bottom: 14px;
} }
@ -280,6 +280,7 @@ td a {
.form-footer { .form-footer {
justify-content: space-between; justify-content: space-between;
flex-wrap: wrap;
} }
.settings-head { .settings-head {

View File

@ -102,6 +102,53 @@ function getRedisKey(name) {
return `${getConfig("REDIS_KEY_PREFIX", "fns-receipt-service")}:${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() { function createTransporter() {
const smtpTimeoutMs = getSmtpTimeoutMs(); const smtpTimeoutMs = getSmtpTimeoutMs();
@ -217,7 +264,7 @@ async function readReceipts() {
try { try {
const client = await getRedisClient(); const client = await getRedisClient();
if (!client) return await fallbackReceipts(); if (!client) return (await fallbackReceipts()).map(withReceiptFinancials);
const value = await withTimeout( const value = await withTimeout(
client.get(getRedisKey("receipts")), client.get(getRedisKey("receipts")),
@ -230,19 +277,19 @@ async function readReceipts() {
if (localReceipts.length > 0) { if (localReceipts.length > 0) {
await writeReceipts(localReceipts); await writeReceipts(localReceipts);
} }
return localReceipts; return localReceipts.map(withReceiptFinancials);
} }
const parsed = JSON.parse(value); const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed : []; return Array.isArray(parsed) ? parsed.map(withReceiptFinancials) : [];
} catch (err) { } catch (err) {
console.error("Не удалось прочитать чеки из Redis, используется локальный файл:", err.message || err); console.error("Не удалось прочитать чеки из Redis, используется локальный файл:", err.message || err);
return await fallbackReceipts(); return (await fallbackReceipts()).map(withReceiptFinancials);
} }
} }
async function writeReceipts(receipts) { async function writeReceipts(receipts) {
const normalizedReceipts = Array.isArray(receipts) ? receipts : []; const normalizedReceipts = Array.isArray(receipts) ? receipts.map(withReceiptFinancials) : [];
try { try {
const client = await getRedisClient(); const client = await getRedisClient();
@ -485,14 +532,15 @@ async function saveReceipt(receiptData) {
createdAt: receiptData.createdAt || new Date().toISOString(), createdAt: receiptData.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString() updatedAt: new Date().toISOString()
}; };
const receiptWithFinancials = withReceiptFinancials(normalizedReceipt);
if (existingIndex >= 0) { if (existingIndex >= 0) {
nextReceipts[existingIndex] = { nextReceipts[existingIndex] = {
...nextReceipts[existingIndex], ...nextReceipts[existingIndex],
...normalizedReceipt ...receiptWithFinancials
}; };
} else { } else {
nextReceipts.unshift(normalizedReceipt); nextReceipts.unshift(receiptWithFinancials);
} }
await writeReceipts(nextReceipts); await writeReceipts(nextReceipts);
@ -563,12 +611,15 @@ function normalizeFnsIncome(item) {
const receiptId = item.receiptUuid || item.uuid || item.approvedReceiptUuid || item.receiptId || item.id; const receiptId = item.receiptUuid || item.uuid || item.approvedReceiptUuid || item.receiptId || item.id;
const amount = Number(item.totalAmount || item.amount || item.incomeInfo?.totalAmount || 0); const amount = Number(item.totalAmount || item.amount || item.incomeInfo?.totalAmount || 0);
const cancelled = isCancelledFnsIncome(item); const cancelled = isCancelledFnsIncome(item);
const clientType = normalizeClientType(item.client?.incomeType || item.incomeInfo?.client?.incomeType || item.incomeType);
return { return withReceiptFinancials({
source: "fns", source: "fns",
receiptId, receiptId,
email: "", email: "",
amount, amount,
grossAmount: amount,
clientType,
status: cancelled ? "cancelled" : "created", status: cancelled ? "cancelled" : "created",
cancelled, cancelled,
emailSent: null, emailSent: null,
@ -576,7 +627,7 @@ function normalizeFnsIncome(item) {
printLink: receiptId ? receiptPrintLink(receiptId) : "", printLink: receiptId ? receiptPrintLink(receiptId) : "",
items: item.services || [], items: item.services || [],
raw: item raw: item
}; });
} }
function monthRange(month) { function monthRange(month) {
@ -655,6 +706,7 @@ async function syncFnsReceipts({ month } = {}) {
...existingReceipt, ...existingReceipt,
...fnsReceipt, ...fnsReceipt,
email: existingReceipt.email || fnsReceipt.email, email: existingReceipt.email || fnsReceipt.email,
clientType: existingReceipt.clientType || fnsReceipt.clientType,
emailSent: existingReceipt.emailSent ?? fnsReceipt.emailSent, emailSent: existingReceipt.emailSent ?? fnsReceipt.emailSent,
items: existingReceipt.items?.length ? existingReceipt.items : fnsReceipt.items, items: existingReceipt.items?.length ? existingReceipt.items : fnsReceipt.items,
fns: fnsReceipt.raw, 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)); const receiptsWithFinancials = merged.map(withReceiptFinancials);
await writeReceipts(merged); receiptsWithFinancials.sort((a, b) => new Date(b.createdAt || 0) - new Date(a.createdAt || 0));
await writeReceipts(receiptsWithFinancials);
return { return {
synced: fnsReceipts.items.length, synced: fnsReceipts.items.length,
activeSynced: fnsReceipts.items.filter(item => item.status !== "cancelled").length, activeSynced: fnsReceipts.items.filter(item => item.status !== "cancelled").length,
cancelledSynced: 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, month: targetMonth,
from: fnsReceipts.from, from: fnsReceipts.from,
to: fnsReceipts.to, 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) => { app.post("/api/v1/create-receipt", async (req, res) => {
try { try {
const { email, items } = req.body; const { email, items } = req.body;
const clientType = normalizeClientType(req.body?.clientType);
if (!hasReceiptAccess(req)) { if (!hasReceiptAccess(req)) {
return res.status(401).json({ error: "Unauthorized" }); return res.status(401).json({ error: "Unauthorized" });
@ -1019,6 +1073,7 @@ ${getConfig("APPNAME")}
email, email,
items, items,
amount: total, amount: total,
clientType,
printLink, printLink,
status: "created", status: "created",
emailSent: false, emailSent: false,
@ -1032,7 +1087,8 @@ ${getConfig("APPNAME")}
receiptId, receiptId,
printLink, printLink,
warning: "Чек создан в ФНС, но письмо клиенту не отправлено. Данные сохранены в error.json.", warning: "Чек создан в ФНС, но письмо клиенту не отправлено. Данные сохранены в error.json.",
technicalError technicalError,
...withReceiptFinancials({ amount: total, clientType })
}); });
} }
@ -1041,6 +1097,7 @@ ${getConfig("APPNAME")}
email, email,
items, items,
amount: total, amount: total,
clientType,
printLink, printLink,
status: "created", status: "created",
emailSent: true emailSent: true
@ -1051,7 +1108,8 @@ ${getConfig("APPNAME")}
receiptCreated: true, receiptCreated: true,
emailSent: true, emailSent: true,
receiptId, receiptId,
printLink printLink,
...withReceiptFinancials({ amount: total, clientType })
}); });
} catch (err) { } catch (err) {