# Анализ CommerceML (обмен 1С с сайтом)

> **Область:** Парсинг и интерпретация XML-файлов формата CommerceML 2.x — стандарт обмена данными между 1С:Предприятие и внешними системами (интернет-магазины, маркетплейсы, ERP).

## Корневой элемент

`<КоммерческаяИнформация>` — обязательные атрибуты:
- `ВерсияСхемы` — версия CommerceML (обычно `2.08`, `2.10`)
- `ДатаФормирования` — дата/время генерации файла

---

## Структура каталога (import.xml)

`<КоммерческаяИнформация>` → `<Классификатор>` + `<Каталог>`

### Классификатор

| Элемент | Описание |
|---------|----------|
| `<Ид>` | Уникальный GUID классификатора |
| `<Наименование>` | Название классификатора |
| `<Группы>` → `<Группа>` | Иерархия категорий товаров (вложенные `<Группы>`) |
| `<Свойства>` → `<Свойство>` | Справочник свойств (характеристик) с вариантами значений |

### Каталог товаров

| Элемент | Описание |
|---------|----------|
| `<Каталог>` → `<Товары>` → `<Товар>` | Список товаров |
| `<Товар>` / `<Ид>` | GUID товара |
| `<Товар>` / `<Наименование>` | Название товара |
| `<Товар>` / `<Артикул>` | Артикул (SKU) |
| `<Товар>` / `<Группы>` → `<Ид>` | Привязка к категориям классификатора |
| `<Товар>` / `<Описание>` | Текстовое описание |
| `<Товар>` / `<Картинка>` | Путь к изображению |
| `<Товар>` / `<ЗначенияСвойств>` → `<ЗначенияСвойства>` | Значения характеристик товара |
| `<Товар>` / `<ЗначенияРеквизитов>` → `<ЗначениеРеквизита>` | Реквизиты (ВидНоменклатуры, ТипНоменклатуры, Вес и др.) |

### Характеристики товаров

`<ХарактеристикиТовара>` → `<ХарактеристикаТовара>` — варианты одного товара (размер, цвет):
- `<Ид>` — GUID характеристики
- `<Наименование>` — название (например, «Красный, XL»)
- `<Значение>` — составное значение характеристики

---

## Структура предложений (offers.xml)

`<КоммерческаяИнформация>` → `<ПакетПредложений>`

