# Выгрузка данных CRM1 из Полариса (Казахстан)

Две ручки отдают CSV с ровно теми полями, что отдаёт API CRM1 (`/api/loans` и `/api/payments`,
версия без `/v1`), но со значениями, **прочитанными из БД Полариса**. Нужны для сверки: выгружаем
файл из Полариса и файл из CRM1 на один момент и сравниваем построчно.

Пояснительная записка для команды CRM — `docs/crm1-export-handover.md`; она описывает то же самое,
но без внутренней терминологии. Правки в поведении выгрузки нужно отражать в обоих файлах.

```
GET https://<host>/api/export/crm1/loans
GET https://<host>/api/export/crm1/payments
```

```bash
curl -OJ https://<host>/api/export/crm1/loans
```

- **Авторизации нет** — обычный GET. Сверку проводит внешняя команда, и токен решили не заводить;
  выгрузка при этом отдаёт ФИО, телефоны, почты, документы и адреса всего портфеля CRM1, так что
  ограничение доступа, если оно понадобится, живёт на уровне nginx, а не приложения.
- Инстанс не Казахстан — `404`: `crm_source` есть только там, где два CRM.
- Параметров нет. Один запрос — весь портфель CRM1 одним файлом; ответ стримится построчно.

**Формат файла:** CSV, разделитель `;`, UTF-8 с BOM, первая строка — заголовки.

**Имя файла** датирует содержимое моментом, когда закончилась последняя **успешная** актуализация
этого источника: `crm1_loans_<Y-m-d_H-i>.csv` / `crm1_payments_<Y-m-d_H-i>.csv`, время в таймзоне
`config('app.timezone')`. Берётся из `actualization_histories` — последняя строка со
`status = STATUS_COMPLETED` и `process_type` `PROCESS_TYPE_LOANS_ENTIRE_CRM1` (5) или
`PROCESS_TYPE_PAYMENTS_ENTIRE_CRM1` (6), поле `updated_at`
(`ActualizationHistory::findLatestCompletedByProcessType()`). Незавершённый, отменённый или упавший
прогон таблицы не менял и в имя не попадает. Если успешных прогонов не было ни разу — `unknown`, и
файл содержит только заголовок.

**Что попадает в выгрузку:** займы с `9_cobranzas.crm_source = 'crm1'`; платежи `pagos`, чей
`obligacion` ссылается на такой займ.

## Займы

53 колонки в порядке ответа CRM1.

| Колонка CRM1 | Откуда берём |
| --- | --- |
| `loan_id` | `9_cobranzas.obligacion` |
| `agreement_id` | `9_cobranzas.agreement_id` |
| `document` | `9_cobranzas.documento` |
| `names` | `10_clientes.nombre` |
| `principal_outstanding` | `9_cobranzas.credito` |
| `disbursement_date` | `9_cobranzas.fechadesembolso` |
| `final_date` | `9_cobranzas.fechavencimiento` |
| `due_date` | `9_cobranzas.fechavencimiento` — то же значение, что и в `final_date` |
| `dpd` | `9_cobranzas.dpd` |
| `interests` | `9_cobranzas.porc_tasa` |
| `loan_penalty_amount` | `9_cobranzas.loan_penalty_amount` |
| `summ_of_fees` | `9_cobranzas.summ_of_fees` |
| `comissions` | `9_cobranzas.total_gastos` |
| `total` | **пусто, не сохраняется** |
| `gac` | `9_cobranzas.gac` |
| `total_actual` | `9_cobranzas.saldoaldia` |
| `status` | `9_cobranzas.estado`, в верхнем регистре |
| `order_id` | **пусто, не сохраняется** |
| `sex` | `10_clientes.cargo` |
| `phone` | `14_telefonos.telefono`, `parentesco = 'Principal Titular'` |
| `email` | `mails.email`, `idactivo = 1` |
| `client_type` | `9_cobranzas.estrategia` |
| `address` | `10_clientes.address` |
| `residence_address` | `10_clientes.residence_address` |
| `registration_address` | `10_clientes.registration_address` |
| `municipality_name` | **пусто, не сохраняется** |
| `department_name` | `14_telefonos.idciudad`, `parentesco = 'Principal Titular'` |
| `contact_number` | `14_telefonos.telefono`, `parentesco = 'Numero Contacto'` |
| `company_name` | `10_clientes.empresa` |
| `year` / `week` | `9_cobranzas.year` / `9_cobranzas.week` |
| `month` | `9_cobranzas.month`, обратно в английское название месяца |
| `amount_repay` | `9_cobranzas.amount_to_repay` |
| `status_change_date` | **пусто, не сохраняется** |
| `postal_code` | `10_clientes.postal_code` |
| `client_id` | `10_clientes.client_id` |
| `brand`, `oxxo_ref`, `stp_ref` | `9_cobranzas.*` |
| `first_name`, `second_name`, `first_surname`, `second_surname` | `10_clientes.*` |
| `amount_dd` | `9_cobranzas.amount_dd` |
| `birthday` | `10_clientes.birthday`, обратно в epoch-миллисекунды |
| `account_number`, `bank`, `term`, `ext_agency`, `product_type`, `payments_plan` | `9_cobranzas.*` |
| `due_date_inicial` | `9_cobranzas.due_date_inicial` |
| `restruct` | JSON-массив из `resctruct_history` по займу |

