invoice_az/invoice_az/vat_api.py

1226 lines
53 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# ======= VAT OPERATIONS API MODULE =======
# Module for importing VAT Account operations from E-Taxes Azərbaycan
# 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
from invoice_az.utils import resolve_customer_group
from invoice_az import background_tasks
# 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"
}
# Маппинг типов операций из API в азербайджанские названия
OPERATION_TYPE_MAPPING = {
'SUB_TO_SUB': 'Sub uçot hesabı → Sub uçot hesabı',
'UNKNOWN_TO_OTHER_SUB': 'Naməlum → digər Sub hesab',
'CURRENT_TO_SUB': 'Cari → Sub uçot hesabı',
'CURRENT_TO_UNKNOWN': 'Cari → Naməlum',
'SUB_TO_IMPORT': 'Sub uçot hesabı → İdxal',
'UNKNOWN_TO_IMPORT': 'Naməlum → İdxal',
'SUB_TO_BUDGET': 'Sub uçot hesabı → Büdcə',
'UNKNOWN_TO_BUDGET': 'Naməlum → Büdcə',
'AUTO': 'Sub uçot hesabı → Büdcə' # AUTO операции трактуются как SUB_TO_BUDGET
}
def map_operation_type(api_operation_type):
"""
Конвертирует тип операции из API в азербайджанское название.
Args:
api_operation_type (str): Тип операции из API (например, SUB_TO_SUB)
Returns:
str: Азербайджанское название (например, Sub uçot hesabı → Sub uçot hesabı)
"""
return OPERATION_TYPE_MAPPING.get(api_operation_type, api_operation_type)
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()
# Преобразуем типы операций в азербайджанские названия
if 'operations' in result and isinstance(result['operations'], list):
operations = result['operations']
for op in operations:
# Преобразуем тип операции в красивое азербайджанское название
if 'operationType' in op:
op['operationType'] = map_operation_type(op['operationType'])
frappe.logger().info(f"VAT Operations loaded: {len(operations)} 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 normalize_customer_name(name):
"""
Нормализует имя клиента для поиска
Использует normalize_string() из sales_api.py
Args:
name (str): Имя клиента
Returns:
str: Нормализованное имя
"""
if not name:
return ''
try:
from invoice_az.sales_api import normalize_string
return normalize_string(name, consider_azeri=True)
except Exception as e:
frappe.log_error(f"Error normalizing customer name '{name}': {str(e)}", "VAT Customer Normalize Error")
# Fallback to simple lowercase if normalize_string fails
return name.lower().strip()
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_or_create_customer_from_vat_operation(tin, name, settings_doc, auto_create=False):
"""
Найти или создать клиента из VAT операции с автосозданием маппингов
Логика поиска (ПРАВИЛЬНЫЙ ПРИОРИТЕТ):
0. Проверка маппингов (НАИВЫСШИЙ ПРИОРИТЕТ) - что указано в маппинге, то и используется
- Если найден маппинг с заполненным erp_customer → возвращаем его
- Если найден маппинг с пустым erp_customer → запоминаем и продолжаем поиск
1. Если auto_create=False → return None
2. Поиск Customer в базе (только если маппинг пустой или отсутствует):
- По tax_id
- По точному имени
- По нормализованному имени
- Если найден → создать/обновить маппинг
3. Создание нового Customer + создание/обновление маппинга
Args:
tin (str): TIN клиента
name (str): Имя клиента из операции
settings_doc (Document): Объект E-Taxes Settings
auto_create (bool): Разрешить автосоздание
Returns:
str: Имя клиента или None
"""
if not tin:
return None
try:
# Шаг 0: НАИВЫСШИЙ ПРИОРИТЕТ - Проверка маппингов
existing_mapping_row = None
if settings_doc and hasattr(settings_doc, 'customer_mappings'):
for mapping in settings_doc.customer_mappings:
if mapping.etaxes_tax_id == tin:
if mapping.erp_customer:
# Маппинг с заполненным erp_customer - это источник истины!
return mapping.erp_customer
else:
# Маппинг существует, но erp_customer пустой - запоминаем для обновления
existing_mapping_row = mapping
break
# Если auto_create=False, завершаем поиск
if not auto_create:
return None
# Шаг 1: Поиск Customer по tax_id в базе
customer_name = frappe.db.get_value('Customer', {'tax_id': tin}, 'name')
if customer_name:
# Найден Customer - создаем/обновляем маппинг
_create_or_update_etaxes_customer_and_mapping(tin, name, customer_name, settings_doc, existing_mapping_row)
return customer_name
# Шаг 2: Поиск Customer по точному имени
if name:
customer_name = frappe.db.get_value('Customer', {'customer_name': name}, 'name')
if customer_name:
# Обновляем tax_id если нужно (с защитой от конфликта)
try:
customer_doc = frappe.get_doc('Customer', customer_name)
if not customer_doc.tax_id or customer_doc.tax_id != tin:
# Проверяем нет ли уже другого Customer с таким tax_id
existing_customer_with_tin = frappe.db.get_value('Customer', {'tax_id': tin}, 'name')
if not existing_customer_with_tin or existing_customer_with_tin == customer_name:
customer_doc.tax_id = tin
customer_doc.save(ignore_permissions=True)
frappe.db.commit()
except Exception:
# Игнорируем ошибки обновления tax_id
pass
# Создаем/обновляем маппинг
_create_or_update_etaxes_customer_and_mapping(tin, name, customer_name, settings_doc, existing_mapping_row)
return customer_name
# Шаг 3: Поиск по нормализованному имени
if name:
normalized_search_name = normalize_customer_name(name)
if normalized_search_name:
all_customers = frappe.get_all('Customer', fields=['name', 'customer_name'])
for customer in all_customers:
customer_normalized = normalize_customer_name(customer.get('customer_name', ''))
if customer_normalized == normalized_search_name:
# Обновляем tax_id если нужно (с защитой от конфликта)
try:
customer_doc = frappe.get_doc('Customer', customer['name'])
if not customer_doc.tax_id or customer_doc.tax_id != tin:
# Проверяем нет ли уже другого Customer с таким tax_id
existing_customer_with_tin = frappe.db.get_value('Customer', {'tax_id': tin}, 'name')
if not existing_customer_with_tin or existing_customer_with_tin == customer['name']:
customer_doc.tax_id = tin
customer_doc.save(ignore_permissions=True)
frappe.db.commit()
except Exception:
# Игнорируем ошибки обновления tax_id
pass
# Создаем/обновляем маппинг
_create_or_update_etaxes_customer_and_mapping(tin, name, customer['name'], settings_doc, existing_mapping_row)
return customer['name']
# Шаг 4: Создание нового Customer
# Берём настроенную группу только если она валидна и не является group-узлом;
# иначе создаём Customer без группы (ERPNext запрещает group-type Customer Group).
default_customer_group = resolve_customer_group(
settings_doc.default_customer_group if settings_doc else None
)
# Создаем Customer (с защитой от race condition)
try:
customer_doc = frappe.new_doc('Customer')
customer_doc.customer_name = name or f"Customer-{tin}"
customer_doc.customer_type = 'Company'
if default_customer_group:
customer_doc.customer_group = default_customer_group
customer_doc.territory = 'All Territories'
customer_doc.tax_id = tin
# Payment terms если есть
if settings_doc and hasattr(settings_doc, 'default_payment_terms') and settings_doc.default_payment_terms:
customer_doc.payment_terms = settings_doc.default_payment_terms
customer_doc.insert(ignore_permissions=True)
frappe.db.commit()
# Создаем/обновляем маппинг
_create_or_update_etaxes_customer_and_mapping(tin, name, customer_doc.name, settings_doc, existing_mapping_row)
return customer_doc.name
except frappe.exceptions.DuplicateEntryError:
# Race condition: кто-то уже создал Customer с таким именем или tax_id
# Попробуем найти снова по tax_id
customer_name = frappe.db.get_value('Customer', {'tax_id': tin}, 'name')
if customer_name:
_create_or_update_etaxes_customer_and_mapping(tin, name, customer_name, settings_doc, existing_mapping_row)
return customer_name
# Если не нашли - возвращаем None
return None
except Exception as e:
frappe.log_error(
f"Error finding/creating customer for TIN {tin}, name {name}: {str(e)}\n{frappe.get_traceback()}",
"VAT Customer Auto-Create Error"
)
return None
def _create_or_update_etaxes_customer_and_mapping(tin, etaxes_name, erp_customer, settings_doc, existing_mapping_row=None):
"""
Вспомогательная функция для создания/обновления E-Taxes Customers и маппинга
Работает ТИХО - без сообщений пользователю
Args:
tin (str): TIN клиента
etaxes_name (str): Имя из e-taxes
erp_customer (str): Имя Customer в ERP
settings_doc (Document): Объект E-Taxes Settings
existing_mapping_row (object): Существующая строка маппинга с пустым erp_customer (если есть)
"""
# Сначала обновляем маппинг (это главное!)
try:
if settings_doc:
if existing_mapping_row:
# Используем прямое обновление child table через SQL
default_customer_group = resolve_customer_group(settings_doc.default_customer_group)
# Обновляем через frappe.db.sql напрямую
frappe.db.sql("""
UPDATE `tabE-Taxes Customer Mappings`
SET erp_customer = %s,
customer_group = COALESCE(NULLIF(customer_group, ''), %s),
territory = COALESCE(NULLIF(territory, ''), 'All Territories'),
mapping_type = COALESCE(NULLIF(mapping_type, ''), 'Automatic')
WHERE parent = %s
AND etaxes_tax_id = %s
""", (erp_customer, default_customer_group, settings_doc.name, tin))
frappe.db.commit()
else:
# Проверяем, нет ли уже маппинга с таким TIN (на всякий случай)
mapping_exists = False
if hasattr(settings_doc, 'customer_mappings'):
for mapping in settings_doc.customer_mappings:
if mapping.etaxes_tax_id == tin:
mapping_exists = True
break
if not mapping_exists:
# Создаем новую строку маппинга
settings_doc.append('customer_mappings', {
'etaxes_customer_name': etaxes_name or f"Customer-{tin}",
'etaxes_tax_id': tin,
'erp_customer': erp_customer,
'customer_group': resolve_customer_group(settings_doc.default_customer_group),
'territory': 'All Territories',
'mapping_type': 'Automatic'
})
settings_doc.save(ignore_permissions=True)
frappe.db.commit()
except Exception as e:
# Тихая обработка ошибок маппинга - только в лог
frappe.log_error(
f"Error updating mapping for TIN {tin}: {str(e)}\n{frappe.get_traceback()}",
"VAT Mapping Update Error"
)
# Потом пытаемся создать/обновить E-Taxes Customers (если упадет - не страшно)
try:
# Проверяем существование по полям, а не по name (autoname может генерировать другой формат!)
existing = frappe.db.get_value('E-Taxes Customers',
{'etaxes_tax_id': tin},
'name')
if existing:
try:
etaxes_customer_doc = frappe.get_doc('E-Taxes Customers', existing)
if not etaxes_customer_doc.mapped_customer:
etaxes_customer_doc.mapped_customer = erp_customer
etaxes_customer_doc.save(ignore_permissions=True)
frappe.db.commit()
except Exception:
# Игнорируем ошибки при обновлении существующего
pass
else:
try:
etaxes_customer_doc = frappe.new_doc('E-Taxes Customers')
etaxes_customer_doc.etaxes_party_name = etaxes_name or f"Customer-{tin}"
etaxes_customer_doc.etaxes_tax_id = tin
etaxes_customer_doc.etaxes_party_type = 'Receiver'
etaxes_customer_doc.status = 'New'
etaxes_customer_doc.mapped_customer = erp_customer
etaxes_customer_doc.insert(ignore_permissions=True)
frappe.db.commit()
except frappe.exceptions.DuplicateEntryError:
# Игнорируем ошибку дубликата - кто-то уже создал
pass
except Exception:
# Игнорируем любые другие ошибки создания E-Taxes Customers
pass
except Exception:
# Игнорируем ошибки E-Taxes Customers полностью
pass
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 get_accounts_for_operation_type(operation_type, company, expense_income_type, tax_code=None):
"""
Получить счета дебета и кредита для типа операции из E-Taxes Settings.
Если маппинг не найден, вернуть (None, None).
Логика строгого соответствия (БЕЗ fallback):
- Если передан tax_code - ищем ТОЛЬКО маппинг с operation_type + expense_income_type + этот tax_code
- Если НЕ передан tax_code - ищем ТОЛЬКО маппинг с operation_type + expense_income_type + пустой classification_code
- Не нашли точное совпадение - возвращаем (None, None)
Args:
operation_type (str): Тип операции (например, Sub uçot hesabı → Sub uçot hesabı, Sub uçot hesabı → Büdcə)
company (str): Название компании
expense_income_type (str): Тип операции - 'Expense' или 'Income'
tax_code (str, optional): Код классификации из taxCodeInfo.code
Returns:
tuple: (debit_account, credit_account) или (None, None) если маппинг не найден
"""
try:
settings_name = 'E-Taxes Settings'
if settings_name and operation_type:
# СТРОГОЕ СООТВЕТСТВИЕ: ищем в зависимости от наличия tax_code
if tax_code:
# Случай А: Есть tax_code - ищем ТОЛЬКО с этим конкретным classification_code
mapping = frappe.db.get_value('E-Taxes VAT Account Mapping',
{'parent': settings_name,
'parenttype': 'E-Taxes Settings',
'operation_type': operation_type,
'expense_income': expense_income_type,
'classification_code': tax_code},
['debit_account', 'credit_account'],
as_dict=True)
if mapping:
frappe.logger().info(f"Found mapping with classification_code: {tax_code}, expense/income: {expense_income_type}")
return (mapping.get('debit_account'), mapping.get('credit_account'))
else:
# Не нашли - возвращаем None (БЕЗ fallback на пустой classification_code)
frappe.logger().info(f"No mapping found for operation_type: {operation_type}, expense/income: {expense_income_type} with classification_code: {tax_code}")
return (None, None)
else:
# Случай Б: Нет tax_code - ищем ТОЛЬКО с пустым classification_code
mappings = frappe.get_all('E-Taxes VAT Account Mapping',
filters={
'parent': settings_name,
'parenttype': 'E-Taxes Settings',
'operation_type': operation_type,
'expense_income': expense_income_type
},
fields=['debit_account', 'credit_account', 'classification_code'])
# Ищем маппинг с пустым classification_code
for mapping in mappings:
if not mapping.get('classification_code'):
frappe.logger().info(f"Found default mapping (no classification_code) for expense/income: {expense_income_type}")
return (mapping.get('debit_account'), mapping.get('credit_account'))
# Не нашли маппинг с пустым classification_code
frappe.logger().info(f"No default mapping found for operation_type: {operation_type}, expense/income: {expense_income_type}")
return (None, None)
# Маппинг не найден - возвращаем None, None
return (None, None)
except Exception as e:
frappe.log_error(f"Error getting accounts for operation type {operation_type}: {str(e)}",
"VAT Account Mapping Error")
# В случае ошибки возвращаем None, None
return (None, None)
def ensure_classification_code(tax_code_info):
"""Вернуть name записи "Classification code" для taxCodeInfo, создав её
из payload (code + description), если её ещё нет в справочнике.
Возвращает код (str) или None, если taxCodeInfo пуст/без code. Никогда не
бросает исключений — отсутствующий təsnifat kodu не должен ломать создание
Journal Entry (поле — Link, поэтому ссылка должна резолвиться).
"""
if not tax_code_info or not isinstance(tax_code_info, dict):
return None
code = tax_code_info.get('code')
if not code:
return None
code = str(code).strip()
if not code:
return None
try:
if frappe.db.exists('Classification code', code):
return code
description = (tax_code_info.get('description') or '').strip()
doc = frappe.new_doc('Classification code')
doc.name = code
doc.classification_name = description
doc.insert(ignore_permissions=True, ignore_if_duplicate=True)
frappe.db.commit()
frappe.logger().info(f"[VAT] Created Classification code '{code}' from taxCodeInfo")
return code
except Exception as e:
frappe.log_error(f"Could not ensure Classification code '{code}': {e}\n{frappe.get_traceback()}",
"VAT Classification Code Error")
return None
def create_journal_entry_from_vat_operation(operation_data, company, create_as_draft=0, auto_create_customers=0):
"""
Создание Journal Entry из VAT операции
Args:
operation_data (dict): Данные операции из API
company (str): Название компании
create_as_draft (int): 1 для создания в Draft, 0 для auto-submit
auto_create_customers (int): 1 для автосоздания клиентов, 0 для отключения
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}'
}
# Получаем сумму (income или expense)
income = operation_data.get('income', 0)
expense = operation_data.get('expense', 0)
# Определяем тип операции (Expense/Income) и сумму
if income and income > 0:
expense_income_type = 'Income'
amount = income
elif expense and expense > 0:
expense_income_type = 'Expense'
amount = expense
else:
return {
'success': False,
'message': 'Invalid amount: both income and expense are empty or zero'
}
# Получаем тип операции и tax code
api_operation_type = operation_data.get('operationType', '')
operation_type = map_operation_type(api_operation_type)
# Извлекаем tax code из taxCodeInfo
tax_code = None
tax_code_info = operation_data.get('taxCodeInfo')
if tax_code_info and isinstance(tax_code_info, dict):
tax_code = tax_code_info.get('code')
# Təsnifat kodu: ссылка на "Classification code" (заполняем ТОЛЬКО если код есть)
classification_code_name = ensure_classification_code(tax_code_info)
# Ищем счета по маппингу с учетом tax_code (если есть)
debit_account, credit_account = get_accounts_for_operation_type(
operation_type, company, expense_income_type, tax_code
)
# Проверяем что маппинг найден
if not debit_account or not credit_account:
# Формируем понятное сообщение об ошибке
expense_income_label = expense_income_type.lower() # "Income" → "income", "Expense" → "expense"
error_message = f"VAT account mapping not found: operation type '{operation_type}', {expense_income_label}"
if tax_code:
error_message += f", classification code '{tax_code}'"
error_dict = {
'success': False,
'message': error_message,
'error_type': 'mapping_not_found',
'operation_type': operation_type,
'expense_income_type': expense_income_type
}
if tax_code:
error_dict['classification_code'] = tax_code
return error_dict
# Проверяем типы счетов - нужен ли party
debit_account_type = frappe.db.get_value('Account', debit_account, 'account_type')
credit_account_type = frappe.db.get_value('Account', credit_account, 'account_type')
party_required_types = ('Receivable', 'Payable')
debit_needs_party = debit_account_type in party_required_types
credit_needs_party = credit_account_type in party_required_types
# Получаем TIN и ищем/создаем клиента (если хотя бы один счет требует party)
tin = operation_data.get('tin', '').strip()
customer_name_from_operation = operation_data.get('name', '')
customer = None
if debit_needs_party or credit_needs_party:
# Получаем настройки E-Taxes для маппингов
settings_doc = None
try:
settings_doc = frappe.get_single('E-Taxes Settings')
except Exception as e:
frappe.logger().warning(f"[VAT] Could not load E-Taxes Settings: {str(e)}")
# Используем новую функцию для поиска/создания клиента
customer = find_or_create_customer_from_vat_operation(
tin=tin,
name=customer_name_from_operation,
settings_doc=settings_doc,
auto_create=bool(auto_create_customers)
)
if not customer:
error_message = f'Customer not found for TIN: {tin}'
if auto_create_customers:
error_message = f'Could not find or create customer for TIN: {tin}'
return {
'success': False,
'message': error_message,
'error_type': 'customer_creation_failed' if auto_create_customers else 'customer_not_found',
'tin': tin
}
# Создаем 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', '')}"
je.expense_income_type = expense_income_type
je.customer_name_etaxes = operation_data.get('name', '')
je.etaxes_document_type = 'ədv' # Always ədv for VAT operations
je.loaded_from_etaxes = 1 # Flag that this was loaded from e-taxes
je.etaxes_explanation = operation_data.get('explanation', '') # İzahat из VAT операции
# Təsnifat kodu из taxCodeInfo — только для документов, где он есть
if classification_code_name:
je.etaxes_classification_code = classification_code_name
# Строка 1: Дебет
debit_entry = {
'account': debit_account,
'debit_in_account_currency': amount,
'credit_in_account_currency': 0
}
# Добавляем party если дебетовый счет Receivable/Payable
if debit_needs_party and customer:
debit_entry['party_type'] = 'Customer'
debit_entry['party'] = customer
je.append('accounts', debit_entry)
# Строка 2: Кредит
credit_entry = {
'account': credit_account,
'debit_in_account_currency': 0,
'credit_in_account_currency': amount
}
# Добавляем party если кредитовый счет Receivable/Payable
if credit_needs_party and customer:
credit_entry['party_type'] = 'Customer'
credit_entry['party'] = customer
je.append('accounts', credit_entry)
# Сохраняем и submit (если не draft режим)
je.insert()
if not create_as_draft:
je.submit()
else:
frappe.logger().info(f"Journal Entry {je.name} created as Draft (not submitted)")
frappe.db.commit()
return {
'success': True,
'journal_entry': je.name,
'posting_date': str(posting_date),
'status': 'Draft' if create_as_draft else 'Submitted'
}
except Exception as e:
error_context = "creating draft" if create_as_draft else "creating and submitting"
frappe.log_error(f"Error {error_context} JE from VAT operation: {str(e)}\n{frappe.get_traceback()}",
"VAT JE Creation Error")
return {
'success': False,
'message': f'Error {error_context} 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 = map_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, create_as_draft=0, auto_create_customers=0):
"""
Импорт VAT операций и создание Journal Entries
Args:
operations_data (str/list): JSON строка или список операций
company (str): Название компании (опционально)
create_as_draft (int): 1 для создания в Draft, 0 для auto-submit
auto_create_customers (int): 1 для автосоздания клиентов, 0 для отключения
Returns:
dict: Результат импорта с статистикой
"""
record_etaxes_activity()
try:
# Десериализация если нужно
if isinstance(operations_data, str):
operations_data = json.loads(operations_data)
# Обработка create_as_draft параметра
try:
create_as_draft = int(create_as_draft)
except (ValueError, TypeError):
create_as_draft = 0
# Обработка auto_create_customers параметра
try:
auto_create_customers = int(auto_create_customers)
except (ValueError, TypeError):
auto_create_customers = 0
# Получаем компанию
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, create_as_draft, auto_create_customers)
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
# ДОБАВЛЯЕМ ДЕТАЛЬНУЮ ИНФОРМАЦИЮ
# Используем income если есть, иначе expense
amount = operation.get('income') or operation.get('expense')
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': amount, # Используем 'income' как поле для amount (для обратной совместимости в UI)
'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')
# Если ошибка связана с отсутствием маппинга, добавляем operation_type из результата
if je_result.get('error_type') == 'mapping_not_found':
error_details['operation_type'] = je_result.get('operation_type')
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)
frappe.logger().info(f"VAT Operations loaded: {len(operations)} operations")
return {
'success': True,
'operations': operations,
'hasMore': has_more,
'token': token,
'total': 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)}'
}
@frappe.whitelist()
def import_bulk_vat_operations(operations_data, company=None, create_as_draft=0, auto_create_customers=0):
"""Enqueue bulk VAT operations import as a background job with realtime progress via Socket.IO."""
if isinstance(operations_data, str):
operations_data = json.loads(operations_data)
try:
create_as_draft = int(create_as_draft)
except (ValueError, TypeError):
create_as_draft = 0
try:
auto_create_customers = int(auto_create_customers)
except (ValueError, TypeError):
auto_create_customers = 0
user = frappe.session.user
job_id = background_tasks.new_job_id()
frappe.enqueue(
_process_bulk_vat_import,
operations=operations_data,
company=company,
create_as_draft=create_as_draft,
auto_create_customers=auto_create_customers,
user=user,
bg_job_id=job_id,
queue="default",
timeout=1200,
)
return {"success": True, "enqueued": True, "total": len(operations_data), "job_id": job_id}
def _process_bulk_vat_import(operations, company, create_as_draft, auto_create_customers, user, bg_job_id=None):
"""Background job: import each VAT operation with realtime progress."""
job_id = bg_job_id
frappe.set_user(user)
record_etaxes_activity()
total = len(operations)
imported_count = 0
errors = []
background_tasks.register_task(job_id, user, "vat_import", "Importing VAT operations", total=total)
for idx, operation in enumerate(operations):
if job_id and background_tasks.is_cancel_requested(job_id):
background_tasks.finish_task(job_id, user, "cancelled")
frappe.publish_realtime(
"vat_import_complete",
{"total": total, "imported": imported_count, "errors": errors,
"create_as_draft": create_as_draft, "cancelled": True},
user=user,
)
return
background_tasks.update_task(job_id, user, current=idx, total=total,
message=f"Importing operation {idx + 1}/{total}")
try:
result = import_vat_operations(
operations_data=json.dumps([operation]),
company=company,
create_as_draft=create_as_draft,
auto_create_customers=auto_create_customers,
)
if result and result.get("success"):
if result.get("imported", 0) > 0:
imported_count += 1
elif result.get("failed", 0) > 0 and result.get("errors"):
err = result["errors"][0]
errors.append({
"operation_id": operation.get("id", "Unknown"),
"error": err.get("message", "Unknown error"),
"error_type": err.get("error_type", "Import Error"),
"tin": err.get("tin"),
"customer_name": err.get("customer_name"),
})
else:
error_msg = result.get("message", "Unknown error") if result else "Empty result"
errors.append({"operation_id": operation.get("id", "Unknown"), "error": error_msg})
except Exception as e:
errors.append({"operation_id": operation.get("id", "Unknown"), "error": str(e)})
frappe.db.commit()
frappe.publish_realtime(
"vat_import_progress",
{"current": idx + 1, "total": total},
user=user,
)
background_tasks.finish_task(job_id, user, "done")
frappe.publish_realtime(
"vat_import_complete",
{
"total": total,
"imported": imported_count,
"errors": errors,
"create_as_draft": create_as_draft,
},
user=user,
)