Some checks failed
Build and Publish Docker Image / build-and-push (push) Has been cancelled
2089 lines
92 KiB
Python
2089 lines
92 KiB
Python
"""
|
||
Генерация XML документов в формате ВерсФорм 5.03
|
||
УПД (Универсальный передаточный документ) и ТН (Товарная накладная)
|
||
"""
|
||
from lxml import etree
|
||
from datetime import datetime
|
||
from decimal import Decimal, ROUND_HALF_UP
|
||
from typing import Dict, List, Optional, Tuple
|
||
import logging
|
||
|
||
from config import settings
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
# Константы для российских кодов и идентификаторов
|
||
RUB_ALPHA_CODE = "RUB"
|
||
RUB_NUMERIC_CODE = "643"
|
||
RUB_FULL_NAME = "Российский рубль"
|
||
RUB_SHORT_NAME = "руб."
|
||
RUB_CURRENCY_GUID = "72357d18-63a8-11e8-80dd-0050569f5448"
|
||
|
||
RUSSIA_COUNTRY_GUID = "67700c1b-63a8-11e8-80dd-0050569f5448"
|
||
RUSSIA_OKSM_CODE = "643"
|
||
RUSSIA_COUNTRY_NAME = "РОССИЯ"
|
||
|
||
DEFAULT_UOM_GUID = "cdf130e2-63a8-11e8-80dd-0050569f5448"
|
||
|
||
ACCOUNTING_ACCOUNT_TAGS = (
|
||
("СчетУчета", "43"),
|
||
("СчетДоходов", "90.01.1"),
|
||
("СчетРасходов", "90.02.1"),
|
||
("СчетУчетаНДСПоРеализации", "90.03"),
|
||
)
|
||
|
||
|
||
def get_currency_codes(currency: Optional[Dict]) -> Tuple[str, str]:
|
||
"""Возвращает буквенный и цифровой коды валюты.
|
||
|
||
При отсутствии данных возвращаются значения по умолчанию.
|
||
"""
|
||
if not currency:
|
||
return RUB_ALPHA_CODE, RUB_NUMERIC_CODE
|
||
|
||
iso_value = currency.get("isoCode")
|
||
if isinstance(iso_value, str):
|
||
alpha_code = iso_value.strip().upper()
|
||
else:
|
||
alpha_code = ""
|
||
|
||
numeric_value = currency.get("code")
|
||
if isinstance(numeric_value, str):
|
||
numeric_code = numeric_value.strip()
|
||
else:
|
||
numeric_code = ""
|
||
|
||
if not alpha_code and numeric_code == RUB_NUMERIC_CODE:
|
||
alpha_code = RUB_ALPHA_CODE
|
||
|
||
if not numeric_code and alpha_code == RUB_ALPHA_CODE:
|
||
numeric_code = RUB_NUMERIC_CODE
|
||
|
||
return alpha_code or RUB_ALPHA_CODE, numeric_code or RUB_NUMERIC_CODE
|
||
|
||
|
||
def _is_service(assortment: Dict) -> bool:
|
||
"""Определяет, является ли номенклатурная позиция услугой.
|
||
|
||
Проверяет как meta.type, так и meta.href для надёжности.
|
||
|
||
Args:
|
||
assortment: Данные ассортимента из МойСклад
|
||
|
||
Returns:
|
||
True, если это услуга, False в противном случае
|
||
"""
|
||
meta = (assortment or {}).get("meta") or {}
|
||
t = (meta.get("type") or "").lower()
|
||
href = (meta.get("href") or "").lower()
|
||
return t == "service" or "/service/" in href
|
||
|
||
|
||
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}"
|
||
|
||
@staticmethod
|
||
def _get_preferred_address(data: Dict, fields: List[str]) -> str:
|
||
"""Возвращает первый непустой адрес по списку полей."""
|
||
for field in fields:
|
||
value = data.get(field)
|
||
if value:
|
||
return value
|
||
return ""
|
||
|
||
def _add_bank_details(
|
||
self,
|
||
parent: etree.Element,
|
||
accounts: Optional[List[Dict]]
|
||
) -> None:
|
||
"""Добавляет банковские реквизиты (первый счет с БИК)."""
|
||
if not accounts:
|
||
return
|
||
|
||
for account in accounts:
|
||
if not account.get("bic"):
|
||
continue
|
||
|
||
bank_info = etree.SubElement(
|
||
parent,
|
||
"БанкРекв",
|
||
НомерСчета=account.get("accountNumber", "")
|
||
)
|
||
etree.SubElement(
|
||
bank_info,
|
||
"СвБанк",
|
||
НаимБанк=account.get("bankName", ""),
|
||
БИК=account.get("bic", ""),
|
||
КорСчет=account.get("correspondentAccount", "")
|
||
)
|
||
break
|
||
|
||
@staticmethod
|
||
def _add_contact_details(
|
||
parent: etree.Element,
|
||
phone: Optional[str] = None,
|
||
email: Optional[str] = None
|
||
) -> None:
|
||
"""Добавляет контактную информацию, если она есть."""
|
||
if not phone and not email:
|
||
return
|
||
|
||
contact = etree.SubElement(parent, "Контакт")
|
||
if phone:
|
||
etree.SubElement(contact, "Тлф").text = phone
|
||
if email:
|
||
etree.SubElement(contact, "ЭлПочта").text = email
|
||
|
||
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", "")
|
||
_ = 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
|
||
)
|
||
|
||
# Банковские реквизиты
|
||
self._add_bank_details(seller, accounts)
|
||
|
||
# Контактная информация
|
||
self._add_contact_details(
|
||
seller,
|
||
phone=organization.get("phone", ""),
|
||
email=organization.get("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)
|
||
|
||
# Адрес (приоритет: deliveryAddress -> actualAddress -> legalAddress)
|
||
address = self._get_preferred_address(
|
||
counterparty,
|
||
["deliveryAddress", "actualAddress", "legalAddress"]
|
||
)
|
||
if address:
|
||
addr_element = etree.SubElement(shipper, "Адрес")
|
||
etree.SubElement(
|
||
addr_element, "АдрТекст",
|
||
Текст=address
|
||
)
|
||
|
||
# Банковские реквизиты
|
||
self._add_bank_details(shipper, accounts)
|
||
|
||
# Контактная информация
|
||
self._add_contact_details(
|
||
shipper,
|
||
phone=counterparty.get("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 = self._get_preferred_address(
|
||
counterparty,
|
||
["legalAddress", "actualAddress"]
|
||
)
|
||
if address:
|
||
addr_element = etree.SubElement(buyer, "Адрес")
|
||
etree.SubElement(
|
||
addr_element, "АдрТекст",
|
||
Текст=address
|
||
)
|
||
|
||
# Банковские реквизиты
|
||
self._add_bank_details(buyer, accounts)
|
||
|
||
# Контактная информация
|
||
self._add_contact_details(
|
||
buyer,
|
||
phone=counterparty.get("phone", ""),
|
||
email=counterparty.get("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"]
|
||
_, currency_numeric_code = get_currency_codes(currency)
|
||
etree.SubElement(
|
||
invoice_info,
|
||
"ДенИзм",
|
||
КодОКВ=currency_numeric_code,
|
||
НаимОКВ=currency.get("fullName", RUB_FULL_NAME)
|
||
)
|
||
|
||
# Таблица товаров
|
||
_ = 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)
|
||
|
||
# Таблица товаров
|
||
_ = 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
|
||
|
||
|
||
class XML1CGenerator:
|
||
"""Генератор XML документов в формате 1С EnterpriseData 1.20"""
|
||
|
||
FORMAT_VERSION = "1.20"
|
||
FORMAT_URI = "http://v8.1c.ru/edi/edi_stnd/EnterpriseData/1.20"
|
||
|
||
def __init__(self):
|
||
self.encoding = "UTF-8"
|
||
# Регистрация namespace
|
||
self.nsmap = {
|
||
None: self.FORMAT_URI,
|
||
'msg': 'http://www.1c.ru/SSL/Exchange/Message',
|
||
'xs': 'http://www.w3.org/2001/XMLSchema',
|
||
'xsi': 'http://www.w3.org/2001/XMLSchema-instance'
|
||
}
|
||
|
||
def _format_datetime(self, date_str: str) -> str:
|
||
"""
|
||
Форматирование даты в ISO формат для 1С
|
||
|
||
Args:
|
||
date_str: Дата в ISO формате
|
||
|
||
Returns:
|
||
Дата в формате ISO 8601 (YYYY-MM-DDTHH:MM:SS)
|
||
|
||
Raises:
|
||
ValueError: Если дата некорректна
|
||
"""
|
||
try:
|
||
dt = datetime.fromisoformat(date_str.replace('Z', '+00:00'))
|
||
return dt.strftime("%Y-%m-%dT%H:%M:%S")
|
||
except Exception as e:
|
||
logger.error(f"Критическая ошибка: некорректная дата '{date_str}': {e}")
|
||
raise ValueError(
|
||
f"Невозможно сформировать документ с некорректной датой: {date_str}. "
|
||
"Проверьте исходные данные."
|
||
)
|
||
|
||
def _format_money_1c(self, amount: int) -> str:
|
||
"""
|
||
Форматирование денежной суммы для 1С (копейки -> рубли)
|
||
|
||
Args:
|
||
amount: Сумма в копейках
|
||
|
||
Returns:
|
||
Сумма в рублях с копейками, если они есть
|
||
"""
|
||
rubles = Decimal(str(amount)) / 100
|
||
return str(int(rubles)) if rubles == int(rubles) else f"{rubles:.2f}"
|
||
|
||
@staticmethod
|
||
def _position_amounts(pos: Dict) -> Dict[str, Decimal]:
|
||
"""Суммы позиции в рублях с учетом процентной скидки МойСклад."""
|
||
price = Decimal(str(pos.get("price", 0))) / 100
|
||
quantity = Decimal(str(pos.get("quantity", 0)))
|
||
discount = Decimal(str(pos.get("discount") or 0))
|
||
vat_rate = Decimal(str(pos.get("vat", 20)))
|
||
|
||
discounted_price = price * (100 - discount) / 100
|
||
gross_total = (price * quantity).quantize(
|
||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||
)
|
||
total = (discounted_price * quantity).quantize(
|
||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||
)
|
||
vat_amount = (
|
||
(total * vat_rate / (100 + vat_rate)).quantize(
|
||
Decimal("0.01"), rounding=ROUND_HALF_UP
|
||
) if vat_rate else Decimal("0.00")
|
||
)
|
||
return {
|
||
"price": price,
|
||
"discounted_price": discounted_price,
|
||
"gross_total": gross_total,
|
||
"total": total,
|
||
"discount": discount,
|
||
"discount_amount": gross_total - total,
|
||
"vat_amount": vat_amount,
|
||
}
|
||
|
||
def _format_money_with_decimals(self, amount: float) -> str:
|
||
"""
|
||
Форматирование денежной суммы с копейками
|
||
|
||
Args:
|
||
amount: Сумма в рублях
|
||
|
||
Returns:
|
||
Сумма с 2 знаками после запятой
|
||
"""
|
||
return f"{amount:.2f}"
|
||
|
||
def generate_realization(
|
||
self,
|
||
order_data: Dict,
|
||
output_path: Optional[str] = None
|
||
) -> str:
|
||
"""
|
||
Генерация документа Реализация товаров и услуг в формате 1С
|
||
|
||
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", [])
|
||
|
||
# Корневой элемент Message
|
||
root = etree.Element(
|
||
"Message",
|
||
nsmap={
|
||
'msg': self.nsmap['msg'],
|
||
'xs': self.nsmap['xs'],
|
||
'xsi': self.nsmap['xsi']
|
||
}
|
||
)
|
||
|
||
# Header
|
||
header = etree.SubElement(root, "{%s}Header" % self.nsmap['msg'])
|
||
etree.SubElement(header, "{%s}Format" % self.nsmap['msg']).text = self.FORMAT_URI
|
||
etree.SubElement(header, "{%s}CreationDate" % self.nsmap['msg']).text = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
||
etree.SubElement(header, "{%s}AvailableVersion" % self.nsmap['msg']).text = self.FORMAT_VERSION
|
||
|
||
# Body
|
||
body = etree.SubElement(root, "Body", nsmap={None: self.FORMAT_URI})
|
||
|
||
# Документ.РеализацияТоваровУслуг
|
||
doc = etree.SubElement(body, "Документ.РеализацияТоваровУслуг")
|
||
|
||
# КлючевыеСвойства
|
||
key_props = etree.SubElement(doc, "КлючевыеСвойства")
|
||
|
||
# Ссылка (UUID документа)
|
||
order_id = order.get("id", "")
|
||
etree.SubElement(key_props, "Ссылка").text = order_id
|
||
|
||
# Дата и Номер
|
||
order_date = self._format_datetime(order.get("moment", ""))
|
||
order_number = order.get("name", "").strip() # Убираем лишние пробелы!
|
||
etree.SubElement(key_props, "Дата").text = order_date
|
||
etree.SubElement(key_props, "Номер").text = order_number
|
||
|
||
# Организация
|
||
org_elem = etree.SubElement(key_props, "Организация")
|
||
# Извлекаем UUID из meta.href
|
||
org_href = organization.get("meta", {}).get("href", "") if organization.get("meta") else ""
|
||
org_id = org_href.split("/")[-1].split("?")[0] if org_href else organization.get("id", "")
|
||
etree.SubElement(org_elem, "Ссылка").text = org_id
|
||
etree.SubElement(org_elem, "Наименование").text = organization.get("name", "")
|
||
|
||
# Полные наименования для ИП
|
||
company_type = organization.get("companyType", "")
|
||
if company_type == "entrepreneur":
|
||
legal_name = organization.get("legalTitle", "")
|
||
etree.SubElement(org_elem, "НаименованиеСокращенное").text = legal_name
|
||
etree.SubElement(org_elem, "НаименованиеПолное").text = legal_name
|
||
else:
|
||
etree.SubElement(org_elem, "НаименованиеСокращенное").text = organization.get("name", "")
|
||
etree.SubElement(org_elem, "НаименованиеПолное").text = organization.get("legalTitle", "")
|
||
|
||
etree.SubElement(org_elem, "ИНН").text = organization.get("inn", "")
|
||
|
||
# Тип лица
|
||
if company_type == "entrepreneur":
|
||
etree.SubElement(org_elem, "ЮридическоеФизическоеЛицо").text = "ФизическоеЛицо"
|
||
else:
|
||
etree.SubElement(org_elem, "ЮридическоеФизическоеЛицо").text = "ЮридическоеЛицо"
|
||
|
||
# ВидОперации
|
||
etree.SubElement(doc, "ВидОперации").text = "РеализацияКлиенту"
|
||
|
||
# Валюта
|
||
rate = order.get("rate", {})
|
||
currency = rate.get("currency", {}) if rate else {}
|
||
currency_elem = etree.SubElement(doc, "Валюта")
|
||
|
||
currency_href = ""
|
||
if currency.get("meta"):
|
||
currency_href = currency.get("meta", {}).get("href", "")
|
||
|
||
currency_id = ""
|
||
if currency_href:
|
||
currency_id = currency_href.split("/")[-1].split("?")[0]
|
||
if not currency_id:
|
||
currency_id = RUB_CURRENCY_GUID
|
||
etree.SubElement(currency_elem, "Ссылка").text = currency_id
|
||
|
||
alpha_code, numeric_code = get_currency_codes(currency)
|
||
currency_data = etree.SubElement(currency_elem, "ДанныеКлассификатора")
|
||
etree.SubElement(currency_data, "Код").text = numeric_code
|
||
etree.SubElement(
|
||
currency_data,
|
||
"Наименование"
|
||
).text = currency.get("name", RUB_SHORT_NAME)
|
||
|
||
# Сумма (в рублях, без копеек)
|
||
order_sum = order.get("sum", 0)
|
||
etree.SubElement(doc, "Сумма").text = self._format_money_1c(order_sum)
|
||
|
||
# СуммаВключаетНДС
|
||
etree.SubElement(doc, "СуммаВключаетНДС").text = "true"
|
||
|
||
# Склад (если есть)
|
||
store = order.get("store", {})
|
||
if store:
|
||
store_elem = etree.SubElement(doc, "Склад")
|
||
store_id = store.get("meta", {}).get("href", "").split("/")[-1] if store.get("meta") else ""
|
||
etree.SubElement(store_elem, "Ссылка").text = store_id
|
||
etree.SubElement(store_elem, "Наименование").text = store.get("name", "Основной склад")
|
||
etree.SubElement(store_elem, "ТипСклада").text = "Оптовый"
|
||
|
||
# Руководитель (для ИП - сам ИП)
|
||
if company_type == "entrepreneur":
|
||
chief_elem = etree.SubElement(doc, "Руководитель")
|
||
etree.SubElement(chief_elem, "Ссылка").text = org_id
|
||
|
||
# ФИО
|
||
fio_parts = []
|
||
last_name = organization.get("legalLastName", "")
|
||
first_name = organization.get("legalFirstName", "")
|
||
middle_name = organization.get("legalMiddleName", "")
|
||
|
||
if last_name:
|
||
fio_parts.append(last_name)
|
||
if first_name:
|
||
fio_parts.append(first_name)
|
||
if middle_name:
|
||
fio_parts.append(middle_name)
|
||
|
||
full_fio = " ".join(fio_parts)
|
||
etree.SubElement(chief_elem, "ФИО").text = full_fio
|
||
etree.SubElement(chief_elem, "Фамилия").text = last_name
|
||
etree.SubElement(chief_elem, "Имя").text = first_name
|
||
etree.SubElement(chief_elem, "Отчество").text = middle_name
|
||
etree.SubElement(chief_elem, "ИНН").text = organization.get("inn", "")
|
||
|
||
# БанковскийСчетОрганизации
|
||
if org_accounts and len(org_accounts) > 0:
|
||
acc = org_accounts[0]
|
||
bank_acc_elem = etree.SubElement(doc, "БанковскийСчетОрганизации")
|
||
acc_id = acc.get("id", "")
|
||
etree.SubElement(bank_acc_elem, "Ссылка").text = acc_id
|
||
etree.SubElement(bank_acc_elem, "НомерСчета").text = acc.get("accountNumber", "")
|
||
|
||
# Банк
|
||
bank_elem = etree.SubElement(bank_acc_elem, "Банк")
|
||
etree.SubElement(bank_elem, "Ссылка").text = acc_id # Используем тот же ID
|
||
|
||
bank_data = etree.SubElement(bank_elem, "ДанныеКлассификатораБанков")
|
||
etree.SubElement(bank_data, "Наименование").text = acc.get("bankName", "")
|
||
etree.SubElement(bank_data, "БИК").text = acc.get("bic", "")
|
||
etree.SubElement(bank_data, "КоррСчет").text = acc.get("correspondentAccount", "")
|
||
|
||
# Владелец
|
||
owner_elem = etree.SubElement(bank_acc_elem, "Владелец")
|
||
org_ref = etree.SubElement(owner_elem, "ОрганизацииСсылка")
|
||
etree.SubElement(org_ref, "Ссылка").text = org_id
|
||
etree.SubElement(org_ref, "Наименование").text = organization.get("name", "")
|
||
|
||
if company_type == "entrepreneur":
|
||
etree.SubElement(org_ref, "НаименованиеСокращенное").text = organization.get("legalTitle", "")
|
||
etree.SubElement(org_ref, "НаименованиеПолное").text = organization.get("legalTitle", "")
|
||
else:
|
||
etree.SubElement(org_ref, "НаименованиеСокращенное").text = organization.get("name", "")
|
||
etree.SubElement(org_ref, "НаименованиеПолное").text = organization.get("legalTitle", "")
|
||
|
||
# ИНН должен быть ПЕРЕД ЮридическоеФизическоеЛицо
|
||
etree.SubElement(org_ref, "ИНН").text = organization.get("inn", "")
|
||
|
||
if company_type == "entrepreneur":
|
||
etree.SubElement(org_ref, "ЮридическоеФизическоеЛицо").text = "ФизическоеЛицо"
|
||
else:
|
||
etree.SubElement(org_ref, "ЮридическоеФизическоеЛицо").text = "ЮридическоеЛицо"
|
||
|
||
# Контрагент
|
||
cp_elem = etree.SubElement(doc, "Контрагент")
|
||
# Извлекаем UUID из meta.href
|
||
cp_href = counterparty.get("meta", {}).get("href", "") if counterparty.get("meta") else ""
|
||
cp_id = cp_href.split("/")[-1].split("?")[0] if cp_href else counterparty.get("id", "")
|
||
etree.SubElement(cp_elem, "Ссылка").text = cp_id
|
||
etree.SubElement(cp_elem, "Наименование").text = counterparty.get("name", "")
|
||
etree.SubElement(cp_elem, "НаименованиеПолное").text = counterparty.get("legalTitle", counterparty.get("name", ""))
|
||
etree.SubElement(cp_elem, "ИНН").text = counterparty.get("inn", "")
|
||
|
||
cp_type = counterparty.get("companyType", "legal")
|
||
if cp_type == "entrepreneur":
|
||
etree.SubElement(cp_elem, "ЮридическоеФизическоеЛицо").text = "ФизическоеЛицо"
|
||
else:
|
||
etree.SubElement(cp_elem, "ЮридическоеФизическоеЛицо").text = "ЮридическоеЛицо"
|
||
|
||
# Страна регистрации (ПЕРЕД ИндивидуальныйПредприниматель!)
|
||
country_elem = etree.SubElement(cp_elem, "СтранаРегистрации")
|
||
etree.SubElement(country_elem, "Ссылка").text = RUSSIA_COUNTRY_GUID
|
||
country_data = etree.SubElement(
|
||
country_elem,
|
||
"ДанныеКлассификатора"
|
||
)
|
||
etree.SubElement(country_data, "Код").text = RUSSIA_OKSM_CODE
|
||
etree.SubElement(
|
||
country_data,
|
||
"Наименование"
|
||
).text = RUSSIA_COUNTRY_NAME
|
||
|
||
# ИндивидуальныйПредприниматель только для ИП (после СтранаРегистрации)
|
||
if cp_type == "entrepreneur":
|
||
etree.SubElement(cp_elem, "ИндивидуальныйПредприниматель").text = "true"
|
||
|
||
# ДанныеВзаиморасчетов
|
||
settlement_elem = etree.SubElement(doc, "ДанныеВзаиморасчетов")
|
||
|
||
# Договор
|
||
contract_elem = etree.SubElement(settlement_elem, "Договор")
|
||
# Генерируем детерминированный UUID для договора на основе контрагента
|
||
# ВАЖНО: включаем валюту и признак у.е. в seed для uuid5
|
||
# Это обеспечивает создание отдельных договоров для разных валют:
|
||
# - RUB/не-у.е. → 62.01/62.02 (задолженность/авансы)
|
||
# - валюта → 62.21/62.22 (валютные расчёты)
|
||
# - у.е. → 62.31/62.32 (расчёты в у.е.)
|
||
import uuid
|
||
currency_code_for_seed = alpha_code or numeric_code
|
||
is_ue = "false" # В текущей реализации всегда false
|
||
seed = (
|
||
f"contract-{cp_id}-{org_id}-CUR-"
|
||
f"{currency_code_for_seed}-UE-{is_ue}"
|
||
)
|
||
contract_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, seed))
|
||
|
||
logger.info(
|
||
f"Создан договор с учётом валюты: "
|
||
f"контрагент={cp_id}, организация={org_id}, "
|
||
f"валюта={alpha_code}/{numeric_code}, "
|
||
f"у.е.={is_ue}, UUID={contract_uuid}"
|
||
)
|
||
etree.SubElement(contract_elem, "Ссылка").text = contract_uuid
|
||
etree.SubElement(contract_elem, "ВидДоговора").text = "СПокупателем"
|
||
|
||
# Организация в договоре (ссылка)
|
||
contract_org = etree.SubElement(contract_elem, "Организация")
|
||
etree.SubElement(contract_org, "Ссылка").text = org_id
|
||
etree.SubElement(contract_org, "Наименование").text = organization.get("name", "")
|
||
if company_type == "entrepreneur":
|
||
etree.SubElement(contract_org, "НаименованиеСокращенное").text = organization.get("legalTitle", "")
|
||
etree.SubElement(contract_org, "НаименованиеПолное").text = organization.get("legalTitle", "")
|
||
else:
|
||
etree.SubElement(contract_org, "НаименованиеСокращенное").text = organization.get("name", "")
|
||
etree.SubElement(contract_org, "НаименованиеПолное").text = organization.get("legalTitle", "")
|
||
|
||
# ИНН должен быть ПЕРЕД ЮридическоеФизическоеЛицо
|
||
etree.SubElement(contract_org, "ИНН").text = organization.get("inn", "")
|
||
|
||
if company_type == "entrepreneur":
|
||
etree.SubElement(contract_org, "ЮридическоеФизическоеЛицо").text = "ФизическоеЛицо"
|
||
else:
|
||
etree.SubElement(contract_org, "ЮридическоеФизическоеЛицо").text = "ЮридическоеЛицо"
|
||
|
||
# Контрагент в договоре
|
||
contract_cp = etree.SubElement(contract_elem, "Контрагент")
|
||
etree.SubElement(contract_cp, "Ссылка").text = cp_id
|
||
etree.SubElement(contract_cp, "Наименование").text = counterparty.get("name", "")
|
||
etree.SubElement(contract_cp, "НаименованиеПолное").text = counterparty.get("legalTitle", "")
|
||
etree.SubElement(contract_cp, "ИНН").text = counterparty.get("inn", "")
|
||
|
||
if cp_type == "entrepreneur":
|
||
etree.SubElement(contract_cp, "ЮридическоеФизическоеЛицо").text = "ФизическоеЛицо"
|
||
else:
|
||
etree.SubElement(contract_cp, "ЮридическоеФизическоеЛицо").text = "ЮридическоеЛицо"
|
||
|
||
# Страна регистрации (ПЕРЕД ИндивидуальныйПредприниматель!)
|
||
cp_country = etree.SubElement(contract_cp, "СтранаРегистрации")
|
||
etree.SubElement(cp_country, "Ссылка").text = RUSSIA_COUNTRY_GUID
|
||
cp_country_data = etree.SubElement(
|
||
cp_country,
|
||
"ДанныеКлассификатора"
|
||
)
|
||
etree.SubElement(cp_country_data, "Код").text = RUSSIA_OKSM_CODE
|
||
etree.SubElement(
|
||
cp_country_data,
|
||
"Наименование"
|
||
).text = RUSSIA_COUNTRY_NAME
|
||
|
||
# ИндивидуальныйПредприниматель только для ИП (после СтранаРегистрации)
|
||
if cp_type == "entrepreneur":
|
||
etree.SubElement(contract_cp, "ИндивидуальныйПредприниматель").text = "true"
|
||
|
||
# Валюта взаиморасчетов в договоре
|
||
contract_currency = etree.SubElement(contract_elem, "ВалютаВзаиморасчетов")
|
||
etree.SubElement(contract_currency, "Ссылка").text = currency_id
|
||
contract_curr_data = etree.SubElement(
|
||
contract_currency,
|
||
"ДанныеКлассификатора"
|
||
)
|
||
etree.SubElement(contract_curr_data, "Код").text = numeric_code
|
||
etree.SubElement(
|
||
contract_curr_data,
|
||
"Наименование"
|
||
).text = currency.get("name", RUB_SHORT_NAME)
|
||
|
||
etree.SubElement(contract_elem, "РасчетыВУсловныхЕдиницах").text = "false"
|
||
etree.SubElement(contract_elem, "Наименование").text = "Основной договор"
|
||
|
||
# Валюта взаиморасчетов
|
||
settlement_currency = etree.SubElement(
|
||
settlement_elem,
|
||
"ВалютаВзаиморасчетов"
|
||
)
|
||
etree.SubElement(settlement_currency, "Ссылка").text = currency_id
|
||
settlement_curr_data = etree.SubElement(
|
||
settlement_currency,
|
||
"ДанныеКлассификатора"
|
||
)
|
||
etree.SubElement(settlement_curr_data, "Код").text = numeric_code
|
||
etree.SubElement(
|
||
settlement_curr_data,
|
||
"Наименование"
|
||
).text = currency.get("name", RUB_SHORT_NAME)
|
||
|
||
etree.SubElement(settlement_elem, "КурсВзаиморасчетов").text = "1"
|
||
etree.SubElement(settlement_elem, "КратностьВзаиморасчетов").text = "1"
|
||
etree.SubElement(settlement_elem, "РасчетыВУсловныхЕдиницах").text = "false"
|
||
|
||
# Налогообложение
|
||
etree.SubElement(doc, "Налогообложение").text = "ПродажаОблагаетсяНДС"
|
||
|
||
# Разделяем позиции на товары и услуги
|
||
товары = []
|
||
услуги = []
|
||
|
||
for pos in positions:
|
||
assortment = pos.get("assortment", {})
|
||
if _is_service(assortment):
|
||
услуги.append(pos)
|
||
else:
|
||
товары.append(pos)
|
||
|
||
# Товары (если есть)
|
||
if товары:
|
||
goods_elem = etree.SubElement(doc, "Товары")
|
||
for pos in товары:
|
||
row_elem = etree.SubElement(goods_elem, "Строка")
|
||
|
||
assortment = pos.get("assortment", {})
|
||
# Очищаем GUID от query-параметров (например, ?expand=product.uom)
|
||
product_href = assortment.get("meta", {}).get("href", "") if assortment.get("meta") else ""
|
||
product_id = product_href.split("/")[-1].split("?")[0] if product_href else ""
|
||
product_name = assortment.get("name", "")
|
||
|
||
# ДанныеНоменклатуры
|
||
nomenclature_data = etree.SubElement(row_elem, "ДанныеНоменклатуры")
|
||
nomenclature = etree.SubElement(nomenclature_data, "Номенклатура")
|
||
etree.SubElement(nomenclature, "Ссылка").text = product_id
|
||
etree.SubElement(nomenclature, "НаименованиеПолное").text = product_name
|
||
etree.SubElement(nomenclature, "КодВПрограмме").text = assortment.get("code", "")
|
||
etree.SubElement(nomenclature, "Наименование").text = product_name
|
||
|
||
# ВидНоменклатуры (для конфигураций УТ 11, ERP)
|
||
kind_elem = etree.SubElement(nomenclature, "ВидНоменклатуры")
|
||
etree.SubElement(kind_elem, "Наименование").text = "Товар"
|
||
|
||
# ЕдиницаИзмерения
|
||
uom = assortment.get("uom", {})
|
||
uom_elem = etree.SubElement(row_elem, "ЕдиницаИзмерения")
|
||
# Очищаем GUID от query-параметров
|
||
uom_href = uom.get("meta", {}).get("href", "") if uom and uom.get("meta") else ""
|
||
uom_id = uom_href.split("/")[-1].split("?")[0] if uom_href else ""
|
||
# Если нет UUID, используем стандартный UUID для "Штука (796)"
|
||
if not uom_id:
|
||
uom_id = "cdf130e2-63a8-11e8-80dd-0050569f5448" # Стандартный GUID для "шт"
|
||
etree.SubElement(uom_elem, "Ссылка").text = uom_id
|
||
|
||
uom_data = etree.SubElement(uom_elem, "ДанныеКлассификатора")
|
||
etree.SubElement(uom_data, "Код").text = "796" # Код ОКЕИ для "шт"
|
||
etree.SubElement(uom_data, "Наименование").text = uom.get("name", "шт") if uom else "шт"
|
||
|
||
# Количество
|
||
quantity = pos.get("quantity", 0)
|
||
etree.SubElement(row_elem, "Количество").text = str(quantity)
|
||
|
||
# Для товаров схема 1С передает скидку отдельными полями.
|
||
amounts = self._position_amounts(pos)
|
||
|
||
# Форматируем с 2 знаками после запятой (НЕ округляем до целых!)
|
||
etree.SubElement(row_elem, "Сумма").text = f"{amounts['gross_total']:.2f}"
|
||
etree.SubElement(row_elem, "Цена").text = f"{amounts['price']:.2f}"
|
||
|
||
# СтавкаНДС
|
||
vat_rate = pos.get("vat", 20)
|
||
vat_elem = etree.SubElement(row_elem, "СтавкаНДС")
|
||
etree.SubElement(vat_elem, "Ставка").text = str(vat_rate)
|
||
etree.SubElement(vat_elem, "РасчетнаяСтавка").text = "false"
|
||
etree.SubElement(vat_elem, "НеОблагается").text = "false"
|
||
etree.SubElement(vat_elem, "ВидСтавки").text = "Общая"
|
||
|
||
vat_country = etree.SubElement(vat_elem, "Страна")
|
||
# Используем стандартный UUID России из классификатора 1С
|
||
etree.SubElement(vat_country, "Ссылка").text = RUSSIA_COUNTRY_GUID
|
||
vat_country_data = etree.SubElement(
|
||
vat_country,
|
||
"ДанныеКлассификатора"
|
||
)
|
||
etree.SubElement(vat_country_data, "Код").text = RUSSIA_OKSM_CODE
|
||
etree.SubElement(
|
||
vat_country_data,
|
||
"Наименование"
|
||
).text = RUSSIA_COUNTRY_NAME
|
||
|
||
# СуммаНДС (извлекаем из суммы)
|
||
etree.SubElement(row_elem, "СуммаНДС").text = self._format_money_with_decimals(amounts["vat_amount"])
|
||
if amounts["discount"]:
|
||
etree.SubElement(row_elem, "ВидСкидки").text = "НаТовар"
|
||
etree.SubElement(row_elem, "ПроцентСкидки").text = f"{amounts['discount']:.2f}"
|
||
etree.SubElement(row_elem, "СуммаСкидки").text = f"{amounts['discount_amount']:.2f}"
|
||
etree.SubElement(row_elem, "СуммаСУчетомСкидки").text = f"{amounts['total']:.2f}"
|
||
|
||
# ТипЗапасов для товаров
|
||
etree.SubElement(
|
||
row_elem, "ТипЗапасов"
|
||
).text = "СобственныеТовары"
|
||
|
||
# Счета учета (только для товаров)
|
||
if settings.include_accounting_accounts:
|
||
for tag, value in ACCOUNTING_ACCOUNT_TAGS:
|
||
etree.SubElement(row_elem, tag).text = value
|
||
|
||
# Уникальный идентификатор строки (uuid уже импортирован выше)
|
||
etree.SubElement(row_elem, "ИдентификаторСтроки").text = str(uuid.uuid4())
|
||
|
||
etree.SubElement(row_elem, "ПрослеживаемыйТовар").text = "false"
|
||
|
||
# Услуги (если есть)
|
||
if услуги:
|
||
services_elem = etree.SubElement(doc, "Услуги")
|
||
for pos in услуги:
|
||
row_elem = etree.SubElement(services_elem, "Строка")
|
||
|
||
assortment = pos.get("assortment", {})
|
||
# Очищаем GUID от query-параметров
|
||
product_href = assortment.get("meta", {}).get("href", "") if assortment.get("meta") else ""
|
||
product_id = product_href.split("/")[-1].split("?")[0] if product_href else ""
|
||
product_name = assortment.get("name", "")
|
||
|
||
# Номенклатура (для услуг - без ДанныеНоменклатуры)
|
||
nomenclature = etree.SubElement(row_elem, "Номенклатура")
|
||
etree.SubElement(nomenclature, "Ссылка").text = product_id
|
||
etree.SubElement(nomenclature, "НаименованиеПолное").text = product_name
|
||
etree.SubElement(nomenclature, "КодВПрограмме").text = assortment.get("code", "")
|
||
etree.SubElement(nomenclature, "Наименование").text = product_name
|
||
|
||
# Флаг услуги (обязателен для услуг)
|
||
etree.SubElement(nomenclature, "Услуга").text = "true"
|
||
|
||
# ВидНоменклатуры
|
||
kind_elem = etree.SubElement(nomenclature, "ВидНоменклатуры")
|
||
etree.SubElement(kind_elem, "Наименование").text = "Услуга"
|
||
|
||
# Характеристика (опционально, может отсутствовать)
|
||
# etree.SubElement(row_elem, "Характеристика")
|
||
|
||
# Количество
|
||
quantity = pos.get("quantity", 0)
|
||
etree.SubElement(row_elem, "Количество").text = str(quantity)
|
||
|
||
# В секции услуг 1С нет полей скидки: передаем цену и сумму после скидки.
|
||
amounts = self._position_amounts(pos)
|
||
|
||
# Форматируем с 2 знаками после запятой
|
||
etree.SubElement(row_elem, "Сумма").text = f"{amounts['total']:.2f}"
|
||
etree.SubElement(row_elem, "Цена").text = f"{amounts['discounted_price']:.2f}"
|
||
|
||
# СтавкаНДС
|
||
vat_rate = pos.get("vat", 20)
|
||
vat_elem = etree.SubElement(row_elem, "СтавкаНДС")
|
||
etree.SubElement(vat_elem, "Ставка").text = str(vat_rate)
|
||
etree.SubElement(vat_elem, "РасчетнаяСтавка").text = "false"
|
||
etree.SubElement(vat_elem, "НеОблагается").text = "false"
|
||
etree.SubElement(vat_elem, "ВидСтавки").text = "Общая"
|
||
|
||
vat_country = etree.SubElement(vat_elem, "Страна")
|
||
etree.SubElement(vat_country, "Ссылка").text = RUSSIA_COUNTRY_GUID
|
||
vat_country_data = etree.SubElement(
|
||
vat_country,
|
||
"ДанныеКлассификатора"
|
||
)
|
||
etree.SubElement(vat_country_data, "Код").text = RUSSIA_OKSM_CODE
|
||
etree.SubElement(
|
||
vat_country_data,
|
||
"Наименование"
|
||
).text = RUSSIA_COUNTRY_NAME
|
||
|
||
# СуммаНДС (извлекаем из суммы)
|
||
etree.SubElement(row_elem, "СуммаНДС").text = self._format_money_with_decimals(amounts["vat_amount"])
|
||
|
||
# Содержание (опционально)
|
||
etree.SubElement(row_elem, "Содержание").text = product_name
|
||
|
||
# Счета учета для услуг (немного другие, чем для товаров)
|
||
if settings.include_accounting_accounts:
|
||
etree.SubElement(row_elem, "СчетДоходов").text = "90.01.1"
|
||
etree.SubElement(row_elem, "СчетРасходов").text = "90.02.1"
|
||
etree.SubElement(row_elem, "СчетУчетаНДСПоРеализации").text = "90.03"
|
||
|
||
# Уникальный идентификатор строки
|
||
etree.SubElement(row_elem, "ИдентификаторСтроки").text = str(uuid.uuid4())
|
||
|
||
# СпособПогашенияЗадолженности
|
||
etree.SubElement(doc, "СпособПогашенияЗадолженности").text = "Автоматически"
|
||
|
||
# КомплектПечатныхДокументовПодписанПолучен
|
||
etree.SubElement(doc, "КомплектПечатныхДокументовПодписанПолучен").text = "false"
|
||
|
||
# ЭтоУПД
|
||
etree.SubElement(doc, "ЭтоУПД").text = "false"
|
||
|
||
# Конвертация в строку
|
||
xml_string = etree.tostring(
|
||
root,
|
||
pretty_print=True,
|
||
xml_declaration=True,
|
||
encoding=self.encoding
|
||
).decode(self.encoding)
|
||
|
||
# ДИАГНОСТИКА: Проверяем соответствие валюты документа и договора
|
||
# Если валюта RUB и не у.е., 1С должна использовать счета 62.01/62.02
|
||
# Если валюта иностранная → 62.21/62.22
|
||
# Если у.е. → 62.31/62.32
|
||
currency_label = f"{alpha_code}/{numeric_code}"
|
||
|
||
if numeric_code == RUB_NUMERIC_CODE:
|
||
logger.info(
|
||
"✓ Документ в рублях (RUB, код 643), не в у.е. "
|
||
"→ ожидаются счета 62.01 (задолженность) / 62.02 (авансы). "
|
||
"Если 1С подставляет 62.21/62.22 или 62.31/62.32, проверьте: "
|
||
"1) Регистр 'Счета расчетов с контрагентами' - нет ли "
|
||
"индивидуальных правил на контрагента/договор. "
|
||
"2) В карточке договора: Валюта взаиморасчётов = RUB, "
|
||
"Расчёты в условных единицах = нет."
|
||
)
|
||
else:
|
||
logger.info(
|
||
f"⚠ Документ в иностранной валюте ({currency_label}) "
|
||
"→ ожидаются счета 62.21/62.22. "
|
||
"Убедитесь, что договор создан с валютой взаиморасчётов "
|
||
f"= {currency_label}."
|
||
)
|
||
|
||
# Сохранение в файл
|
||
if output_path:
|
||
with open(output_path, "w", encoding=self.encoding) as f:
|
||
f.write(xml_string)
|
||
logger.info(f"Документ реализации 1С сохранен: {output_path}")
|
||
|
||
return xml_string
|
||
|
||
def generate_batch_realization(
|
||
self,
|
||
orders_data: list,
|
||
output_path: Optional[str] = None
|
||
) -> str:
|
||
"""
|
||
Генерация множественных документов реализации в один XML
|
||
|
||
Args:
|
||
orders_data: Список полных данных заказов из МойСклад
|
||
output_path: Путь для сохранения файла
|
||
|
||
Returns:
|
||
XML в виде строки
|
||
"""
|
||
# Корневой элемент Message
|
||
root = etree.Element(
|
||
"Message",
|
||
nsmap={
|
||
'msg': self.nsmap['msg'],
|
||
'xs': self.nsmap['xs'],
|
||
'xsi': self.nsmap['xsi']
|
||
}
|
||
)
|
||
|
||
# Header
|
||
header = etree.SubElement(root, "{%s}Header" % self.nsmap['msg'])
|
||
etree.SubElement(
|
||
header,
|
||
"{%s}Format" % self.nsmap['msg']
|
||
).text = self.FORMAT_URI
|
||
etree.SubElement(
|
||
header,
|
||
"{%s}CreationDate" % self.nsmap['msg']
|
||
).text = datetime.now().strftime("%Y-%m-%dT%H:%M:%S")
|
||
etree.SubElement(
|
||
header,
|
||
"{%s}AvailableVersion" % self.nsmap['msg']
|
||
).text = self.FORMAT_VERSION
|
||
|
||
# Body - будет содержать несколько документов
|
||
body = etree.SubElement(root, "Body", nsmap={None: self.FORMAT_URI})
|
||
|
||
logger.info(f"Генерация пакета из {len(orders_data)} документов")
|
||
|
||
# Генерируем каждый документ внутри одного Body
|
||
for idx, order_data in enumerate(orders_data, 1):
|
||
logger.info(
|
||
f"Обработка документа {idx}/{len(orders_data)}: "
|
||
f"{order_data['order'].get('name', 'N/A')}"
|
||
)
|
||
|
||
# Генерируем содержимое документа (без root/header/body)
|
||
doc_xml = self._generate_single_document_content(order_data)
|
||
|
||
# Добавляем документ в Body
|
||
body.append(doc_xml)
|
||
|
||
# Конвертация в строку
|
||
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"Пакетный документ реализации 1С сохранен: {output_path} "
|
||
f"({len(orders_data)} документов)"
|
||
)
|
||
|
||
return xml_string
|
||
|
||
def _generate_single_document_content(
|
||
self,
|
||
order_data: Dict
|
||
) -> etree.Element:
|
||
"""
|
||
Генерация содержимого одного документа (без обёртки Message)
|
||
|
||
Args:
|
||
order_data: Полные данные заказа
|
||
|
||
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", [])
|
||
|
||
# Документ.РеализацияТоваровУслуг
|
||
doc = etree.Element("Документ.РеализацияТоваровУслуг")
|
||
|
||
# КлючевыеСвойства
|
||
key_props = etree.SubElement(doc, "КлючевыеСвойства")
|
||
|
||
# Ссылка (UUID документа)
|
||
order_id = order.get("id", "")
|
||
etree.SubElement(key_props, "Ссылка").text = order_id
|
||
|
||
# Дата и Номер
|
||
order_date = self._format_datetime(order.get("moment", ""))
|
||
order_number = order.get("name", "").strip()
|
||
etree.SubElement(key_props, "Дата").text = order_date
|
||
etree.SubElement(key_props, "Номер").text = order_number
|
||
|
||
# Организация
|
||
org_elem = etree.SubElement(key_props, "Организация")
|
||
org_href = (
|
||
organization.get("meta", {}).get("href", "")
|
||
if organization.get("meta") else ""
|
||
)
|
||
org_id = (
|
||
org_href.split("/")[-1].split("?")[0]
|
||
if org_href else organization.get("id", "")
|
||
)
|
||
etree.SubElement(org_elem, "Ссылка").text = org_id
|
||
etree.SubElement(
|
||
org_elem, "Наименование"
|
||
).text = organization.get("name", "")
|
||
|
||
# Полные наименования для ИП
|
||
company_type = organization.get("companyType", "")
|
||
if company_type == "entrepreneur":
|
||
legal_name = organization.get("legalTitle", "")
|
||
etree.SubElement(
|
||
org_elem, "НаименованиеСокращенное"
|
||
).text = legal_name
|
||
etree.SubElement(
|
||
org_elem, "НаименованиеПолное"
|
||
).text = legal_name
|
||
else:
|
||
etree.SubElement(
|
||
org_elem, "НаименованиеСокращенное"
|
||
).text = organization.get("name", "")
|
||
etree.SubElement(
|
||
org_elem, "НаименованиеПолное"
|
||
).text = organization.get("legalTitle", "")
|
||
|
||
etree.SubElement(org_elem, "ИНН").text = organization.get("inn", "")
|
||
|
||
# Тип лица
|
||
if company_type == "entrepreneur":
|
||
etree.SubElement(
|
||
org_elem, "ЮридическоеФизическоеЛицо"
|
||
).text = "ФизическоеЛицо"
|
||
else:
|
||
etree.SubElement(
|
||
org_elem, "ЮридическоеФизическоеЛицо"
|
||
).text = "ЮридическоеЛицо"
|
||
|
||
# ВидОперации
|
||
etree.SubElement(doc, "ВидОперации").text = "РеализацияКлиенту"
|
||
|
||
# Валюта
|
||
rate = order.get("rate", {})
|
||
currency = rate.get("currency", {}) if rate else {}
|
||
currency_elem = etree.SubElement(doc, "Валюта")
|
||
currency_href = (
|
||
currency.get("meta", {}).get("href", "")
|
||
if currency.get("meta") else ""
|
||
)
|
||
currency_id = (
|
||
currency_href.split("/")[-1].split("?")[0]
|
||
if currency_href else ""
|
||
)
|
||
if not currency_id:
|
||
currency_id = RUB_CURRENCY_GUID
|
||
etree.SubElement(currency_elem, "Ссылка").text = currency_id
|
||
|
||
alpha_code, numeric_code = get_currency_codes(currency)
|
||
currency_data = etree.SubElement(
|
||
currency_elem, "ДанныеКлассификатора"
|
||
)
|
||
etree.SubElement(currency_data, "Код").text = numeric_code
|
||
etree.SubElement(
|
||
currency_data, "Наименование"
|
||
).text = currency.get("name", RUB_SHORT_NAME)
|
||
|
||
# Сумма
|
||
order_sum = order.get("sum", 0)
|
||
etree.SubElement(doc, "Сумма").text = self._format_money_1c(order_sum)
|
||
|
||
# СуммаВключаетНДС
|
||
etree.SubElement(doc, "СуммаВключаетНДС").text = "true"
|
||
|
||
# Склад
|
||
store = order.get("store", {})
|
||
if store:
|
||
store_elem = etree.SubElement(doc, "Склад")
|
||
store_id = (
|
||
store.get("meta", {}).get("href", "").split("/")[-1]
|
||
if store.get("meta") else ""
|
||
)
|
||
etree.SubElement(store_elem, "Ссылка").text = store_id
|
||
etree.SubElement(
|
||
store_elem, "Наименование"
|
||
).text = store.get("name", "Основной склад")
|
||
etree.SubElement(store_elem, "ТипСклада").text = "Оптовый"
|
||
|
||
# Руководитель (для ИП)
|
||
if company_type == "entrepreneur":
|
||
chief_elem = etree.SubElement(doc, "Руководитель")
|
||
etree.SubElement(chief_elem, "Ссылка").text = org_id
|
||
|
||
fio_parts = []
|
||
last_name = organization.get("legalLastName", "")
|
||
first_name = organization.get("legalFirstName", "")
|
||
middle_name = organization.get("legalMiddleName", "")
|
||
|
||
if last_name:
|
||
fio_parts.append(last_name)
|
||
if first_name:
|
||
fio_parts.append(first_name)
|
||
if middle_name:
|
||
fio_parts.append(middle_name)
|
||
|
||
full_fio = " ".join(fio_parts)
|
||
etree.SubElement(chief_elem, "ФИО").text = full_fio
|
||
etree.SubElement(chief_elem, "Фамилия").text = last_name
|
||
etree.SubElement(chief_elem, "Имя").text = first_name
|
||
etree.SubElement(chief_elem, "Отчество").text = middle_name
|
||
etree.SubElement(
|
||
chief_elem, "ИНН"
|
||
).text = organization.get("inn", "")
|
||
|
||
# БанковскийСчетОрганизации
|
||
if org_accounts and len(org_accounts) > 0:
|
||
acc = org_accounts[0]
|
||
bank_acc_elem = etree.SubElement(
|
||
doc, "БанковскийСчетОрганизации"
|
||
)
|
||
acc_id = acc.get("id", "")
|
||
etree.SubElement(bank_acc_elem, "Ссылка").text = acc_id
|
||
etree.SubElement(
|
||
bank_acc_elem, "НомерСчета"
|
||
).text = acc.get("accountNumber", "")
|
||
|
||
bank_elem = etree.SubElement(bank_acc_elem, "Банк")
|
||
etree.SubElement(bank_elem, "Ссылка").text = acc_id
|
||
|
||
bank_data = etree.SubElement(
|
||
bank_elem, "ДанныеКлассификатораБанков"
|
||
)
|
||
etree.SubElement(
|
||
bank_data, "Наименование"
|
||
).text = acc.get("bankName", "")
|
||
etree.SubElement(bank_data, "БИК").text = acc.get("bic", "")
|
||
etree.SubElement(
|
||
bank_data, "КоррСчет"
|
||
).text = acc.get("correspondentAccount", "")
|
||
|
||
owner_elem = etree.SubElement(bank_acc_elem, "Владелец")
|
||
org_ref = etree.SubElement(owner_elem, "ОрганизацииСсылка")
|
||
etree.SubElement(org_ref, "Ссылка").text = org_id
|
||
etree.SubElement(
|
||
org_ref, "Наименование"
|
||
).text = organization.get("name", "")
|
||
|
||
if company_type == "entrepreneur":
|
||
etree.SubElement(
|
||
org_ref, "НаименованиеСокращенное"
|
||
).text = organization.get("legalTitle", "")
|
||
etree.SubElement(
|
||
org_ref, "НаименованиеПолное"
|
||
).text = organization.get("legalTitle", "")
|
||
else:
|
||
etree.SubElement(
|
||
org_ref, "НаименованиеСокращенное"
|
||
).text = organization.get("name", "")
|
||
etree.SubElement(
|
||
org_ref, "НаименованиеПолное"
|
||
).text = organization.get("legalTitle", "")
|
||
|
||
etree.SubElement(org_ref, "ИНН").text = organization.get("inn", "")
|
||
|
||
if company_type == "entrepreneur":
|
||
etree.SubElement(
|
||
org_ref, "ЮридическоеФизическоеЛицо"
|
||
).text = "ФизическоеЛицо"
|
||
else:
|
||
etree.SubElement(
|
||
org_ref, "ЮридическоеФизическоеЛицо"
|
||
).text = "ЮридическоеЛицо"
|
||
|
||
# Контрагент
|
||
cp_elem = etree.SubElement(doc, "Контрагент")
|
||
cp_href = (
|
||
counterparty.get("meta", {}).get("href", "")
|
||
if counterparty.get("meta") else ""
|
||
)
|
||
cp_id = (
|
||
cp_href.split("/")[-1].split("?")[0]
|
||
if cp_href else counterparty.get("id", "")
|
||
)
|
||
etree.SubElement(cp_elem, "Ссылка").text = cp_id
|
||
etree.SubElement(
|
||
cp_elem, "Наименование"
|
||
).text = counterparty.get("name", "")
|
||
etree.SubElement(
|
||
cp_elem, "НаименованиеПолное"
|
||
).text = counterparty.get("legalTitle", counterparty.get("name", ""))
|
||
etree.SubElement(cp_elem, "ИНН").text = counterparty.get("inn", "")
|
||
|
||
cp_type = counterparty.get("companyType", "legal")
|
||
if cp_type == "entrepreneur":
|
||
etree.SubElement(
|
||
cp_elem, "ЮридическоеФизическоеЛицо"
|
||
).text = "ФизическоеЛицо"
|
||
else:
|
||
etree.SubElement(
|
||
cp_elem, "ЮридическоеФизическоеЛицо"
|
||
).text = "ЮридическоеЛицо"
|
||
|
||
country_elem = etree.SubElement(cp_elem, "СтранаРегистрации")
|
||
etree.SubElement(country_elem, "Ссылка").text = RUSSIA_COUNTRY_GUID
|
||
country_data = etree.SubElement(
|
||
country_elem,
|
||
"ДанныеКлассификатора"
|
||
)
|
||
etree.SubElement(country_data, "Код").text = RUSSIA_OKSM_CODE
|
||
etree.SubElement(
|
||
country_data,
|
||
"Наименование"
|
||
).text = RUSSIA_COUNTRY_NAME
|
||
|
||
if cp_type == "entrepreneur":
|
||
etree.SubElement(
|
||
cp_elem, "ИндивидуальныйПредприниматель"
|
||
).text = "true"
|
||
|
||
# ДанныеВзаиморасчетов
|
||
settlement_elem = etree.SubElement(doc, "ДанныеВзаиморасчетов")
|
||
|
||
# Договор
|
||
contract_elem = etree.SubElement(settlement_elem, "Договор")
|
||
import uuid
|
||
currency_code_for_seed = alpha_code or numeric_code
|
||
is_ue = "false"
|
||
seed = (
|
||
f"contract-{cp_id}-{org_id}-CUR-"
|
||
f"{currency_code_for_seed}-UE-{is_ue}"
|
||
)
|
||
contract_uuid = str(uuid.uuid5(uuid.NAMESPACE_DNS, seed))
|
||
|
||
etree.SubElement(contract_elem, "Ссылка").text = contract_uuid
|
||
etree.SubElement(
|
||
contract_elem, "ВидДоговора"
|
||
).text = "СПокупателем"
|
||
|
||
# Организация в договоре
|
||
contract_org = etree.SubElement(contract_elem, "Организация")
|
||
etree.SubElement(contract_org, "Ссылка").text = org_id
|
||
etree.SubElement(
|
||
contract_org, "Наименование"
|
||
).text = organization.get("name", "")
|
||
if company_type == "entrepreneur":
|
||
etree.SubElement(
|
||
contract_org, "НаименованиеСокращенное"
|
||
).text = organization.get("legalTitle", "")
|
||
etree.SubElement(
|
||
contract_org, "НаименованиеПолное"
|
||
).text = organization.get("legalTitle", "")
|
||
else:
|
||
etree.SubElement(
|
||
contract_org, "НаименованиеСокращенное"
|
||
).text = organization.get("name", "")
|
||
etree.SubElement(
|
||
contract_org, "НаименованиеПолное"
|
||
).text = organization.get("legalTitle", "")
|
||
|
||
etree.SubElement(
|
||
contract_org, "ИНН"
|
||
).text = organization.get("inn", "")
|
||
|
||
if company_type == "entrepreneur":
|
||
etree.SubElement(
|
||
contract_org, "ЮридическоеФизическоеЛицо"
|
||
).text = "ФизическоеЛицо"
|
||
else:
|
||
etree.SubElement(
|
||
contract_org, "ЮридическоеФизическоеЛицо"
|
||
).text = "ЮридическоеЛицо"
|
||
|
||
# Контрагент в договоре
|
||
contract_cp = etree.SubElement(contract_elem, "Контрагент")
|
||
etree.SubElement(contract_cp, "Ссылка").text = cp_id
|
||
etree.SubElement(
|
||
contract_cp, "Наименование"
|
||
).text = counterparty.get("name", "")
|
||
etree.SubElement(
|
||
contract_cp, "НаименованиеПолное"
|
||
).text = counterparty.get("legalTitle", "")
|
||
etree.SubElement(
|
||
contract_cp, "ИНН"
|
||
).text = counterparty.get("inn", "")
|
||
|
||
if cp_type == "entrepreneur":
|
||
etree.SubElement(
|
||
contract_cp, "ЮридическоеФизическоеЛицо"
|
||
).text = "ФизическоеЛицо"
|
||
else:
|
||
etree.SubElement(
|
||
contract_cp, "ЮридическоеФизическоеЛицо"
|
||
).text = "ЮридическоеЛицо"
|
||
|
||
cp_country = etree.SubElement(contract_cp, "СтранаРегистрации")
|
||
etree.SubElement(cp_country, "Ссылка").text = RUSSIA_COUNTRY_GUID
|
||
cp_country_data = etree.SubElement(
|
||
cp_country,
|
||
"ДанныеКлассификатора"
|
||
)
|
||
etree.SubElement(cp_country_data, "Код").text = RUSSIA_OKSM_CODE
|
||
etree.SubElement(
|
||
cp_country_data,
|
||
"Наименование"
|
||
).text = RUSSIA_COUNTRY_NAME
|
||
|
||
if cp_type == "entrepreneur":
|
||
etree.SubElement(
|
||
contract_cp, "ИндивидуальныйПредприниматель"
|
||
).text = "true"
|
||
|
||
# Валюта взаиморасчетов в договоре
|
||
contract_currency = etree.SubElement(
|
||
contract_elem, "ВалютаВзаиморасчетов"
|
||
)
|
||
etree.SubElement(contract_currency, "Ссылка").text = currency_id
|
||
contract_curr_data = etree.SubElement(
|
||
contract_currency, "ДанныеКлассификатора"
|
||
)
|
||
etree.SubElement(contract_curr_data, "Код").text = numeric_code
|
||
etree.SubElement(
|
||
contract_curr_data,
|
||
"Наименование"
|
||
).text = currency.get("name", RUB_SHORT_NAME)
|
||
|
||
etree.SubElement(
|
||
contract_elem, "РасчетыВУсловныхЕдиницах"
|
||
).text = "false"
|
||
etree.SubElement(
|
||
contract_elem, "Наименование"
|
||
).text = "Основной договор"
|
||
|
||
# Валюта взаиморасчетов
|
||
settlement_currency = etree.SubElement(
|
||
settlement_elem, "ВалютаВзаиморасчетов"
|
||
)
|
||
etree.SubElement(settlement_currency, "Ссылка").text = currency_id
|
||
settlement_curr_data = etree.SubElement(
|
||
settlement_currency, "ДанныеКлассификатора"
|
||
)
|
||
etree.SubElement(settlement_curr_data, "Код").text = numeric_code
|
||
etree.SubElement(
|
||
settlement_curr_data,
|
||
"Наименование"
|
||
).text = currency.get("name", RUB_SHORT_NAME)
|
||
|
||
etree.SubElement(
|
||
settlement_elem, "КурсВзаиморасчетов"
|
||
).text = "1"
|
||
etree.SubElement(
|
||
settlement_elem, "КратностьВзаиморасчетов"
|
||
).text = "1"
|
||
etree.SubElement(
|
||
settlement_elem, "РасчетыВУсловныхЕдиницах"
|
||
).text = "false"
|
||
|
||
# Налогообложение
|
||
etree.SubElement(
|
||
doc, "Налогообложение"
|
||
).text = "ПродажаОблагаетсяНДС"
|
||
|
||
# Разделяем позиции на товары и услуги
|
||
товары = []
|
||
услуги = []
|
||
|
||
for pos in positions:
|
||
assortment = pos.get("assortment", {})
|
||
if _is_service(assortment):
|
||
услуги.append(pos)
|
||
else:
|
||
товары.append(pos)
|
||
|
||
# Секция Товары (если есть товары)
|
||
if товары:
|
||
goods_elem = etree.SubElement(doc, "Товары")
|
||
|
||
for pos in товары:
|
||
row_elem = etree.SubElement(goods_elem, "Строка")
|
||
|
||
assortment = pos.get("assortment", {})
|
||
product_href = (
|
||
assortment.get("meta", {}).get("href", "")
|
||
if assortment.get("meta") else ""
|
||
)
|
||
product_id = (
|
||
product_href.split("/")[-1].split("?")[0]
|
||
if product_href else ""
|
||
)
|
||
product_name = assortment.get("name", "")
|
||
|
||
nomenclature_data = etree.SubElement(
|
||
row_elem, "ДанныеНоменклатуры"
|
||
)
|
||
nomenclature = etree.SubElement(
|
||
nomenclature_data, "Номенклатура"
|
||
)
|
||
etree.SubElement(nomenclature, "Ссылка").text = product_id
|
||
etree.SubElement(
|
||
nomenclature, "НаименованиеПолное"
|
||
).text = product_name
|
||
etree.SubElement(
|
||
nomenclature, "КодВПрограмме"
|
||
).text = assortment.get("code", "")
|
||
etree.SubElement(
|
||
nomenclature, "Наименование"
|
||
).text = product_name
|
||
|
||
# ВидНоменклатуры
|
||
kind_elem = etree.SubElement(nomenclature, "ВидНоменклатуры")
|
||
etree.SubElement(kind_elem, "Наименование").text = "Товар"
|
||
|
||
uom = assortment.get("uom", {})
|
||
uom_elem = etree.SubElement(row_elem, "ЕдиницаИзмерения")
|
||
uom_href = (
|
||
uom.get("meta", {}).get("href", "")
|
||
if uom and uom.get("meta") else ""
|
||
)
|
||
uom_id = (
|
||
uom_href.split("/")[-1].split("?")[0]
|
||
if uom_href else ""
|
||
)
|
||
if not uom_id:
|
||
uom_id = DEFAULT_UOM_GUID
|
||
etree.SubElement(uom_elem, "Ссылка").text = uom_id
|
||
|
||
uom_data = etree.SubElement(uom_elem, "ДанныеКлассификатора")
|
||
etree.SubElement(uom_data, "Код").text = "796"
|
||
etree.SubElement(
|
||
uom_data, "Наименование"
|
||
).text = uom.get("name", "шт") if uom else "шт"
|
||
|
||
quantity = pos.get("quantity", 0)
|
||
etree.SubElement(row_elem, "Количество").text = str(quantity)
|
||
|
||
amounts = self._position_amounts(pos)
|
||
|
||
etree.SubElement(row_elem, "Сумма").text = f"{amounts['gross_total']:.2f}"
|
||
etree.SubElement(row_elem, "Цена").text = f"{amounts['price']:.2f}"
|
||
|
||
vat_rate = pos.get("vat", 20)
|
||
vat_elem = etree.SubElement(row_elem, "СтавкаНДС")
|
||
etree.SubElement(vat_elem, "Ставка").text = str(vat_rate)
|
||
etree.SubElement(vat_elem, "РасчетнаяСтавка").text = "false"
|
||
etree.SubElement(vat_elem, "НеОблагается").text = "false"
|
||
etree.SubElement(vat_elem, "ВидСтавки").text = "Общая"
|
||
|
||
vat_country = etree.SubElement(vat_elem, "Страна")
|
||
etree.SubElement(
|
||
vat_country, "Ссылка"
|
||
).text = RUSSIA_COUNTRY_GUID
|
||
vat_country_data = etree.SubElement(
|
||
vat_country, "ДанныеКлассификатора"
|
||
)
|
||
etree.SubElement(vat_country_data, "Код").text = RUSSIA_OKSM_CODE
|
||
etree.SubElement(
|
||
vat_country_data,
|
||
"Наименование"
|
||
).text = RUSSIA_COUNTRY_NAME
|
||
|
||
etree.SubElement(
|
||
row_elem, "СуммаНДС"
|
||
).text = self._format_money_with_decimals(amounts["vat_amount"])
|
||
if amounts["discount"]:
|
||
etree.SubElement(row_elem, "ВидСкидки").text = "НаТовар"
|
||
etree.SubElement(row_elem, "ПроцентСкидки").text = f"{amounts['discount']:.2f}"
|
||
etree.SubElement(row_elem, "СуммаСкидки").text = f"{amounts['discount_amount']:.2f}"
|
||
etree.SubElement(row_elem, "СуммаСУчетомСкидки").text = f"{amounts['total']:.2f}"
|
||
|
||
# ТипЗапасов для товаров
|
||
etree.SubElement(
|
||
row_elem, "ТипЗапасов"
|
||
).text = "СобственныеТовары"
|
||
|
||
# Счета учета (опционально)
|
||
if settings.include_accounting_accounts:
|
||
for tag, value in ACCOUNTING_ACCOUNT_TAGS:
|
||
etree.SubElement(row_elem, tag).text = value
|
||
|
||
etree.SubElement(
|
||
row_elem, "ИдентификаторСтроки"
|
||
).text = str(uuid.uuid4())
|
||
etree.SubElement(
|
||
row_elem, "ПрослеживаемыйТовар"
|
||
).text = "false"
|
||
|
||
# Секция Услуги (если есть услуги)
|
||
if услуги:
|
||
services_elem = etree.SubElement(doc, "Услуги")
|
||
|
||
for pos in услуги:
|
||
row_elem = etree.SubElement(services_elem, "Строка")
|
||
|
||
assortment = pos.get("assortment", {})
|
||
product_href = (
|
||
assortment.get("meta", {}).get("href", "")
|
||
if assortment.get("meta") else ""
|
||
)
|
||
product_id = (
|
||
product_href.split("/")[-1].split("?")[0]
|
||
if product_href else ""
|
||
)
|
||
product_name = assortment.get("name", "")
|
||
|
||
# Номенклатура напрямую (без ДанныеНоменклатуры)
|
||
nomenclature = etree.SubElement(row_elem, "Номенклатура")
|
||
etree.SubElement(nomenclature, "Ссылка").text = product_id
|
||
etree.SubElement(
|
||
nomenclature, "НаименованиеПолное"
|
||
).text = product_name
|
||
etree.SubElement(
|
||
nomenclature, "КодВПрограмме"
|
||
).text = assortment.get("code", "")
|
||
etree.SubElement(
|
||
nomenclature, "Наименование"
|
||
).text = product_name
|
||
|
||
# Флаг услуги
|
||
etree.SubElement(nomenclature, "Услуга").text = "true"
|
||
|
||
# ВидНоменклатуры
|
||
kind_elem = etree.SubElement(nomenclature, "ВидНоменклатуры")
|
||
etree.SubElement(kind_elem, "Наименование").text = "Услуга"
|
||
|
||
uom = assortment.get("uom", {})
|
||
uom_elem = etree.SubElement(row_elem, "ЕдиницаИзмерения")
|
||
uom_href = (
|
||
uom.get("meta", {}).get("href", "")
|
||
if uom and uom.get("meta") else ""
|
||
)
|
||
uom_id = (
|
||
uom_href.split("/")[-1].split("?")[0]
|
||
if uom_href else ""
|
||
)
|
||
if not uom_id:
|
||
uom_id = DEFAULT_UOM_GUID
|
||
etree.SubElement(uom_elem, "Ссылка").text = uom_id
|
||
|
||
uom_data = etree.SubElement(uom_elem, "ДанныеКлассификатора")
|
||
etree.SubElement(uom_data, "Код").text = "796"
|
||
etree.SubElement(
|
||
uom_data, "Наименование"
|
||
).text = uom.get("name", "шт") if uom else "шт"
|
||
|
||
# Содержание для услуг
|
||
etree.SubElement(row_elem, "Содержание").text = product_name
|
||
|
||
quantity = pos.get("quantity", 0)
|
||
etree.SubElement(row_elem, "Количество").text = str(quantity)
|
||
|
||
amounts = self._position_amounts(pos)
|
||
|
||
etree.SubElement(row_elem, "Сумма").text = f"{amounts['total']:.2f}"
|
||
etree.SubElement(row_elem, "Цена").text = f"{amounts['discounted_price']:.2f}"
|
||
|
||
vat_rate = pos.get("vat", 20)
|
||
vat_elem = etree.SubElement(row_elem, "СтавкаНДС")
|
||
etree.SubElement(vat_elem, "Ставка").text = str(vat_rate)
|
||
etree.SubElement(vat_elem, "РасчетнаяСтавка").text = "false"
|
||
etree.SubElement(vat_elem, "НеОблагается").text = "false"
|
||
etree.SubElement(vat_elem, "ВидСтавки").text = "Общая"
|
||
|
||
vat_country = etree.SubElement(vat_elem, "Страна")
|
||
etree.SubElement(
|
||
vat_country, "Ссылка"
|
||
).text = RUSSIA_COUNTRY_GUID
|
||
vat_country_data = etree.SubElement(
|
||
vat_country, "ДанныеКлассификатора"
|
||
)
|
||
etree.SubElement(vat_country_data, "Код").text = RUSSIA_OKSM_CODE
|
||
etree.SubElement(
|
||
vat_country_data,
|
||
"Наименование"
|
||
).text = RUSSIA_COUNTRY_NAME
|
||
|
||
etree.SubElement(
|
||
row_elem, "СуммаНДС"
|
||
).text = self._format_money_with_decimals(amounts["vat_amount"])
|
||
|
||
# Счета учета для услуг (БЕЗ счета 43)
|
||
if settings.include_accounting_accounts:
|
||
etree.SubElement(row_elem, "СчетДоходов").text = "90.01.1"
|
||
etree.SubElement(row_elem, "СчетРасходов").text = "90.02.1"
|
||
etree.SubElement(row_elem, "СчетУчетаНДСПоРеализации").text = "90.03"
|
||
|
||
etree.SubElement(
|
||
row_elem, "ИдентификаторСтроки"
|
||
).text = str(uuid.uuid4())
|
||
|
||
# СпособПогашенияЗадолженности
|
||
etree.SubElement(
|
||
doc, "СпособПогашенияЗадолженности"
|
||
).text = "Автоматически"
|
||
|
||
# КомплектПечатныхДокументовПодписанПолучен
|
||
etree.SubElement(
|
||
doc, "КомплектПечатныхДокументовПодписанПолучен"
|
||
).text = "false"
|
||
|
||
# ЭтоУПД
|
||
etree.SubElement(doc, "ЭтоУПД").text = "false"
|
||
|
||
return doc
|
||
|