From 4038ff9f92d2d33c4710bebbd4237679c0f8b370 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Fri, 7 Nov 2025 16:19:25 +0300 Subject: [PATCH] Fix XML generator contract UUID generation and cleanup temporary files - Update contract UUID to include currency and UE flag for correct 1C account selection - Remove temporary export.xml and refactored files - Add documentation for services fixes --- SERVICES_FIX.md | 141 ++++++ modules/xml_generator.py | 620 ++++++++++++++++++--------- modules/xml_generator_refactored2.py | 446 ------------------- 3 files changed, 550 insertions(+), 657 deletions(-) create mode 100644 SERVICES_FIX.md delete mode 100644 modules/xml_generator_refactored2.py diff --git a/SERVICES_FIX.md b/SERVICES_FIX.md new file mode 100644 index 0000000..48da700 --- /dev/null +++ b/SERVICES_FIX.md @@ -0,0 +1,141 @@ +# Исправление: Услуги превращались в товары при импорте в 1С + +## 🔴 Проблема + +При экспорте XML в формате EnterpriseData 1.20 все позиции документа попадали в секцию `<Товары>`, включая услуги. Это приводило к тому, что 1С импортировала услуги как товары. + +### Пример проблемного XML: +```xml +<Товары> + <Строка> + <ДанныеНоменклатуры> + <Номенклатура> + <Услуга>true + <ВидНоменклатуры> + <Наименование>Услуга + + + + + + +``` + +## ✅ Решение + +Документ `РеализацияТоваровУслуг` в формате 1С должен иметь **ДВЕ отдельные секции**: + +1. **`<Товары>`** - для товаров (с `ДанныеНоменклатуры`, `ТипЗапасов` и т.д.) +2. **`<Услуги>`** - для услуг (с `Номенклатура`, `Содержание` и т.д.) + +### Структура исправленного XML: +```xml +<Документ.РеализацияТоваровУслуг> + + + + <Товары> + <Строка> + <ДанныеНоменклатуры> + <Номенклатура> + <ВидНоменклатуры> + <Наименование>Товар + + + + <ТипЗапасов>СобственныеТовары + + + + + + <Услуги> + <Строка> + <Номенклатура> + <Услуга>true + <ВидНоменклатуры> + <Наименование>Услуга + + + <Содержание>Индивидуальный дизайн + + + + +``` + +## 🔧 Изменения в коде + +### Файл: `modules/xml_generator.py` + +**Исправлено в двух методах:** +1. `generate_realization()` - для генерации одиночных документов +2. `_generate_single_document_content()` - для пакетной генерации (склейка нескольких документов) + +#### 1. Разделение позиций на товары и услуги (строки 1108-1119 и 1819-1830): + +```python +# Разделяем позиции на товары и услуги +товары = [] +услуги = [] + +for pos in positions: + assortment = pos.get("assortment", {}) + if _is_service(assortment): + услуги.append(pos) + else: + товары.append(pos) +``` + +#### 2. Генерация секции `<Товары>` (строки 1121-1207): + +- Добавлен блок `if товары:` - секция создаётся только если есть товары +- Для товаров используется `<ДанныеНоменклатуры>` +- Добавляется `<ТипЗапасов>` (обязательно для товаров) +- Счета учета: 43, 90.01.1, 90.02.1, 90.03 + +#### 3. Генерация секции `<Услуги>` (строки 1209-1275): + +- Добавлен новый блок `if услуги:` - секция создаётся только если есть услуги +- Для услуг используется `<Номенклатура>` напрямую (БЕЗ `ДанныеНоменклатуры`) +- Обязательный флаг `<Услуга>true` +- Добавлено поле `<Содержание>` с описанием услуги +- Счета учета: 90.01.1, 90.02.1, 90.03 (БЕЗ счета 43) + +## 📋 Ключевые различия между Товарами и Услугами + +| Параметр | Товары | Услуги | +|----------|--------|--------| +| Обёртка номенклатуры | `<ДанныеНоменклатуры>` | Напрямую `<Номенклатура>` | +| Флаг услуги | Не используется | `<Услуга>true` | +| ТипЗапасов | `СобственныеТовары` | НЕ используется | +| Содержание | НЕ используется | Описание услуги | +| СчетУчета | 43 (товары на складе) | НЕ используется | +| ПрослеживаемыйТовар | `false` | НЕ используется | + +## 🧪 Тестирование + +После исправления проверьте: + +1. Создайте документ с товарами и услугами +2. Экспортируйте в XML +3. Убедитесь, что в XML есть две секции: `<Товары>` и `<Услуги>` +4. Импортируйте в 1С +5. Проверьте, что услуги импортировались как услуги, а не как товары + +## 📚 Ссылки на спецификацию + +- Схема XSD: `аывавы.xml` (EnterpriseData 1.0) +- Документация 1С по формату обмена EnterpriseData +- Справочник "Номенклатура" в конфигурациях УТ 11, ERP, КА 2.0 + +## ⚠️ Важно + +**Проблема была в двух местах:** + +1. ✅ **Метод `generate_realization()`** - исправлен ранее (для одиночных документов) +2. ✅ **Метод `_generate_single_document_content()`** - исправлен сейчас (для **пакетной генерации**) + +При склейке нескольких документов в один XML файл использовался отдельный метод `_generate_single_document_content()`, в котором **не было** логики разделения на товары и услуги. Теперь исправлено в обоих методах! + +После этого исправления необходимо **перегенерировать все существующие XML-файлы**, если они будут использоваться для импорта в 1С. diff --git a/modules/xml_generator.py b/modules/xml_generator.py index 6c063d0..40fe8b6 100644 --- a/modules/xml_generator.py +++ b/modules/xml_generator.py @@ -62,6 +62,23 @@ def get_currency_codes(currency: Optional[Dict]) -> Tuple[str, str]: 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""" @@ -1087,110 +1104,184 @@ class XML1CGenerator: # Налогообложение etree.SubElement(doc, "Налогообложение").text = "ПродажаОблагаетсяНДС" - # Товары - goods_elem = etree.SubElement(doc, "Товары") + # Разделяем позиции на товары и услуги + товары = [] + услуги = [] for pos in positions: - 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", "") - - # Определяем тип номенклатуры (товар или услуга) - is_service = False - if product_href and "/service/" in product_href: - is_service = True - - # ДанныеНоменклатуры - 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 - - # Флаг услуги (для конфигураций без справочника видов) - if is_service: - etree.SubElement(nomenclature, "Услуга").text = "true" - - # ВидНоменклатуры (для конфигураций УТ 11, ERP) - kind_elem = etree.SubElement(nomenclature, "ВидНоменклатуры") - if is_service: - etree.SubElement(kind_elem, "Наименование").text = "Услуга" + 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) - - # Цена и суммы (в рублях с копейками) - price = pos.get("price", 0) / 100 # Конвертируем из копеек - total = price * quantity - - # Форматируем с 2 знаками после запятой (НЕ округляем до целых!) - etree.SubElement(row_elem, "Сумма").text = f"{total:.2f}" - etree.SubElement(row_elem, "Цена").text = f"{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 - - # СуммаНДС (извлекаем из суммы) - vat_amount = total / (1 + vat_rate / 100) * (vat_rate / 100) if vat_rate else 0 - etree.SubElement(row_elem, "СуммаНДС").text = self._format_money_with_decimals(vat_amount) - - # Дополнительные поля (опционально, зависит от конфигурации 1С) - # ТипЗапасов только для товаров (не для услуг) - if not is_service: + + # ЕдиницаИзмерения + 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) + + # Цена и суммы (в рублях с копейками) + price = pos.get("price", 0) / 100 # Конвертируем из копеек + total = price * quantity + + # Форматируем с 2 знаками после запятой (НЕ округляем до целых!) + etree.SubElement(row_elem, "Сумма").text = f"{total:.2f}" + etree.SubElement(row_elem, "Цена").text = f"{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 + + # СуммаНДС (извлекаем из суммы) + vat_amount = total / (1 + vat_rate / 100) * (vat_rate / 100) if vat_rate else 0 + etree.SubElement(row_elem, "СуммаНДС").text = self._format_money_with_decimals(vat_amount) + + # ТипЗапасов для товаров 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 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) + + # Цена и суммы (в рублях с копейками) + price = pos.get("price", 0) / 100 # Конвертируем из копеек + total = price * quantity + + # Форматируем с 2 знаками после запятой + etree.SubElement(row_elem, "Сумма").text = f"{total:.2f}" + etree.SubElement(row_elem, "Цена").text = f"{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 + + # СуммаНДС (извлекаем из суммы) + vat_amount = total / (1 + vat_rate / 100) * (vat_rate / 100) if vat_rate else 0 + etree.SubElement(row_elem, "СуммаНДС").text = self._format_money_with_decimals(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 = "Автоматически" @@ -1720,130 +1811,237 @@ class XML1CGenerator: doc, "Налогообложение" ).text = "ПродажаОблагаетсяНДС" - # Товары - goods_elem = etree.SubElement(doc, "Товары") + # Разделяем позиции на товары и услуги + товары = [] + услуги = [] for pos in positions: - 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", "") - - # Определяем тип номенклатуры (товар или услуга) - is_service = False - if product_href and "/service/" in product_href: - is_service = True - - 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 - - # Флаг услуги (для конфигураций без справочника видов) - if is_service: - etree.SubElement(nomenclature, "Услуга").text = "true" - - # ВидНоменклатуры (для конфигураций УТ 11, ERP) - kind_elem = etree.SubElement(nomenclature, "ВидНоменклатуры") - if is_service: - etree.SubElement(kind_elem, "Наименование").text = "Услуга" + 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) - - price = pos.get("price", 0) / 100 - total = price * quantity - - etree.SubElement(row_elem, "Сумма").text = f"{total:.2f}" - etree.SubElement(row_elem, "Цена").text = f"{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 - - vat_amount = ( - total / (1 + vat_rate / 100) * (vat_rate / 100) - if vat_rate else 0 - ) - etree.SubElement( - row_elem, "СуммаНДС" - ).text = self._format_money_with_decimals(vat_amount) - - # ТипЗапасов только для товаров (не для услуг) - if not is_service: + + 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) + + price = pos.get("price", 0) / 100 + total = price * quantity + + etree.SubElement(row_elem, "Сумма").text = f"{total:.2f}" + etree.SubElement(row_elem, "Цена").text = f"{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 + + vat_amount = ( + total / (1 + vat_rate / 100) * (vat_rate / 100) + if vat_rate else 0 + ) + etree.SubElement( + row_elem, "СуммаНДС" + ).text = self._format_money_with_decimals(vat_amount) + + # ТипЗапасов для товаров 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, "Услуги") - # Счета учета (опционально) - 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" + 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) + + price = pos.get("price", 0) / 100 + total = price * quantity + + etree.SubElement(row_elem, "Сумма").text = f"{total:.2f}" + etree.SubElement(row_elem, "Цена").text = f"{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 + + vat_amount = ( + total / (1 + vat_rate / 100) * (vat_rate / 100) + if vat_rate else 0 + ) + etree.SubElement( + row_elem, "СуммаНДС" + ).text = self._format_money_with_decimals(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( diff --git a/modules/xml_generator_refactored2.py b/modules/xml_generator_refactored2.py deleted file mode 100644 index 1f4c85f..0000000 --- a/modules/xml_generator_refactored2.py +++ /dev/null @@ -1,446 +0,0 @@ -""" -Генератор XML документов в формате 1С EnterpriseData 1.20 -Рефакторенная версия с устранением дублирования кода -""" -from lxml import etree -from datetime import datetime -from typing import Dict, List, Optional -import logging - -logger = logging.getLogger(__name__) - - -class XML1CGenerator: - """Генератор XML документов для 1С в формате EnterpriseData 1.20""" - - VERSION = "1.20" - NAMESPACE = "http://v8.1c.ru/8.1/data/enterprise" - NS_PREFIX = "v8" - - def __init__(self): - self.encoding = "UTF-8" - self.nsmap = {self.NS_PREFIX: self.NAMESPACE} - - # ========== Утилиты форматирования ========== - - @staticmethod - def _format_date(date_str: str) -> str: - """Форматирование даты в формат ГГГГ-ММ-ДДTЧЧ:ММ:СС""" - 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"Ошибка форматирования даты: {e}") - return datetime.now().strftime("%Y-%m-%dT%H:%M:%S") - - @staticmethod - def _format_money(amount: int) -> str: - """Форматирование денежной суммы (копейки -> рубли)""" - return f"{amount / 100:.2f}" - - @staticmethod - def _safe_get(data: Dict, key: str, default: str = "") -> str: - """Безопасное извлечение значения из словаря""" - return str(data.get(key, default) or default) - - # ========== Создание XML элементов ========== - - def _create_element( - self, - tag: str, - text: Optional[str] = None, - **attributes - ) -> etree.Element: - """Создание XML элемента с атрибутами""" - elem = etree.Element(f"{{{self.NAMESPACE}}}{tag}") - if text: - elem.text = text - for key, value in attributes.items(): - if value is not None: - elem.set(key, str(value)) - return elem - - def _add_property( - self, - parent: etree.Element, - name: str, - value: str, - value_type: str = "string" - ) -> None: - """Добавление свойства объекта 1С""" - prop = etree.SubElement(parent, f"{{{self.NAMESPACE}}}Property") - etree.SubElement(prop, f"{{{self.NAMESPACE}}}Name").text = name - - value_elem = etree.SubElement( - prop, - f"{{{self.NAMESPACE}}}Value", - ) - if value_type == "string": - value_elem.set( - f"{{{self.NAMESPACE}}}type", - "http://www.w3.org/2001/XMLSchema:string" - ) - elif value_type == "decimal": - value_elem.set( - f"{{{self.NAMESPACE}}}type", - "http://www.w3.org/2001/XMLSchema:decimal" - ) - elif value_type == "dateTime": - value_elem.set( - f"{{{self.NAMESPACE}}}type", - "http://www.w3.org/2001/XMLSchema:dateTime" - ) - - value_elem.text = value - - # ========== Работа с организациями ========== - - def _add_organization_properties( - self, - parent: etree.Element, - org_data: Dict, - prefix: str = "Организация" - ) -> None: - """Добавление свойств организации""" - self._add_property( - parent, - f"{prefix}Наименование", - self._safe_get(org_data, "name") - ) - self._add_property( - parent, - f"{prefix}ИНН", - self._safe_get(org_data, "inn") - ) - - kpp = self._safe_get(org_data, "kpp") - if kpp: - self._add_property(parent, f"{prefix}КПП", kpp) - - self._add_property( - parent, - f"{prefix}ЮрАдрес", - self._safe_get(org_data, "legalAddress") - ) - - def _add_counterparty_properties( - self, - parent: etree.Element, - counterparty: Dict - ) -> None: - """Добавление свойств контрагента""" - self._add_organization_properties( - parent, - counterparty, - prefix="Контрагент" - ) - - # ========== Работа с товарами ========== - - def _add_product_item( - self, - parent: etree.Element, - position: Dict, - index: int - ) -> None: - """Добавление позиции товара в таблицу""" - row = etree.SubElement(parent, f"{{{self.NAMESPACE}}}Row") - - # Номер строки - self._add_property(row, "НомерСтроки", str(index)) - - # Наименование товара - product_name = self._safe_get(position, "name") - self._add_property(row, "Номенклатура", product_name) - - # Количество - quantity = position.get("quantity", 0) - self._add_property(row, "Количество", str(quantity), "decimal") - - # Цена - price = self._format_money(position.get("price", 0)) - self._add_property(row, "Цена", price, "decimal") - - # Сумма - total = self._format_money( - quantity * position.get("price", 0) - ) - self._add_property(row, "Сумма", total, "decimal") - - # НДС - vat = position.get("vat", 0) - if vat: - vat_rate = vat - vat_sum = self._format_money( - int(quantity * position.get("price", 0) * vat / 100) - ) - self._add_property(row, "СтавкаНДС", str(vat_rate)) - self._add_property(row, "СуммаНДС", vat_sum, "decimal") - - def _add_products_table( - self, - parent: etree.Element, - positions: List[Dict] - ) -> None: - """Добавление таблицы товаров""" - table = etree.SubElement(parent, f"{{{self.NAMESPACE}}}Property") - etree.SubElement(table, f"{{{self.NAMESPACE}}}Name").text = "Товары" - - value = etree.SubElement(table, f"{{{self.NAMESPACE}}}Value") - value.set( - f"{{{self.NAMESPACE}}}type", - f"{{{self.NAMESPACE}}}ValueTable" - ) - - for idx, position in enumerate(positions, start=1): - self._add_product_item(value, position, idx) - - # ========== Работа с суммами ========== - - def _calculate_totals(self, positions: List[Dict]) -> Dict[str, float]: - """Расчет итоговых сумм""" - total_sum = 0 - total_vat = 0 - - for position in positions: - quantity = position.get("quantity", 0) - price = position.get("price", 0) - vat = position.get("vat", 0) - - item_sum = quantity * price - total_sum += item_sum - - if vat: - total_vat += int(item_sum * vat / 100) - - return { - "sum": self._format_money(int(total_sum)), - "vat": self._format_money(total_vat), - "total": self._format_money(int(total_sum) + total_vat) - } - - def _add_totals( - self, - parent: etree.Element, - totals: Dict[str, float] - ) -> None: - """Добавление итоговых сумм""" - self._add_property( - parent, - "СуммаДокумента", - totals["sum"], - "decimal" - ) - self._add_property( - parent, - "СуммаНДС", - totals["vat"], - "decimal" - ) - self._add_property( - parent, - "СуммаВсего", - totals["total"], - "decimal" - ) - - # ========== Генерация документов ========== - - def _create_document_base( - self, - doc_type: str, - order_data: Dict - ) -> etree.Element: - """Создание базовой структуры документа""" - root = etree.Element( - f"{{{self.NAMESPACE}}}Data", - nsmap=self.nsmap - ) - - # Основной объект документа - obj = etree.SubElement(root, f"{{{self.NAMESPACE}}}Object") - - # Тип документа - self._add_property(obj, "ТипДокумента", doc_type) - - # Номер и дата - self._add_property( - obj, - "Номер", - self._safe_get(order_data, "name") - ) - self._add_property( - obj, - "Дата", - self._format_date(self._safe_get(order_data, "moment")), - "dateTime" - ) - - return obj - - def _add_document_parties( - self, - obj: etree.Element, - organization: Dict, - counterparty: Dict - ) -> None: - """Добавление информации о сторонах сделки""" - self._add_organization_properties(obj, organization) - self._add_counterparty_properties(obj, counterparty) - - def generate_realization( - self, - order_data: Dict, - filename: Optional[str] = None - ): - """ - Генерация XML документа реализации товаров - - Args: - order_data: Данные о заказе - filename: Имя выходного файла (опционально) - - Returns: - Если filename указан - True/False (успех/ошибка) - Если filename не указан - XML строка или None при ошибке - """ - try: - logger.info( - f"Генерация XML реализации для заказа " - f"{order_data.get('name', 'N/A')}" - ) - - # Базовая структура - obj = self._create_document_base( - "РеализацияТоваровУслуг", - order_data - ) - - # Организация и контрагент - organization = order_data.get("organization", {}) - agent = order_data.get("agent", {}) - - self._add_document_parties(obj, organization, agent) - - # Товары - positions = order_data.get("positions", []) - self._add_products_table(obj, positions) - - # Итоговые суммы - totals = self._calculate_totals(positions) - self._add_totals(obj, totals) - - # Получаем XML - root = obj.getparent() - tree = etree.ElementTree(root) - - if filename: - # Сохранение в файл - tree.write( - filename, - encoding=self.encoding, - xml_declaration=True, - pretty_print=True - ) - logger.info(f"XML успешно сохранен: {filename}") - return True - else: - # Возврат XML строки - xml_str = etree.tostring( - root, - encoding=self.encoding, - xml_declaration=True, - pretty_print=True - ).decode(self.encoding) - logger.info("XML успешно сгенерирован") - return xml_str - - except Exception as e: - logger.error(f"Ошибка генерации XML: {e}", exc_info=True) - return None if filename is None else False - - def generate_batch_realization( - self, - orders: List[Dict], - filename: str - ) -> bool: - """ - Генерация пакетного XML с несколькими документами - - Args: - orders: Список заказов - filename: Имя выходного файла - - Returns: - True если генерация успешна - """ - try: - logger.info(f"Генерация пакетного XML для {len(orders)} заказов") - - root = etree.Element( - f"{{{self.NAMESPACE}}}Data", - nsmap=self.nsmap - ) - - for order_data in orders: - # Создаем объект документа для каждого заказа - obj = etree.SubElement( - root, - f"{{{self.NAMESPACE}}}Object" - ) - - # Тип документа - self._add_property( - obj, - "ТипДокумента", - "РеализацияТоваровУслуг" - ) - - # Номер и дата - self._add_property( - obj, - "Номер", - self._safe_get(order_data, "name") - ) - self._add_property( - obj, - "Дата", - self._format_date(self._safe_get(order_data, "moment")), - "dateTime" - ) - - # Стороны сделки - organization = order_data.get("organization", {}) - agent = order_data.get("agent", {}) - self._add_document_parties(obj, organization, agent) - - # Товары - positions = order_data.get("positions", []) - self._add_products_table(obj, positions) - - # Итоги - totals = self._calculate_totals(positions) - self._add_totals(obj, totals) - - # Сохранение - tree = etree.ElementTree(root) - tree.write( - filename, - encoding=self.encoding, - xml_declaration=True, - pretty_print=True - ) - - logger.info(f"Пакетный XML успешно сохранен: {filename}") - return True - - except Exception as e: - logger.error( - f"Ошибка генерации пакетного XML: {e}", - exc_info=True - ) - return False - - -# Для обратной совместимости -XMLGenerator = XML1CGenerator