fix #7
@ -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%.
|
||||||
|
|||||||
@ -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 ? "Чек создан и отправлен" : "Чек создан, но письмо не ушло");
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
86
server.js
86
server.js
@ -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) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user