| Элемент | Описание |
|---------|----------|
| `<ПакетПредложений>` | Контейнер с ценами и остатками |
| `<Предложения>` → `<Предложение>` | Список предложений |
| `<Предложение>` / `<Ид>` | GUID товара (или товар#характеристика) |
| `<Предложение>` / `<Цены>` → `<Цена>` | Цены по типам (`<ТипЦены>`, `<ЦенаЗаЕдиницу>`, `<Валюта>`) |
| `<Предложение>` / `<Количество>` | Остаток на складе |
| `<Предложение>` / `<Склад>` | Остатки по складам (атрибуты `ИдСклада`, `КоличествоНаСкладе`) |
| `<ТипыЦен>` → `<ТипЦены>` | Справочник типов цен (розничная, оптовая, закупочная) |

---

## Структура заказов (orders.xml)

`<КоммерческаяИнформация>` → `<Документ>`

| Элемент | Описание |
|---------|----------|
| `<Документ>` / `<Ид>` | Идентификатор заказа |
| `<Документ>` / `<Номер>` | Номер заказа |
| `<Документ>` / `<Дата>` | Дата заказа (YYYY-MM-DD) |
| `<Документ>` / `<Время>` | Время заказа (HH:MM:SS) |
| `<Документ>` / `<ХозОперация>` | Тип операции («Заказ товара») |
| `<Документ>` / `<Роль>` | Роль документа (Продавец / Покупатель) |
| `<Документ>` / `<Валюта>` | Валюта документа |
| `<Документ>` / `<Курс>` | Курс валюты |
| `<Документ>` / `<Сумма>` | Итого по документу |
| `<Документ>` / `<Контрагенты>` → `<Контрагент>` | Данные покупателя |
| `<Документ>` / `<Товары>` → `<Товар>` | Позиции заказа |
| `<Документ>` / `<ЗначенияРеквизитов>` | Статус, способ доставки, оплата |

### Контрагент заказа

| Элемент | Описание |
|---------|----------|
| `<Ид>` | GUID контрагента |
| `<Наименование>` | ФИО или название организации |
| `<ПолноеНаименование>` | Полное наименование |
| `<Роль>` | «Покупатель» |
| `<АдресРегистрации>` → `<АдресноеПоле>` | Структурированный адрес (Почтовый индекс, Страна, Регион, Город, Улица, Дом) |
| `<Контакты>` → `<Контакт>` | Телефон, email (`<Тип>`, `<Значение>`) |

### Товар в заказе

| Элемент | Описание |
|---------|----------|
| `<Ид>` | GUID товара (или товар#характеристика) |
| `<Наименование>` | Название позиции |
| `<БазоваяЕдиница>` | Единица измерения (Код, НаименованиеПолное, МеsждународноеСокращение) |
| `<ЦенаЗаЕдиницу>` | Цена |
| `<Количество>` | Количество |
| `<Сумма>` | Сумма позиции |
| `<Скидки>` → `<Скидка>` | Скидки (Наименование, Сумма, Процент, УчтеноВСумме) |
| `<ЗначенияРеквизитов>` | Доп. реквизиты позиции (ТипНоменклатуры, ВидНоменклатуры, Склад) |

### Статусы заказа (в ЗначенияРеквизитов)

| Реквизит | Значения |
|----------|----------|
| `Статус заказа` | `Новый`, `Принят`, `Выполняется`, `Выполнен`, `Отменён` |
| `Проведён` | `true` / `false` |
| `Оплачен` | `true` / `false` |
| `Способ доставки` | `Самовывоз`, `Курьер`, `Почта`, `ТК` |
| `Способ оплаты` | `Наличные`, `Безналичные`, `Карта`, `Онлайн` |
| `Дата отгрузки` | YYYY-MM-DD |
| `Номер по 1С` | Номер документа в 1С после загрузки |

---

## Протокол обмена (sale/exchange)

### Последовательность HTTP-запросов

| Шаг | Метод | URL | Описание |
|-----|-------|-----|----------|
| 1 | GET | `/sale/exchange?type=sale&mode=checkauth` | Авторизация (Basic Auth) → cookie, sessid |
| 2 | GET | `/sale/exchange?type=sale&mode=init` | Инициализация → zip=yes/no, file_limit |
| 3 | POST | `/sale/exchange?type=sale&mode=file&filename=orders.xml` | Загрузка файла заказов в 1С |
| 4 | GET | `/sale/exchange?type=sale&mode=import&filename=orders.xml` | Обработка загруженного файла |
| 5 | GET | `/sale/exchange?type=sale&mode=query` | Запрос изменений из 1С |

### Для каталога (catalog/exchange)

| Шаг | Метод | URL | Описание |
|-----|-------|-----|----------|
| 1 | GET | `/catalog/exchange?type=catalog&mode=checkauth` | Авторизация |
| 2 | GET | `/catalog/exchange?type=catalog&mode=init` | Инициализация |
| 3 | POST | `/catalog/exchange?type=catalog&mode=file&filename=import.xml` | Загрузка каталога |
| 4 | POST | `/catalog/exchange?type=catalog&mode=file&filename=offers.xml` | Загрузка предложений |
| 5 | GET | `/catalog/exchange?type=catalog&mode=import&filename=import.xml` | Обработка каталога |
| 6 | GET | `/catalog/exchange?type=catalog&mode=import&filename=offers.xml` | Обработка предложений |

---

## Парсинг Python — паттерны кода

### Базовый парсинг import.xml

```python
import xml.etree.ElementTree as ET
from pathlib import Path

def parse_catalog(filepath: str) -> dict:
    """Парсинг каталога CommerceML. Возвращает группы + товары."""
    tree = ET.parse(filepath)
    root = tree.getroot()

    # Группы (категории) — рекурсивная иерархия
    groups = {}
    def walk_groups(parent_el, parent_id=None):
        for g in parent_el.findall('Группа'):
            gid = g.findtext('Ид')
            groups[gid] = {
                'name': g.findtext('Наименование'),
                'parent_id': parent_id
            }
            sub = g.find('Группы')
            if sub is not None:
                walk_groups(sub, gid)

    classifier = root.find('Классификатор')
    if classifier is not None:
        top_groups = classifier.find('Группы')
        if top_groups is not None:
            walk_groups(top_groups)

    # Свойства (характеристики) — справочник
    properties = {}
    props_el = classifier.find('Свойства') if classifier is not None else None
    if props_el is not None:
        for prop in props_el.findall('Свойство'):
            pid = prop.findtext('Ид')
            variants = {}
            vv = prop.find('ВариантыЗначений')
            if vv is not None:
                for v in vv.findall('Справочник'):
                    variants[v.findtext('ИдЗначения')] = v.findtext('Значение')
            properties[pid] = {
                'name': prop.findtext('Наименование'),
                'type': prop.findtext('ТипЗначений', 'Строка'),
                'variants': variants
            }

    # Товары
    products = []
    catalog = root.find('Каталог')
    if catalog is not None:
        for item in catalog.findall('.//Товар'):
            product = {
                'id': item.findtext('Ид'),
                'name': item.findtext('Наименование'),
                'sku': item.findtext('Артикул', ''),
                'description': item.findtext('Описание', ''),
                'group_ids': [g.text for g in item.findall('Группы/Ид')],
                'images': [img.text for img in item.findall('Картинка')],
                'properties': {},
                'requisites': {},
                'characteristics': []
            }
            # Значения свойств
            for zs in item.findall('ЗначенияСвойств/ЗначенияСвойства'):
                prop_id = zs.findtext('Ид')
                value = zs.findtext('Значение', '')
                product['properties'][prop_id] = value

            # Реквизиты
            for zr in item.findall('ЗначенияРеквизитов/ЗначениеРеквизита'):
                product['requisites'][zr.findtext('Наименование')] = zr.findtext('Значение', '')

            # Характеристики товара
            for ch in item.findall('ХарактеристикиТовара/ХарактеристикаТовара'):
                product['characteristics'].append({
                    'id': ch.findtext('Ид'),
                    'name': ch.findtext('Наименование', '')
                })

            products.append(product)

    return {'groups': groups, 'properties': properties, 'products': products}
```

### Парсинг offers.xml (цены и остатки)

```python
def parse_offers(filepath: str) -> dict:
    """Парсинг предложений: цены и остатки по товарам."""
    tree = ET.parse(filepath)
    root = tree.getroot()
    packet = root.find('ПакетПредложений')
    if packet is None:
        return {'price_types': {}, 'offers': []}

    # Типы цен
    price_types = {}
    for pt in packet.findall('ТипыЦен/ТипЦены'):
        price_types[pt.findtext('Ид')] = {
            'name': pt.findtext('Наименование'),
            'currency': pt.findtext('Валюта', 'RUB')
        }

    # Предложения
    offers = []
    for offer in packet.findall('Предложения/Предложение'):
        offer_id = offer.findtext('Ид', '')
        # Разделение товар#характеристика
        parts = offer_id.split('#')
        product_id = parts[0]
        char_id = parts[1] if len(parts) > 1 else None

        prices = {}
        for price in offer.findall('Цены/Цена'):
            pt_id = price.findtext('ИдТипаЦены')
            prices[pt_id] = {
                'value': float(price.findtext('ЦенаЗаЕдиницу', '0')),
                'currency': price.findtext('Валюта', 'RUB'),
                'unit': price.findtext('Единица', 'шт')
            }

        # Остатки — общий или по складам
        quantity = float(offer.findtext('Количество', '0'))
        warehouses = {}
        for wh in offer.findall('Склад'):
            wh_id = wh.get('ИдСклада', '')
            warehouses[wh_id] = float(wh.get('КоличествоНаСкладе', '0'))

        offers.append({
            'product_id': product_id,
            'characteristic_id': char_id,
            'prices': prices,
            'quantity': quantity,
            'warehouses': warehouses
        })

    return {'price_types': price_types, 'offers': offers}
```

### Парсинг orders.xml

```python
def parse_orders(filepath: str) -> list[dict]:
    """Парсинг заказов CommerceML."""
    tree = ET.parse(filepath)
    root = tree.getroot()
    orders = []

    for doc in root.findall('Документ'):
        order = {
            'id': doc.findtext('Ид'),
            'number': doc.findtext('Номер'),
            'date': doc.findtext('Дата'),
            'time': doc.findtext('Время', ''),
            'operation': doc.findtext('ХозОперация', ''),
            'role': doc.findtext('Роль', ''),
            'currency': doc.findtext('Валюта', 'RUB'),
            'total': float(doc.findtext('Сумма', '0')),
            'comment': doc.findtext('Комментарий', ''),
            'counterparties': [],
            'items': [],
            'requisites': {}
        }

        # Контрагенты
        for cp in doc.findall('Контрагенты/Контрагент'):
            party = {
                'id': cp.findtext('Ид'),
                'name': cp.findtext('Наименование'),
                'full_name': cp.findtext('ПолноеНаименование', ''),
                'role': cp.findtext('Роль', ''),
                'inn': cp.findtext('ИНН', ''),
                'address': {},
                'contacts': []
            }
            for af in cp.findall('АдресРегистрации/АдресноеПоле'):
                party['address'][af.findtext('Тип')] = af.findtext('Значение', '')
            for ct in cp.findall('Контакты/Контакт'):
                party['contacts'].append({
                    'type': ct.findtext('Тип'),
                    'value': ct.findtext('Значение')
                })
            order['counterparties'].append(party)

        # Товары
        for item in doc.findall('Товары/Товар'):
            parts = item.findtext('Ид', '').split('#')
            order['items'].append({
                'product_id': parts[0],
                'characteristic_id': parts[1] if len(parts) > 1 else None,
                'name': item.findtext('Наименование', ''),
                'price': float(item.findtext('ЦенаЗаЕдиницу', '0')),
                'quantity': float(item.findtext('Количество', '0')),
                'total': float(item.findtext('Сумма', '0')),
                'unit': item.findtext('БазоваяЕдиница', 'шт')
            })

        # Реквизиты
        for zr in doc.findall('ЗначенияРеквизитов/ЗначениеРеквизита'):
            order['requisites'][zr.findtext('Наименование')] = zr.findtext('Значение', '')

        orders.append(order)

    return orders
```

### Сопоставление каталога с предложениями

```python
def merge_catalog_offers(catalog: dict, offers: dict) -> list[dict]:
    """Объединение товаров с ценами/остатками. Найти товары без цен."""
    offer_map = {}
    for o in offers['offers']:
        key = o['product_id']
        if o['characteristic_id']:
            key += '#' + o['characteristic_id']
        offer_map[key] = o

    result = []
    for product in catalog['products']:
        pid = product['id']
        if product['characteristics']:
            for ch in product['characteristics']:
                key = pid + '#' + ch['id']
                offer = offer_map.get(key)
                result.append({
                    **product,
                    'characteristic': ch['name'],
                    'prices': offer['prices'] if offer else {},
                    'quantity': offer['quantity'] if offer else 0,
                    'has_offer': offer is not None
                })
        else:
            offer = offer_map.get(pid)
            result.append({
                **product,
                'characteristic': None,
                'prices': offer['prices'] if offer else {},
                'quantity': offer['quantity'] if offer else 0,
                'has_offer': offer is not None
            })
    return result
```

---

## Маппинг CommerceML → объекты 1С

| CommerceML элемент | Объект 1С | Комментарий |
|--------------------|-----------|-------------|
| `<Группа>` | Справочник.НоменклатурныеГруппы / Справочник.КатегорииНоменклатуры | Зависит от конфигурации |
| `<Товар>` | Справочник.Номенклатура | Основной справочник товаров |
| `<ХарактеристикаТовара>` | Справочник.ХарактеристикиНоменклатуры | Размер, цвет, вариант |
| `<Свойство>` | ПланВидовХарактеристик.ДополнительныеРеквизитыИСведения | Произвольные свойства |
| `<ТипЦены>` | Справочник.ВидыЦен (УТ) / Справочник.ТипыЦенНоменклатуры (БП) | Розничная, оптовая |
| `<Склад>` | Справочник.Склады | Складские остатки |
| `<Контрагент>` | Справочник.Контрагенты | Покупатели/поставщики |
| `<Документ>` (заказ) | Документ.ЗаказПокупателя (УТ) / Документ.ЗаказКлиента (ERP) | Заказ с сайта |
| `<Цена>` | РегистрСведений.ЦеныНоменклатуры | Хранение цен |
| `<Количество>` (остаток) | РегистрНакопления.ТоварыНаСкладах / СвободныеОстатки | Текущие остатки |

---

## Типичные ошибки и проблемы

### 1. Кодировка windows-1251

Старые выгрузки из 1С могут использовать windows-1251 вместо UTF-8:
```python
# Проверка и перекодировка
with open(filepath, 'rb') as f:
    raw = f.read(100)
if b'encoding="windows-1251"' in raw or b'encoding="Windows-1251"' in raw:
    with open(filepath, 'r', encoding='windows-1251') as f:
        content = f.read()
    # Перезаписать в UTF-8 для стандартного парсинга
    content = content.replace('encoding="windows-1251"', 'encoding="utf-8"')
    content = content.replace('encoding="Windows-1251"', 'encoding="utf-8"')
```

### 2. Дубли GUID товаров

Один и тот же `<Ид>` может встречаться несколько раз — при обновлении выгрузки 1С отправляет полную замену. Всегда используй upsert-логику, а не append.

### 3. Связь товар#характеристика

В `offers.xml` идентификатор предложения может быть составным: `GUID_товара#GUID_характеристики`. Обязательно split('#'):
```python
offer_id = "aaa-bbb-ccc#ddd-eee-fff"
product_id, char_id = offer_id.split('#') if '#' in offer_id else (offer_id, None)
```

### 4. Порционная выгрузка

Большие каталоги разбиваются на файлы: `import0_1.xml`, `import0_2.xml`... Каждый файл — самостоятельный XML с тем же корневым элементом. Нужно обрабатывать все файлы:
```python
from pathlib import Path

files = sorted(Path(directory).glob('import*.xml'))
all_products = []
for f in files:
    result = parse_catalog(str(f))
    all_products.extend(result['products'])
```

### 5. Пустые значения свойств

`<ЗначенияСвойства>` может содержать пустое `<Значение/>` — это означает сброс свойства. Не игнорировать:
```python
value = zs.findtext('Значение')  # Может быть None или ''
# None = тег отсутствует, '' = значение сброшено
```

### 6. Несовпадение ИД типов цен

GUID типов цен в `offers.xml` → `<ТипыЦен>` и в самих `<Предложение>` → `<Цена>` → `<ИдТипаЦены>` должны совпадать. Если выгрузка неполная, типы цен могут быть не определены.

### 7. Большие файлы (>100MB)

Для файлов >100MB используй `iterparse` вместо `parse`:
```python
import xml.etree.ElementTree as ET

def parse_large_catalog(filepath: str):
    """Потоковый парсинг больших каталогов."""
    for event, elem in ET.iterparse(filepath, events=('end',)):
        if elem.tag == 'Товар':
            yield {
                'id': elem.findtext('Ид'),
                'name': elem.findtext('Наименование'),
                'sku': elem.findtext('Артикул', '')
            }
            elem.clear()  # Освобождаем память
```

---

## Стандартные имена файлов

| Файл | Назначение |
|------|------------|
| `import.xml` | Каталог товаров (классификатор + товары) |
| `offers.xml` | Цены и остатки |
| `orders.xml` | Заказы (выгрузка из сайта в 1С) |
| `import0_1.xml` | Порционная выгрузка каталога (часть 1) |
| `offers0_1.xml` | Порционная выгрузка предложений (часть 1) |

## Типичные задачи анализа

| Задача | Что делать |
|--------|------------|
| Извлечь каталог товаров | Разобрать `<Классификатор>` → `<Группы>` + `<Каталог>` → `<Товары>`, построить дерево категорий |
| Сравнить цены | Сопоставить `<Предложение>` / `<Ид>` с `<Товар>` / `<Ид>`, вывести таблицу товар-цена |
| Проверить остатки | Извлечь `<Количество>` или `<Склад КоличествоНаСкладе>` из offers.xml |
| Сверить заказы | Сопоставить `<Документ>` / `<Товары>` с каталогом по GUID, проверить цены и суммы |
| Найти товары без цен | Найти товары из import.xml, отсутствующие в offers.xml по `<Ид>` |
| Анализ характеристик | Извлечь `<ХарактеристикиТовара>`, сгруппировать по товару |
| Проверить загрузку заказов | Сверить `orders.xml` с ответом 1С (статусы, номера по 1С) |
| Валидация целостности | GUID товаров в offers.xml должны быть в import.xml |

## Особенности формата

- GUID идентификаторы: все `<Ид>` — UUID v4 в формате `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`
- Связь товар-предложение: `<Предложение>/<Ид>` = `<Товар>/<Ид>` (простой) или `<Товар>/<Ид>#<Характеристика>/<Ид>` (с характеристиками)
- Кодировки: обычно UTF-8, реже windows-1251 в старых выгрузках
- Большие файлы: каталоги с 10 000+ товарами — порционная выгрузка (import0_1.xml, import0_2.xml...)

## Anti-patterns

- **Не изменять** CommerceML-файлы — только читать и анализировать
- **Не генерировать** CommerceML XML — для этого используется 1С:Предприятие
- **Не путать** с XML ФНС (2-НДФЛ, 3-НДФЛ) — это другой формат
- **Не парсить регулярками** — только XML-парсер (ElementTree, lxml)
- **Не загружать целиком в память** файлы >100MB — использовать iterparse
