BUH_bot/modules/xml_generator.py
Игорь Надеин 5b6ab02e53 Initial commit: Core bot application
- Add main bot file with Telegram handlers
- Add configuration module
- Add Python dependencies
- Add modules: excel_generator, moysklad_client, xml_generator, user_manager
- Add .gitignore for Python, Docker, user data
2025-11-03 14:19:48 +03:00

667 lines
24 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.

"""
Генерация XML документов в формате ВерсФорм 5.03
УПД (Универсальный передаточный документ) и ТН (Товарная накладная)
"""
from lxml import etree
from datetime import datetime
from typing import Dict, Optional
import logging
logger = logging.getLogger(__name__)
class XMLGenerator:
"""Генератор XML документов в формате ВерсФорм 5.03"""
VERSION = "5.03"
PROGRAM_VERSION = "BUH_BOT_1.0"
def __init__(self):
self.encoding = "UTF-8"
def _format_date(self, date_str: str) -> str:
"""
Форматирование даты в формат ДД.ММ.ГГГГ
Args:
date_str: Дата в ISO формате
Returns:
Дата в формате ДД.ММ.ГГГГ
"""
try:
dt = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
return dt.strftime("%d.%m.%Y")
except Exception as e:
logger.error(f"Ошибка форматирования даты: {e}")
return datetime.now().strftime("%d.%m.%Y")
def _format_money(self, amount: int) -> str:
"""
Форматирование денежной суммы (копейки -> рубли)
Args:
amount: Сумма в копейках
Returns:
Сумма в рублях с 2 знаками после запятой
"""
return f"{amount / 100:.2f}"
def _add_seller_info(
self,
parent: etree.Element,
organization: Dict,
accounts: list = None
) -> None:
"""
Добавление информации о продавце
Args:
parent: Родительский XML элемент
organization: Данные организации
accounts: Список банковских счетов
"""
seller = etree.SubElement(parent, "СвПрод")
# ОКПО
okpo = organization.get("okpo", "")
if okpo:
seller.set("ОКПО", okpo)
# ИдСв - зависит от типа организации
seller_info = etree.SubElement(seller, "ИдСв")
company_type = organization.get("companyType", "")
inn = organization.get("inn", "")
if company_type == "entrepreneur":
# Индивидуальный предприниматель
ogrnip = organization.get("ogrnip", "")
sv_ip = etree.SubElement(
seller_info, "СвИП",
ИННФЛ=inn,
СвГосРегИП=ogrnip if ogrnip else ""
)
# ФИО
last_name = organization.get("legalLastName", "")
first_name = organization.get("legalFirstName", "")
middle_name = organization.get("legalMiddleName", "")
if last_name or first_name:
fio = etree.SubElement(sv_ip, "ФИО")
if last_name:
fio.set("Фамилия", last_name)
if first_name:
fio.set("Имя", first_name)
if middle_name:
fio.set("Отчество", middle_name)
else:
# Юридическое лицо
kpp = organization.get("kpp", "")
sv_ul = etree.SubElement(
seller_info, "СвЮЛУч",
НаимОрг=organization.get("name", ""),
ИННЮЛ=inn,
КПП=kpp if kpp else ""
)
# Адрес
address = organization.get("legalAddress", "")
if address:
addr_element = etree.SubElement(seller, "Адрес")
etree.SubElement(
addr_element, "АдрТекст",
Текст=address
)
# Банковские реквизиты
if accounts and len(accounts) > 0:
# Берём первый счёт с БИК
for acc in accounts:
if acc.get('bic'):
bank_info = etree.SubElement(
seller, "БанкРекв",
НомерСчета=acc.get('accountNumber', '')
)
etree.SubElement(
bank_info, "СвБанк",
НаимБанк=acc.get('bankName', ''),
БИК=acc.get('bic', ''),
КорСчет=acc.get('correspondentAccount', '')
)
break
# Контактная информация
phone = organization.get("phone", "")
email = organization.get("email", "")
if phone or email:
contact = etree.SubElement(seller, "Контакт")
if phone:
etree.SubElement(contact, "Тлф").text = phone
if email:
etree.SubElement(contact, "ЭлПочта").text = email
def _add_shipper_info(
self,
parent: etree.Element,
counterparty: Dict,
accounts: list = None
) -> None:
"""
Добавление информации о грузополучателе
Args:
parent: Родительский XML элемент
counterparty: Данные контрагента
accounts: Список банковских счетов
"""
shipper = etree.SubElement(parent, "ГрузПолуч")
# ОКПО
okpo = counterparty.get("okpo", "")
if okpo:
shipper.set("ОКПО", okpo)
# ИНН/КПП и определение типа компании
inn = counterparty.get("inn", "")
kpp = counterparty.get("kpp", "")
company_type = counterparty.get("companyType", "legal")
shipper_info = etree.SubElement(shipper, "ИдСв")
# Для ИП используем структуру СвИП
if company_type == "entrepreneur":
ogrnip = counterparty.get("ogrnip", "")
sv_ip = etree.SubElement(
shipper_info, "СвИП",
ИННФЛ=inn,
СвГосРегИП=ogrnip
)
# ФИО для ИП
last_name = counterparty.get("legalLastName", "")
first_name = counterparty.get("legalFirstName", "")
middle_name = counterparty.get("legalMiddleName", "")
fio = etree.SubElement(sv_ip, "ФИО")
fio.set("Фамилия", last_name)
fio.set("Имя", first_name)
if middle_name:
fio.set("Отчество", middle_name)
else:
# Для юрлиц используем СвЮЛУч
shipper_info.set("СвЮЛУч", inn)
shipper_info.set("КПП", kpp if kpp else "")
# Наименование
name = counterparty.get("name", "")
if not name:
name = counterparty.get("legalTitle", "")
etree.SubElement(shipper_info, "НаимОрг", Наим=name)
# Адрес
address = counterparty.get("legalAddress", "")
if address:
addr_element = etree.SubElement(shipper, "Адрес")
etree.SubElement(
addr_element, "АдрТекст",
Текст=address
)
# Банковские реквизиты
if accounts and len(accounts) > 0:
for acc in accounts:
if acc.get('bic'):
bank_info = etree.SubElement(
shipper, "БанкРекв",
НомерСчета=acc.get('accountNumber', '')
)
etree.SubElement(
bank_info, "СвБанк",
НаимБанк=acc.get('bankName', ''),
БИК=acc.get('bic', ''),
КорСчет=acc.get('correspondentAccount', '')
)
break
# Контактная информация
phone = counterparty.get("phone", "")
if phone:
contact = etree.SubElement(shipper, "Контакт")
etree.SubElement(contact, "Тлф").text = phone
def _add_buyer_info(
self,
parent: etree.Element,
counterparty: Dict,
accounts: list = None
) -> None:
"""
Добавление информации о покупателе
Args:
parent: Родительский XML элемент
counterparty: Данные контрагента
accounts: Список банковских счетов
"""
buyer = etree.SubElement(parent, "СвПокуп")
# ОКПО
okpo = counterparty.get("okpo", "")
if okpo:
buyer.set("ОКПО", okpo)
# ИНН/КПП и определение типа компании
inn = counterparty.get("inn", "")
kpp = counterparty.get("kpp", "")
company_type = counterparty.get("companyType", "legal")
buyer_info = etree.SubElement(buyer, "ИдСв")
# Для ИП используем структуру СвИП
if company_type == "entrepreneur":
ogrnip = counterparty.get("ogrnip", "")
sv_ip = etree.SubElement(
buyer_info, "СвИП",
ИННФЛ=inn,
СвГосРегИП=ogrnip
)
# ФИО для ИП
last_name = counterparty.get("legalLastName", "")
first_name = counterparty.get("legalFirstName", "")
middle_name = counterparty.get("legalMiddleName", "")
fio = etree.SubElement(sv_ip, "ФИО")
fio.set("Фамилия", last_name)
fio.set("Имя", first_name)
if middle_name:
fio.set("Отчество", middle_name)
else:
# Для юрлиц используем СвЮЛУч
buyer_info.set("СвЮЛУч", inn)
buyer_info.set("КПП", kpp if kpp else "")
# Наименование
name = counterparty.get("name", "")
if not name:
name = counterparty.get("legalTitle", "")
etree.SubElement(buyer_info, "НаимОрг", Наим=name)
# Адрес
address = counterparty.get("legalAddress", "")
if address:
addr_element = etree.SubElement(buyer, "Адрес")
etree.SubElement(
addr_element, "АдрТекст",
Текст=address
)
# Банковские реквизиты
if accounts and len(accounts) > 0:
# Берём первый счёт с БИК
for acc in accounts:
if acc.get('bic'):
bank_info = etree.SubElement(
buyer, "БанкРекв",
НомерСчета=acc.get('accountNumber', '')
)
etree.SubElement(
bank_info, "СвБанк",
НаимБанк=acc.get('bankName', ''),
БИК=acc.get('bic', ''),
КорСчет=acc.get('correspondentAccount', '')
)
break
# Контактная информация
phone = counterparty.get("phone", "")
email = counterparty.get("email", "")
if phone or email:
contact = etree.SubElement(buyer, "Контакт")
if phone:
etree.SubElement(contact, "Тлф").text = phone
if email:
etree.SubElement(contact, "ЭлПочта").text = email
def _add_positions_table(
self,
parent: etree.Element,
positions: list
) -> Dict[str, float]:
"""
Добавление таблицы товаров
Args:
parent: Родительский XML элемент
positions: Список позиций заказа
Returns:
Словарь с итоговыми суммами
"""
table = etree.SubElement(parent, "ТаблСчФакт")
total_without_vat = 0
total_vat = 0
total_with_vat = 0
for index, pos in enumerate(positions, start=1):
assortment = pos.get("assortment", {})
# Данные товара
name = assortment.get("name", "Товар без наименования")
quantity = pos.get("quantity", 0)
# Цена из API уже включает НДС!
price = pos.get("price", 0) / 100
vat_rate = pos.get("vat", 20)
# Расчет сумм (цена из API уже включает НДС!)
total_amount = quantity * price # Сумма С НДС
# Извлекаем НДС из общей суммы
amount = (total_amount / (1 + vat_rate / 100)
if vat_rate else total_amount)
vat_amount = total_amount - amount # Сумма НДС
total_without_vat += amount
total_vat += vat_amount
total_with_vat += total_amount
# Создание строки таблицы
row = etree.SubElement(
table, "СведТов",
НомСтр=str(index)
)
# Наименование товара
etree.SubElement(row, "НаимТов", Наим=name)
# Единица измерения
uom = assortment.get("uom", {})
uom_name = uom.get("name", "шт") if uom else "шт"
etree.SubElement(row, "ОКЕИ_Тов", НаимЕдИзм=uom_name)
# Количество
etree.SubElement(row, "КолТов", Количество=str(quantity))
# Цена
etree.SubElement(row, "ЦенаТов", Цена=f"{price:.2f}")
# Стоимость без НДС
etree.SubElement(row, "СтТовБезНДС", СтоимБезНДС=f"{amount:.2f}")
# НДС
etree.SubElement(
row, "НалСт",
СтавНДС=str(vat_rate),
СумНДС=f"{vat_amount:.2f}"
)
# Стоимость с НДС
etree.SubElement(
row, "СтТовУчНал",
СтоимУчНДС=f"{total_amount:.2f}"
)
# Итоговые суммы
totals = etree.SubElement(table, "ВсегоОпл")
etree.SubElement(
totals, "СтТовБезНДСВсего",
СтоимБезНДС=f"{total_without_vat:.2f}"
)
etree.SubElement(
totals, "СумНДСВсего",
СумНДС=f"{total_vat:.2f}"
)
etree.SubElement(
totals, "СтТовУчНалВсего",
СтоимУчНДС=f"{total_with_vat:.2f}"
)
return {
"without_vat": total_without_vat,
"vat": total_vat,
"with_vat": total_with_vat
}
def generate_upd(
self,
order_data: Dict,
output_path: Optional[str] = None
) -> str:
"""
Генерация УПД (Универсальный передаточный документ)
Args:
order_data: Полные данные заказа
output_path: Путь для сохранения файла
Returns:
XML в виде строки
"""
order = order_data["order"]
organization = order_data["organization"]
counterparty = order_data["counterparty"]
positions = order_data["positions"]
org_accounts = order_data.get("org_accounts", [])
cp_accounts = order_data.get("cp_accounts", [])
# Корневой элемент
root = etree.Element(
"Файл",
ВерсПрог=self.PROGRAM_VERSION,
ВерсФорм=self.VERSION
)
# Документ
doc = etree.SubElement(root, "Документ")
# Сведения о счет-фактуре
invoice_info = etree.SubElement(doc, "СвСчФакт")
# Номер и дата
order_number = order.get("name", "б/н")
order_date = self._format_date(order.get("moment", ""))
etree.SubElement(
invoice_info, "НомерДата",
Номер=order_number,
Дата=order_date
)
# Информация о продавце
self._add_seller_info(invoice_info, organization, org_accounts)
# Грузоотправитель (он же продавец)
gruz_ot = etree.SubElement(invoice_info, "ГрузОт")
etree.SubElement(gruz_ot, "ОнЖе").text = "он же"
# Грузополучатель (покупатель)
self._add_shipper_info(invoice_info, counterparty, cp_accounts)
# Документ подтверждающий отгрузку
etree.SubElement(
invoice_info, "ДокПодтвОтгрНом",
РеквНаимДок="УПД",
РеквНомерДок=order_number,
РеквДатаДок=order_date
)
# Информация о покупателе
self._add_buyer_info(invoice_info, counterparty, cp_accounts)
# Валюта
rate = order.get("rate", {})
if rate and "currency" in rate:
currency = rate["currency"]
etree.SubElement(
invoice_info, "ДенИзм",
КодОКВ=currency.get("code", "643"),
НаимОКВ=currency.get("fullName", "Российский рубль")
)
# Таблица товаров
totals = self._add_positions_table(doc, positions)
# Сведения о передаче товара
sv_prod_per = etree.SubElement(doc, "СвПродПер")
sv_per = etree.SubElement(
sv_prod_per, "СвПер",
СодОпер="Товары переданы",
ДатаПер=order_date
)
# Без документа-основания передачи
etree.SubElement(sv_per, "БезДокОснПер").text = "1"
# Подписант
podpisant = etree.SubElement(doc, "Подписант", СпосПодтПолном="6")
# Используем данные из организации
if organization.get("companyType") == "entrepreneur":
# Для ИП используем ФИО
fio = etree.SubElement(podpisant, "ФИО")
last_name = organization.get("legalLastName", "")
first_name = organization.get("legalFirstName", "")
middle_name = organization.get("legalMiddleName", "")
if last_name:
fio.set("Фамилия", last_name)
if first_name:
fio.set("Имя", first_name)
if middle_name:
fio.set("Отчество", middle_name)
else:
# Для ЮЛ можем указать директора (если данные есть)
# Пока просто заглушка
fio = etree.SubElement(podpisant, "ФИО")
fio.set("Фамилия", "Подписант")
fio.set("Имя", "Представитель")
# Конвертация в строку
xml_string = etree.tostring(
root,
pretty_print=True,
xml_declaration=True,
encoding=self.encoding
).decode(self.encoding)
# Сохранение в файл
if output_path:
with open(output_path, "w", encoding=self.encoding) as f:
f.write(xml_string)
logger.info(f"УПД сохранен: {output_path}")
return xml_string
def generate_tn(
self,
order_data: Dict,
output_path: Optional[str] = None
) -> str:
"""
Генерация ТН (Товарная накладная ТОРГ-12)
Args:
order_data: Полные данные заказа
output_path: Путь для сохранения файла
Returns:
XML в виде строки
"""
order = order_data["order"]
organization = order_data["organization"]
counterparty = order_data["counterparty"]
positions = order_data["positions"]
# Корневой элемент
root = etree.Element(
"Файл",
ВерсПрог=self.PROGRAM_VERSION,
ВерсФорм=self.VERSION,
ТипДок="ТОРГ-12"
)
# Документ
doc = etree.SubElement(root, "Документ")
# Номер и дата
order_number = order.get("name", "б/н")
order_date = self._format_date(order.get("moment", ""))
doc_info = etree.SubElement(doc, "СвНакл")
etree.SubElement(
doc_info, "НомерДата",
Номер=order_number,
Дата=order_date
)
# Информация о продавце
self._add_seller_info(doc, organization)
# Информация о покупателе
self._add_buyer_info(doc, counterparty)
# Таблица товаров
totals = self._add_positions_table(doc, positions)
# Конвертация в строку
xml_string = etree.tostring(
root,
pretty_print=True,
xml_declaration=True,
encoding=self.encoding
).decode(self.encoding)
# Сохранение в файл
if output_path:
with open(output_path, "w", encoding=self.encoding) as f:
f.write(xml_string)
logger.info(f"ТН сохранена: {output_path}")
return xml_string
# Пример использования
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
# Тестовые данные
test_order = {
"order": {
"name": "00001",
"moment": "2024-01-15T10:00:00Z",
"sum": 120000
},
"organization": {
"name": "ООО Тестовая организация",
"inn": "7707083893",
"kpp": "770701001",
"legalAddress": "123456, г. Москва, ул. Тестовая, д. 1"
},
"counterparty": {
"name": "ООО Покупатель",
"inn": "7707083894",
"kpp": "770701002",
"legalAddress": "654321, г. Москва, ул. Другая, д. 2"
},
"positions": [
{
"assortment": {"name": "Товар 1", "uom": {"name": "шт"}},
"quantity": 10,
"price": 10000,
"vat": 20
}
]
}
generator = XMLGenerator()
# Генерация УПД
upd_xml = generator.generate_upd(test_order, "test_upd.xml")
print("УПД создан")
# Генерация ТН
tn_xml = generator.generate_tn(test_order, "test_tn.xml")
print("ТН создана")