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 +18 -0
- cdc_1c/__main__.py +30 -0
- cdc_1c/change_reader.py +77 -0
- cdc_1c/common_functions.py +30 -0
- cdc_1c/config.py +41 -0
- cdc_1c/data_reader.py +452 -0
- cdc_1c/db_logs.py +103 -0
- cdc_1c/db_writer.py +180 -0
- cdc_1c/logging_config.py +23 -0
- cdc_1c/metadata_reader.py +388 -0
- cdc_1c/name_mapper.py +94 -0
- cdc_1c/py.typed +0 -0
- cdc_1c/replicator.py +377 -0
- cdc_1c-0.1.0.dist-info/METADATA +200 -0
- cdc_1c-0.1.0.dist-info/RECORD +18 -0
- cdc_1c-0.1.0.dist-info/WHEEL +4 -0
- cdc_1c-0.1.0.dist-info/entry_points.txt +2 -0
- cdc_1c-0.1.0.dist-info/licenses/LICENSE +21 -0
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()
|
cdc_1c/change_reader.py
ADDED
|
@@ -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]
|