amochka 0.1.8__py3-none-any.whl → 0.3.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.
- amochka/__init__.py +1 -1
- amochka/client.py +305 -31
- amochka-0.3.0.dist-info/METADATA +126 -0
- amochka-0.3.0.dist-info/RECORD +14 -0
- {amochka-0.1.8.dist-info → amochka-0.3.0.dist-info}/top_level.txt +1 -0
- etl/__init__.py +7 -0
- etl/config.py +236 -0
- etl/extractors.py +354 -0
- etl/loaders.py +813 -0
- etl/migrations/001_create_tables.sql +346 -0
- etl/run_etl.py +684 -0
- etl/transformers.py +470 -0
- amochka-0.1.8.dist-info/METADATA +0 -40
- amochka-0.1.8.dist-info/RECORD +0 -7
- {amochka-0.1.8.dist-info → amochka-0.3.0.dist-info}/WHEEL +0 -0
etl/loaders.py
ADDED
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Загрузка данных в PostgreSQL.
|
|
3
|
+
|
|
4
|
+
Реализует UPSERT-логику для инкрементальной загрузки.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
import logging
|
|
9
|
+
from contextlib import contextmanager
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any, Dict, Iterator, List, Optional, Set, Tuple
|
|
13
|
+
|
|
14
|
+
from .config import DatabaseConfig
|
|
15
|
+
from .transformers import TransformedContact, TransformedEvent, TransformedLead
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class PostgresLoader:
|
|
21
|
+
"""Загрузчик данных в PostgreSQL."""
|
|
22
|
+
|
|
23
|
+
def __init__(self, config: DatabaseConfig):
|
|
24
|
+
"""
|
|
25
|
+
Инициализирует загрузчик.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
config: Конфигурация подключения к БД
|
|
29
|
+
"""
|
|
30
|
+
self.config = config
|
|
31
|
+
self._connection = None
|
|
32
|
+
self._psycopg = None
|
|
33
|
+
self._sql = None
|
|
34
|
+
self._json_module = None
|
|
35
|
+
|
|
36
|
+
def _ensure_imports(self):
|
|
37
|
+
"""Ленивый импорт psycopg."""
|
|
38
|
+
if self._psycopg is None:
|
|
39
|
+
try:
|
|
40
|
+
import psycopg
|
|
41
|
+
from psycopg import sql
|
|
42
|
+
from psycopg.types import json as psycopg_json
|
|
43
|
+
|
|
44
|
+
self._psycopg = psycopg
|
|
45
|
+
self._sql = sql
|
|
46
|
+
self._json_module = psycopg_json
|
|
47
|
+
except ImportError:
|
|
48
|
+
raise ImportError("psycopg не установлен. Установите: pip install psycopg[binary]")
|
|
49
|
+
|
|
50
|
+
@contextmanager
|
|
51
|
+
def connection(self):
|
|
52
|
+
"""Context manager для подключения к БД."""
|
|
53
|
+
self._ensure_imports()
|
|
54
|
+
conn = self._psycopg.connect(**self.config.connection_kwargs())
|
|
55
|
+
try:
|
|
56
|
+
# Устанавливаем search_path на нужную схему
|
|
57
|
+
with conn.cursor() as cursor:
|
|
58
|
+
cursor.execute(
|
|
59
|
+
self._sql.SQL("SET search_path TO {}").format(
|
|
60
|
+
self._sql.Identifier(self.config.schema)
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
yield conn
|
|
64
|
+
finally:
|
|
65
|
+
conn.close()
|
|
66
|
+
|
|
67
|
+
def run_migrations(self, migrations_dir: Path) -> None:
|
|
68
|
+
"""
|
|
69
|
+
Выполняет SQL-миграции из указанной директории.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
migrations_dir: Путь к директории с .sql файлами
|
|
73
|
+
"""
|
|
74
|
+
self._ensure_imports()
|
|
75
|
+
|
|
76
|
+
migration_files = sorted(migrations_dir.glob("*.sql"))
|
|
77
|
+
if not migration_files:
|
|
78
|
+
logger.warning("Файлы миграций не найдены в %s", migrations_dir)
|
|
79
|
+
return
|
|
80
|
+
|
|
81
|
+
# Сначала создаём схему если её нет (без search_path)
|
|
82
|
+
conn = self._psycopg.connect(**self.config.connection_kwargs())
|
|
83
|
+
try:
|
|
84
|
+
with conn.cursor() as cursor:
|
|
85
|
+
# Создаём схему
|
|
86
|
+
cursor.execute(
|
|
87
|
+
self._sql.SQL("CREATE SCHEMA IF NOT EXISTS {}").format(
|
|
88
|
+
self._sql.Identifier(self.config.schema)
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
logger.info("Схема '%s' готова", self.config.schema)
|
|
92
|
+
conn.commit()
|
|
93
|
+
finally:
|
|
94
|
+
conn.close()
|
|
95
|
+
|
|
96
|
+
# Теперь выполняем миграции в нужной схеме
|
|
97
|
+
with self.connection() as conn:
|
|
98
|
+
with conn.cursor() as cursor:
|
|
99
|
+
for migration_file in migration_files:
|
|
100
|
+
logger.info("Выполняем миграцию: %s", migration_file.name)
|
|
101
|
+
sql_content = migration_file.read_text(encoding="utf-8")
|
|
102
|
+
cursor.execute(sql_content)
|
|
103
|
+
conn.commit()
|
|
104
|
+
logger.info("Миграции выполнены успешно")
|
|
105
|
+
|
|
106
|
+
def _get_or_create_date_id(self, cursor, ts: Optional[datetime]) -> Optional[int]:
|
|
107
|
+
"""
|
|
108
|
+
Получает или создаёт ID даты в general_dates.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
cursor: Курсор БД
|
|
112
|
+
ts: Timestamp для поиска/создания
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
ID записи в general_dates или None
|
|
116
|
+
"""
|
|
117
|
+
if ts is None:
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
cursor.execute("SELECT get_or_create_date_id(%s)", (ts,))
|
|
121
|
+
result = cursor.fetchone()
|
|
122
|
+
return result[0] if result else None
|
|
123
|
+
|
|
124
|
+
def upsert_lead(self, cursor, lead_data: Dict[str, Any]) -> int:
|
|
125
|
+
"""
|
|
126
|
+
Вставляет или обновляет сделку.
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
ID записи в amocrm_leads (внутренний автоинкремент)
|
|
130
|
+
"""
|
|
131
|
+
cursor.execute(
|
|
132
|
+
"""
|
|
133
|
+
INSERT INTO amocrm_leads (
|
|
134
|
+
account_id, lead_id, name, pipeline, pipeline_id, status, status_id,
|
|
135
|
+
status_order, request_id, loss_reason, loss_reason_id, is_deleted
|
|
136
|
+
) VALUES (
|
|
137
|
+
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s
|
|
138
|
+
)
|
|
139
|
+
ON CONFLICT (account_id, lead_id) DO UPDATE SET
|
|
140
|
+
name = EXCLUDED.name,
|
|
141
|
+
pipeline = EXCLUDED.pipeline,
|
|
142
|
+
pipeline_id = EXCLUDED.pipeline_id,
|
|
143
|
+
status = EXCLUDED.status,
|
|
144
|
+
status_id = EXCLUDED.status_id,
|
|
145
|
+
status_order = EXCLUDED.status_order,
|
|
146
|
+
request_id = EXCLUDED.request_id,
|
|
147
|
+
loss_reason = EXCLUDED.loss_reason,
|
|
148
|
+
loss_reason_id = EXCLUDED.loss_reason_id,
|
|
149
|
+
is_deleted = EXCLUDED.is_deleted
|
|
150
|
+
RETURNING id
|
|
151
|
+
""",
|
|
152
|
+
(
|
|
153
|
+
lead_data["account_id"],
|
|
154
|
+
lead_data["lead_id"],
|
|
155
|
+
lead_data.get("name"),
|
|
156
|
+
lead_data.get("pipeline"),
|
|
157
|
+
lead_data.get("pipeline_id"),
|
|
158
|
+
lead_data.get("status"),
|
|
159
|
+
lead_data.get("status_id"),
|
|
160
|
+
lead_data.get("status_order"),
|
|
161
|
+
lead_data.get("request_id"),
|
|
162
|
+
lead_data.get("loss_reason"),
|
|
163
|
+
lead_data.get("loss_reason_id"),
|
|
164
|
+
lead_data.get("is_deleted", False),
|
|
165
|
+
),
|
|
166
|
+
)
|
|
167
|
+
result = cursor.fetchone()
|
|
168
|
+
return result[0] if result else None
|
|
169
|
+
|
|
170
|
+
def upsert_lead_facts(self, cursor, facts_data: Dict[str, Any], leads_id: int) -> int:
|
|
171
|
+
"""Вставляет или обновляет факты по сделке."""
|
|
172
|
+
# Получаем ID дат
|
|
173
|
+
created_id = self._get_or_create_date_id(cursor, facts_data.get("created_date"))
|
|
174
|
+
closed_at_raw = facts_data.get("_closed_at_raw")
|
|
175
|
+
closed_id = None
|
|
176
|
+
if closed_at_raw:
|
|
177
|
+
from .transformers import _timestamp_to_datetime
|
|
178
|
+
closed_id = self._get_or_create_date_id(cursor, _timestamp_to_datetime(closed_at_raw))
|
|
179
|
+
|
|
180
|
+
cursor.execute(
|
|
181
|
+
"""
|
|
182
|
+
INSERT INTO amocrm_leads_facts (
|
|
183
|
+
account_id, leads_id, contacts_id, companies_id, users_id,
|
|
184
|
+
created_id, closed_id, price, labor_cost, score,
|
|
185
|
+
created_date, modified_date
|
|
186
|
+
) VALUES (
|
|
187
|
+
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s
|
|
188
|
+
)
|
|
189
|
+
ON CONFLICT (account_id, leads_id) DO UPDATE SET
|
|
190
|
+
contacts_id = EXCLUDED.contacts_id,
|
|
191
|
+
companies_id = EXCLUDED.companies_id,
|
|
192
|
+
users_id = EXCLUDED.users_id,
|
|
193
|
+
created_id = EXCLUDED.created_id,
|
|
194
|
+
closed_id = EXCLUDED.closed_id,
|
|
195
|
+
price = EXCLUDED.price,
|
|
196
|
+
labor_cost = EXCLUDED.labor_cost,
|
|
197
|
+
score = EXCLUDED.score,
|
|
198
|
+
created_date = EXCLUDED.created_date,
|
|
199
|
+
modified_date = EXCLUDED.modified_date
|
|
200
|
+
RETURNING id
|
|
201
|
+
""",
|
|
202
|
+
(
|
|
203
|
+
facts_data["account_id"],
|
|
204
|
+
leads_id,
|
|
205
|
+
facts_data.get("contacts_id"),
|
|
206
|
+
facts_data.get("companies_id"),
|
|
207
|
+
facts_data.get("users_id"),
|
|
208
|
+
created_id,
|
|
209
|
+
closed_id,
|
|
210
|
+
facts_data.get("price"),
|
|
211
|
+
facts_data.get("labor_cost"),
|
|
212
|
+
facts_data.get("score"),
|
|
213
|
+
facts_data.get("created_date"),
|
|
214
|
+
facts_data.get("modified_date"),
|
|
215
|
+
),
|
|
216
|
+
)
|
|
217
|
+
result = cursor.fetchone()
|
|
218
|
+
return result[0] if result else None
|
|
219
|
+
|
|
220
|
+
def upsert_lead_attributes(self, cursor, attributes: List[Dict[str, Any]], leads_id: int) -> int:
|
|
221
|
+
"""
|
|
222
|
+
Вставляет атрибуты сделки.
|
|
223
|
+
|
|
224
|
+
Удаляет старые атрибуты и вставляет новые (полная замена).
|
|
225
|
+
"""
|
|
226
|
+
if not attributes:
|
|
227
|
+
return 0
|
|
228
|
+
|
|
229
|
+
account_id = attributes[0]["account_id"]
|
|
230
|
+
|
|
231
|
+
# Удаляем старые атрибуты
|
|
232
|
+
cursor.execute(
|
|
233
|
+
"DELETE FROM amocrm_leads_attributes WHERE account_id = %s AND leads_id = %s",
|
|
234
|
+
(account_id, leads_id),
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# Вставляем новые
|
|
238
|
+
inserted = 0
|
|
239
|
+
for attr in attributes:
|
|
240
|
+
cursor.execute(
|
|
241
|
+
"""
|
|
242
|
+
INSERT INTO amocrm_leads_attributes (account_id, leads_id, attribute_id, name, value)
|
|
243
|
+
VALUES (%s, %s, %s, %s, %s)
|
|
244
|
+
""",
|
|
245
|
+
(account_id, leads_id, attr["attribute_id"], attr["name"], attr.get("value")),
|
|
246
|
+
)
|
|
247
|
+
inserted += 1
|
|
248
|
+
|
|
249
|
+
return inserted
|
|
250
|
+
|
|
251
|
+
def upsert_lead_tags(self, cursor, tags: List[Dict[str, Any]], leads_id: int) -> int:
|
|
252
|
+
"""Вставляет теги сделки (полная замена)."""
|
|
253
|
+
if not tags:
|
|
254
|
+
return 0
|
|
255
|
+
|
|
256
|
+
account_id = tags[0]["account_id"]
|
|
257
|
+
|
|
258
|
+
# Удаляем старые теги
|
|
259
|
+
cursor.execute(
|
|
260
|
+
"DELETE FROM amocrm_leads_tags WHERE account_id = %s AND leads_id = %s",
|
|
261
|
+
(account_id, leads_id),
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
# Вставляем новые
|
|
265
|
+
inserted = 0
|
|
266
|
+
for tag in tags:
|
|
267
|
+
cursor.execute(
|
|
268
|
+
"""
|
|
269
|
+
INSERT INTO amocrm_leads_tags (account_id, leads_id, tag_id, name)
|
|
270
|
+
VALUES (%s, %s, %s, %s)
|
|
271
|
+
""",
|
|
272
|
+
(account_id, leads_id, tag["tag_id"], tag["name"]),
|
|
273
|
+
)
|
|
274
|
+
inserted += 1
|
|
275
|
+
|
|
276
|
+
return inserted
|
|
277
|
+
|
|
278
|
+
def upsert_lead_contacts(self, cursor, contacts: List[Dict[str, Any]], leads_id: int) -> int:
|
|
279
|
+
"""Вставляет связи сделки с контактами (полная замена)."""
|
|
280
|
+
if not contacts:
|
|
281
|
+
return 0
|
|
282
|
+
|
|
283
|
+
account_id = contacts[0]["account_id"]
|
|
284
|
+
|
|
285
|
+
# Удаляем старые связи
|
|
286
|
+
cursor.execute(
|
|
287
|
+
"DELETE FROM amocrm_leads_contacts WHERE account_id = %s AND leads_id = %s",
|
|
288
|
+
(account_id, leads_id),
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
# Вставляем новые
|
|
292
|
+
inserted = 0
|
|
293
|
+
for contact in contacts:
|
|
294
|
+
cursor.execute(
|
|
295
|
+
"""
|
|
296
|
+
INSERT INTO amocrm_leads_contacts (account_id, leads_id, contacts_id, main)
|
|
297
|
+
VALUES (%s, %s, %s, %s)
|
|
298
|
+
""",
|
|
299
|
+
(account_id, leads_id, contact["contacts_id"], contact.get("main", False)),
|
|
300
|
+
)
|
|
301
|
+
inserted += 1
|
|
302
|
+
|
|
303
|
+
return inserted
|
|
304
|
+
|
|
305
|
+
def load_transformed_lead(
|
|
306
|
+
self,
|
|
307
|
+
cursor,
|
|
308
|
+
transformed: TransformedLead,
|
|
309
|
+
user_id_map: Optional[Dict[int, int]] = None,
|
|
310
|
+
contact_id_map: Optional[Dict[int, int]] = None,
|
|
311
|
+
) -> int:
|
|
312
|
+
"""
|
|
313
|
+
Загружает полностью трансформированную сделку.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
cursor: Курсор БД
|
|
317
|
+
transformed: Трансформированная сделка
|
|
318
|
+
user_id_map: Маппинг {amo_user_id -> internal_id} для users_id
|
|
319
|
+
contact_id_map: Маппинг {amo_contact_id -> internal_id} для contacts_id
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
ID записи в amocrm_leads
|
|
323
|
+
"""
|
|
324
|
+
# 1. Сначала upsert основной записи leads
|
|
325
|
+
leads_id = self.upsert_lead(cursor, transformed.lead)
|
|
326
|
+
|
|
327
|
+
# 2. Обновляем leads_id в связанных данных и загружаем
|
|
328
|
+
transformed.lead_facts["leads_id"] = leads_id
|
|
329
|
+
|
|
330
|
+
# Преобразуем users_id из amoCRM ID во внутренний ID
|
|
331
|
+
if user_id_map:
|
|
332
|
+
amo_user_id = transformed.lead_facts.get("users_id")
|
|
333
|
+
if amo_user_id is not None:
|
|
334
|
+
internal_user_id = user_id_map.get(amo_user_id)
|
|
335
|
+
transformed.lead_facts["users_id"] = internal_user_id
|
|
336
|
+
|
|
337
|
+
# Преобразуем contacts_id из amoCRM ID во внутренний ID
|
|
338
|
+
if contact_id_map:
|
|
339
|
+
amo_contact_id = transformed.lead_facts.get("contacts_id")
|
|
340
|
+
if amo_contact_id is not None:
|
|
341
|
+
internal_contact_id = contact_id_map.get(amo_contact_id)
|
|
342
|
+
transformed.lead_facts["contacts_id"] = internal_contact_id
|
|
343
|
+
|
|
344
|
+
self.upsert_lead_facts(cursor, transformed.lead_facts, leads_id)
|
|
345
|
+
|
|
346
|
+
# 3. Атрибуты
|
|
347
|
+
self.upsert_lead_attributes(cursor, transformed.attributes, leads_id)
|
|
348
|
+
|
|
349
|
+
# 4. Теги
|
|
350
|
+
self.upsert_lead_tags(cursor, transformed.tags, leads_id)
|
|
351
|
+
|
|
352
|
+
# 5. Связи с контактами - преобразуем contacts_id и фильтруем ненайденные
|
|
353
|
+
if transformed.contacts_links:
|
|
354
|
+
valid_contacts_links = []
|
|
355
|
+
for link in transformed.contacts_links:
|
|
356
|
+
amo_contact_id = link.get("contacts_id")
|
|
357
|
+
if amo_contact_id is not None:
|
|
358
|
+
if contact_id_map:
|
|
359
|
+
internal_id = contact_id_map.get(amo_contact_id)
|
|
360
|
+
if internal_id is not None:
|
|
361
|
+
link["contacts_id"] = internal_id
|
|
362
|
+
valid_contacts_links.append(link)
|
|
363
|
+
# Пропускаем если контакт не найден в маппинге
|
|
364
|
+
else:
|
|
365
|
+
# Без маппинга - используем как есть (amo_id)
|
|
366
|
+
valid_contacts_links.append(link)
|
|
367
|
+
self.upsert_lead_contacts(cursor, valid_contacts_links, leads_id)
|
|
368
|
+
else:
|
|
369
|
+
self.upsert_lead_contacts(cursor, [], leads_id)
|
|
370
|
+
|
|
371
|
+
return leads_id
|
|
372
|
+
|
|
373
|
+
def upsert_contact(self, cursor, contact_data: Dict[str, Any]) -> int:
|
|
374
|
+
"""Вставляет или обновляет контакт."""
|
|
375
|
+
cursor.execute(
|
|
376
|
+
"""
|
|
377
|
+
INSERT INTO amocrm_contacts (
|
|
378
|
+
account_id, contact_id, name, company, post, phone, email,
|
|
379
|
+
request_id, is_deleted, first_name, last_name
|
|
380
|
+
) VALUES (
|
|
381
|
+
%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s
|
|
382
|
+
)
|
|
383
|
+
ON CONFLICT (account_id, contact_id) DO UPDATE SET
|
|
384
|
+
name = EXCLUDED.name,
|
|
385
|
+
company = EXCLUDED.company,
|
|
386
|
+
post = EXCLUDED.post,
|
|
387
|
+
phone = EXCLUDED.phone,
|
|
388
|
+
email = EXCLUDED.email,
|
|
389
|
+
request_id = EXCLUDED.request_id,
|
|
390
|
+
is_deleted = EXCLUDED.is_deleted,
|
|
391
|
+
first_name = EXCLUDED.first_name,
|
|
392
|
+
last_name = EXCLUDED.last_name
|
|
393
|
+
RETURNING id
|
|
394
|
+
""",
|
|
395
|
+
(
|
|
396
|
+
contact_data["account_id"],
|
|
397
|
+
contact_data["contact_id"],
|
|
398
|
+
contact_data.get("name"),
|
|
399
|
+
contact_data.get("company"),
|
|
400
|
+
contact_data.get("post"),
|
|
401
|
+
contact_data.get("phone"),
|
|
402
|
+
contact_data.get("email"),
|
|
403
|
+
contact_data.get("request_id"),
|
|
404
|
+
contact_data.get("is_deleted", False),
|
|
405
|
+
contact_data.get("first_name"),
|
|
406
|
+
contact_data.get("last_name"),
|
|
407
|
+
),
|
|
408
|
+
)
|
|
409
|
+
result = cursor.fetchone()
|
|
410
|
+
return result[0] if result else None
|
|
411
|
+
|
|
412
|
+
def load_transformed_contact(self, cursor, transformed: TransformedContact) -> int:
|
|
413
|
+
"""Загружает полностью трансформированный контакт."""
|
|
414
|
+
contacts_id = self.upsert_contact(cursor, transformed.contact)
|
|
415
|
+
|
|
416
|
+
# Факты по контакту
|
|
417
|
+
transformed.contact_facts["contacts_id"] = contacts_id
|
|
418
|
+
registered_id = self._get_or_create_date_id(cursor, transformed.contact_facts.get("created_date"))
|
|
419
|
+
|
|
420
|
+
cursor.execute(
|
|
421
|
+
"""
|
|
422
|
+
INSERT INTO amocrm_contacts_facts (
|
|
423
|
+
account_id, contacts_id, companies_id, users_id,
|
|
424
|
+
registered_id, created_date, modified_date
|
|
425
|
+
) VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
426
|
+
ON CONFLICT (account_id, contacts_id) DO UPDATE SET
|
|
427
|
+
companies_id = EXCLUDED.companies_id,
|
|
428
|
+
users_id = EXCLUDED.users_id,
|
|
429
|
+
registered_id = EXCLUDED.registered_id,
|
|
430
|
+
created_date = EXCLUDED.created_date,
|
|
431
|
+
modified_date = EXCLUDED.modified_date
|
|
432
|
+
""",
|
|
433
|
+
(
|
|
434
|
+
transformed.contact_facts["account_id"],
|
|
435
|
+
contacts_id,
|
|
436
|
+
transformed.contact_facts.get("companies_id"),
|
|
437
|
+
transformed.contact_facts.get("users_id"),
|
|
438
|
+
registered_id,
|
|
439
|
+
transformed.contact_facts.get("created_date"),
|
|
440
|
+
transformed.contact_facts.get("modified_date"),
|
|
441
|
+
),
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
# Атрибуты контакта
|
|
445
|
+
if transformed.attributes:
|
|
446
|
+
cursor.execute(
|
|
447
|
+
"DELETE FROM amocrm_contacts_attributes WHERE account_id = %s AND contacts_id = %s",
|
|
448
|
+
(transformed.contact["account_id"], contacts_id),
|
|
449
|
+
)
|
|
450
|
+
for attr in transformed.attributes:
|
|
451
|
+
cursor.execute(
|
|
452
|
+
"""
|
|
453
|
+
INSERT INTO amocrm_contacts_attributes (account_id, contacts_id, attribute_id, name, value)
|
|
454
|
+
VALUES (%s, %s, %s, %s, %s)
|
|
455
|
+
""",
|
|
456
|
+
(
|
|
457
|
+
transformed.contact["account_id"],
|
|
458
|
+
contacts_id,
|
|
459
|
+
attr["attribute_id"],
|
|
460
|
+
attr["name"],
|
|
461
|
+
attr.get("value"),
|
|
462
|
+
),
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
return contacts_id
|
|
466
|
+
|
|
467
|
+
def upsert_event(self, cursor, event_data: Dict[str, Any]) -> int:
|
|
468
|
+
"""Вставляет или обновляет событие."""
|
|
469
|
+
cursor.execute(
|
|
470
|
+
"""
|
|
471
|
+
INSERT INTO amocrm_leads_events (
|
|
472
|
+
account_id, leads_id, event_id, type, created_by,
|
|
473
|
+
created_at, value_after, value_before
|
|
474
|
+
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
|
475
|
+
ON CONFLICT (account_id, event_id) DO UPDATE SET
|
|
476
|
+
leads_id = EXCLUDED.leads_id,
|
|
477
|
+
type = EXCLUDED.type,
|
|
478
|
+
created_by = EXCLUDED.created_by,
|
|
479
|
+
created_at = EXCLUDED.created_at,
|
|
480
|
+
value_after = EXCLUDED.value_after,
|
|
481
|
+
value_before = EXCLUDED.value_before
|
|
482
|
+
RETURNING id
|
|
483
|
+
""",
|
|
484
|
+
(
|
|
485
|
+
event_data["account_id"],
|
|
486
|
+
event_data.get("leads_id"),
|
|
487
|
+
event_data["event_id"],
|
|
488
|
+
event_data["type"],
|
|
489
|
+
event_data.get("created_by"),
|
|
490
|
+
event_data.get("created_at"),
|
|
491
|
+
event_data.get("value_after"),
|
|
492
|
+
event_data.get("value_before"),
|
|
493
|
+
),
|
|
494
|
+
)
|
|
495
|
+
result = cursor.fetchone()
|
|
496
|
+
return result[0] if result else None
|
|
497
|
+
|
|
498
|
+
def upsert_pipeline(self, cursor, pipeline_data: Dict[str, Any]) -> int:
|
|
499
|
+
"""Вставляет или обновляет воронку."""
|
|
500
|
+
cursor.execute(
|
|
501
|
+
"""
|
|
502
|
+
INSERT INTO amocrm_pipelines (
|
|
503
|
+
account_id, pipeline_id, name, sort, is_main, is_unsorted_on, is_archive
|
|
504
|
+
) VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
505
|
+
ON CONFLICT (account_id, pipeline_id) DO UPDATE SET
|
|
506
|
+
name = EXCLUDED.name,
|
|
507
|
+
sort = EXCLUDED.sort,
|
|
508
|
+
is_main = EXCLUDED.is_main,
|
|
509
|
+
is_unsorted_on = EXCLUDED.is_unsorted_on,
|
|
510
|
+
is_archive = EXCLUDED.is_archive
|
|
511
|
+
RETURNING id
|
|
512
|
+
""",
|
|
513
|
+
(
|
|
514
|
+
pipeline_data["account_id"],
|
|
515
|
+
pipeline_data["pipeline_id"],
|
|
516
|
+
pipeline_data.get("name"),
|
|
517
|
+
pipeline_data.get("sort"),
|
|
518
|
+
pipeline_data.get("is_main", False),
|
|
519
|
+
pipeline_data.get("is_unsorted_on", False),
|
|
520
|
+
pipeline_data.get("is_archive", False),
|
|
521
|
+
),
|
|
522
|
+
)
|
|
523
|
+
result = cursor.fetchone()
|
|
524
|
+
return result[0] if result else None
|
|
525
|
+
|
|
526
|
+
def upsert_status(self, cursor, status_data: Dict[str, Any]) -> int:
|
|
527
|
+
"""Вставляет или обновляет статус."""
|
|
528
|
+
cursor.execute(
|
|
529
|
+
"""
|
|
530
|
+
INSERT INTO amocrm_statuses (
|
|
531
|
+
account_id, pipeline_id, status_id, name, color, sort, is_editable, type
|
|
532
|
+
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
|
|
533
|
+
ON CONFLICT (account_id, pipeline_id, status_id) DO UPDATE SET
|
|
534
|
+
name = EXCLUDED.name,
|
|
535
|
+
color = EXCLUDED.color,
|
|
536
|
+
sort = EXCLUDED.sort,
|
|
537
|
+
is_editable = EXCLUDED.is_editable,
|
|
538
|
+
type = EXCLUDED.type
|
|
539
|
+
RETURNING id
|
|
540
|
+
""",
|
|
541
|
+
(
|
|
542
|
+
status_data["account_id"],
|
|
543
|
+
status_data["pipeline_id"],
|
|
544
|
+
status_data["status_id"],
|
|
545
|
+
status_data.get("name"),
|
|
546
|
+
status_data.get("color"),
|
|
547
|
+
status_data.get("sort"),
|
|
548
|
+
status_data.get("is_editable", True),
|
|
549
|
+
status_data.get("type", 0),
|
|
550
|
+
),
|
|
551
|
+
)
|
|
552
|
+
result = cursor.fetchone()
|
|
553
|
+
return result[0] if result else None
|
|
554
|
+
|
|
555
|
+
def upsert_user(self, cursor, user_data: Dict[str, Any]) -> int:
|
|
556
|
+
"""Вставляет или обновляет пользователя."""
|
|
557
|
+
cursor.execute(
|
|
558
|
+
"""
|
|
559
|
+
INSERT INTO amocrm_users (
|
|
560
|
+
account_id, user_id, login, name, phone, email,
|
|
561
|
+
group_name, group_id, role_id, role_name,
|
|
562
|
+
is_admin, is_active, is_free, mail_access, catalog_access
|
|
563
|
+
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
564
|
+
ON CONFLICT (account_id, user_id) DO UPDATE SET
|
|
565
|
+
login = EXCLUDED.login,
|
|
566
|
+
name = EXCLUDED.name,
|
|
567
|
+
phone = EXCLUDED.phone,
|
|
568
|
+
email = EXCLUDED.email,
|
|
569
|
+
group_name = EXCLUDED.group_name,
|
|
570
|
+
group_id = EXCLUDED.group_id,
|
|
571
|
+
role_id = EXCLUDED.role_id,
|
|
572
|
+
role_name = EXCLUDED.role_name,
|
|
573
|
+
is_admin = EXCLUDED.is_admin,
|
|
574
|
+
is_active = EXCLUDED.is_active,
|
|
575
|
+
is_free = EXCLUDED.is_free,
|
|
576
|
+
mail_access = EXCLUDED.mail_access,
|
|
577
|
+
catalog_access = EXCLUDED.catalog_access
|
|
578
|
+
RETURNING id
|
|
579
|
+
""",
|
|
580
|
+
(
|
|
581
|
+
user_data["account_id"],
|
|
582
|
+
user_data["user_id"],
|
|
583
|
+
user_data.get("login"),
|
|
584
|
+
user_data.get("name"),
|
|
585
|
+
user_data.get("phone"),
|
|
586
|
+
user_data.get("email"),
|
|
587
|
+
user_data.get("group_name"),
|
|
588
|
+
user_data.get("group_id"),
|
|
589
|
+
user_data.get("role_id"),
|
|
590
|
+
user_data.get("role_name"),
|
|
591
|
+
user_data.get("is_admin", False),
|
|
592
|
+
user_data.get("is_active", True),
|
|
593
|
+
user_data.get("is_free", False),
|
|
594
|
+
user_data.get("mail_access", False),
|
|
595
|
+
user_data.get("catalog_access", False),
|
|
596
|
+
),
|
|
597
|
+
)
|
|
598
|
+
result = cursor.fetchone()
|
|
599
|
+
return result[0] if result else None
|
|
600
|
+
|
|
601
|
+
def upsert_note(self, cursor, note_data: Dict[str, Any]) -> int:
|
|
602
|
+
"""Вставляет или обновляет примечание."""
|
|
603
|
+
cursor.execute(
|
|
604
|
+
"""
|
|
605
|
+
INSERT INTO amocrm_leads_notes (
|
|
606
|
+
account_id, leads_id, creator_id, responsible_id,
|
|
607
|
+
note_id, note_type, note_type_id, created_at, updated_at, text, params
|
|
608
|
+
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
609
|
+
ON CONFLICT (account_id, note_id) DO UPDATE SET
|
|
610
|
+
leads_id = EXCLUDED.leads_id,
|
|
611
|
+
creator_id = EXCLUDED.creator_id,
|
|
612
|
+
responsible_id = EXCLUDED.responsible_id,
|
|
613
|
+
note_type = EXCLUDED.note_type,
|
|
614
|
+
note_type_id = EXCLUDED.note_type_id,
|
|
615
|
+
created_at = EXCLUDED.created_at,
|
|
616
|
+
updated_at = EXCLUDED.updated_at,
|
|
617
|
+
text = EXCLUDED.text,
|
|
618
|
+
params = EXCLUDED.params
|
|
619
|
+
RETURNING id
|
|
620
|
+
""",
|
|
621
|
+
(
|
|
622
|
+
note_data["account_id"],
|
|
623
|
+
note_data.get("leads_id"),
|
|
624
|
+
note_data.get("creator_id"),
|
|
625
|
+
note_data.get("responsible_id"),
|
|
626
|
+
note_data["note_id"],
|
|
627
|
+
note_data.get("note_type"),
|
|
628
|
+
note_data.get("note_type_id"),
|
|
629
|
+
note_data.get("created_at"),
|
|
630
|
+
note_data.get("updated_at"),
|
|
631
|
+
note_data.get("text"),
|
|
632
|
+
note_data.get("params"),
|
|
633
|
+
),
|
|
634
|
+
)
|
|
635
|
+
result = cursor.fetchone()
|
|
636
|
+
return result[0] if result else None
|
|
637
|
+
|
|
638
|
+
# =========================================================================
|
|
639
|
+
# Маппинг внешних ID на внутренние
|
|
640
|
+
# =========================================================================
|
|
641
|
+
|
|
642
|
+
def get_internal_lead_id(self, cursor, account_id: int, lead_id: int) -> Optional[int]:
|
|
643
|
+
"""
|
|
644
|
+
Получает внутренний ID сделки по (account_id, lead_id).
|
|
645
|
+
|
|
646
|
+
Args:
|
|
647
|
+
account_id: mybi account_id
|
|
648
|
+
lead_id: ID сделки из amoCRM
|
|
649
|
+
|
|
650
|
+
Returns:
|
|
651
|
+
Внутренний id из amocrm_leads или None
|
|
652
|
+
"""
|
|
653
|
+
cursor.execute(
|
|
654
|
+
"SELECT id FROM amocrm_leads WHERE account_id = %s AND lead_id = %s",
|
|
655
|
+
(account_id, lead_id),
|
|
656
|
+
)
|
|
657
|
+
result = cursor.fetchone()
|
|
658
|
+
return result[0] if result else None
|
|
659
|
+
|
|
660
|
+
def get_internal_contact_id(self, cursor, account_id: int, contact_id: int) -> Optional[int]:
|
|
661
|
+
"""
|
|
662
|
+
Получает внутренний ID контакта по (account_id, contact_id).
|
|
663
|
+
|
|
664
|
+
Args:
|
|
665
|
+
account_id: mybi account_id
|
|
666
|
+
contact_id: ID контакта из amoCRM
|
|
667
|
+
|
|
668
|
+
Returns:
|
|
669
|
+
Внутренний id из amocrm_contacts или None
|
|
670
|
+
"""
|
|
671
|
+
cursor.execute(
|
|
672
|
+
"SELECT id FROM amocrm_contacts WHERE account_id = %s AND contact_id = %s",
|
|
673
|
+
(account_id, contact_id),
|
|
674
|
+
)
|
|
675
|
+
result = cursor.fetchone()
|
|
676
|
+
return result[0] if result else None
|
|
677
|
+
|
|
678
|
+
def build_lead_id_map(self, cursor, account_id: int) -> Dict[int, int]:
|
|
679
|
+
"""
|
|
680
|
+
Строит маппинг {lead_id -> internal_id} для аккаунта.
|
|
681
|
+
|
|
682
|
+
Полезно для пакетной загрузки events/notes.
|
|
683
|
+
|
|
684
|
+
Args:
|
|
685
|
+
account_id: mybi account_id
|
|
686
|
+
|
|
687
|
+
Returns:
|
|
688
|
+
Dict[lead_id, internal_id]
|
|
689
|
+
"""
|
|
690
|
+
cursor.execute(
|
|
691
|
+
"SELECT lead_id, id FROM amocrm_leads WHERE account_id = %s",
|
|
692
|
+
(account_id,),
|
|
693
|
+
)
|
|
694
|
+
return {row[0]: row[1] for row in cursor.fetchall()}
|
|
695
|
+
|
|
696
|
+
def build_contact_id_map(self, cursor, account_id: int) -> Dict[int, int]:
|
|
697
|
+
"""
|
|
698
|
+
Строит маппинг {contact_id -> internal_id} для аккаунта.
|
|
699
|
+
|
|
700
|
+
Args:
|
|
701
|
+
account_id: mybi account_id
|
|
702
|
+
|
|
703
|
+
Returns:
|
|
704
|
+
Dict[contact_id, internal_id]
|
|
705
|
+
"""
|
|
706
|
+
cursor.execute(
|
|
707
|
+
"SELECT contact_id, id FROM amocrm_contacts WHERE account_id = %s",
|
|
708
|
+
(account_id,),
|
|
709
|
+
)
|
|
710
|
+
return {row[0]: row[1] for row in cursor.fetchall()}
|
|
711
|
+
|
|
712
|
+
def get_contact_ids_from_leads(
|
|
713
|
+
self, cursor, account_id: int, pipeline_ids: Optional[List[int]] = None
|
|
714
|
+
) -> Set[int]:
|
|
715
|
+
"""
|
|
716
|
+
Получает contact_id из сделок в указанных воронках.
|
|
717
|
+
|
|
718
|
+
Args:
|
|
719
|
+
account_id: mybi account_id
|
|
720
|
+
pipeline_ids: Список ID воронок (если None - все воронки)
|
|
721
|
+
|
|
722
|
+
Returns:
|
|
723
|
+
Set[contact_id] - amoCRM contact_ids из сделок
|
|
724
|
+
"""
|
|
725
|
+
if pipeline_ids:
|
|
726
|
+
cursor.execute(
|
|
727
|
+
"""
|
|
728
|
+
SELECT DISTINCT c.contact_id
|
|
729
|
+
FROM amocrm_leads_contacts lc
|
|
730
|
+
JOIN amocrm_leads l ON lc.leads_id = l.id
|
|
731
|
+
JOIN amocrm_contacts c ON lc.contacts_id = c.id
|
|
732
|
+
WHERE l.account_id = %s AND l.pipeline_id = ANY(%s)
|
|
733
|
+
""",
|
|
734
|
+
(account_id, pipeline_ids),
|
|
735
|
+
)
|
|
736
|
+
else:
|
|
737
|
+
cursor.execute(
|
|
738
|
+
"""
|
|
739
|
+
SELECT DISTINCT c.contact_id
|
|
740
|
+
FROM amocrm_leads_contacts lc
|
|
741
|
+
JOIN amocrm_leads l ON lc.leads_id = l.id
|
|
742
|
+
JOIN amocrm_contacts c ON lc.contacts_id = c.id
|
|
743
|
+
WHERE l.account_id = %s
|
|
744
|
+
""",
|
|
745
|
+
(account_id,),
|
|
746
|
+
)
|
|
747
|
+
return {row[0] for row in cursor.fetchall()}
|
|
748
|
+
|
|
749
|
+
def build_user_id_map(self, cursor, account_id: int) -> Dict[int, int]:
|
|
750
|
+
"""
|
|
751
|
+
Строит маппинг {user_id -> internal_id} для аккаунта.
|
|
752
|
+
|
|
753
|
+
Args:
|
|
754
|
+
account_id: mybi account_id
|
|
755
|
+
|
|
756
|
+
Returns:
|
|
757
|
+
Dict[user_id, internal_id]
|
|
758
|
+
"""
|
|
759
|
+
cursor.execute(
|
|
760
|
+
"SELECT user_id, id FROM amocrm_users WHERE account_id = %s",
|
|
761
|
+
(account_id,),
|
|
762
|
+
)
|
|
763
|
+
return {row[0]: row[1] for row in cursor.fetchall()}
|
|
764
|
+
|
|
765
|
+
# =========================================================================
|
|
766
|
+
# ETL State
|
|
767
|
+
# =========================================================================
|
|
768
|
+
|
|
769
|
+
def get_etl_state(
|
|
770
|
+
self, cursor, entity_type: str, account_id: int, pipeline_id: Optional[int] = None
|
|
771
|
+
) -> Optional[datetime]:
|
|
772
|
+
"""Получает last_updated_at из etl_state."""
|
|
773
|
+
cursor.execute(
|
|
774
|
+
"""
|
|
775
|
+
SELECT last_updated_at FROM etl_state
|
|
776
|
+
WHERE entity_type = %s AND account_id = %s AND COALESCE(pipeline_id, 0) = COALESCE(%s, 0)
|
|
777
|
+
""",
|
|
778
|
+
(entity_type, account_id, pipeline_id),
|
|
779
|
+
)
|
|
780
|
+
result = cursor.fetchone()
|
|
781
|
+
return result[0] if result else None
|
|
782
|
+
|
|
783
|
+
def update_etl_state(
|
|
784
|
+
self,
|
|
785
|
+
cursor,
|
|
786
|
+
entity_type: str,
|
|
787
|
+
account_id: int,
|
|
788
|
+
last_updated_at: datetime,
|
|
789
|
+
records_loaded: int,
|
|
790
|
+
pipeline_id: Optional[int] = None,
|
|
791
|
+
error_message: Optional[str] = None,
|
|
792
|
+
) -> None:
|
|
793
|
+
"""Обновляет состояние ETL."""
|
|
794
|
+
cursor.execute(
|
|
795
|
+
"""
|
|
796
|
+
INSERT INTO etl_state (entity_type, account_id, pipeline_id, last_updated_at, last_run_at, records_loaded, error_message)
|
|
797
|
+
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
|
798
|
+
ON CONFLICT (entity_type, account_id, COALESCE(pipeline_id, 0)) DO UPDATE SET
|
|
799
|
+
last_updated_at = EXCLUDED.last_updated_at,
|
|
800
|
+
last_run_at = EXCLUDED.last_run_at,
|
|
801
|
+
records_loaded = etl_state.records_loaded + EXCLUDED.records_loaded,
|
|
802
|
+
error_message = EXCLUDED.error_message
|
|
803
|
+
""",
|
|
804
|
+
(
|
|
805
|
+
entity_type,
|
|
806
|
+
account_id,
|
|
807
|
+
pipeline_id,
|
|
808
|
+
last_updated_at,
|
|
809
|
+
datetime.now(timezone.utc),
|
|
810
|
+
records_loaded,
|
|
811
|
+
error_message,
|
|
812
|
+
),
|
|
813
|
+
)
|