invoice_az/invoice_az/vat_api.py

769 lines
31 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 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"
}
# Маппинг типов операций из 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 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 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:
# Ищем дефолтные E-Taxes Settings
settings_name = frappe.db.get_value('E-Taxes Settings',
{'is_default': 1, 'is_active': 1},
'name')
if not settings_name:
# Если нет дефолтных, берем любые активные
settings_name = frappe.db.get_value('E-Taxes Settings',
{'is_active': 1},
'name')
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 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}'
}
# Получаем сумму (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')
# Ищем счета по маппингу с учетом 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 = None
if debit_needs_party or credit_needs_party:
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
}
# Создаем 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: Дебет
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
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 = 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):
"""
Импорт 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
# ДОБАВЛЯЕМ ДЕТАЛЬНУЮ ИНФОРМАЦИЮ
# Используем 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)}'
}