cdc-1c 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cdc_1c/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ from importlib.metadata import version, PackageNotFoundError
2
+
3
+ try:
4
+ __version__ = version("cdc-1c")
5
+ except PackageNotFoundError:
6
+ __version__ = "dev"
7
+
8
+ from .metadata_reader import MetadataReader1C
9
+ from .data_reader import DataReader1C, DataObject1C
10
+ from .change_reader import ChangeReader1C
11
+ from .name_mapper import NameMapper1C
12
+ from .db_writer import DBWriter1C
13
+ from .config import Config
14
+ from .replicator import Replicator1C
15
+
16
+ __all__ = ["MetadataReader1C", "DataReader1C", "DataObject1C", "ChangeReader1C", "NameMapper1C",
17
+ "DBWriter1C", "Config", "Replicator1C", "__version__"]
18
+
cdc_1c/__main__.py ADDED
@@ -0,0 +1,30 @@
1
+ """
2
+ Entrypoint для запуска из окружения: `python -m cdc_1c` или команда `cdc-1c`.
3
+
4
+ Настройки читаются из переменных окружения CDC1C_* (см. Config.from_env), из них строится
5
+ Replicator1C. Режим — CDC1C_MODE: `loop` (по умолчанию) запускает run_forever с периодом
6
+ CDC1C_POLL_INTERVAL, `once` — один run_once.
7
+ """
8
+ import logging
9
+
10
+ from cdc_1c.config import Config
11
+ from cdc_1c.replicator import Replicator1C
12
+
13
+
14
+ def main() -> None:
15
+ config = Config.from_env()
16
+ replicator = Replicator1C.from_config(config)
17
+ # Уровень логирования из конфига — после конструктора: он вешает обработчик на логгер cdc_1c
18
+ # (по умолчанию INFO), а тут переопределяем на заданный (например, DEBUG/WARNING).
19
+ logging.getLogger("cdc_1c").setLevel(config.log_level.upper())
20
+
21
+ if config.mode == "once":
22
+ replicator.run_once()
23
+ elif config.mode == "loop":
24
+ replicator.run_forever(interval=config.poll_interval)
25
+ else:
26
+ raise SystemExit(f"Unknown CDC1C_MODE={config.mode!r} (expected 'loop' or 'once')")
27
+
28
+
29
+ if __name__ == "__main__":
30
+ main()
@@ -0,0 +1,77 @@
1
+ import requests
2
+ import logging
3
+
4
+ import xmltodict
5
+
6
+ from cdc_1c.data_reader import DataReader1C
7
+ from cdc_1c.metadata_reader import MetadataReader1C, resolve_timeout
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class ChangeReader1C(DataReader1C):
13
+ def __init__(self, odata_url: str, exchange_name: str, queue_guid: str,
14
+ metadata: MetadataReader1C, odata_auth: tuple[str, str] | None = None,
15
+ request_timeout: float | None = None):
16
+ super().__init__(odata_url, metadata, odata_auth, request_timeout)
17
+ self.exchange_name = exchange_name
18
+ self.queue_guid = queue_guid
19
+ self.message_no = 0
20
+
21
+ def read_changes(self):
22
+ # Сбрасываем накопленные данные предыдущего цикла (важно для run_forever).
23
+ self.clear()
24
+ self.message_no = self.get_last_received_no()+1
25
+ self.exchange_message_no = self.message_no
26
+
27
+ logger.info(f"Reading changes from 1C (message {self.message_no})")
28
+
29
+ url = f"{self.odata_url}/SelectChanges?DataExchangePoint='{self.odata_url}/ExchangePlan_{self.exchange_name}(guid'{self.queue_guid}')'&MessageNo={self.message_no}"
30
+
31
+ response = requests.post(url,auth=self.odata_auth,timeout=resolve_timeout(self.request_timeout))
32
+ response.raise_for_status()
33
+
34
+ change_data = xmltodict.parse(response.text,force_list=('d:element','entry'))
35
+ change_entries = (change_data.get('feed') or {}).get('entry') or []
36
+
37
+ self.read_data_entries(change_entries)
38
+
39
+ def notify_changes_received(self):
40
+ """
41
+ Подтвердить получение изменений, отправив запрос на сервер
42
+ """
43
+ url = f"{self.odata_url}/NotifyChangesReceived?DataExchangePoint='{self.odata_url}/ExchangePlan_{self.exchange_name}(guid'{self.queue_guid}')'&MessageNo={self.message_no}"
44
+ response = requests.post(url,auth=self.odata_auth,timeout=resolve_timeout(self.request_timeout))
45
+ # Не-2xx -> HTTPError. Подтверждение не прошло — изменения не списаны и придут снова
46
+ # (в run_forever цикл повторится, save идемпотентен).
47
+ response.raise_for_status()
48
+ logger.info(f"Changes confirmed for queue {self.queue_guid} (message {self.message_no})")
49
+
50
+
51
+ def get_last_received_no(self)->int:
52
+ """
53
+ Получить номер последнего пакета обмена, который был получен и подтвержден
54
+ """
55
+ url = f"{self.odata_url}/ExchangePlan_{self.exchange_name}?$format=json"
56
+ response = requests.get(url,auth=self.odata_auth,timeout=resolve_timeout(self.request_timeout))
57
+ response.raise_for_status()
58
+ queues_data = response.json()
59
+
60
+ queues = queues_data.get('value') or []
61
+ receive_no = 0
62
+ found = False
63
+
64
+ for queue in queues:
65
+ if self.queue_guid == queue['Ref_Key']:
66
+ receive_no = int(queue['ReceivedNo'])
67
+ found = True
68
+
69
+ if not found:
70
+ # Очередь по guid не нашлась — вернём 0 (запросится пакет №1), но это почти наверняка
71
+ # неверный queue_guid или план обмена: без предупреждения ошибку конфигурации не видно.
72
+ logger.warning("Exchange queue %s not found in plan %s (check queue_guid/exchange_name)",
73
+ self.queue_guid, self.exchange_name)
74
+
75
+ return receive_no
76
+
77
+
@@ -0,0 +1,30 @@
1
+ import logging
2
+
3
+ ODATA_PREFIX = 'StandardODATA.'
4
+
5
+ logger = logging.getLogger(__name__)
6
+
7
+ def parse_object_full_name(object_full_name):
8
+ """
9
+ Очищаем имя объекта от разных префиксов, постфиксов и скобок.
10
+ Возвращает очищенное имя и тип объекта
11
+ """
12
+ if object_full_name is None:
13
+ logger.error(f'Object full name is None')
14
+ return None, None
15
+
16
+ object_name = object_full_name
17
+
18
+ if object_name.startswith('Collection'):
19
+ object_name = object_name.removeprefix('Collection(')
20
+ object_name = object_name.removesuffix(')')
21
+
22
+ object_name = object_name.removeprefix(ODATA_PREFIX)
23
+ object_name = object_name.removesuffix('_RowType')
24
+
25
+ if '_' in object_name:
26
+ object_type = object_name.split('_')[0]
27
+ else:
28
+ logger.error(f'Object type not found in object full name {object_full_name}')
29
+ return None, None
30
+ return object_name, object_type
cdc_1c/config.py ADDED
@@ -0,0 +1,41 @@
1
+ import os
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass
6
+ class Config:
7
+ """
8
+ Настройки оркестратора. Ядро не зависит от окружения: Config заполняется явно (библиотечный
9
+ сценарий) либо через from_env() (entrypoint контейнера). Строку подключения к БД (db_url)
10
+ в engine превращает Replicator1C.from_config.
11
+ """
12
+
13
+ odata_url: str
14
+ odata_user: str | None
15
+ odata_password: str | None
16
+ exchange_name: str
17
+ queue_guid: str
18
+ db_url: str
19
+ db_schema: str | None = None
20
+ log_level: str = "INFO"
21
+ full_load_workers: int = 2
22
+ # Параметры цикла для entrypoint (python -m cdc_1c): режим и период опроса run_forever.
23
+ mode: str = "loop" # loop → run_forever, once → один run_once
24
+ poll_interval: float = 60.0
25
+
26
+ @classmethod
27
+ def from_env(cls) -> "Config":
28
+ """Читает настройки из переменных окружения CDC1C_* (используется только entrypoint-ом)."""
29
+ return cls(
30
+ odata_url=os.environ["CDC1C_ODATA_URL"],
31
+ odata_user=os.environ.get("CDC1C_ODATA_USER"),
32
+ odata_password=os.environ.get("CDC1C_ODATA_PASSWORD"),
33
+ exchange_name=os.environ["CDC1C_EXCHANGE_NAME"],
34
+ queue_guid=os.environ["CDC1C_QUEUE_GUID"],
35
+ db_url=os.environ["CDC1C_DB_URL"],
36
+ db_schema=os.environ.get("CDC1C_DB_SCHEMA"),
37
+ log_level=os.environ.get("CDC1C_LOG_LEVEL", "INFO"),
38
+ full_load_workers=int(os.environ.get("CDC1C_FULL_LOAD_WORKERS", "2")),
39
+ mode=os.environ.get("CDC1C_MODE", "loop"),
40
+ poll_interval=float(os.environ.get("CDC1C_POLL_INTERVAL", "60")),
41
+ )
cdc_1c/data_reader.py ADDED
@@ -0,0 +1,452 @@
1
+ from __future__ import annotations
2
+
3
+ import requests
4
+ import logging
5
+ from typing import Any
6
+ from collections import UserDict
7
+ from datetime import datetime, date
8
+ from urllib.parse import quote
9
+ import uuid
10
+
11
+ import xmltodict
12
+
13
+ from cdc_1c.metadata_reader import MetadataReader1C, resolve_timeout
14
+ from cdc_1c.name_mapper import NameMapper1C
15
+ from cdc_1c.common_functions import parse_object_full_name
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ def _json_safe(value: Any) -> Any:
21
+ """Приводит значение к JSON-сериализуемому виду: UUID -> str, datetime/date -> ISO."""
22
+ if isinstance(value, uuid.UUID):
23
+ return str(value)
24
+ if isinstance(value, (datetime, date)):
25
+ return value.isoformat()
26
+ return value
27
+
28
+ REGISTER_TYPES = ('InformationRegister','AccumulationRegister')
29
+ ENTITY_TYPES = ('Catalog','Document')
30
+ METADATA_POSTFIXES = ('_RecordType','_RowType','_Balance','_Turnover','_BalanceAndTurnover')
31
+ ODATA_PREFIX = 'StandardODATA.'
32
+
33
+ # Поле 1С с пометкой удаления у документов и справочников.
34
+ DELETION_MARK_FIELD = 'DeletionMark'
35
+ # Спец-поле, заполняемое при загрузке: True для удаленных объектов (DeletionMark)
36
+ # и для фиктивных записей (удаленный набор регистра, опустевшая табличная часть).
37
+ IS_DELETED_OR_EMPTY_FIELD = 'is_deleted_or_empty'
38
+ # Спец-поле: номер пакета обмена (message_no), проставляется во все записи при чтении изменений.
39
+ EXCHANGE_MESSAGE_NO_FIELD = 'exchange_message_no'
40
+
41
+
42
+ def _odata_literal(value: Any, type_name: str) -> str:
43
+ """
44
+ OData-литерал значения по типу поля (для keyset-фильтра): Guid → guid'…', DateTime → datetime'…',
45
+ числа — как есть, Boolean → true/false, остальное (String и пр.) — строка в кавычках (кавычка
46
+ внутри экранируется удвоением). value приходит уже сконвертированным (_convert_value): UUID/datetime.
47
+ """
48
+ if type_name == 'Guid':
49
+ return f"guid'{value}'"
50
+ if type_name == 'DateTime':
51
+ v = value.strftime('%Y-%m-%dT%H:%M:%S') if isinstance(value, (datetime, date)) else str(value)
52
+ return f"datetime'{v}'"
53
+ if type_name in ('Int64', 'Int16', 'Double'):
54
+ return str(value)
55
+ if type_name == 'Boolean':
56
+ return 'true' if value else 'false'
57
+ return "'" + str(value).replace("'", "''") + "'"
58
+
59
+
60
+ def _keyset_filter(key_fields: list[str], after_values: list, key_types: list[str]) -> str:
61
+ """
62
+ Лексикографический keyset-фильтр «строка ключа > последней строки предыдущей страницы» для
63
+ составного ключа (key_fields, порядок = порядок $orderby):
64
+ (k1 gt v1) or (k1 eq v1 and k2 gt v2) or (k1 eq v1 and k2 eq v2 and k3 gt v3) ...
65
+ Для одиночного ключа сводится к «k1 gt v1». Литералы — по типам key_types (_odata_literal).
66
+ """
67
+ terms = []
68
+ for i in range(len(key_fields)):
69
+ conj = [f"{key_fields[j]} eq {_odata_literal(after_values[j], key_types[j])}" for j in range(i)]
70
+ conj.append(f"{key_fields[i]} gt {_odata_literal(after_values[i], key_types[i])}")
71
+ terms.append(" and ".join(conj))
72
+ return " or ".join(f"({t})" if " and " in t else t for t in terms)
73
+
74
+
75
+ class DataObject1C(UserDict):
76
+ def __init__(self, metadata_obj=None, records: list = []):
77
+ super().__init__()
78
+ self.metadata_obj = metadata_obj # MetadataObject1C
79
+ # Табличные части этого объекта: {имя_части: DataObject1C}. Заполняются при чтении
80
+ # (_get_entity_records связывает владельца с его ТЧ), используются to_nested_records.
81
+ self.table_parts: dict[str, DataObject1C] = {}
82
+ self.data_length = 0
83
+ self.add_records(records)
84
+
85
+ def add_records(self, records: list):
86
+ for record in records:
87
+ for k,v in record.items():
88
+ if k not in self.data.keys():
89
+ # встретилось новое поле, нужно добавить его во все записи, которые уже есть.
90
+ if self.data_length > 0:
91
+ self.data[k] = [None] * self.data_length
92
+ else:
93
+ self.data[k] = []
94
+ self.data[k].append(v)
95
+
96
+ for k in self.data.keys():
97
+ # если в новой записи нет какого-то поля, которое уже есть в данных, то нужно добавить это поле со значением None
98
+ if k not in record.keys():
99
+ self.data[k].append(None)
100
+
101
+ self.data_length += 1
102
+
103
+ def to_records_mapped(self, column_mapping: dict[str, str] | None = None,
104
+ json_safe: bool = False) -> list[dict]:
105
+ """
106
+ Преобразует колоночное хранилище (dict of lists) в список записей (list of dict)
107
+ для передачи в dbmerge. Если задан column_mapping, имена колонок заменяются на лету,
108
+ без отдельной переименованной копии данных (значения колонок переиспользуются по ссылке).
109
+
110
+ json_safe=True приводит значения к JSON-сериализуемому виду (UUID->str, datetime->ISO) —
111
+ для экспорта в JSON; по умолчанию False, чтобы dbmerge получал исходные типы.
112
+ """
113
+ keys = list(self.data.keys())
114
+ out_keys = [column_mapping.get(k, k) for k in keys] if column_mapping else keys
115
+ cols = list(self.data.values())
116
+ if json_safe:
117
+ return [dict(zip(out_keys, (_json_safe(v) for v in row))) for row in zip(*cols)]
118
+ return [dict(zip(out_keys, row)) for row in zip(*cols)]
119
+
120
+ def group_by(self, key_field: str = 'Ref_Key', name_mapper: NameMapper1C | None = None,
121
+ json_safe: bool = False, skip_deleted: bool = False) -> dict[Any, list[dict]]:
122
+ """
123
+ Группирует записи объекта в {значение key_field: [записи]} одним проходом (hash group-by).
124
+ Записи — как в to_records_mapped (с маппингом/json_safe). Индекс не хранится в объекте, а
125
+ строится на вызов: платим только при экспорте, без накладных в add_records и без устаревания.
126
+
127
+ skip_deleted=True пропускает записи с is_deleted_or_empty (удалённые/фиктивные строки) —
128
+ для вложенных табличных частей: опустевшая ТЧ приходит фиктивной записью и должна дать
129
+ пустой список, а не группу с записью-пустышкой.
130
+ """
131
+ col_map = name_mapper.get_column_mapping(list(self.data.keys())) if name_mapper else None
132
+ key = name_mapper.map_field_name(key_field) if name_mapper else key_field
133
+ del_key = (name_mapper.map_field_name(IS_DELETED_OR_EMPTY_FIELD)
134
+ if name_mapper else IS_DELETED_OR_EMPTY_FIELD)
135
+ grouped: dict[Any, list[dict]] = {}
136
+ for row in self.to_records_mapped(col_map, json_safe=json_safe):
137
+ if skip_deleted and row.get(del_key):
138
+ continue
139
+ grouped.setdefault(row.get(key), []).append(row)
140
+ return grouped
141
+
142
+ def to_nested_records(self, name_mapper: NameMapper1C | None = None,
143
+ json_safe: bool = False) -> list[dict]:
144
+ """
145
+ Записи этого объекта (list of dict) с вложенными табличными частями — например, чтобы
146
+ отправить во внешний приёмник (RabbitMQ и т.п.) вместо записи в БД.
147
+
148
+ Табличные части берутся из self.table_parts (их связал контейнер при чтении), кладутся
149
+ вложенным списком под ключом = имя части, строки группируются по Ref_Key (group_by).
150
+
151
+ name_mapper=None → имена полей/частей как в 1С; передан — транслитерируем (как в БД).
152
+ json_safe=True → значения JSON-сериализуемы (UUID->str, datetime->ISO), готово к json.dumps.
153
+ """
154
+ col_map = name_mapper.get_column_mapping(list(self.data.keys())) if name_mapper else None
155
+ key = name_mapper.map_field_name('Ref_Key') if name_mapper else 'Ref_Key'
156
+ records = self.to_records_mapped(col_map, json_safe=json_safe)
157
+
158
+ for part_name, part_obj in self.table_parts.items():
159
+ part_key = name_mapper.map_field_name(part_name) if name_mapper else part_name
160
+ grouped = part_obj.group_by('Ref_Key', name_mapper, json_safe, skip_deleted=True)
161
+ for rec in records:
162
+ rec[part_key] = grouped.get(rec.get(key), [])
163
+ return records
164
+
165
+
166
+
167
+ class DataReader1C(UserDict):
168
+ def __init__(self, odata_url: str, metadata: MetadataReader1C,
169
+ odata_auth: tuple[str, str] | None = None,
170
+ request_timeout: float | None = None):
171
+ super().__init__()
172
+ self.odata_url = odata_url
173
+ self.metadata = metadata
174
+ self.odata_auth = odata_auth
175
+ self.request_timeout = request_timeout
176
+ self.exchange_message_no = None # номер пакета обмена, проставляется в записи при чтении изменений
177
+
178
+ def read_object(self, object_name: str, top: int | None = None,
179
+ key_fields: list[str] | None = None, after_values: list | None = None,
180
+ key_types: list[str] | None = None, extra_filter: str | None = None) -> int:
181
+ """
182
+ Читает объект 1С в reader (предыдущее содержимое очищается). Постраничная выгрузка —
183
+ keyset-пагинация по составному ключу key_fields: сортировка по ключу ($orderby), лимит $top,
184
+ а следующая страница берётся лексикографическим фильтром «ключ больше последней строки
185
+ предыдущей страницы» (after_values). В отличие от $skip это не заставляет 1С перечитывать
186
+ пропущенные строки — каждая страница читается за один проход по индексу.
187
+
188
+ key_fields/key_types (порядок = порядок сортировки; типы — для литералов, см. _odata_literal):
189
+ - справочник/документ: ['Ref_Key'] / ['Guid'] (Ref_Key gt guid'...');
190
+ - регистраторный регистр: ['Recorder'] / ['String'] — в OData Recorder отдаётся строкой,
191
+ одна entry = целый набор записей регистратора, поэтому keyset по Recorder не рвёт набор;
192
+ - независимый регистр (нет Ref_Key/Recorder): весь первичный ключ (Period + измерения) —
193
+ составной keyset, т.к. одиночного уникального курсора нет.
194
+
195
+ extra_filter — дополнительный OData-фрагмент $filter (например, диапазон по дате), который
196
+ объединяется с keyset-условием по AND (составной keyset содержит OR — оборачиваем в скобки).
197
+
198
+ Возвращает число прочитанных записей верхнего уровня (entry) — по нему вызывающий понимает,
199
+ что страница последняя (меньше top).
200
+ """
201
+ key_fields = key_fields or ['Ref_Key']
202
+ key_types = key_types or ['Guid'] * len(key_fields)
203
+ params = []
204
+ if top is not None:
205
+ params.append(f"$top={top}")
206
+ params.append("$orderby=" + ','.join(key_fields))
207
+ # keyset-курсор и extra_filter объединяем по AND в один $filter.
208
+ filters = []
209
+ if after_values is not None:
210
+ keyset = _keyset_filter(key_fields, after_values, key_types)
211
+ # составной keyset содержит верхнеуровневый OR — оборачиваем в скобки, чтобы AND с
212
+ # extra_filter не исказил приоритет; одиночный ключ (без OR) оставляем как есть.
213
+ filters.append(f"({keyset})" if ' or ' in keyset else keyset)
214
+ if extra_filter:
215
+ filters.append(extra_filter)
216
+ if filters:
217
+ # URL собираем строкой — кодируем пробелы сами (requests строку не кодирует); двоеточия
218
+ # в datetime-литералах оставляем как есть (safe).
219
+ params.append("$filter=" + quote(" and ".join(filters), safe="':"))
220
+ query = '?' + '&'.join(params)
221
+ url = f"{self.odata_url}/{object_name}{query}"
222
+ response = requests.get(url, auth=self.odata_auth, timeout=resolve_timeout(self.request_timeout))
223
+ response.raise_for_status()
224
+
225
+ object_data = xmltodict.parse(response.text, force_list=('d:element', 'entry'))
226
+ object_entries = (object_data.get('feed') or {}).get('entry') or []
227
+
228
+ self.clear()
229
+ self.read_data_entries(object_entries)
230
+ return len(object_entries)
231
+
232
+ def read_data_entries(self, object_entries: list):
233
+ for object_entry in object_entries:
234
+
235
+ object_full_name = (object_entry.get('category') or {}).get('@term')
236
+ object_name, object_type = parse_object_full_name(object_full_name)
237
+
238
+ logger.info(f'Parsing {object_name}')
239
+
240
+ if object_name and self.metadata.get(object_name) is None:
241
+ self.metadata.get_metadata()
242
+ if self.metadata.get(object_name) is None:
243
+ logger.warning(f'Metadata not found for {object_name}')
244
+
245
+ properties = (object_entry.get('content') or {}).get('m:properties') or {}
246
+
247
+ if object_type in REGISTER_TYPES:
248
+ self._get_register_records(object_name, properties)
249
+
250
+ if object_type in ENTITY_TYPES:
251
+ self._get_entity_records(object_name, properties)
252
+
253
+
254
+ def _add_records(self, object_name, new_records: list):
255
+ if new_records:
256
+ if object_name in self.keys():
257
+ self[object_name].add_records(new_records)
258
+ else:
259
+ metadata_obj = self.metadata.get(object_name)
260
+ self[object_name] = DataObject1C(metadata_obj=metadata_obj, records=new_records)
261
+
262
+ @staticmethod
263
+ def _convert_value(value: Any, type_name: str) -> Any:
264
+ # Конвертируем значение в нужный тип данных на основе метаданных.
265
+ if value is None:
266
+ return None
267
+ if not isinstance(value, str):
268
+ logger.warning(f'Expected value to be a string for conversion, got {type(value).__name__}: {value!r}')
269
+ return value
270
+ try:
271
+ if type_name == 'Boolean':
272
+ return value.lower() == 'true'
273
+ if type_name in ('Int64', 'Int16'):
274
+ return int(value)
275
+ if type_name == 'Double':
276
+ return float(value)
277
+ if type_name == 'DateTime':
278
+ return datetime.fromisoformat(value)
279
+ if type_name == 'Guid':
280
+ if value == '00000000-0000-0000-0000-000000000000':
281
+ return None
282
+ else:
283
+ return uuid.UUID(value)
284
+ except (ValueError, TypeError) as e:
285
+ logger.warning(f'Failed to convert value {value!r} to type {type_name}: {e}')
286
+ return value
287
+
288
+ def _get_record_fields(self, properties: dict, object_name: str | None = None) -> dict:
289
+ if object_name not in self.metadata.keys():
290
+ self.metadata.get_metadata()
291
+ if object_name not in self.metadata.keys():
292
+ logger.error(f'Metadata not found for {object_name}')
293
+ raise ValueError(f'Metadata not found for {object_name}')
294
+
295
+ metadata_obj = self.metadata[object_name]
296
+
297
+ fields = {}
298
+ for k, v in properties.items():
299
+ if k.startswith('d:') and isinstance(v, str):
300
+
301
+ field_name = k.removeprefix('d:')
302
+
303
+ if field_name.endswith('_Type'):
304
+ # если поле заканчивается на _Type, то это поле составного типа
305
+ # и в значении будет полное имя типа, например "StandardODATA.СправочникСсылка.Контрагенты"
306
+ # для удобства убираем префикс и оставляем только имя типа, например "СправочникСсылка.Контрагенты"
307
+ value = v.removeprefix(ODATA_PREFIX)
308
+ else:
309
+ value = v
310
+
311
+ if field_name not in metadata_obj.keys():
312
+ # поле не найдено в метаданных, пробуем их перечитать.
313
+ self.metadata.get_metadata()
314
+ metadata_obj = self.metadata[object_name]
315
+ if field_name not in metadata_obj.keys():
316
+ logger.warning(f'Metadata field {field_name} not found for object {object_name}')
317
+
318
+ type_name = metadata_obj.get(field_name) or 'String'
319
+
320
+ converted = self._convert_value(value, type_name)
321
+
322
+ fields[field_name] = converted
323
+
324
+ # Спец-поле: для документов/справочников True по пометке удаления,
325
+ # для регистров/табличных частей (нет DeletionMark) — False.
326
+ fields[IS_DELETED_OR_EMPTY_FIELD] = bool(fields.get(DELETION_MARK_FIELD))
327
+ fields[EXCHANGE_MESSAGE_NO_FIELD] = self.exchange_message_no
328
+
329
+ return fields
330
+
331
+ @staticmethod
332
+ def _default_key_value(type_name: str) -> Any:
333
+ # Дефолтное значение поля ключа для удаленной записи регистра:
334
+ # '' для строк, 0 для чисел, чтобы получить непустой составной ключ.
335
+ if type_name == 'String':
336
+ return ''
337
+ if type_name in ('Int64', 'Int16', 'Double'):
338
+ return 0
339
+ if type_name == 'Boolean':
340
+ return False
341
+ if type_name == 'DateTime':
342
+ return datetime(1, 1, 1) # пустая дата 1С
343
+ return None # Guid (Recorder перекрывается ниже) и прочее
344
+
345
+ def _make_deleted_register_record(self, object_name: str, recorder: str, recorder_type: str) -> dict:
346
+ """
347
+ Создает запись для удаленного набора записей регистра (пришел пустой RecordSet).
348
+ Заполняет полный первичный ключ из метаданных дефолтами по типу поля
349
+ и проставляет реальные Recorder и Recorder_Type.
350
+ """
351
+ primary_key = self.metadata[object_name].primary_key
352
+ record = {field: self._default_key_value(type_name)
353
+ for field, type_name in primary_key.items()}
354
+
355
+ recorder_name, _ = parse_object_full_name(recorder_type)
356
+ record['Recorder'] = recorder
357
+ record['Recorder_Type'] = recorder_name
358
+ record[IS_DELETED_OR_EMPTY_FIELD] = True
359
+ record[EXCHANGE_MESSAGE_NO_FIELD] = self.exchange_message_no
360
+ return record
361
+
362
+ def _make_empty_table_part_record(self, table_part_name: str, ref_key: Any) -> dict:
363
+ """
364
+ Создает запись для опустевшей табличной части (пришла без строк), по аналогии с удаленным
365
+ набором регистра. Полный ключ из метаданных заполняется дефолтами, а Ref_Key —
366
+ реальным значением владельца, чтобы scoped-удаление по Ref_Key убрало старые строки.
367
+ """
368
+ primary_key = self.metadata[table_part_name].primary_key
369
+ record = {field: self._default_key_value(type_name)
370
+ for field, type_name in primary_key.items()}
371
+ record['Ref_Key'] = ref_key
372
+ record[IS_DELETED_OR_EMPTY_FIELD] = True
373
+ record[EXCHANGE_MESSAGE_NO_FIELD] = self.exchange_message_no
374
+ return record
375
+
376
+ def _get_register_records(self, object_name: str, properties: dict):
377
+ """
378
+ Функция забирает записи регистра.
379
+
380
+ Регистраторный регистр приходит набором по одному регистратору (d:RecordSet с движениями);
381
+ пустой набор = набор удалён (фиктивная запись). Независимый регистр сведений приходит плоско —
382
+ поля прямо в properties, без Recorder/RecordSet, как у справочника/документа (форма прямого
383
+ чтения GET /InformationRegister_X — проверить на живой 1С).
384
+
385
+ Текущий объект это словарь, в котором ключом является объект 1С, например "Document_ЗаказКлиента",
386
+ значения содержат массив записей в виде list of dict.
387
+ """
388
+ recorder = properties.get('d:Recorder')
389
+ recorder_type = properties.get('d:Recorder_Type')
390
+
391
+ records = (properties.get('d:RecordSet') or {}).get('d:element') or []
392
+
393
+ if records:
394
+ new_records = [self._get_record_fields(record, object_name) for record in records]
395
+ elif recorder and recorder_type:
396
+ # Регистраторный регистр с пустым набором — набор записей удалён.
397
+ new_records = [self._make_deleted_register_record(object_name, recorder, recorder_type)]
398
+ elif recorder is None:
399
+ # Независимый регистр сведений: одна плоская запись, поля прямо в properties.
400
+ new_records = [self._get_record_fields(properties, object_name)]
401
+ else:
402
+ logger.error(f'No recorder or recorder type for {object_name}')
403
+ new_records = []
404
+
405
+ self._add_records(object_name, new_records)
406
+
407
+ def _get_record_table_parts(self, properties):
408
+ """
409
+ Ищем табличные части в свойствах объекта.
410
+ Если тип данных dict и если префикс в названии 'd:', то будем считать что это табличная часть
411
+ """
412
+ table_parts = {k.removeprefix('d:'): v for k, v in properties.items()
413
+ if k.startswith('d:') and isinstance(v, dict) and v.get('@xsi:nil') != 'true'}
414
+ return table_parts
415
+
416
+ def _get_entity_records(self, object_name: str, properties: dict):
417
+ """
418
+ Функция забирает поля документа или справочника.
419
+ Запись одна, но могут быть табличные части, которые будут записаны в отдельные элементы структуры changes
420
+ """
421
+ fields = self._get_record_fields(properties, object_name)
422
+ self._add_records(object_name, [fields])
423
+
424
+ # Строки табличных частей приходят без ссылки на владельца — проставляем Ref_Key документа.
425
+ ref_key = fields.get('Ref_Key')
426
+ # Пометку удаления документа/справочника распространяем на его табличные части.
427
+ parent_deleted = fields.get(IS_DELETED_OR_EMPTY_FIELD)
428
+
429
+ table_parts = self._get_record_table_parts(properties)
430
+
431
+ for table_part_key, table_part in table_parts.items():
432
+ table_part_full_name = table_part.get('@m:type')
433
+ if table_part_full_name:
434
+ table_part_name, _ = parse_object_full_name(table_part_full_name)
435
+ table_part_rows = table_part.get('d:element') or []
436
+ if table_part_rows:
437
+ for table_part_row in table_part_rows:
438
+ row = self._get_record_fields(table_part_row, table_part_name)
439
+ row['Ref_Key'] = ref_key
440
+ row[IS_DELETED_OR_EMPTY_FIELD] = parent_deleted
441
+ self._add_records(table_part_name, [row])
442
+ else:
443
+ # Табличная часть пришла без строк — добавляем фиктивную запись,
444
+ # чтобы scoped-удаление по Ref_Key убрало ранее сохраненные строки.
445
+ ref_key = fields.get('Ref_Key')
446
+ if ref_key is not None and table_part_name in self.metadata.keys():
447
+ self._add_records(table_part_name,
448
+ [self._make_empty_table_part_record(table_part_name, ref_key)])
449
+
450
+ # Связываем объект-ТЧ с владельцем — чтобы to_nested_records нашёл его сам.
451
+ if table_part_name in self:
452
+ self[object_name].table_parts[table_part_key] = self[table_part_name]