From 4ed778e56715162c65596ea76b631b75d533712d Mon Sep 17 00:00:00 2001
From: Ali <010109ali@gmail.com>
Date: Tue, 13 Jan 2026 22:31:30 +0400
Subject: [PATCH] added import from etaxes to journal entry, with all bugfixes
---
BUGS_VAT_OPERATIONS.md | 359 +++++
invoice_az/client/journal_entry.js | 1358 +++++++++++++++++
invoice_az/hooks.py | 10 +-
.../e_taxes_vat_operations/__init__.py | 0
.../e_taxes_vat_operations.json | 205 +++
.../e_taxes_vat_operations.py | 37 +
invoice_az/vat_api.py | 622 ++++++++
invoice_az/vat_operations.py | 42 +
8 files changed, 2631 insertions(+), 2 deletions(-)
create mode 100644 BUGS_VAT_OPERATIONS.md
create mode 100644 invoice_az/client/journal_entry.js
create mode 100644 invoice_az/invoice_az/doctype/e_taxes_vat_operations/__init__.py
create mode 100644 invoice_az/invoice_az/doctype/e_taxes_vat_operations/e_taxes_vat_operations.json
create mode 100644 invoice_az/invoice_az/doctype/e_taxes_vat_operations/e_taxes_vat_operations.py
create mode 100644 invoice_az/vat_api.py
create mode 100644 invoice_az/vat_operations.py
diff --git a/BUGS_VAT_OPERATIONS.md b/BUGS_VAT_OPERATIONS.md
new file mode 100644
index 0000000..41dd672
--- /dev/null
+++ b/BUGS_VAT_OPERATIONS.md
@@ -0,0 +1,359 @@
+# Статус исправления багов VAT Operations Import
+
+**Дата:** 2026-01-13
+**Статус лимита токенов:** ~73K остаток
+
+---
+
+## ✅ ИСПРАВЛЕНО И РАБОТАЕТ
+
+### БАГ 1: Фильтрация операций (income без expense)
+**Статус:** ✅ РАБОТАЕТ
+**Исправление:** Добавлена фильтрация в `vat_api.py` в функцию `get_vat_operations()` (строки 124-143)
+**Проверено:** Пользователь подтвердил - фильтр работает
+
+---
+
+## ❌ НЕ РАБОТАЕТ / ЕСТЬ ПРОБЛЕМЫ
+
+### БАГ 2: Визуальные баги (прогресс-бар, алерты)
+**Статус:** ⚠️ ЧАСТИЧНО (требует проверки после bench build)
+**Что исправлено в коде:**
+- `journal_entry.js` строка 1198: `currentIndex - 1` → `currentIndex`
+- `journal_entry.js` строки 1237-1239: убраны индивидуальные `frappe.show_alert()` во время импорта
+
+**Почему может не работать:**
+- Нужно выполнить `bench build --app invoice_az` чтобы скомпилировать JavaScript
+- Нужно обновить страницу в браузере (Ctrl+F5)
+
+**Проблема:** Пользователь не проверял после исправлений
+
+---
+
+### БАГ 3: Детальные ошибки не отображаются
+**Статус:** ❌ НЕ РАБОТАЕТ
+**Проблема:** При "Import Completed with Errors" нет информации о том, что именно пошло не так
+
+**Что исправлено в коде:**
+1. **vat_api.py** (строки 489-513): Добавлены поля `tin`, `customer_name`, `income`, `operation_date`, `operation_type`, `account_number`, `missing_tin` в errors
+2. **journal_entry.js** (строка 1242): Изменено с `VATETaxes.errors.add(operation, error.error_type, error.message)` на `VATETaxes.errors.add(operation, error.error_type, error.message, error)` - передается полный объект
+3. **journal_entry.js** (строки 628-654): Добавлено отображение Amount, Customer Name, Missing Account, Missing TIN
+
+**Почему не работает:**
+- JavaScript не пересобран: нужен `bench build --app invoice_az`
+- Кэш браузера: нужен Ctrl+F5
+
+**Как проверить что работает:**
+1. Импортировать операцию с несуществующим TIN
+2. Кликнуть "View Error Details"
+3. Должны видеть:
+ - **Amount:** сумма в AZN
+ - **Customer Name:** название клиента из операции
+ - **Missing TIN:** TIN который не найден
+ - **Missing Account:** номер счета (226 или 211) который отсутствует
+
+---
+
+### БАГ 4: ПАРАДОКС УДАЛЕНИЯ (КРИТИЧЕСКАЯ ПРОБЛЕМА!)
+**Статус:** 🔴 КРИТИЧЕСКИЙ БАГ - СОЗДАН DEADLOCK
+
+**Проблема:**
+1. Нельзя удалить **E-Taxes VAT Operations** → выходит ошибка "Cannot delete... because it is linked with Journal Entry"
+2. Нельзя удалить **Journal Entry** → при попытке удалить JE, система пытается удалить связанный E-Taxes VAT Operations
+3. E-Taxes VAT Operations выбрасывает ошибку из `on_trash()` → удаление JE прерывается
+4. **РЕЗУЛЬТАТ: Невозможно удалить ни JE, ни VAT Operations!**
+
+**Текущий код:**
+
+**e_taxes_vat_operations.py** (строки 10-19):
+```python
+def on_trash(self):
+ """Защита от удаления если связано с Journal Entry"""
+ if self.journal_entry:
+ # Проверяем, существует ли Journal Entry
+ if frappe.db.exists("Journal Entry", self.journal_entry):
+ frappe.throw(
+ _("Cannot delete or cancel E-Taxes VAT Operations {0} because it is linked with Journal Entry {1}").format(
+ self.name,
+ self.journal_entry
+ )
+ )
+```
+
+**vat_operations.py** (строки 28-34):
+```python
+# Удаляем с force=True чтобы обойти before_delete()
+frappe.delete_doc(
+ 'E-Taxes VAT Operations',
+ record.name,
+ ignore_permissions=True,
+ force=True # ⚠️ НЕ ОБХОДИТ on_trash!!!
+)
+```
+
+**ПРОБЛЕМА:** `force=True` игнорирует только permissions, но **НЕ** игнорирует `on_trash()`!
+
+---
+
+## 🔧 РЕШЕНИЯ ДЛЯ БАГА 4 (ПАРАДОКС УДАЛЕНИЯ)
+
+### Вариант 1: Использовать frappe.db.delete() (РЕКОМЕНДУЕТСЯ)
+
+**Изменить `vat_operations.py`:**
+```python
+# ВМЕСТО:
+frappe.delete_doc('E-Taxes VAT Operations', record.name, ignore_permissions=True, force=True)
+
+# ИСПОЛЬЗОВАТЬ:
+frappe.db.delete('E-Taxes VAT Operations', {'name': record.name})
+frappe.db.commit()
+```
+
+**Плюсы:**
+- Прямое удаление из БД, обходит все хуки
+- Простое и надежное
+
+**Минусы:**
+- Не запускает cascade delete для связанных документов (но у VAT Operations нет связанных)
+
+---
+
+### Вариант 2: Проверка контекста в on_trash()
+
+**Изменить `e_taxes_vat_operations.py`:**
+```python
+def on_trash(self):
+ """Защита от удаления если связано с Journal Entry"""
+ # Проверяем флаг принудительного удаления
+ if getattr(self, '_skip_trash_validation', False):
+ return
+
+ if self.journal_entry:
+ if frappe.db.exists("Journal Entry", self.journal_entry):
+ frappe.throw(
+ _("Cannot delete or cancel E-Taxes VAT Operations {0} because it is linked with Journal Entry {1}").format(
+ self.name,
+ self.journal_entry
+ )
+ )
+```
+
+**Изменить `vat_operations.py`:**
+```python
+for record in vat_records:
+ # Получаем документ
+ doc = frappe.get_doc('E-Taxes VAT Operations', record.name)
+
+ # Устанавливаем флаг для обхода валидации
+ doc._skip_trash_validation = True
+
+ # Удаляем
+ doc.delete(ignore_permissions=True, force=True)
+```
+
+**Плюсы:**
+- Сохраняет логику валидации для ручного удаления
+- Разрешает каскадное удаление из кода
+
+**Минусы:**
+- Более сложная логика
+- Использует "внутренний" флаг
+
+---
+
+### Вариант 3: Изменить логику защиты (САМЫЙ ПРАВИЛЬНЫЙ)
+
+**Идея:** Разрешить удаление VAT Operations всегда, но проверять только если пытаемся удалить ВРУЧНУЮ (не программно)
+
+**Изменить `e_taxes_vat_operations.py`:**
+```python
+def on_trash(self):
+ """Защита от удаления если связано с Journal Entry"""
+ # Пропускаем проверку если удаление идет из кода (не от пользователя)
+ if frappe.flags.in_test or frappe.flags.in_migrate:
+ return
+
+ # Проверяем только при ручном удалении
+ if self.journal_entry:
+ if frappe.db.exists("Journal Entry", self.journal_entry):
+ frappe.throw(
+ _("Cannot delete E-Taxes VAT Operations {0} because it is linked with Journal Entry {1}. "
+ "Please delete the Journal Entry first.").format(
+ self.name,
+ self.journal_entry
+ )
+ )
+```
+
+**НО:** Это не различает программное удаление от ручного! Все равно заблокирует.
+
+---
+
+### Вариант 4: Вообще убрать защиту от удаления
+
+**Просто удалить метод `on_trash()` из `e_taxes_vat_operations.py`**
+
+**Плюсы:**
+- Просто
+- Нет парадокса
+
+**Минусы:**
+- Пользователь может случайно удалить VAT Operations вручную
+- Нарушится связь с Journal Entry
+
+---
+
+## 📋 РЕКОМЕНДУЕМОЕ РЕШЕНИЕ
+
+### Использовать **Вариант 1** - frappe.db.delete()
+
+**Файл:** `/home/frappe/frappe-bench/apps/invoice_az/invoice_az/vat_operations.py`
+
+**Заменить строки 28-34:**
+
+```python
+# СТАРЫЙ КОД (не работает):
+frappe.delete_doc(
+ 'E-Taxes VAT Operations',
+ record.name,
+ ignore_permissions=True,
+ force=True
+)
+
+# НОВЫЙ КОД:
+frappe.db.delete('E-Taxes VAT Operations', {'name': record.name})
+```
+
+**Полная функция:**
+```python
+@frappe.whitelist()
+def on_delete_journal_entry(doc, method):
+ """Удаляет связанные E-Taxes VAT Operations при удалении Journal Entry"""
+ try:
+ vat_records = frappe.get_all(
+ "E-Taxes VAT Operations",
+ filters={"journal_entry": doc.name},
+ fields=["name"]
+ )
+
+ if vat_records:
+ for record in vat_records:
+ frappe.logger().info(
+ f"Deleting E-Taxes VAT Operations {record.name} "
+ f"due to Journal Entry {doc.name} deletion"
+ )
+
+ # Прямое удаление из БД (обходит on_trash)
+ frappe.db.delete('E-Taxes VAT Operations', {'name': record.name})
+
+ frappe.db.commit()
+ frappe.logger().info(f"Successfully deleted {len(vat_records)} VAT Operations records")
+
+ except Exception as e:
+ frappe.log_error(
+ f"Error deleting E-Taxes VAT Operations for Journal Entry {doc.name}: {str(e)}\n{frappe.get_traceback()}",
+ "Journal Entry Delete Error"
+ )
+```
+
+**После исправления:**
+```bash
+bench restart
+```
+
+**Тест:**
+1. Импортировать операцию → создается JE + VAT Operations
+2. Попытаться удалить VAT Operations вручную → ошибка (защита работает)
+3. Удалить Journal Entry → VAT Operations автоматически удаляется (каскад работает)
+
+---
+
+## 📝 КОМАНДЫ ДЛЯ ПРИМЕНЕНИЯ ВСЕХ ИСПРАВЛЕНИЙ
+
+```bash
+# 1. Очистить кэш
+bench clear-cache
+
+# 2. Собрать JavaScript (для БАГ 2 и БАГ 3)
+bench build --app invoice_az
+
+# 3. Перезапустить сервер (для всех Python изменений)
+bench restart
+
+# 4. В браузере: Ctrl+F5 (полная перезагрузка страницы)
+```
+
+---
+
+## 🧪 ТЕСТИРОВАНИЕ ПОСЛЕ ИСПРАВЛЕНИЙ
+
+### Тест 1: Фильтрация
+✅ **Работает** - пользователь подтвердил
+
+### Тест 2: Визуальные баги
+**Проверить:**
+1. Импортировать 5-10 операций
+2. Прогресс-бар должен идти от 0% до 100% плавно
+3. Не должно быть зеленых алертов во время импорта
+4. Только один диалог в конце
+
+### Тест 3: Детальные ошибки
+**Проверить:**
+1. Импортировать операцию с несуществующим TIN
+2. Кликнуть "View Error Details"
+3. Должны увидеть: Amount, Customer Name, Missing TIN
+4. Экспорт в CSV должен содержать эти поля
+
+### Тест 4: Удаление (после исправления)
+**Проверить:**
+1. Импортировать операцию → создается JE + VAT Operations
+2. Открыть VAT Operations напрямую → попытаться удалить → должна быть ошибка
+3. Открыть Journal Entry → удалить → JE удален + VAT Operations автоматически удален
+4. Проверить что VAT Operations больше нет в списке
+
+---
+
+## 📊 ИТОГОВАЯ СТАТИСТИКА
+
+| Баг | Статус | Что нужно |
+|-----|--------|-----------|
+| **1. Фильтрация** | ✅ Работает | - |
+| **2. Визуальные баги** | ⚠️ Требует bench build | `bench build --app invoice_az` |
+| **3. Детальные ошибки** | ⚠️ Требует bench build | `bench build --app invoice_az` |
+| **4. Парадокс удаления** | 🔴 Критический | Заменить `frappe.delete_doc` на `frappe.db.delete` |
+
+---
+
+## 📁 ИЗМЕНЕННЫЕ ФАЙЛЫ
+
+1. ✅ `/home/frappe/frappe-bench/apps/invoice_az/invoice_az/vat_api.py`
+ - Строки 124-143: фильтрация в `get_vat_operations()`
+ - Строки 489-513: детальные ошибки в `import_vat_operations()`
+
+2. ✅ `/home/frappe/frappe-bench/apps/invoice_az/invoice_az/client/journal_entry.js`
+ - Строка 1198: исправлен прогресс-бар
+ - Строки 1237-1239: убраны алерты
+ - Строка 1242: передача полного объекта ошибки
+ - Строки 628-654: отображение детальной информации
+
+3. ✅ `/home/frappe/frappe-bench/apps/invoice_az/invoice_az/invoice_az/doctype/e_taxes_vat_operations/e_taxes_vat_operations.py`
+ - Строки 10-19: защита от удаления через `on_trash()`
+
+4. ✅ `/home/frappe/frappe-bench/apps/invoice_az/invoice_az/vat_operations.py`
+ - Создан новый файл с каскадным удалением
+ - ⚠️ **ТРЕБУЕТ ИСПРАВЛЕНИЯ** - заменить `frappe.delete_doc` на `frappe.db.delete`
+
+5. ✅ `/home/frappe/frappe-bench/apps/invoice_az/invoice_az/hooks.py`
+ - Добавлен хук для Journal Entry `on_trash` и `on_cancel`
+
+---
+
+## 🎯 ПРИОРИТЕТ ИСПРАВЛЕНИЙ
+
+1. **ВЫСОКИЙ:** БАГ 4 - Парадокс удаления (блокирует удаление документов)
+2. **СРЕДНИЙ:** БАГ 3 - Детальные ошибки (нужно для отладки)
+3. **НИЗКИЙ:** БАГ 2 - Визуальные баги (косметические)
+
+---
+
+**Конец документа**
diff --git a/invoice_az/client/journal_entry.js b/invoice_az/client/journal_entry.js
new file mode 100644
index 0000000..b79be1a
--- /dev/null
+++ b/invoice_az/client/journal_entry.js
@@ -0,0 +1,1358 @@
+// ======= ОБЩИЕ УТИЛИТЫ И КОНСТАНТЫ =======
+const VATETaxes = {
+ // Константы
+ PROGRESS_UPDATE_DELAY: 50,
+ AUTH_POLL_INTERVAL: 6000,
+ AUTH_MAX_ATTEMPTS: 20,
+
+ // Глобальные переменные
+ loadingDialog: null,
+ cancelLoading: false,
+ loadingErrors: [],
+
+ // Кэш
+ cache: {
+ defaultLogin: null,
+ cacheTime: null,
+ cacheDuration: 300000 // 5 минут
+ }
+};
+
+// ======= БАЗОВЫЕ УТИЛИТЫ =======
+VATETaxes.utils = {
+ // Кэшированное получение настроек входа
+ getDefaultLogin: function(callback, useCache = true) {
+ const now = Date.now();
+
+ if (useCache && VATETaxes.cache.defaultLogin && VATETaxes.cache.cacheTime &&
+ (now - VATETaxes.cache.cacheTime) < VATETaxes.cache.cacheDuration) {
+ callback(VATETaxes.cache.defaultLogin);
+ return;
+ }
+
+ frappe.call({
+ method: 'invoice_az.auth.get_default_asan_login',
+ callback: function(r) {
+ if (r.message) {
+ VATETaxes.cache.defaultLogin = r.message;
+ VATETaxes.cache.cacheTime = now;
+ }
+ callback(r.message);
+ },
+ error: function() {
+ callback({found: false, error: 'Network error'});
+ }
+ });
+ },
+
+ // Проверка валидности токена
+ checkTokenValidity: function(callback) {
+ frappe.call({
+ method: 'invoice_az.auth.check_token_validity',
+ callback: function(r) {
+ callback(r.message && r.message.valid);
+ },
+ error: function() {
+ callback(false);
+ }
+ });
+ },
+
+ // Форматирование валюты
+ formatCurrency: function(amount) {
+ return new Intl.NumberFormat('az-AZ', {
+ style: 'currency',
+ currency: 'AZN',
+ minimumFractionDigits: 2
+ }).format(amount || 0);
+ },
+
+ // Парсинг даты операции из формата "20250319154646"
+ parseOperationDate: function(dateStr) {
+ if (!dateStr || dateStr.length < 8) {
+ return '';
+ }
+ const year = dateStr.substring(0, 4);
+ const month = dateStr.substring(4, 6);
+ const day = dateStr.substring(6, 8);
+ return `${day}.${month}.${year}`;
+ },
+
+ // Гуманизация типа ошибки
+ humanizeErrorType: function(errorType) {
+ const errorTypeMap = {
+ 'customer_not_found': __('Customer Not Found'),
+ 'account_not_found': __('Account Not Found'),
+ 'import_error': __('Import Error'),
+ 'network_error': __('Network Error'),
+ 'validation_error': __('Validation Error'),
+ 'duplicate': __('Duplicate'),
+ 'permission_error': __('Permission Error')
+ };
+ return errorTypeMap[errorType] || errorType;
+ },
+
+ // Очистка кэша
+ clearCache: function() {
+ VATETaxes.cache.defaultLogin = null;
+ VATETaxes.cache.cacheTime = null;
+ }
+};
+
+// ======= ДИАЛОГИ ЗАГРУЗКИ =======
+VATETaxes.dialogs = {
+ // Показать диалог загрузки
+ showLoading: function(title, message, submessage) {
+ if (VATETaxes.loadingDialog) {
+ this.updateLoading(title, message, submessage);
+ return;
+ }
+
+ VATETaxes.loadingDialog = new frappe.ui.Dialog({
+ title: title,
+ fields: [{
+ fieldname: 'loading_html',
+ fieldtype: 'HTML',
+ options: this._getLoadingHTML(title, message, submessage)
+ }],
+ primary_action_label: __('Cancel'),
+ primary_action: function() {
+ VATETaxes.cancelLoading = true;
+ VATETaxes.loadingDialog.$wrapper.find('.primary-action').prop('disabled', true);
+ VATETaxes.loadingDialog.$wrapper.find('.primary-action').html(__('Cancelling...'));
+ $('#status_message p').text(__('Cancelling the operation.'));
+ }
+ });
+
+ VATETaxes.cancelLoading = false;
+ VATETaxes.loadingDialog.show();
+ VATETaxes.loadingDialog.$wrapper.find('.modal-dialog').css('max-width', '450px');
+ },
+
+ // Обновить сообщение в диалоге
+ updateLoading: function(title, message, submessage) {
+ if (!VATETaxes.loadingDialog) return;
+
+ if (title) $('#loading_title').text(title);
+ if (message) $('#loading_message').text(message);
+ if (submessage !== undefined) {
+ $('#loading_submessage').text(submessage);
+ $('#loading_submessage').toggle(!!submessage);
+ }
+ },
+
+ // Показать код верификации
+ showVerificationCode: function(code) {
+ if (VATETaxes.loadingDialog && code) {
+ $('#verification_code').text(code);
+ $('#verification_code_container').show();
+ }
+ },
+
+ // Установить статус успеха
+ setSuccess: function(message, submessage, callback, delay = 2000) {
+ if (!VATETaxes.loadingDialog) {
+ if (callback) callback();
+ return;
+ }
+
+ VATETaxes.loadingDialog.$wrapper.find('.loading-animation').html(`
+
';
+ statsHtml += '
';
+ statsHtml += '
';
+ statsHtml += '' + __('Total Errors:') + ' ' + VATETaxes.loadingErrors.length + '
';
+ statsHtml += '';
+ Object.keys(errorTypes).forEach(function(type, index) {
+ if (index > 0) statsHtml += ' • ';
+ statsHtml += VATETaxes.utils.humanizeErrorType(type) + ': ' + errorTypes[type];
+ });
+ statsHtml += '';
+ statsHtml += '
';
+ statsHtml += '
';
+ statsHtml += '';
+ statsHtml += '';
+ statsHtml += '
';
+
+ const d = new frappe.ui.Dialog({
+ title: __('Error Details') + ' (' + VATETaxes.loadingErrors.length + ')',
+ size: 'large',
+ fields: [
+ {
+ fieldname: 'stats_html',
+ fieldtype: 'HTML',
+ options: statsHtml
+ },
+ {
+ fieldname: 'errors_html',
+ fieldtype: 'HTML',
+ options: '
' + errorsHtml + '
'
+ }
+ ],
+ primary_action_label: __('Close'),
+ primary_action: function() {
+ d.hide();
+ }
+ });
+
+ d.show();
+
+ // Добавляем обработчики событий
+ setTimeout(function() {
+ $('#export-csv-dialog-btn').off('click').on('click', function() {
+ VATETaxes.errors.exportCSV();
+ });
+
+ $('#clear-log-dialog-btn').off('click').on('click', function() {
+ VATETaxes.errors.clear();
+ d.hide();
+ });
+ }, 200);
+ },
+
+ // Экспорт в CSV
+ exportCSV: function() {
+ if (VATETaxes.loadingErrors.length === 0) {
+ frappe.msgprint(__('No errors to export'));
+ return;
+ }
+
+ try {
+ let csvContent = "data:text/csv;charset=utf-8,";
+ csvContent += "Operation ID,Date,TIN,Name,Error Type,Error Message,Timestamp\n";
+
+ VATETaxes.loadingErrors.forEach(function(error) {
+ const cleanMessage = error.error_message.replace(/"/g, '""').replace(/\n/g, ' ').replace(/\r/g, ' ');
+ const cleanName = error.name.replace(/"/g, '""');
+
+ const row = [
+ error.operation_id,
+ error.operation_date,
+ error.tin,
+ cleanName,
+ error.error_type,
+ cleanMessage,
+ moment(error.timestamp).format('DD.MM.YYYY HH:mm:ss')
+ ].map(function(field) {
+ return '"' + (field || '') + '"';
+ }).join(',');
+
+ csvContent += row + "\n";
+ });
+
+ const encodedUri = encodeURI(csvContent);
+ const link = document.createElement("a");
+ link.setAttribute("href", encodedUri);
+ link.setAttribute("download", "vat_import_errors_" + moment().format('YYYY-MM-DD_HH-mm-ss') + ".csv");
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+
+ frappe.show_alert({
+ message: __('Error log exported successfully'),
+ indicator: 'green'
+ }, 3);
+ } catch (e) {
+ console.error('Export error:', e);
+ frappe.show_alert({
+ message: __('Failed to export error log'),
+ indicator: 'red'
+ }, 3);
+ }
+ },
+
+ // Показать сводку результатов
+ showSummary: function(totalOperations, processedCount, errorsCount) {
+ let title = '';
+ let indicator = '';
+ let message = '';
+
+ if (errorsCount === 0) {
+ title = __('Import Completed Successfully');
+ indicator = 'green';
+ message = __('All ') + totalOperations + __(' VAT operations were imported successfully.');
+
+ frappe.msgprint({
+ title: title,
+ indicator: indicator,
+ message: message
+ });
+ } else {
+ title = errorsCount === totalOperations ? __('Import Failed') : __('Import Completed with Errors');
+ indicator = processedCount > 0 ? 'orange' : 'red';
+
+ message = '
';
+ if (processedCount > 0) {
+ message += __('Successfully imported: ') + '' + processedCount + '' + __(' operations') + '
';
+ }
+ message += __('Failed: ') + '' + errorsCount + '' + __(' operations');
+ message += '
';
+
+ message += '
' +
+ '' +
+ '' +
+ '
';
+
+ const summaryDialog = frappe.msgprint({
+ title: title,
+ indicator: indicator,
+ message: message
+ });
+
+ // Добавляем обработчики событий
+ setTimeout(function() {
+ $('#view-error-details-btn').off('click').on('click', function() {
+ summaryDialog.hide();
+ VATETaxes.errors.showDetails();
+ });
+
+ $('#export-error-log-btn').off('click').on('click', function() {
+ VATETaxes.errors.exportCSV();
+ });
+ }, 200);
+ }
+ }
+};
+
+// ======= МОДУЛЬ ИМПОРТА VAT ОПЕРАЦИЙ =======
+VATETaxes.import = {
+ // Показать диалог импорта из E-Taxes
+ showDialog: function() {
+ VATETaxes.auth.checkAndProcess(function() {
+ const d = new frappe.ui.Dialog({
+ title: __('Import VAT Operations from E-Taxes'),
+ fields: [
+ {
+ fieldname: 'date_range_section',
+ fieldtype: 'Section Break',
+ label: __('Period')
+ },
+ {
+ fieldname: 'creationDateFrom',
+ fieldtype: 'Date',
+ label: __('Date from'),
+ default: moment().subtract(1, 'month').format('YYYY-MM-DD')
+ },
+ {
+ fieldname: 'creationDateTo',
+ fieldtype: 'Date',
+ label: __('Date to'),
+ default: moment().format('YYYY-MM-DD')
+ },
+ {
+ fieldname: 'settings_section',
+ fieldtype: 'Section Break',
+ label: __('Settings')
+ },
+ {
+ fieldname: 'company',
+ fieldtype: 'Link',
+ label: __('Company'),
+ options: 'Company',
+ reqd: true,
+ default: frappe.defaults.get_user_default('Company')
+ }
+ ],
+ primary_action_label: __('Search'),
+ primary_action: function() {
+ const values = d.get_values();
+
+ // Validate date range
+ const fromDateMoment = moment(values.creationDateFrom, "YYYY-MM-DD");
+ const toDateMoment = moment(values.creationDateTo, "YYYY-MM-DD");
+ const todayMoment = moment();
+
+ if (toDateMoment.isAfter(todayMoment)) {
+ frappe.msgprint(__('To Date cannot be later than today'));
+ return;
+ }
+
+ if (fromDateMoment.isAfter(toDateMoment)) {
+ frappe.msgprint(__('From Date cannot be later than To Date'));
+ return;
+ }
+
+ if (!values.company) {
+ frappe.msgprint({
+ title: __('Warning'),
+ indicator: 'orange',
+ message: __('Please select a company')
+ });
+ return;
+ }
+
+ const fromDate = values.creationDateFrom;
+ const toDate = values.creationDateTo;
+
+ d.hide();
+ VATETaxes.import.loadOperations(fromDate, toDate, values.company);
+ }
+ });
+
+ d.show();
+ });
+ },
+
+ // Загрузить операции с фильтрацией дубликатов
+ loadOperations: function(fromDate, toDate, company, accumulatedOperations = [], offset = 0) {
+ VATETaxes.cancelLoading = false;
+ $(document).off('progress-cancel.vat_import');
+
+ if (offset === 0) {
+ frappe.show_alert({
+ message: __('Fetching VAT operations from E-Taxes...'),
+ indicator: 'blue'
+ }, 3);
+ }
+
+ VATETaxes.utils.getDefaultLogin(function(loginResponse) {
+ if (VATETaxes.cancelLoading) return;
+
+ if (loginResponse && loginResponse.found) {
+ const mainToken = loginResponse.main_token;
+
+ if (!mainToken) {
+ frappe.msgprint({
+ title: __('Authentication Error'),
+ indicator: 'red',
+ message: __('Authentication token not found. Please complete the authentication process in E-Taxes settings.')
+ });
+ return;
+ }
+
+ const maxCount = 50;
+
+ frappe.call({
+ method: 'invoice_az.vat_api.get_vat_operations',
+ args: {
+ 'token': mainToken,
+ 'date_from': fromDate,
+ 'date_to': toDate,
+ 'max_count': maxCount,
+ 'offset': offset
+ },
+ callback: function(r) {
+ if (VATETaxes.cancelLoading) return;
+
+ if (r.message && !r.message.error) {
+ const currentOperations = r.message.operations || [];
+ const hasMore = r.message.hasMore || false;
+ const allOperations = accumulatedOperations.concat(currentOperations);
+
+ if (hasMore && currentOperations.length > 0) {
+ const newOffset = offset + currentOperations.length;
+ VATETaxes.import.loadOperations(fromDate, toDate, company, allOperations, newOffset);
+ } else {
+ if (allOperations.length > 0) {
+ frappe.show_alert({
+ message: __('Processing ') + allOperations.length + __(' VAT operations...'),
+ indicator: 'blue'
+ }, 3);
+
+ VATETaxes.import.filterDuplicates(allOperations, mainToken, company);
+ } else {
+ frappe.msgprint({
+ title: __('Information'),
+ indicator: 'blue',
+ message: __('No VAT operations found for the specified period')
+ });
+ }
+ }
+ } else if (r.message && r.message.error === 'unauthorized') {
+ frappe.confirm(
+ __('Your E-Taxes session has expired. Would you like to authenticate now?'),
+ function() {
+ VATETaxes.auth.startProcess(function() {
+ VATETaxes.import.loadOperations(fromDate, toDate, company);
+ });
+ },
+ function() {
+ frappe.show_alert({
+ message: __('Authentication canceled. Operation cannot be completed.'),
+ indicator: 'red'
+ }, 5);
+ }
+ );
+ } else {
+ frappe.msgprint({
+ title: __('Error'),
+ indicator: 'red',
+ message: r.message ? r.message.message : __('Error loading VAT operations')
+ });
+ }
+ },
+ error: function(xhr, status, error) {
+ console.error("Error fetching VAT operations:", error);
+ frappe.msgprint({
+ title: __('Network Error'),
+ indicator: 'red',
+ message: __('Error fetching data from server: ') + (error || 'Unknown error')
+ });
+ }
+ });
+ } else {
+ frappe.msgprint({
+ title: __('Error'),
+ indicator: 'red',
+ message: __('E-Taxes authentication settings not found')
+ });
+ }
+ });
+ },
+
+ // Фильтрация дубликатов
+ filterDuplicates: function(allOperations, token, company) {
+ frappe.call({
+ method: 'frappe.client.get_list',
+ args: {
+ doctype: 'E-Taxes VAT Operations',
+ fields: ['operation_id'],
+ limit_page_length: 0
+ },
+ callback: function(r) {
+ try {
+ if (VATETaxes.cancelLoading) return;
+
+ const importedOperations = {};
+
+ if (r.message) {
+ r.message.forEach(function(op) {
+ if (op.operation_id) {
+ const normId = String(op.operation_id).trim();
+ importedOperations[normId] = true;
+ }
+ });
+ }
+
+ const filteredOperations = [];
+
+ for (let i = 0; i < allOperations.length; i++) {
+ const operation = allOperations[i];
+ const operationId = String(operation.id).trim();
+
+ if (!importedOperations.hasOwnProperty(operationId)) {
+ filteredOperations.push(operation);
+ }
+ }
+
+ if (filteredOperations.length > 0) {
+ VATETaxes.import.showOperationSelection(filteredOperations, token, company);
+ } else {
+ frappe.msgprint({
+ title: __('Information'),
+ indicator: 'blue',
+ message: __('No new VAT operations found for the specified period (all ' +
+ allOperations.length + ' are already imported)')
+ });
+ }
+ } catch (e) {
+ console.error("Error processing VAT operations data:", e);
+ frappe.msgprint({
+ title: __('Error'),
+ indicator: 'red',
+ message: __('An error occurred while processing data: ') + e.message
+ });
+ }
+ },
+ error: function(err) {
+ console.error("Error fetching VAT operations:", err);
+ VATETaxes.import.showOperationSelection(allOperations, token, company);
+ frappe.show_alert({
+ message: __('Failed to check for duplicates. Showing all operations.'),
+ indicator: 'orange'
+ }, 5);
+ }
+ });
+ },
+
+ // Показать диалог выбора операций
+ showOperationSelection: function(operations, token, company) {
+ let operationTable = '
';
+
+ const infoMessage = '
' +
+ __('New VAT operations found: ') + operations.length +
+ '
';
+
+ const d = new frappe.ui.Dialog({
+ title: __('Select VAT Operations'),
+ size: 'large',
+ fields: [
+ {
+ fieldname: 'info_html',
+ fieldtype: 'HTML',
+ options: infoMessage
+ },
+ {
+ fieldname: 'operations_html',
+ fieldtype: 'HTML',
+ options: operationTable
+ },
+ {
+ fieldname: 'company_display',
+ fieldtype: 'HTML',
+ options: '
' +
+ __('Selected company: ') + '' + company + '
'
+ }
+ ],
+ primary_action_label: __('Load Selected'),
+ primary_action: function() {
+ const selectedOperationIds = [];
+ d.$wrapper.find('.select-operation:checked').each(function() {
+ selectedOperationIds.push($(this).data('id'));
+ });
+
+ if (selectedOperationIds.length === 0) {
+ frappe.msgprint({
+ title: __('Warning'),
+ indicator: 'orange',
+ message: __('No operations selected')
+ });
+ return;
+ }
+
+ // Найти полные данные операций
+ const selectedOperations = [];
+ selectedOperationIds.forEach(function(id) {
+ const operation = operations.find(op => op.id === id);
+ if (operation) {
+ selectedOperations.push(operation);
+ }
+ });
+
+ d.hide();
+ VATETaxes.cancelLoading = false;
+ VATETaxes.import.loadSelectedOperations(selectedOperations, token, company);
+ },
+ secondary_action_label: __('Cancel'),
+ secondary_action: function() {
+ d.hide();
+ }
+ });
+
+ d.$wrapper.find('.modal-dialog').css({
+ 'max-width': '80%',
+ 'width': '80%',
+ 'margin': '30px auto'
+ });
+
+ d.$wrapper.find('.modal-body').css({
+ 'padding': '15px'
+ });
+
+ d.show();
+
+ // Обработчик для чекбокса "выбрать все"
+ d.$wrapper.find('.select-all-operations').on('change', function() {
+ const isChecked = $(this).prop('checked');
+ d.$wrapper.find('.select-operation').prop('checked', isChecked);
+ });
+ },
+
+ // Загрузка выбранных операций
+ loadSelectedOperations: function(operations, token, company, processedCount = 0, currentIndex = 0) {
+ if (processedCount === 0 && currentIndex === 0) {
+ VATETaxes.loadingErrors = [];
+ window.totalOperationsToProcess = operations.length;
+ }
+
+ $(document).off('progress-cancel.loading_operations');
+
+ if (operations.length === 0) {
+ const errorsCount = VATETaxes.loadingErrors.length;
+ const totalOperations = window.totalOperationsToProcess || processedCount;
+
+ // Искусственная задержка перед показом summary, чтобы прогресс-бар успел закрыться
+ setTimeout(function() {
+ // Закрываем прогресс-бар прямо перед показом summary
+ frappe.hide_progress();
+
+ // Дополнительная микрозадержка для гарантии
+ setTimeout(function() {
+ VATETaxes.errors.showSummary(totalOperations, processedCount, errorsCount);
+ window.totalOperationsToProcess = null;
+
+ if (cur_list) {
+ cur_list.refresh();
+ }
+ }, 100);
+ }, 500);
+
+ return;
+ }
+
+ if (VATETaxes.cancelLoading) {
+ operations = [];
+ frappe.hide_progress();
+ VATETaxes.import.loadSelectedOperations(operations, token, company, processedCount, currentIndex);
+ return;
+ }
+
+ const operation = operations.shift();
+ const isLastOperation = operations.length === 0;
+ const totalToProcess = window.totalOperationsToProcess || (processedCount + operations.length + 1);
+
+ currentIndex++;
+
+ frappe.show_progress(__('Importing VAT Operations'), currentIndex, totalToProcess,
+ __('Processing operation ') + currentIndex + __(' of ') + totalToProcess, null, true);
+
+ $(document).on('progress-cancel.loading_operations', function() {
+ VATETaxes.cancelLoading = true;
+ frappe.show_alert({
+ message: __('Cancelling import... Finishing current operation.'),
+ indicator: 'orange'
+ }, 3);
+ frappe.hide_progress();
+ });
+
+ // Импорт одной операции
+ frappe.call({
+ method: 'invoice_az.vat_api.import_vat_operations',
+ args: {
+ 'operations_data': JSON.stringify([operation]),
+ 'company': company
+ },
+ callback: function(r) {
+ VATETaxes.import._handleImportResult(r, operation,
+ operations, token, company, processedCount, currentIndex, isLastOperation);
+ },
+ error: function(xhr, status, error) {
+ const errorMessage = 'Network error during import: ' + (error || 'Unknown network error');
+ VATETaxes.errors.add(operation, 'Network Error', errorMessage, {
+ xhr_status: xhr.status,
+ xhr_response: xhr.responseText
+ });
+
+ VATETaxes.import._continueOrFinish(operations, token, company, processedCount, currentIndex, isLastOperation);
+ }
+ });
+ },
+
+ // Обработка результата импорта
+ _handleImportResult: function(importR, operation, operations, token, company, processedCount, currentIndex, isLastOperation) {
+ if (importR.message && importR.message.success) {
+ if (importR.message.imported > 0) {
+ // НЕ показываем индивидуальные алерты во время массового импорта
+ // Только счетчик в прогресс-баре
+ processedCount++;
+ } else if (importR.message.failed > 0 && importR.message.errors && importR.message.errors.length > 0) {
+ const error = importR.message.errors[0];
+ // Передаем ВСЕ данные ошибки включая tin, customer_name, income, account_number
+ VATETaxes.errors.add(operation, error.error_type || 'Import Error', error.message, error);
+ }
+
+ VATETaxes.import._continueOrFinish(operations, token, company, processedCount, currentIndex, isLastOperation);
+ } else {
+ const errorMessage = importR.message ? importR.message.message : 'Unknown import error';
+ VATETaxes.errors.add(operation, 'Import Error', errorMessage, {
+ server_response: importR.message
+ });
+
+ VATETaxes.import._continueOrFinish(operations, token, company, processedCount, currentIndex, isLastOperation);
+ }
+ },
+
+ // Продолжить или завершить обработку
+ _continueOrFinish: function(operations, token, company, processedCount, currentIndex, isLastOperation) {
+ if (isLastOperation) {
+ $(document).off('progress-cancel.loading_operations');
+
+ const totalToProcess = window.totalOperationsToProcess || processedCount;
+ const errorsCount = VATETaxes.loadingErrors.length;
+
+ // Искусственная задержка перед показом summary, чтобы прогресс-бар успел закрыться
+ setTimeout(function() {
+ // Закрываем прогресс-бар прямо перед показом summary
+ frappe.hide_progress();
+
+ // Дополнительная микрозадержка для гарантии
+ setTimeout(function() {
+ VATETaxes.errors.showSummary(totalToProcess, processedCount, errorsCount);
+
+ if (cur_list) {
+ cur_list.refresh();
+ }
+ }, 100);
+ }, 500);
+
+ return;
+ }
+
+ setTimeout(function() {
+ if (operations.length === 0) {
+ frappe.hide_progress();
+ }
+
+ VATETaxes.import.loadSelectedOperations(operations, token, company, processedCount, currentIndex);
+ }, VATETaxes.PROGRESS_UPDATE_DELAY);
+ }
+};
+
+// ======= ОСНОВНЫЕ ОБРАБОТЧИКИ СПИСКОВ =======
+
+// Journal Entry List
+frappe.listview_settings['Journal Entry'] = {
+ add_fields: ['posting_date', 'voucher_type', 'total_debit', 'total_credit', 'docstatus'],
+
+ get_indicator: function(doc) {
+ if (doc.docstatus == 1) {
+ return [__('Submitted'), 'blue', 'docstatus,=,1'];
+ } else if (doc.docstatus == 0) {
+ return [__('Draft'), 'red', 'docstatus,=,0'];
+ } else if (doc.docstatus == 2) {
+ return [__('Cancelled'), 'grey', 'docstatus,=,2'];
+ }
+ },
+
+ onload: function(listview) {
+ listview.page.add_menu_item(__('Import VAT Operations from E-Taxes'), function() {
+ VATETaxes.import.showDialog();
+ });
+
+ listview.refresh();
+ }
+};
diff --git a/invoice_az/hooks.py b/invoice_az/hooks.py
index dbde110..9a945fe 100644
--- a/invoice_az/hooks.py
+++ b/invoice_az/hooks.py
@@ -9,7 +9,8 @@ doctype_js = {
"Purchase Order": "client/purchase_order.js",
"Purchase Invoice": "client/purchase_invoice.js",
"Sales Order": "client/sales_order.js",
- "Sales Invoice": "client/sales_invoice.js"
+ "Sales Invoice": "client/sales_invoice.js",
+ "Journal Entry": "client/journal_entry.js"
}
doctype_list_js = {
@@ -21,7 +22,8 @@ doctype_list_js = {
"Purchase Order": "client/purchase_order.js",
"Purchase Invoice": "client/purchase_invoice.js",
"Sales Order": "client/sales_order.js",
- "Sales Invoice": "client/sales_invoice.js"
+ "Sales Invoice": "client/sales_invoice.js",
+ "Journal Entry": "client/journal_entry.js"
}
# Хуки для добавления обработчиков событий Purchase Order
@@ -38,6 +40,10 @@ doc_events = {
"on_trash": "invoice_az.send_sales_api.on_delete_sales_invoice",
"on_cancel": "invoice_az.send_sales_api.on_delete_sales_invoice"
},
+ "Journal Entry": {
+ "on_trash": "invoice_az.vat_operations.on_delete_journal_entry",
+ "on_cancel": "invoice_az.vat_operations.on_delete_journal_entry"
+ },
"E-Taxes Settings": {
"on_update": "invoice_az.api.update_mapped_statuses"
}
diff --git a/invoice_az/invoice_az/doctype/e_taxes_vat_operations/__init__.py b/invoice_az/invoice_az/doctype/e_taxes_vat_operations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/invoice_az/invoice_az/doctype/e_taxes_vat_operations/e_taxes_vat_operations.json b/invoice_az/invoice_az/doctype/e_taxes_vat_operations/e_taxes_vat_operations.json
new file mode 100644
index 0000000..8b955c4
--- /dev/null
+++ b/invoice_az/invoice_az/doctype/e_taxes_vat_operations/e_taxes_vat_operations.json
@@ -0,0 +1,205 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "autoname": "format:ETVAT-{#####}",
+ "creation": "2026-01-13 12:00:00.000000",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "operation_id",
+ "operation_date",
+ "operation_type",
+ "payment_type",
+ "column_break_1",
+ "tin",
+ "name_field",
+ "income",
+ "journal_entry_section",
+ "journal_entry",
+ "import_date",
+ "column_break_2",
+ "status",
+ "details_section",
+ "account",
+ "destination",
+ "receipt_number",
+ "explanation",
+ "tax_code_info",
+ "operator_section",
+ "operator_pin",
+ "operator_name",
+ "error_section",
+ "error_message"
+ ],
+ "fields": [
+ {
+ "fieldname": "operation_id",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Operation ID",
+ "reqd": 1,
+ "unique": 1
+ },
+ {
+ "fieldname": "operation_date",
+ "fieldtype": "Datetime",
+ "in_list_view": 1,
+ "label": "Operation Date"
+ },
+ {
+ "fieldname": "tin",
+ "fieldtype": "Data",
+ "label": "TIN"
+ },
+ {
+ "fieldname": "name_field",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Name"
+ },
+ {
+ "fieldname": "operation_type",
+ "fieldtype": "Data",
+ "label": "Operation Type"
+ },
+ {
+ "fieldname": "payment_type",
+ "fieldtype": "Data",
+ "label": "Payment Type"
+ },
+ {
+ "fieldname": "account",
+ "fieldtype": "Data",
+ "label": "Account"
+ },
+ {
+ "fieldname": "destination",
+ "fieldtype": "Data",
+ "label": "Destination"
+ },
+ {
+ "fieldname": "income",
+ "fieldtype": "Currency",
+ "in_list_view": 1,
+ "label": "Income"
+ },
+ {
+ "fieldname": "receipt_number",
+ "fieldtype": "Data",
+ "label": "Receipt Number"
+ },
+ {
+ "fieldname": "explanation",
+ "fieldtype": "Text",
+ "label": "Explanation"
+ },
+ {
+ "fieldname": "tax_code_info",
+ "fieldtype": "Small Text",
+ "label": "Tax Code Info"
+ },
+ {
+ "fieldname": "operator_pin",
+ "fieldtype": "Data",
+ "label": "Operator PIN"
+ },
+ {
+ "fieldname": "operator_name",
+ "fieldtype": "Data",
+ "label": "Operator Name"
+ },
+ {
+ "fieldname": "journal_entry",
+ "fieldtype": "Link",
+ "label": "Journal Entry",
+ "options": "Journal Entry",
+ "read_only": 1
+ },
+ {
+ "default": "Now",
+ "fieldname": "import_date",
+ "fieldtype": "Datetime",
+ "label": "Import Date",
+ "read_only": 1
+ },
+ {
+ "fieldname": "status",
+ "fieldtype": "Select",
+ "in_list_view": 1,
+ "label": "Status",
+ "options": "\nImported\nFailed",
+ "read_only": 1
+ },
+ {
+ "fieldname": "error_message",
+ "fieldtype": "Text",
+ "label": "Error Message",
+ "read_only": 1
+ },
+ {
+ "fieldname": "column_break_1",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "journal_entry_section",
+ "fieldtype": "Section Break",
+ "label": "Journal Entry"
+ },
+ {
+ "fieldname": "column_break_2",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "details_section",
+ "fieldtype": "Section Break",
+ "label": "Details"
+ },
+ {
+ "fieldname": "operator_section",
+ "fieldtype": "Section Break",
+ "label": "Operator Information"
+ },
+ {
+ "fieldname": "error_section",
+ "fieldtype": "Section Break",
+ "label": "Error Information"
+ }
+ ],
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2026-01-13 12:00:00.000000",
+ "modified_by": "Administrator",
+ "module": "Invoice Az",
+ "name": "E-Taxes VAT Operations",
+ "naming_rule": "By fieldname",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ },
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "Accounts Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/invoice_az/invoice_az/doctype/e_taxes_vat_operations/e_taxes_vat_operations.py b/invoice_az/invoice_az/doctype/e_taxes_vat_operations/e_taxes_vat_operations.py
new file mode 100644
index 0000000..4643d47
--- /dev/null
+++ b/invoice_az/invoice_az/doctype/e_taxes_vat_operations/e_taxes_vat_operations.py
@@ -0,0 +1,37 @@
+# Copyright (c) 2026, Jey ERP and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe.model.document import Document
+from frappe import throw, _
+
+
+class ETaxesVATOperations(Document):
+ def on_trash(self):
+ """Защита от удаления если связано с Journal Entry"""
+ if self.journal_entry:
+ # Проверяем, существует ли Journal Entry
+ if frappe.db.exists("Journal Entry", self.journal_entry):
+ frappe.throw(
+ _("Cannot delete or cancel E-Taxes VAT Operations {0} because it is linked with Journal Entry {1}").format(
+ self.name,
+ self.journal_entry
+ )
+ )
+
+ def validate(self):
+ """Валидация при сохранении"""
+ # Проверяем, что operation_id уникален
+ if not self.is_new():
+ return
+
+ existing = frappe.db.get_value(
+ 'E-Taxes VAT Operations',
+ {'operation_id': self.operation_id, 'name': ['!=', self.name]},
+ 'name'
+ )
+
+ if existing:
+ throw(_("Operation ID {0} already exists in {1}").format(
+ self.operation_id, existing
+ ))
diff --git a/invoice_az/vat_api.py b/invoice_az/vat_api.py
new file mode 100644
index 0000000..2f67bed
--- /dev/null
+++ b/invoice_az/vat_api.py
@@ -0,0 +1,622 @@
+# ======= VAT OPERATIONS API MODULE =======
+# Module for importing VAT Account operations from E-Taxes Azerbaijan
+# and creating Journal Entries in ERPNext
+
+import frappe
+import requests
+import json
+from datetime import datetime
+from frappe.utils import now_datetime, getdate
+from invoice_az.auth import record_etaxes_activity, get_default_asan_login
+
+# API Endpoint
+VAT_OPERATIONS_URL = "https://new.e-taxes.gov.az/api/po/vatacc/public/v1/operation/find.outbox"
+
+DEFAULT_HEADERS = {
+ "Accept": "application/json, text/plain, */*",
+ "Content-Type": "application/json",
+ "Cache-Control": "no-cache"
+}
+
+
+def parse_operation_date(operation_date_str):
+ """
+ Парсит дату из формата e-taxes "20250319154646" в datetime.date
+
+ Args:
+ operation_date_str (str): Дата в формате "YYYYMMDDHHMMSS"
+
+ Returns:
+ datetime.date: Объект даты или None при ошибке
+ """
+ try:
+ if not operation_date_str or len(operation_date_str) < 8:
+ frappe.log_error(f"Invalid operation date format: {operation_date_str}", "VAT Date Parse Error")
+ return None
+
+ # Формат: YYYYMMDDHHMMSS
+ year = int(operation_date_str[0:4])
+ month = int(operation_date_str[4:6])
+ day = int(operation_date_str[6:8])
+
+ return datetime(year, month, day).date()
+ except Exception as e:
+ frappe.log_error(f"Error parsing operation date '{operation_date_str}': {str(e)}", "VAT Date Parse Error")
+ return None
+
+
+@frappe.whitelist()
+def get_vat_operations(token, date_from, date_to, max_count=50, offset=0):
+ """
+ Получение списка VAT Account операций из e-taxes
+
+ Args:
+ token (str): Bearer токен для авторизации
+ date_from (str): Дата начала в формате "YYYY-MM-DD"
+ date_to (str): Дата окончания в формате "YYYY-MM-DD"
+ max_count (int): Максимальное количество записей (по умолчанию 50)
+ offset (int): Смещение для пагинации (по умолчанию 0)
+
+ Returns:
+ dict: Словарь с результатами или ошибкой
+ """
+ record_etaxes_activity()
+
+ try:
+ max_count = int(max_count)
+ offset = int(offset)
+ except (ValueError, TypeError):
+ max_count = 50
+ offset = 0
+
+ payload = {
+ "accounts": None,
+ "creationDateFrom": date_from,
+ "creationDateTo": date_to,
+ "maxCount": max_count,
+ "offset": offset,
+ "sortAsc": True,
+ "sortBy": "DATE",
+ "taxCodes": None
+ }
+
+ headers = DEFAULT_HEADERS.copy()
+ headers["x-authorization"] = f"Bearer {token}"
+
+ try:
+ response = requests.post(VAT_OPERATIONS_URL, json=payload, headers=headers)
+
+ # Handle 500 error
+ if response.status_code == 500:
+ frappe.log_error(f"[VAT_API] Server error 500", "E-Taxes Server Error")
+ return {
+ "error": "server_error",
+ "message": "Service temporarily unavailable. Please try again in a few minutes.",
+ "status_code": 500
+ }
+
+ # Handle 401 error - refresh token
+ if response.status_code == 401:
+ frappe.log_error(f"[VAT_API] Unauthorized 401 - token expired", "E-Taxes Auth Error")
+
+ asan_login_settings = get_default_asan_login()
+ if asan_login_settings.get('found'):
+ fresh_token = asan_login_settings['main_token']
+ headers["x-authorization"] = f"Bearer {fresh_token}"
+
+ frappe.log_error(f"[VAT_API] Retrying with fresh token", "E-Taxes Auth Retry")
+
+ response = requests.post(VAT_OPERATIONS_URL, json=payload, headers=headers)
+
+ if response.status_code == 401:
+ frappe.log_error(f"[VAT_API] Still unauthorized after token refresh", "E-Taxes Auth Failed")
+ return {
+ "error": "unauthorized",
+ "message": "Authentication required. Please login again."
+ }
+ else:
+ frappe.log_error(f"[VAT_API] No Asan Login settings found", "E-Taxes Auth Error")
+ return {
+ "error": "unauthorized",
+ "message": "Authentication required. Please login again."
+ }
+
+ response.raise_for_status()
+
+ # Получаем результат
+ result = response.json()
+
+ # ФИЛЬТРУЕМ: только операции с income и без expense
+ if 'operations' in result and isinstance(result['operations'], list):
+ operations = result['operations']
+ filtered_operations = []
+
+ for op in operations:
+ income = op.get('income', 0)
+ expense = op.get('expense', 0)
+
+ # Условие: есть income > 0 и нет expense (или expense == 0)
+ if income and income > 0 and (not expense or expense == 0):
+ filtered_operations.append(op)
+
+ frappe.logger().info(f"VAT Operations filtered: {len(operations)} -> {len(filtered_operations)} (income only)")
+ result['operations'] = filtered_operations
+
+ return result
+
+ except requests.exceptions.HTTPError as e:
+ frappe.log_error(f"[VAT_API] HTTP error: {e.response.status_code} - {str(e)}", "E-Taxes HTTP Error")
+
+ if e.response.status_code == 401:
+ return {
+ "error": "unauthorized",
+ "message": "Authentication required. Please login again."
+ }
+ elif e.response.status_code == 500:
+ return {
+ "error": "server_error",
+ "message": "Service temporarily unavailable. Please try again in a few minutes.",
+ "status_code": 500
+ }
+
+ return {"error": "http_error", "message": "An unknown error occurred, please try again."}
+ except Exception as e:
+ frappe.log_error(f"[VAT_API] Unexpected error: {str(e)}\n{frappe.get_traceback()}", "E-Taxes Error")
+ return {"error": "unknown_error", "message": "An unknown error occurred, please try again."}
+
+
+def find_customer_by_tin(tin):
+ """
+ Поиск клиента по TIN (напрямую в Customer.tax_id)
+ БУДУЩЕЕ: Переключение на customer_mappings
+
+ Args:
+ tin (str): TIN клиента
+
+ Returns:
+ str: Имя клиента или None
+ """
+ if not tin:
+ return None
+
+ try:
+ # ТЕКУЩАЯ РЕАЛИЗАЦИЯ: Прямой поиск по tax_id
+ customer_name = frappe.db.get_value('Customer', {'tax_id': tin}, 'name')
+
+ if customer_name:
+ return customer_name
+
+ # БУДУЩЕЕ: Раскомментировать для использования customer_mappings
+ # settings = frappe.get_all('E-Taxes Settings',
+ # filters={'is_active': 1},
+ # limit=1)
+ # if settings:
+ # settings_doc = frappe.get_doc('E-Taxes Settings', settings[0].name)
+ # for mapping in settings_doc.customer_mappings:
+ # if mapping.etaxes_tax_id == tin and mapping.erp_customer:
+ # return mapping.erp_customer
+
+ return None
+
+ except Exception as e:
+ frappe.log_error(f"Error finding customer by TIN {tin}: {str(e)}", "VAT Customer Lookup Error")
+ return None
+
+
+def find_account_by_number(account_number, company):
+ """
+ Поиск счета по номеру счета
+
+ Args:
+ account_number (str): Номер счета (например, "226" или "211")
+ company (str): Название компании
+
+ Returns:
+ str: Имя счета или None
+ """
+ if not account_number or not company:
+ return None
+
+ try:
+ # Поиск по полю account_number
+ account_name = frappe.db.get_value('Account',
+ {'account_number': account_number,
+ 'company': company},
+ 'name')
+
+ if account_name:
+ return account_name
+
+ # Дополнительный поиск: счет может содержать номер в начале имени
+ # Например: "226 - НДС к возмещению"
+ accounts = frappe.get_all('Account',
+ filters={'company': company},
+ fields=['name', 'account_name'])
+
+ for account in accounts:
+ if account.get('name', '').startswith(account_number + ' '):
+ return account['name']
+ if account.get('account_name', '').startswith(account_number + ' '):
+ return account['name']
+
+ return None
+
+ except Exception as e:
+ frappe.log_error(f"Error finding account {account_number}: {str(e)}", "VAT Account Lookup Error")
+ return None
+
+
+def create_journal_entry_from_vat_operation(operation_data, company):
+ """
+ Создание Journal Entry из VAT операции
+
+ Args:
+ operation_data (dict): Данные операции из API
+ company (str): Название компании
+
+ Returns:
+ dict: Результат создания с именем JE или ошибкой
+ """
+ try:
+ # Парсим дату операции
+ operation_date_str = operation_data.get('operationDate')
+ posting_date = parse_operation_date(operation_date_str)
+
+ if not posting_date:
+ return {
+ 'success': False,
+ 'message': f'Invalid operation date format: {operation_date_str}'
+ }
+
+ # Получаем TIN и ищем клиента
+ tin = operation_data.get('tin', '').strip()
+ customer = find_customer_by_tin(tin)
+
+ if not customer:
+ return {
+ 'success': False,
+ 'message': f'Customer not found for TIN: {tin}',
+ 'error_type': 'customer_not_found',
+ 'tin': tin
+ }
+
+ # Получаем сумму
+ income = operation_data.get('income', 0)
+ if not income or income <= 0:
+ return {
+ 'success': False,
+ 'message': 'Invalid income amount'
+ }
+
+ # Ищем счета
+ account_226 = find_account_by_number('226', company)
+ account_211 = find_account_by_number('211', company)
+
+ if not account_226:
+ return {
+ 'success': False,
+ 'message': 'Account 226 not found in chart of accounts',
+ 'error_type': 'account_not_found',
+ 'account_number': '226'
+ }
+
+ if not account_211:
+ return {
+ 'success': False,
+ 'message': 'Account 211 not found in chart of accounts',
+ 'error_type': 'account_not_found',
+ 'account_number': '211'
+ }
+
+ # Создаем Journal Entry
+ je = frappe.new_doc('Journal Entry')
+ je.posting_date = posting_date
+ je.company = company
+ je.voucher_type = 'Journal Entry'
+ je.user_remark = f"VAT Operation: {operation_data.get('explanation', '')}"
+
+ # Строка 1: Дебет счета 226
+ je.append('accounts', {
+ 'account': account_226,
+ 'debit_in_account_currency': income,
+ 'credit_in_account_currency': 0
+ })
+
+ # Строка 2: Кредит счета 211 с указанием клиента
+ je.append('accounts', {
+ 'account': account_211,
+ 'debit_in_account_currency': 0,
+ 'credit_in_account_currency': income,
+ 'party_type': 'Customer',
+ 'party': customer
+ })
+
+ # Сохраняем и submit
+ je.insert()
+ je.submit()
+
+ frappe.db.commit()
+
+ return {
+ 'success': True,
+ 'journal_entry': je.name,
+ 'posting_date': str(posting_date)
+ }
+
+ except Exception as e:
+ frappe.log_error(f"Error creating JE from VAT operation: {str(e)}\n{frappe.get_traceback()}",
+ "VAT JE Creation Error")
+ return {
+ 'success': False,
+ 'message': f'Error creating Journal Entry: {str(e)}'
+ }
+
+
+def create_vat_operation_record(operation_data, journal_entry_name=None, status='Imported', error_message=None):
+ """
+ Создание записи E-Taxes VAT Operations для отслеживания
+
+ Args:
+ operation_data (dict): Данные операции из API
+ journal_entry_name (str): Имя созданного Journal Entry
+ status (str): Статус импорта
+ error_message (str): Сообщение об ошибке (если есть)
+
+ Returns:
+ dict: Результат создания записи
+ """
+ try:
+ operation_id = operation_data.get('id')
+
+ # Проверяем, не импортирована ли уже эта операция
+ existing = frappe.db.get_value('E-Taxes VAT Operations',
+ {'operation_id': operation_id},
+ 'name')
+
+ if existing:
+ return {
+ 'success': True,
+ 'message': 'Operation already imported',
+ 'name': existing,
+ 'already_exists': True
+ }
+
+ # Парсим дату
+ operation_date_str = operation_data.get('operationDate')
+ operation_date = parse_operation_date(operation_date_str)
+
+ # Создаем новую запись
+ vat_op = frappe.new_doc('E-Taxes VAT Operations')
+ vat_op.operation_id = operation_id
+ vat_op.operation_date = operation_date
+ vat_op.tin = operation_data.get('tin')
+ vat_op.name_field = operation_data.get('name')
+ vat_op.operation_type = operation_data.get('operationType')
+ vat_op.payment_type = operation_data.get('paymentType')
+ vat_op.account = operation_data.get('account')
+ vat_op.destination = operation_data.get('destination')
+ vat_op.income = operation_data.get('income')
+ vat_op.receipt_number = operation_data.get('receiptNumber')
+ vat_op.explanation = operation_data.get('explanation')
+ vat_op.operator_pin = operation_data.get('operatorPin')
+ vat_op.operator_name = operation_data.get('operatorName')
+
+ # Tax code info
+ tax_code_info = operation_data.get('taxCodeInfo', {})
+ if tax_code_info:
+ vat_op.tax_code_info = json.dumps(tax_code_info)
+
+ vat_op.journal_entry = journal_entry_name
+ vat_op.status = status
+ vat_op.error_message = error_message
+
+ vat_op.insert()
+ frappe.db.commit()
+
+ return {
+ 'success': True,
+ 'name': vat_op.name
+ }
+
+ except Exception as e:
+ frappe.log_error(f"Error creating VAT operation record: {str(e)}\n{frappe.get_traceback()}",
+ "VAT Record Creation Error")
+ return {
+ 'success': False,
+ 'message': f'Error creating tracking record: {str(e)}'
+ }
+
+
+@frappe.whitelist()
+def import_vat_operations(operations_data, company=None):
+ """
+ Импорт VAT операций и создание Journal Entries
+
+ Args:
+ operations_data (str/list): JSON строка или список операций
+ company (str): Название компании (опционально)
+
+ Returns:
+ dict: Результат импорта с статистикой
+ """
+ record_etaxes_activity()
+
+ try:
+ # Десериализация если нужно
+ if isinstance(operations_data, str):
+ operations_data = json.loads(operations_data)
+
+ # Получаем компанию
+ if not company:
+ company = frappe.defaults.get_user_default('Company')
+
+ if not company:
+ return {
+ 'success': False,
+ 'message': 'Company not specified'
+ }
+
+ # Статистика
+ total_count = len(operations_data)
+ success_count = 0
+ failed_count = 0
+ skipped_count = 0
+ errors = []
+
+ for operation in operations_data:
+ operation_id = operation.get('id')
+
+ try:
+ # Проверяем, не импортирована ли уже
+ existing = frappe.db.get_value('E-Taxes VAT Operations',
+ {'operation_id': operation_id},
+ 'name')
+
+ if existing:
+ skipped_count += 1
+ continue
+
+ # Создаем Journal Entry
+ je_result = create_journal_entry_from_vat_operation(operation, company)
+
+ if je_result.get('success'):
+ # Создаем запись отслеживания
+ record_result = create_vat_operation_record(
+ operation,
+ je_result.get('journal_entry'),
+ 'Imported',
+ None
+ )
+
+ if record_result.get('success'):
+ success_count += 1
+ else:
+ failed_count += 1
+ errors.append({
+ 'operation_id': operation_id,
+ 'message': 'Journal Entry created but tracking record failed'
+ })
+ else:
+ # НЕ создаем VAT Operations при ошибке - только логируем
+ failed_count += 1
+
+ # ДОБАВЛЯЕМ ДЕТАЛЬНУЮ ИНФОРМАЦИЮ
+ error_details = {
+ 'operation_id': operation_id,
+ 'message': je_result.get('message'),
+ 'error_type': je_result.get('error_type'),
+ # Добавляем контекстную информацию из операции
+ 'tin': operation.get('tin'),
+ 'customer_name': operation.get('name'),
+ 'income': operation.get('income'),
+ 'operation_date': operation.get('operationDate'),
+ 'operation_type': operation.get('operation_type')
+ }
+
+ # Если ошибка связана со счетами, добавляем номер счета
+ if je_result.get('error_type') == 'account_not_found':
+ error_details['account_number'] = je_result.get('account_number')
+
+ # Если ошибка связана с клиентом, подчеркиваем TIN
+ if je_result.get('error_type') == 'customer_not_found':
+ error_details['missing_tin'] = operation.get('tin')
+
+ errors.append(error_details)
+
+ except Exception as e:
+ failed_count += 1
+ error_msg = f"Error processing operation {operation_id}: {str(e)}"
+ frappe.log_error(error_msg, "VAT Import Error")
+ errors.append({
+ 'operation_id': operation_id,
+ 'message': str(e)
+ })
+
+ return {
+ 'success': True,
+ 'total': total_count,
+ 'imported': success_count,
+ 'failed': failed_count,
+ 'skipped': skipped_count,
+ 'errors': errors,
+ 'message': f'Imported {success_count} of {total_count} operations. Failed: {failed_count}, Skipped: {skipped_count}'
+ }
+
+ except Exception as e:
+ frappe.log_error(f"Error in import_vat_operations: {str(e)}\n{frappe.get_traceback()}",
+ "VAT Import Error")
+ return {
+ 'success': False,
+ 'message': f'Import failed: {str(e)}'
+ }
+
+
+@frappe.whitelist()
+def load_vat_operations_from_etaxes(date_from, date_to, max_count=50, offset=0):
+ """
+ Загрузка VAT операций из e-taxes (для фронтенда)
+
+ Args:
+ date_from (str): Дата начала
+ date_to (str): Дата окончания
+ max_count (int): Максимум записей
+ offset (int): Смещение
+
+ Returns:
+ dict: Результат с операциями для обработки на фронтенде
+ """
+ record_etaxes_activity()
+
+ try:
+ # Получаем токен
+ asan_login_settings = get_default_asan_login()
+
+ if not asan_login_settings.get('found'):
+ return {
+ 'success': False,
+ 'message': 'No Asan Login settings found'
+ }
+
+ token = asan_login_settings['main_token']
+
+ # Загружаем операции
+ response = get_vat_operations(token, date_from, date_to, max_count, offset)
+
+ if 'error' in response:
+ return {
+ 'success': False,
+ 'error': response.get('error'),
+ 'message': response.get('message')
+ }
+
+ operations = response.get('operations', [])
+ has_more = response.get('hasMore', False)
+
+ # ФИЛЬТРУЕМ: только операции с income и без expense
+ filtered_operations = []
+ for op in operations:
+ income = op.get('income', 0)
+ expense = op.get('expense', 0)
+
+ # Условие: есть income > 0 и нет expense (или expense == 0)
+ if income and income > 0 and (not expense or expense == 0):
+ filtered_operations.append(op)
+
+ frappe.logger().info(f"VAT Operations filtered: {len(operations)} -> {len(filtered_operations)} (income only)")
+
+ return {
+ 'success': True,
+ 'operations': filtered_operations,
+ 'hasMore': has_more,
+ 'token': token,
+ 'total': len(filtered_operations),
+ 'total_before_filter': len(operations)
+ }
+
+ except Exception as e:
+ frappe.log_error(f"Error in load_vat_operations_from_etaxes: {str(e)}\n{frappe.get_traceback()}",
+ "VAT Load Error")
+ return {
+ 'success': False,
+ 'message': f'Load failed: {str(e)}'
+ }
diff --git a/invoice_az/vat_operations.py b/invoice_az/vat_operations.py
new file mode 100644
index 0000000..ba75f9d
--- /dev/null
+++ b/invoice_az/vat_operations.py
@@ -0,0 +1,42 @@
+# Copyright (c) 2026, Jey ERP and contributors
+# For license information, please see license.txt
+
+import frappe
+
+
+@frappe.whitelist()
+def on_delete_journal_entry(doc, method):
+ """
+ Удаляет связанные E-Taxes VAT Operations при удалении Journal Entry
+
+ Args:
+ doc: Документ Journal Entry
+ method: Метод события (on_trash или on_cancel)
+ """
+ try:
+ # Ищем все VAT Operations, связанные с этим Journal Entry
+ vat_records = frappe.get_all(
+ "E-Taxes VAT Operations",
+ filters={"journal_entry": doc.name},
+ fields=["name"]
+ )
+
+ if vat_records:
+ for record in vat_records:
+ frappe.logger().info(
+ f"Deleting E-Taxes VAT Operations {record.name} "
+ f"due to Journal Entry {doc.name} deletion"
+ )
+
+ # Прямое удаление из БД (обходит on_trash)
+ frappe.db.delete('E-Taxes VAT Operations', {'name': record.name})
+
+ frappe.db.commit()
+ frappe.logger().info(f"Successfully deleted {len(vat_records)} VAT Operations records")
+
+ except Exception as e:
+ frappe.log_error(
+ f"Error deleting E-Taxes VAT Operations for Journal Entry {doc.name}: {str(e)}\n{frappe.get_traceback()}",
+ "Journal Entry Delete Error"
+ )
+ # Не прерываем удаление JE, только логируем ошибку