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.
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
+ )