## Платежи

| Колонка CRM1 | Откуда берём |
| --- | --- |
| `payment_id` | `pagos.external_id` |
| `client_fullname` | `10_clientes.nombre` |
| `document` | `pagos.identificacion` |
| `client_id` | `10_clientes.client_id` |
| `agreement_id` | `9_cobranzas.agreement_id` связанного займа |
| `dpd` | `pagos.dpd` |
| `amount` | `pagos.valor` |
| `external_id` | **пусто, не сохраняется** |
| `status` | `pagos.status`: `PROCESADO → SUCCESS`, `REVERTED → FAILED`, `PROCESSING → PROCESSING` |
| `type` | `pagos.type` |
| `payment_date` | `pagos.fecha` |

## Ожидаемые расхождения

Всё ниже — не баги выгрузки, а свойства того, как актуализация сохраняет данные. Учитывайте при
сверке.

1. **Поля, которые не сохраняются вовсе.** `total`, `order_id`, `status_change_date`,
   `municipality_name` доезжают до промежуточной таблицы `archivo_creditos`, но
   `BaseLoanUpdateHandler::updateLoans()` их в `9_cobranzas` не переносит, а `archivo_creditos`
   очищается в начале каждого прогона. То же с `external_id` платежа — это ссылка на плательщика,
   а не идентификатор платежа, и в `pagos` для неё нет колонки. Такие колонки всегда пустые.
   На проде это реальная потеря, а не формальность: CRM1 заполняет все четыре у **100%** займов.
2. **Теряется время суток.** `disbursement_date`, `final_date`, `due_date_inicial` пишутся через
   `TO_DATE(...)` в `date`-колонки, `pagos.fecha` — через `toDateString()`. Во всех этих полях
   выгрузка отдаёт `00:00:00`, даже если CRM1 прислал время.
3. **`due_date` и `final_date` отдаются одинаковыми.** Прод-CRM1 заполняет оба поля одним и тем же
   значением (проверено на 54383 займах), `resolveDueDate()` оставляет то из них, что непустое, и в
   `fechavencimiento` попадает одна дата. Вернуть её в одно поле и не вернуть в другое было бы
   расхождением на каждой строке, поэтому выгрузка отдаёт её в оба. На тестовом контуре CRM1
   заполняет только `final_date` (`crm1-api-defects.md`, дефект 6) — там `due_date` в выгрузке
   окажется лишним; приоритет у прода.
4. **`names` переставлено.** `AbstractKazakhstanLoanUpdateHandler::composeFullName()` пересобирает
   ФИО как «Фамилия Имя Отчество», а CRM1 в `/loans` присылает «Имя Фамилия Отчество». В `/payments`
   у CRM1 порядок тот же, что у нас, поэтому `client_fullname` совпадает.
5. **Двойные кавычки вырезаются из любого значения.** `refreshLoanArchives()` прогоняет каждое
   поле через `str_replace('"', '', ...)`. Сильнее всего это видно на `payments_plan` — он перестаёт
   быть валидным JSON и лежит как `[{payment_date:2026-10-21T23:59:59,amount:162000.0000}]`, —
   но задевает и обычный текст: на проде у 33 займов из адреса пропадают кавычки
   (`Микрорайон "Самал"` → `Микрорайон Самал`) во всех трёх полях `address`, `residence_address`,
   `registration_address`. Других полей с кавычками в прод-портфеле нет.
6. **`restruct` пересобран, а не сохранён как есть.** Массив восстанавливается из
   `resctruct_history`. При приёме из вложенной коллекции берётся только последняя запись
   (`flattenRestruct()`), зато история накапливается между прогонами — состав элементов может
   отличаться от того, что CRM1 отдаёт сейчас. Ключи и порядок полей сохранены, форматирование
   JSON — postgres-овское (`"loan" : "..."`).
7. **`month` и `birthday` пересобираются в форму CRM1.** CRM1 присылает месяц английским
   названием (`AUGUST`), а дату рождения — epoch-миллисекундами (`950140800000`); `resolveMonth()`
   и `resolveBirthday()` хранят их как число и `Y-m-d`. Выгрузка разворачивает обе обратно.
   `crm1-api-defects.md` (август 2026) фиксировал у CRM1 другие форматы — `"07"` и `1994-10-03`;
   контур с тех пор перешёл на тот же словарь, что и CRM2. Если формат снова поменяется, править
   надо `Crm1LoansCsvExporter::toCrmMonth()` / `toCrmBirthday()`.
