- 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
667 lines
24 KiB
Python
667 lines
24 KiB
Python
"""
|
||
Генерация 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("ТН создана")
|