8. **`comissions` у CRM1 всегда `null`, а у нас всегда `0`.** `numericOrZero()` вынужден подставить
   `"0"`, иначе строка не пройдёт фильтр `WHERE porc_tasa <> ''` и займ не сохранится вообще. То
   есть «данных нет» превращается в «ноль» — по этому полю сверка расходится на 100% строк.
9. **Дата рождения раньше 1900 года отбрасывается.** `resolveBirthday()` считает такое порчей
   данных на стороне источника (`BIRTHDAY_MIN_YEAR`) и пишет `NULL`.
10. **Хвост нулей в числах.** `credito`, `porc_tasa`, `total_gastos`, `gac`, `saldoaldia`, `valor` —
   `numeric` со своей точностью, поэтому `162000.0000` от CRM1 может лежать как `162000` или
   `162000.000`. Сравнивайте как числа, не как строки.
11. **Строк может быть меньше, чем в CRM1.** Займ не сохраняется вовсе, если у него пустой
   `document`, `dpd` или `interests` — `updateLoans()` фильтрует
   `WHERE porc_tasa <> '' AND dpd <> '' AND cedula <> ''`. Платёж не сохраняется, если по его
   `agreement_id` не нашёлся займ, если пуст `payment_id` или `payment_date`, либо если дата вне
   окна `INCOMING_UPDATES_PAYMENTS_WINDOW_DAYS` (31 день). Это и есть то, что стоит смотреть в
   первую очередь.
12. **Клиентские поля общие на документ, а не на займ.** `names`, `phone`, `email`,
   `department_name`, `contact_number`, `company_name`, адреса, `birthday`, `postal_code`,
   `client_id` лежат в `10_clientes` / `14_telefonos` / `mails` по `documento`. У клиента с
   несколькими займами они одинаковые во всех его строках и отражают последний прогон. Телефон и
   почта берутся самые свежие (`ORDER BY id DESC`) — старые номера остаются в таблице активными.
   Хуже того, один и тот же `document` в CRM1 встречается у **разных** клиентов, а `10_clientes`
   уникальна по `documento` — все они схлопываются в одну строку, и выигрывает тот займ, который
   записался последним.
13. **Источник платежа определяется по займу.** У CRM1 `payment_id` числовой, у CRM2 — UUID. Если в
    выгрузке платежей встретился UUID, это платёж из CRM2 на займ, помеченный `crm1`.

## Результаты сверки от 11.09.2026 (прод)

Полный цикл на чистой БД против `pl2-prod.smsfinanceit.ru`: снимок сырого ответа CRM1 →
`actualization:run loans --source=crm1` → выгрузка CSV → пополевое сравнение. Снимок «после»
показал, что за 25 минут портфель сдвинулся на 84 займа (17 изменились, 27 ушли, 40 новых) — они
исключены из сверки, иначе дали бы ложные расхождения.

| | |
| --- | --- |
| CRM1 отдал строк | **54383**, уникальных займов **54382** |
| актуализация скачала | **54391** (портфель живой, её прогон был позже снимка) |
| легло в `archivo_creditos` | **54390** |
| легло в `9_cobranzas` и попало в CSV | **54390** |
| срезано фильтром `updateLoans()` | **0** |
| строк в CSV, которых нет в CRM1 | **0** |
| сверено пополевно | **54336** (остальные — дрейф) |

**Фильтр `WHERE cedula <> '' AND dpd <> '' AND porc_tasa <> ''` на проде не срезает ничего**:
`document`, `dpd` и `interests` заполнены у всех 54383 займов. Вывод прошлого прогона про «половину
портфеля» относился к тестовому контуру, где `document` пуст у 1778 из 3513 записей, и к проду
отношения не имеет.

Из 53 колонок **45 совпали полностью**, 4 — известные пробелы из п. 1, расходятся ровно 4:

| Поле | Расхождений | Причина |
| --- | --- | --- |
| `comissions` | 54336 | CRM1 шлёт `null` во всех записях, мы храним `0` (п. 8) |
| `address`, `residence_address`, `registration_address` | 33 | вырезание двойных кавычек (п. 5) |

Необъяснённых расхождений нет. Правка `due_date` (п. 3) проверена на проде: поле совпадает на всех
54336 сверенных займах.

### Нестабильная пагинация на стороне CRM1

За один проход по 109 страницам портфель меняется, а выборка отдаётся постранично по номеру
страницы без устойчивой сортировки, поэтому записи переезжают через границы страниц:

- заём `2003607784` пришёл **дважды** — на странице 104 и на странице 105;
- займы `2003591532` и `2003599551` актуализация не увидела вовсе: в снимке «до» они были на
  страницах 81 и 97, в снимке «после» — уже на 80 и 96.

Дубль гасится дедупликацией по `orden` в `refreshLoanArchives()`, а пропуск не гасится ничем —
займ просто не попадает в этот прогон и подхватывается следующим. На объёме одного прогона это
единицы записей, но чинится только на стороне CRM1 (устойчивая сортировка или курсорная пагинация).
