From c36c7b9ba904081d21048f9c373291f08a47195c Mon Sep 17 00:00:00 2001 From: Aleksandr Meshchriakov Date: Tue, 3 Feb 2026 17:00:19 +0100 Subject: [PATCH] =?UTF-8?q?feat(parsers):=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=20API=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=20=D0=B4=D0=BB=D1=8F=20checko.ru?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Реализован CheckoClient с поддержкой всех 10 эндпоинтов API v2 - Frozen dataclass модели для запросов и ответов - Справочники ОКВЭД2, ОКФС, ОКОПФ, ОКПД, статусы компаний - Маппинг русских полей API на английские имена - Unit тесты с моками - E2E тесты с реальными запросами - Настройка CHECKO_API_KEY в settings.py --- .env.example | 5 +- docker-compose.prod.yml | 2 - src/apps/parsers/clients/checko/__init__.py | 121 ++ src/apps/parsers/clients/checko/client.py | 1575 +++++++++++++++++ .../clients/checko/datasets/__init__.py | 62 + .../clients/checko/datasets/account_codes.py | 91 + .../parsers/clients/checko/datasets/base.py | 179 ++ .../parsers/clients/checko/datasets/okfs.py | 46 + .../parsers/clients/checko/datasets/okopf.py | 76 + .../parsers/clients/checko/datasets/okpd.py | 116 ++ .../parsers/clients/checko/datasets/okved.py | 132 ++ .../clients/checko/datasets/statuses.py | 139 ++ src/apps/parsers/clients/checko/enums.py | 88 + src/apps/parsers/clients/checko/exceptions.py | 90 + .../clients/checko/schemas/__init__.py | 162 ++ .../parsers/clients/checko/schemas/common.py | 294 +++ .../clients/checko/schemas/requests.py | 423 +++++ .../clients/checko/schemas/responses.py | 1149 ++++++++++++ src/apps/parsers/tests/run_checko_e2e.py | 119 ++ src/apps/parsers/tests/test_checko_e2e.py | 438 +++++ src/config/settings/base.py | 9 +- tests/apps/parsers/test_checko.py | 631 +++++++ 22 files changed, 5943 insertions(+), 4 deletions(-) create mode 100644 src/apps/parsers/clients/checko/__init__.py create mode 100644 src/apps/parsers/clients/checko/client.py create mode 100644 src/apps/parsers/clients/checko/datasets/__init__.py create mode 100644 src/apps/parsers/clients/checko/datasets/account_codes.py create mode 100644 src/apps/parsers/clients/checko/datasets/base.py create mode 100644 src/apps/parsers/clients/checko/datasets/okfs.py create mode 100644 src/apps/parsers/clients/checko/datasets/okopf.py create mode 100644 src/apps/parsers/clients/checko/datasets/okpd.py create mode 100644 src/apps/parsers/clients/checko/datasets/okved.py create mode 100644 src/apps/parsers/clients/checko/datasets/statuses.py create mode 100644 src/apps/parsers/clients/checko/enums.py create mode 100644 src/apps/parsers/clients/checko/exceptions.py create mode 100644 src/apps/parsers/clients/checko/schemas/__init__.py create mode 100644 src/apps/parsers/clients/checko/schemas/common.py create mode 100644 src/apps/parsers/clients/checko/schemas/requests.py create mode 100644 src/apps/parsers/clients/checko/schemas/responses.py create mode 100644 src/apps/parsers/tests/run_checko_e2e.py create mode 100644 src/apps/parsers/tests/test_checko_e2e.py create mode 100644 tests/apps/parsers/test_checko.py diff --git a/.env.example b/.env.example index 58ecefd..a0409f5 100644 --- a/.env.example +++ b/.env.example @@ -32,4 +32,7 @@ SCRAPY_LOG_LEVEL=INFO # Parsers API Tokens # Токен для zakupki.gov.ru (получить через Госуслуги на https://zakupki.gov.ru/pmd/auth/welcome) -ZAKUPKI_TOKEN= \ No newline at end of file +ZAKUPKI_TOKEN= + +# API ключ для checko.ru (информация о юридических лицах) +CHECKO_API_KEY= \ No newline at end of file diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 26e6d61..4653c74 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -61,8 +61,6 @@ services: - ./staticfiles:/app/staticfiles ports: - "8000:8000" - networks: - - mostovik_network command: > sh -c "python src/manage.py migrate && python src/manage.py collectstatic --noinput && diff --git a/src/apps/parsers/clients/checko/__init__.py b/src/apps/parsers/clients/checko/__init__.py new file mode 100644 index 0000000..287aa8d --- /dev/null +++ b/src/apps/parsers/clients/checko/__init__.py @@ -0,0 +1,121 @@ +""" +Checko.ru API Client. + +Клиент для работы с API сервиса Checko.ru (ЕГРЮЛ/ЕГРИП). +Документация API: https://checko.ru/integration/api + +Использование: + from apps.parsers.clients.checko import CheckoClient, CompanyRequest + + client = CheckoClient(api_key="your_api_key") + + # Получить информацию о компании по ИНН + response = client.get_company(CompanyRequest(inn="7707083893")) + print(response.data.full_name) + + # Поиск организаций + from apps.parsers.clients.checko import SearchRequest, SearchType, ObjectType + response = client.search(SearchRequest( + by=SearchType.NAME, + obj=ObjectType.ORGANIZATION, + query="Сбербанк" + )) + + # Использование справочников + from apps.parsers.clients.checko.datasets import OKVED2, OKFS + print(OKVED2.get_name("62.01")) # -> "Разработка компьютерного ПО" + print(OKFS.get_name("12")) # -> "Частная собственность" +""" + +from apps.parsers.clients.checko.client import CheckoClient +from apps.parsers.clients.checko.enums import ( + CaseRole, + ContractLaw, + ContractRole, + ObjectType, + SearchType, + SortOrder, +) +from apps.parsers.clients.checko.exceptions import ( + CheckoAPIError, + CheckoConnectionError, + CheckoError, + CheckoNotFoundError, + CheckoRateLimitError, + CheckoValidationError, +) +from apps.parsers.clients.checko.schemas import ( + # Common + ApiMeta, + # Requests + BankRequest, + # Responses + BankResponse, + CompanyRequest, + CompanyResponse, + CompanyShort, + ContractsRequest, + ContractsResponse, + EnforcementsRequest, + EnforcementsResponse, + EntrepreneurRequest, + EntrepreneurResponse, + EntrepreneurShort, + FinancesRequest, + FinancesResponse, + InspectionsRequest, + InspectionsResponse, + LegalCasesRequest, + LegalCasesResponse, + PaginationInfo, + PersonRequest, + PersonResponse, + SearchRequest, + SearchResponse, +) + +__all__ = [ + # Client + "CheckoClient", + # Enums + "CaseRole", + "ContractLaw", + "ContractRole", + "ObjectType", + "SearchType", + "SortOrder", + # Exceptions + "CheckoAPIError", + "CheckoConnectionError", + "CheckoError", + "CheckoNotFoundError", + "CheckoRateLimitError", + "CheckoValidationError", + # Common schemas + "ApiMeta", + "CompanyShort", + "EntrepreneurShort", + "PaginationInfo", + # Request schemas + "BankRequest", + "CompanyRequest", + "ContractsRequest", + "EnforcementsRequest", + "EntrepreneurRequest", + "FinancesRequest", + "InspectionsRequest", + "LegalCasesRequest", + "PersonRequest", + "SearchRequest", + # Response schemas + "BankResponse", + "CompanyResponse", + "ContractsResponse", + "EnforcementsResponse", + "EntrepreneurResponse", + "FinancesResponse", + "InspectionsResponse", + "LegalCasesResponse", + "PersonResponse", + "SearchResponse", +] diff --git a/src/apps/parsers/clients/checko/client.py b/src/apps/parsers/clients/checko/client.py new file mode 100644 index 0000000..175ce6a --- /dev/null +++ b/src/apps/parsers/clients/checko/client.py @@ -0,0 +1,1575 @@ +""" +Checko API Client. + +Клиент для работы с Checko.ru API v2. +Документация: https://checko.ru/integration/api +""" + +import logging +from collections.abc import Iterator +from dataclasses import dataclass, field +from typing import Any + +from apps.parsers.clients.base import BaseHTTPClient +from apps.parsers.clients.checko.exceptions import ( + CheckoAPIError, + CheckoConnectionError, + CheckoNotFoundError, + CheckoRateLimitError, + CheckoValidationError, +) +from apps.parsers.clients.checko.schemas.common import ( + ApiMeta, + BankruptcyMessage, + CompanyShort, + EntrepreneurShort, + FundRegistration, + License, + MspSupport, + Okved, + PaginationInfo, + Region, + TaxAuthority, + Trademark, + UnfairSupplierRecord, +) +from apps.parsers.clients.checko.schemas.requests import ( + BankRequest, + CompanyRequest, + ContractsRequest, + EnforcementsRequest, + EntrepreneurRequest, + FinancesRequest, + InspectionsRequest, + LegalCasesRequest, + PersonRequest, + SearchRequest, +) +from apps.parsers.clients.checko.schemas.responses import ( + Address, + BankData, + BankResponse, + Branch, + Capital, + CaseInstance, + CaseParty, + CompanyData, + CompanyResponse, + CompanyStatistics, + CompanyStatus, + Contract, + ContractParty, + ContractsData, + ContractsResponse, + Disqualification, + Enforcement, + EnforcementsData, + EnforcementsResponse, + EntrepreneurData, + EntrepreneurResponse, + EntrepreneurStatus, + FinanceReport, + FinanceReportLine, + FinancesData, + FinancesResponse, + FinanceSummary, + Founder, + Inspection, + InspectionsData, + InspectionsResponse, + Leader, + LegalCase, + LegalCasesData, + LegalCasesResponse, + MspCategory, + OkvedInfo, + PersonCompany, + PersonData, + PersonEntrepreneur, + PersonResponse, + Predecessor, + RegistrarInfo, + RelatedCompany, + SearchData, + SearchResponse, + Successor, + TaxDebt, + TaxPenalty, +) + +logger = logging.getLogger(__name__) + + +# Маппинг русских полей API -> английские +RU_FIELD_MAP = { + # Идентификаторы + "ОГРН": "ogrn", + "ИНН": "inn", + "КПП": "kpp", + "ОКПО": "okpo", + "ОГРНИП": "ogrnip", + "БИК": "bic", + # Даты + "ДатаРег": "reg_date", + "ДатаОГРН": "ogrn_date", + "ДатаВып": "issue_date", + "ДатаЛикв": "liquidation_date", + "ДатаПрекр": "termination_date", + # Наименования + "НаимСокр": "short_name", + "НаимПолн": "full_name", + "НаимАнгл": "name_en", + "Наим": "name", + # Статус + "Статус": "status", + "Код": "code", + # Адреса + "ЮрАдрес": "legal_address", + "АдресРФ": "full_address", + "НасПункт": "city", + "Индекс": "postal_code", + "Недост": "is_unreliable", + "МассАдрес": "is_mass_address", + # Регион + "Регион": "region", + # ОКВЭД + "ОКВЭД": "okved", + "ОКВЭДДоп": "okved_additional", + "Версия": "version", + # Классификаторы + "ОКОПФ": "opf", + "ОКФС": "okfs", + "ОКОГУ": "okogu", + # Налоговая + "РегФНС": "tax_authority", + "ТекФНС": "tax_authority_local", + "РегПФР": "pfr", + "РегФСС": "fss", + "НомерРег": "reg_number", + "Дата": "date", + # Капитал + "УстКап": "capital", + "Сумма": "value", + "Тип": "type_name", + # Руководство + "Руковод": "leader", + "Учред": "founders", + "ФИО": "full_name", + "Должн": "position_name", + "НаимДолжн": "position_name", + "ВидДолжн": "position_type", + "КодДолжн": "position_type", + "Доля": "share", + "СуммаВклада": "capital_amount", + "ТипУчред": "founder_type", + "МассРуковод": "is_mass_leader", + "Дисквал": "is_disqualified", + "ДисквЛицо": "is_disqualified", + "НедостФИО": "is_unreliable", + "ДатаЗаписи": "date", + # Лицензии + "Лиценз": "licenses", + "Номер": "number", + "ДатаНач": "start_date", + "ДатаКон": "end_date", + "Орган": "authority", + "Виды": "activities", + # Товарные знаки + "ТоварЗнак": "trademarks", + "Ссылка": "url", + "ДатаИстеч": "expiry_date", + # Подразделения + "Подразд": "branches", + # Правопредшественники/преемники + "Правопредш": "predecessors", + "Правопреем": "successors", + # МСП + "РМСП": "msp", + "Категория": "category", + "ДатаВкл": "include_date", + "ПоддержМСП": "msp_support", + # Банкротство + "ЕФРСБ": "bankruptcy", + "НомерДела": "case_number", + # РНП + "НедобПост": "unfair_supplier", + # Численность + "СЧР": "employees_count", + # Налоги + "Налоги": "taxes", + "Задолж": "tax_debt", + "Пени": "penalties", + "Штраф": "fines", + # Контакты + "Контакты": "contacts", + "Телефон": "phone", + "Email": "email", + "Сайт": "website", + # Пагинация + "ЗапВсего": "total_records", + "СтрВсего": "total_pages", + "СтрТекущ": "current_page", + "Записи": "records", + # Регион + "РегионКод": "region_code", +} + + +def _map_ru_keys(data: dict | None) -> dict | None: + """Map Russian API field names to English.""" + if data is None: + return None + result = {} + for key, value in data.items(): + en_key = RU_FIELD_MAP.get(key, key.lower()) + if isinstance(value, dict): + result[en_key] = _map_ru_keys(value) + elif isinstance(value, list): + result[en_key] = [ + _map_ru_keys(item) if isinstance(item, dict) else item for item in value + ] + else: + result[en_key] = value + return result + + +@dataclass +class CheckoClient: + """ + Клиент Checko.ru API v2. + + Предоставляет методы для работы со всеми эндпоинтами API. + + Использование: + client = CheckoClient(api_key="your_api_key") + + # Получить информацию о компании + response = client.get_company(CompanyRequest(inn="7707083893")) + print(response.data.full_name) + + # Поиск организаций + response = client.search(SearchRequest( + by=SearchType.NAME, + obj=ObjectType.ORGANIZATION, + query="Сбербанк" + )) + + # Итерация по контрактам + for contract in client.iter_contracts(ContractsRequest( + inn="7707083893", + law=ContractLaw.FZ44 + )): + print(contract.registry_number) + """ + + api_key: str + """API ключ Checko.""" + + base_url: str = "https://api.checko.ru/v2" + """Базовый URL API.""" + + timeout: int = 30 + """Таймаут запроса (секунды).""" + + proxies: list[str] | None = None + """Список прокси (опционально).""" + + _http_client: BaseHTTPClient = field(init=False, repr=False) + + def __post_init__(self) -> None: + """Инициализация HTTP клиента.""" + self._http_client = BaseHTTPClient( + base_url=self.base_url, + proxies=self.proxies, + timeout=self.timeout, + # Don't request Brotli compression (br) as it requires extra dependency + headers={"Accept-Encoding": "gzip, deflate"}, + ) + + # ========================================================================= + # HTTP методы + # ========================================================================= + + def _request(self, endpoint: str, params: dict[str, str]) -> dict[str, Any]: + """ + Выполнить запрос к API. + + Args: + endpoint: Эндпоинт API + params: Параметры запроса + + Returns: + JSON ответ как dict + + Raises: + CheckoAPIError: Ошибка API + CheckoConnectionError: Ошибка соединения + CheckoRateLimitError: Превышен лимит запросов + CheckoNotFoundError: Данные не найдены + """ + # Добавляем API ключ + params["key"] = self.api_key + + try: + data = self._http_client.get_json(endpoint, params=params) + except Exception as e: + logger.error("Connection error: %s", e) + raise CheckoConnectionError(f"Failed to connect to Checko API: {e}") from e + + # Проверяем статус ответа + meta = data.get("meta", {}) + status = meta.get("status", "error") + + if status != "ok": + message = meta.get("message", "Unknown error") + balance = meta.get("balance") + + # Определяем тип ошибки + if "лимит" in message.lower() or "limit" in message.lower(): + raise CheckoRateLimitError( + message=message, + balance=balance, + ) + if "не найден" in message.lower() or "not found" in message.lower(): + raise CheckoNotFoundError( + message=message, + balance=balance, + ) + raise CheckoAPIError( + message=message, + balance=balance, + ) + + # Map Russian field names to English + if "data" in data: + data["data"] = _map_ru_keys(data["data"]) + + return data + + # ========================================================================= + # Парсинг общих моделей + # ========================================================================= + + def _parse_meta(self, data: dict) -> ApiMeta: + """Парсинг метаинформации.""" + meta = data.get("meta", {}) + return ApiMeta( + status=meta.get("status", "unknown"), + today_request_count=meta.get("today_request_count", 0), + balance=meta.get("balance", 0.0), + message=meta.get("message"), + ) + + def _parse_region(self, data: dict | None) -> Region | None: + """Парсинг региона.""" + if not data: + return None + return Region( + code=data.get("code", ""), + name=data.get("name", ""), + ) + + def _parse_okved(self, data: dict | None) -> Okved | None: + """Парсинг ОКВЭД.""" + if not data: + return None + return Okved( + code=data.get("code", ""), + name=data.get("name", ""), + version=data.get("version"), + ) + + def _parse_okved_info( + self, data: dict | None, additional: list | None = None + ) -> OkvedInfo | None: + """Парсинг информации о видах деятельности.""" + if not data: + return None + main = self._parse_okved(data) + add_list = additional or [] + additional_items = tuple(self._parse_okved(item) for item in add_list if item) + return OkvedInfo(main=main, additional=additional_items) + + def _parse_tax_authority(self, data: dict | None) -> TaxAuthority | None: + """Парсинг налогового органа.""" + if not data: + return None + return TaxAuthority( + code=data.get("code", ""), + name=data.get("name", ""), + address=data.get("address"), + date=data.get("date"), + ) + + def _parse_fund_registration(self, data: dict | None) -> FundRegistration | None: + """Парсинг регистрации в фонде.""" + if not data: + return None + return FundRegistration( + reg_date=data.get("reg_date"), + reg_number=data.get("reg_number"), + code=data.get("code"), + name=data.get("name"), + ) + + def _parse_license(self, data: dict) -> License: + """Парсинг лицензии.""" + return License( + number=data.get("number"), + date=data.get("date"), + start_date=data.get("start_date"), + end_date=data.get("end_date"), + authority=data.get("authority"), + activities=tuple(data.get("activities", [])), + ) + + def _parse_trademark(self, data: dict) -> Trademark: + """Парсинг товарного знака.""" + return Trademark( + id=data.get("id", 0), + url=data.get("url"), + reg_date=data.get("reg_date"), + expiry_date=data.get("expiry_date"), + ) + + def _parse_bankruptcy_message(self, data: dict) -> BankruptcyMessage: + """Парсинг сообщения о банкротстве.""" + return BankruptcyMessage( + type=data.get("type", ""), + date=data.get("date", ""), + case_number=data.get("case_number"), + ) + + def _parse_unfair_supplier(self, data: dict) -> UnfairSupplierRecord: + """Парсинг записи РНП.""" + return UnfairSupplierRecord( + registry_number=data.get("registry_number", ""), + publish_date=data.get("publish_date"), + approval_date=data.get("approval_date"), + customer_short_name=data.get("customer_short_name"), + customer_full_name=data.get("customer_full_name"), + customer_inn=data.get("customer_inn"), + customer_kpp=data.get("customer_kpp"), + purchase_number=data.get("purchase_number"), + purchase_description=data.get("purchase_description"), + contract_price=data.get("contract_price"), + ) + + def _parse_msp_support(self, data: dict) -> MspSupport: + """Парсинг поддержки МСП.""" + return MspSupport( + date=data.get("date"), + type=data.get("type"), + form=data.get("form"), + org_name=data.get("org_name"), + org_inn=data.get("org_inn"), + amount=data.get("amount"), + violation=data.get("violation", False), + ) + + def _parse_msp_category(self, data: dict | None) -> MspCategory | None: + """Парсинг категории МСП.""" + if not data: + return None + return MspCategory( + category=data.get("category"), + include_date=data.get("include_date"), + type=data.get("type"), + ) + + def _parse_pagination(self, data: dict | None) -> PaginationInfo | None: + """Парсинг информации о пагинации.""" + if not data: + return None + return PaginationInfo( + total_records=data.get("total_records", 0), + total_pages=data.get("total_pages", 0), + current_page=data.get("current_page", 1), + ) + + def _parse_company_short(self, data: dict) -> CompanyShort: + """Парсинг краткой информации о компании.""" + return CompanyShort( + ogrn=data.get("ogrn", ""), + inn=data.get("inn", ""), + kpp=data.get("kpp"), + short_name=data.get("short_name"), + full_name=data.get("full_name"), + reg_date=data.get("reg_date"), + status=data.get("status"), + liquidation_date=data.get("liquidation_date"), + region_code=data.get("region_code"), + legal_address=data.get("legal_address"), + okved=data.get("okved"), + ) + + def _parse_entrepreneur_short(self, data: dict) -> EntrepreneurShort: + """Парсинг краткой информации об ИП.""" + return EntrepreneurShort( + ogrnip=data.get("ogrnip", data.get("ogrn", "")), + inn=data.get("inn", ""), + full_name=data.get("full_name"), + type_name=data.get("type_name"), + reg_date=data.get("reg_date"), + status=data.get("status"), + termination_date=data.get("termination_date"), + region_code=data.get("region_code"), + okved=data.get("okved"), + ) + + # ========================================================================= + # /company endpoint + # ========================================================================= + + def _parse_company_status(self, data: dict | None) -> CompanyStatus | None: + """Парсинг статуса компании.""" + if not data: + return None + return CompanyStatus( + restricted_access=data.get("restricted_access", False), + code=data.get("code"), + name=data.get("name"), + record_date=data.get("record_date"), + ) + + def _parse_address(self, data: dict | None) -> Address | None: + """Парсинг адреса.""" + if not data: + return None + return Address( + restricted_access=data.get("restricted_access", False), + full_address=data.get("full_address"), + region=self._parse_region(data.get("region")), + city=data.get("city"), + street=data.get("street"), + building=data.get("building"), + apartment=data.get("apartment"), + postal_code=data.get("postal_code"), + is_unreliable=data.get("is_unreliable", False), + is_mass_address=data.get("is_mass_address", False), + ) + + def _parse_leader(self, data: dict | list | None) -> Leader | None: + """Парсинг руководителя.""" + if not data: + return None + # API returns list of leaders, take first one + if isinstance(data, list): + if not data: + return None + data = data[0] + return Leader( + restricted_access=data.get("restricted_access", False), + full_name=data.get("full_name"), + inn=data.get("inn"), + position_type=data.get("position_type"), + position_name=data.get("position_name"), + date=data.get("date"), + is_unreliable=data.get("is_unreliable", False), + is_mass_leader=data.get("is_mass_leader", False), + is_disqualified=data.get("is_disqualified", False), + ) + + def _parse_founder(self, data: dict) -> Founder: + """Парсинг учредителя.""" + return Founder( + restricted_access=data.get("restricted_access", False), + founder_type=data.get("founder_type") or data.get("type"), + full_name=data.get("full_name") or data.get("name"), + inn=data.get("inn"), + ogrn=data.get("ogrn"), + share=data.get("share"), + capital_amount=data.get("capital_amount"), + date=data.get("date"), + is_unreliable=data.get("is_unreliable", False), + ) + + def _parse_founders(self, data: dict | list | None) -> tuple[Founder, ...]: + """Парсинг списка учредителей.""" + if not data: + return () + + # If list, parse directly + if isinstance(data, list): + return tuple(self._parse_founder(f) for f in data if isinstance(f, dict)) + + # If dict with nested categories (ФЛ, РосОрг, etc.) + founders = [] + for category in ("фл", "росорг", "инорг", "пиф", "рф"): + items = data.get(category, []) + if isinstance(items, list): + for item in items: + if isinstance(item, dict): + founders.append(self._parse_founder(item)) + + return tuple(founders) + + def _parse_capital(self, data: dict | None) -> Capital | None: + """Парсинг уставного капитала.""" + if not data: + return None + return Capital( + value=data.get("value"), + type_name=data.get("type_name"), + date=data.get("date"), + ) + + def _parse_registrar(self, data: dict | None) -> RegistrarInfo | None: + """Парсинг держателя реестра.""" + if not data: + return None + return RegistrarInfo( + ogrn=data.get("ogrn"), + inn=data.get("inn"), + full_name=data.get("full_name"), + ) + + def _parse_predecessor(self, data: dict) -> Predecessor: + """Парсинг правопредшественника.""" + return Predecessor( + ogrn=data.get("ogrn"), + inn=data.get("inn"), + kpp=data.get("kpp"), + full_name=data.get("full_name"), + ) + + def _parse_successor(self, data: dict) -> Successor: + """Парсинг правопреемника.""" + return Successor( + ogrn=data.get("ogrn"), + inn=data.get("inn"), + kpp=data.get("kpp"), + full_name=data.get("full_name"), + ) + + def _parse_branch(self, data: dict) -> Branch: + """Парсинг филиала.""" + return Branch( + name=data.get("full_name") or data.get("name"), + address=data.get("address"), + type=data.get("type"), + country_code=data.get("country_code") or data.get("country"), + ) + + def _parse_branches(self, data: dict | list | None) -> tuple[Branch, ...]: + """Парсинг списка филиалов.""" + if not data: + return () + + # If list, parse directly + if isinstance(data, list): + return tuple(self._parse_branch(b) for b in data if isinstance(b, dict)) + + # If dict with nested categories (Филиал, Представ, etc.) + branches = [] + for category in ("branch", "representative_office", "филиал", "представ"): + items = data.get(category, []) + if isinstance(items, list): + for item in items: + if isinstance(item, dict): + branches.append(self._parse_branch(item)) + + return tuple(branches) + + def _parse_tax_debt(self, data: dict | None) -> TaxDebt | None: + """Парсинг задолженности по налогам.""" + if not data: + return None + return TaxDebt( + total=data.get("total"), + date=data.get("date"), + ) + + def _parse_tax_penalty(self, data: dict | None) -> TaxPenalty | None: + """Парсинг штрафов и пеней.""" + if not data: + return None + return TaxPenalty( + penalties=data.get("penalties"), + fines=data.get("fines"), + date=data.get("date"), + ) + + def _parse_related_company(self, data: dict) -> RelatedCompany: + """Парсинг связанной компании.""" + return RelatedCompany( + ogrn=data.get("ogrn", ""), + inn=data.get("inn", ""), + kpp=data.get("kpp"), + short_name=data.get("short_name"), + full_name=data.get("full_name"), + relation_type=data.get("relation_type"), + ) + + def _parse_company_statistics(self, data: dict | None) -> CompanyStatistics | None: + """Парсинг статистики компании.""" + if not data: + return None + return CompanyStatistics( + contracts_44_customer_count=data.get("contracts_44_customer_count"), + contracts_44_supplier_count=data.get("contracts_44_supplier_count"), + contracts_223_customer_count=data.get("contracts_223_customer_count"), + contracts_223_supplier_count=data.get("contracts_223_supplier_count"), + legal_cases_plaintiff_count=data.get("legal_cases_plaintiff_count"), + legal_cases_defendant_count=data.get("legal_cases_defendant_count"), + inspections_count=data.get("inspections_count"), + enforcements_count=data.get("enforcements_count"), + ) + + def _parse_company_data(self, data: dict | None) -> CompanyData | None: + """Парсинг данных компании.""" + if not data: + return None + + # Извлекаем классификаторы + opf = data.get("opf") or {} + okfs = data.get("okfs") or {} + okogu = data.get("okogu") or {} + + return CompanyData( + ogrn=data.get("ogrn", ""), + inn=data.get("inn", ""), + kpp=data.get("kpp"), + okpo=data.get("okpo"), + reg_date=data.get("reg_date"), + short_name=data.get("short_name"), + full_name=data.get("full_name"), + status=self._parse_company_status(data.get("status")), + legal_address=self._parse_address(data.get("legal_address")), + leader=self._parse_leader(data.get("leader")), + founders=self._parse_founders(data.get("founders")), + capital=self._parse_capital(data.get("capital")), + okved=self._parse_okved_info( + data.get("okved"), data.get("okved_additional", []) + ), + opf_code=opf.get("code") if isinstance(opf, dict) else None, + opf_name=opf.get("name") if isinstance(opf, dict) else None, + okfs_code=okfs.get("code") if isinstance(okfs, dict) else None, + okfs_name=okfs.get("name") if isinstance(okfs, dict) else None, + okogu_code=okogu.get("code") if isinstance(okogu, dict) else None, + okogu_name=okogu.get("name") if isinstance(okogu, dict) else None, + region=self._parse_region(data.get("region")), + tax_authority=self._parse_tax_authority(data.get("tax_authority")), + tax_authority_local=self._parse_tax_authority( + data.get("tax_authority_local") + ), + pfr=self._parse_fund_registration(data.get("pfr")), + fss=self._parse_fund_registration(data.get("fss")), + registrar=self._parse_registrar(data.get("registrar")), + predecessors=tuple( + self._parse_predecessor(p) for p in data.get("predecessors", []) + ), + successors=tuple( + self._parse_successor(s) for s in data.get("successors", []) + ), + branches=self._parse_branches(data.get("branches")), + licenses=tuple( + self._parse_license(lic) for lic in data.get("licenses", []) + ), + trademarks=tuple( + self._parse_trademark(t) for t in data.get("trademarks", []) + ), + tax_debt=self._parse_tax_debt(data.get("tax_debt")), + tax_penalty=self._parse_tax_penalty(data.get("tax_penalty")), + msp=self._parse_msp_category(data.get("msp")), + msp_support=tuple( + self._parse_msp_support(s) for s in data.get("msp_support", []) + ), + bankruptcy=tuple( + self._parse_bankruptcy_message(b) + for b in data.get("bankruptcy", []) + if isinstance(b, dict) + ), + unfair_supplier=tuple( + self._parse_unfair_supplier(u) + for u in (data.get("unfair_supplier") or []) + if isinstance(u, dict) + ), + related_companies=tuple( + self._parse_related_company(r) + for r in data.get("related_companies", []) + ), + statistics=self._parse_company_statistics(data.get("statistics")), + employees_count=data.get("employees_count"), + employees_count_date=data.get("employees_count_date"), + liquidation_date=data.get("liquidation_date"), + ) + + def get_company(self, request: CompanyRequest) -> CompanyResponse: + """ + Получить информацию о юридическом лице. + + Args: + request: Параметры запроса + + Returns: + CompanyResponse с данными организации + + Raises: + CheckoValidationError: Не указан идентификатор + CheckoNotFoundError: Организация не найдена + CheckoAPIError: Ошибка API + """ + if not (request.ogrn or request.inn or request.okpo): + raise CheckoValidationError("Необходимо указать ogrn, inn или okpo") + + data = self._request("/company", request.to_params()) + return CompanyResponse( + data=self._parse_company_data(data.get("data")), + meta=self._parse_meta(data), + source_data=data.get("source_data"), + ) + + # ========================================================================= + # /entrepreneur endpoint + # ========================================================================= + + def _parse_entrepreneur_status( + self, data: dict | None + ) -> EntrepreneurStatus | None: + """Парсинг статуса ИП.""" + if not data: + return None + return EntrepreneurStatus( + code=data.get("code"), + name=data.get("name"), + record_date=data.get("record_date"), + ) + + def _parse_entrepreneur_data(self, data: dict | None) -> EntrepreneurData | None: + """Парсинг данных ИП.""" + if not data: + return None + return EntrepreneurData( + ogrnip=data.get("ogrnip", data.get("ogrn", "")), + inn=data.get("inn", ""), + okpo=data.get("okpo"), + full_name=data.get("full_name"), + type_code=data.get("type_code"), + type_name=data.get("type_name"), + citizenship_code=data.get("citizenship_code"), + citizenship_name=data.get("citizenship_name"), + reg_date=data.get("reg_date"), + reg_authority_code=data.get("reg_authority_code"), + reg_authority_name=data.get("reg_authority_name"), + status=self._parse_entrepreneur_status(data.get("status")), + termination_date=data.get("termination_date"), + termination_method_code=data.get("termination_method_code"), + termination_method_name=data.get("termination_method_name"), + region=self._parse_region(data.get("region")), + tax_authority=self._parse_tax_authority(data.get("tax_authority")), + tax_authority_local=self._parse_tax_authority( + data.get("tax_authority_local") + ), + pfr=self._parse_fund_registration(data.get("pfr")), + fss=self._parse_fund_registration(data.get("fss")), + okved=self._parse_okved_info(data.get("okved")), + licenses=tuple( + self._parse_license(lic) for lic in data.get("licenses", []) + ), + bankruptcy=tuple( + self._parse_bankruptcy_message(b) for b in data.get("bankruptcy", []) + ), + msp=self._parse_msp_category(data.get("msp")), + ) + + def get_entrepreneur(self, request: EntrepreneurRequest) -> EntrepreneurResponse: + """ + Получить информацию об индивидуальном предпринимателе. + + Args: + request: Параметры запроса + + Returns: + EntrepreneurResponse с данными ИП + + Raises: + CheckoValidationError: Не указан идентификатор + CheckoNotFoundError: ИП не найден + CheckoAPIError: Ошибка API + """ + if not (request.ogrn or request.inn or request.okpo): + raise CheckoValidationError("Необходимо указать ogrn, inn или okpo") + + data = self._request("/entrepreneur", request.to_params()) + return EntrepreneurResponse( + data=self._parse_entrepreneur_data(data.get("data")), + meta=self._parse_meta(data), + source_data=data.get("source_data"), + ) + + # ========================================================================= + # /person endpoint + # ========================================================================= + + def _parse_disqualification(self, data: dict) -> Disqualification: + """Парсинг дисквалификации.""" + return Disqualification( + date_from=data.get("date_from"), + date_to=data.get("date_to"), + reason=data.get("reason"), + authority=data.get("authority"), + ) + + def _parse_person_company(self, data: dict) -> PersonCompany: + """Парсинг компании физлица.""" + return PersonCompany( + ogrn=data.get("ogrn", ""), + inn=data.get("inn", ""), + kpp=data.get("kpp"), + short_name=data.get("short_name"), + full_name=data.get("full_name"), + role=data.get("role"), + position_name=data.get("position_name"), + share=data.get("share"), + status=data.get("status"), + ) + + def _parse_person_entrepreneur(self, data: dict) -> PersonEntrepreneur: + """Парсинг ИП физлица.""" + return PersonEntrepreneur( + ogrnip=data.get("ogrnip", data.get("ogrn", "")), + inn=data.get("inn", ""), + full_name=data.get("full_name"), + status=data.get("status"), + reg_date=data.get("reg_date"), + termination_date=data.get("termination_date"), + ) + + def _parse_person_data(self, data: dict | None) -> PersonData | None: + """Парсинг данных о физлице.""" + if not data: + return None + return PersonData( + inn=data.get("inn", ""), + full_name=data.get("full_name"), + is_mass_leader=data.get("is_mass_leader", False), + is_disqualified=data.get("is_disqualified", False), + disqualifications=tuple( + self._parse_disqualification(d) + for d in data.get("disqualifications", []) + ), + companies_as_leader=tuple( + self._parse_person_company(c) + for c in data.get("companies_as_leader", []) + ), + companies_as_founder=tuple( + self._parse_person_company(c) + for c in data.get("companies_as_founder", []) + ), + entrepreneurs=tuple( + self._parse_person_entrepreneur(e) + for e in data.get("entrepreneurs", []) + ), + ) + + def get_person(self, request: PersonRequest) -> PersonResponse: + """ + Получить информацию о физическом лице. + + Args: + request: Параметры запроса + + Returns: + PersonResponse с данными о физлице + + Raises: + CheckoNotFoundError: Физлицо не найдено + CheckoAPIError: Ошибка API + """ + data = self._request("/person", request.to_params()) + return PersonResponse( + data=self._parse_person_data(data.get("data")), + meta=self._parse_meta(data), + ) + + # ========================================================================= + # /search endpoint + # ========================================================================= + + def _parse_search_data(self, data: dict | None) -> SearchData | None: + """Парсинг результатов поиска.""" + if not data: + return None + + # Records contain both organizations and entrepreneurs + records = data.get("records", data.get("organizations", [])) + + # Parse pagination from top-level data + pagination = None + if data.get("total_records") is not None: + pagination = PaginationInfo( + total_records=data.get("total_records", 0), + total_pages=data.get("total_pages", 0), + current_page=data.get("current_page", 1), + ) + else: + pagination = self._parse_pagination(data.get("pagination")) + + return SearchData( + organizations=tuple( + self._parse_company_short(o) for o in records if isinstance(o, dict) + ), + entrepreneurs=tuple( + self._parse_entrepreneur_short(e) for e in data.get("entrepreneurs", []) + ), + pagination=pagination, + ) + + def search(self, request: SearchRequest) -> SearchResponse: + """ + Поиск организаций и ИП. + + Args: + request: Параметры поиска + + Returns: + SearchResponse с результатами + + Raises: + CheckoValidationError: Некорректные параметры + CheckoAPIError: Ошибка API + """ + if len(request.query) < 4 and request.by.value in ( + "name", + "founder-name", + "leader-name", + ): + raise CheckoValidationError( + "Поисковый запрос должен содержать минимум 4 символа" + ) + + data = self._request("/search", request.to_params()) + return SearchResponse( + data=self._parse_search_data(data.get("data")), + meta=self._parse_meta(data), + ) + + # ========================================================================= + # /finances endpoint + # ========================================================================= + + def _parse_finance_report_line(self, data: dict) -> FinanceReportLine: + """Парсинг строки отчёта.""" + return FinanceReportLine( + code=data.get("code", ""), + name=data.get("name"), + current=data.get("current"), + previous=data.get("previous"), + ) + + def _parse_finance_report(self, data: dict) -> FinanceReport: + """Парсинг финансового отчёта.""" + return FinanceReport( + year=data.get("year", 0), + report_date=data.get("report_date"), + correction_number=data.get("correction_number"), + balance=tuple( + self._parse_finance_report_line(line) + for line in data.get("balance", []) + ), + profit_loss=tuple( + self._parse_finance_report_line(line) + for line in data.get("profit_loss", []) + ), + capital_changes=tuple( + self._parse_finance_report_line(line) + for line in data.get("capital_changes", []) + ), + cash_flow=tuple( + self._parse_finance_report_line(line) + for line in data.get("cash_flow", []) + ), + targeted_use=tuple( + self._parse_finance_report_line(line) + for line in data.get("targeted_use", []) + ), + ) + + def _parse_finance_summary(self, data: dict | None) -> FinanceSummary | None: + """Парсинг сводных показателей.""" + if not data: + return None + return FinanceSummary( + revenue=data.get("revenue"), + profit=data.get("profit"), + assets=data.get("assets"), + equity=data.get("equity"), + ) + + def _parse_finances_data(self, data: dict | None) -> FinancesData | None: + """Парсинг финансовых данных.""" + if not data: + return None + return FinancesData( + ogrn=data.get("ogrn", ""), + inn=data.get("inn", ""), + kpp=data.get("kpp"), + reports=tuple( + self._parse_finance_report(r) for r in data.get("reports", []) + ), + summary=self._parse_finance_summary(data.get("summary")), + ) + + def get_finances(self, request: FinancesRequest) -> FinancesResponse: + """ + Получить финансовую отчетность организации. + + Args: + request: Параметры запроса + + Returns: + FinancesResponse с финансовыми данными + + Raises: + CheckoValidationError: Не указан идентификатор + CheckoNotFoundError: Данные не найдены + CheckoAPIError: Ошибка API + """ + if not (request.ogrn or request.inn): + raise CheckoValidationError("Необходимо указать ogrn или inn") + + data = self._request("/finances", request.to_params()) + return FinancesResponse( + data=self._parse_finances_data(data.get("data")), + meta=self._parse_meta(data), + ) + + # ========================================================================= + # /contracts endpoint + # ========================================================================= + + def _parse_contract_party(self, data: dict | None) -> ContractParty | None: + """Парсинг стороны контракта.""" + if not data: + return None + return ContractParty( + ogrn=data.get("ogrn"), + inn=data.get("inn"), + kpp=data.get("kpp"), + name=data.get("name"), + region_code=data.get("region_code"), + ) + + def _parse_contract(self, data: dict) -> Contract: + """Парсинг контракта.""" + return Contract( + registry_number=data.get("registry_number", ""), + publish_date=data.get("publish_date"), + sign_date=data.get("sign_date"), + execution_date=data.get("execution_date"), + price=data.get("price"), + currency_code=data.get("currency_code"), + status=data.get("status"), + subject=data.get("subject"), + law=data.get("law"), + purchase_number=data.get("purchase_number"), + customer=self._parse_contract_party(data.get("customer")), + suppliers=tuple( + self._parse_contract_party(s) for s in data.get("suppliers", []) if s + ), + url=data.get("url"), + ) + + def _parse_contracts_data(self, data: dict | None) -> ContractsData | None: + """Парсинг данных о контрактах.""" + if not data: + return None + return ContractsData( + contracts=tuple(self._parse_contract(c) for c in data.get("contracts", [])), + pagination=self._parse_pagination(data.get("pagination")), + total_sum=data.get("total_sum"), + ) + + def get_contracts(self, request: ContractsRequest) -> ContractsResponse: + """ + Получить контракты по госзакупкам. + + Args: + request: Параметры запроса + + Returns: + ContractsResponse с данными о контрактах + + Raises: + CheckoValidationError: Не указан идентификатор + CheckoAPIError: Ошибка API + """ + if not (request.ogrn or request.inn): + raise CheckoValidationError("Необходимо указать ogrn или inn") + + data = self._request("/contracts", request.to_params()) + return ContractsResponse( + data=self._parse_contracts_data(data.get("data")), + meta=self._parse_meta(data), + ) + + def iter_contracts(self, request: ContractsRequest) -> Iterator[Contract]: + """ + Итератор по всем контрактам (автопагинация). + + Args: + request: Параметры запроса + + Yields: + Contract объекты + + Raises: + CheckoValidationError: Не указан идентификатор + CheckoAPIError: Ошибка API + """ + page = 1 + while True: + request.page = page + response = self.get_contracts(request) + + if response.data and response.data.contracts: + yield from response.data.contracts + + if ( + response.data.pagination + and page < response.data.pagination.total_pages + ): + page += 1 + else: + break + else: + break + + # ========================================================================= + # /inspections endpoint + # ========================================================================= + + def _parse_inspection(self, data: dict) -> Inspection: + """Парсинг проверки.""" + return Inspection( + id=data.get("id"), + erp_id=data.get("erp_id"), + plan_date_from=data.get("plan_date_from"), + plan_date_to=data.get("plan_date_to"), + actual_date_from=data.get("actual_date_from"), + actual_date_to=data.get("actual_date_to"), + type=data.get("type"), + form=data.get("form"), + status=data.get("status"), + authority_name=data.get("authority_name"), + authority_ogrn=data.get("authority_ogrn"), + subject=data.get("subject"), + result=data.get("result"), + violations_found=data.get("violations_found", False), + ) + + def _parse_inspections_data(self, data: dict | None) -> InspectionsData | None: + """Парсинг данных о проверках.""" + if not data: + return None + return InspectionsData( + inspections=tuple( + self._parse_inspection(i) for i in data.get("inspections", []) + ), + pagination=self._parse_pagination(data.get("pagination")), + ) + + def get_inspections(self, request: InspectionsRequest) -> InspectionsResponse: + """ + Получить проверки организации. + + Args: + request: Параметры запроса + + Returns: + InspectionsResponse с данными о проверках + + Raises: + CheckoValidationError: Не указан идентификатор + CheckoAPIError: Ошибка API + """ + if not (request.ogrn or request.inn): + raise CheckoValidationError("Необходимо указать ogrn или inn") + + data = self._request("/inspections", request.to_params()) + return InspectionsResponse( + data=self._parse_inspections_data(data.get("data")), + meta=self._parse_meta(data), + ) + + def iter_inspections(self, request: InspectionsRequest) -> Iterator[Inspection]: + """ + Итератор по всем проверкам (автопагинация). + + Args: + request: Параметры запроса + + Yields: + Inspection объекты + """ + page = 1 + while True: + request.page = page + response = self.get_inspections(request) + + if response.data and response.data.inspections: + yield from response.data.inspections + + if ( + response.data.pagination + and page < response.data.pagination.total_pages + ): + page += 1 + else: + break + else: + break + + # ========================================================================= + # /enforcements endpoint + # ========================================================================= + + def _parse_enforcement(self, data: dict) -> Enforcement: + """Парсинг исполнительного производства.""" + return Enforcement( + number=data.get("number", ""), + date=data.get("date"), + subject=data.get("subject"), + department=data.get("department"), + bailiff=data.get("bailiff"), + status=data.get("status"), + end_date=data.get("end_date"), + end_reason=data.get("end_reason"), + debt_amount=data.get("debt_amount"), + recovered_amount=data.get("recovered_amount"), + ) + + def _parse_enforcements_data(self, data: dict | None) -> EnforcementsData | None: + """Парсинг данных о производствах.""" + if not data: + return None + return EnforcementsData( + enforcements=tuple( + self._parse_enforcement(e) for e in data.get("enforcements", []) + ), + pagination=self._parse_pagination(data.get("pagination")), + total_debt=data.get("total_debt"), + ) + + def get_enforcements(self, request: EnforcementsRequest) -> EnforcementsResponse: + """ + Получить исполнительные производства. + + Args: + request: Параметры запроса + + Returns: + EnforcementsResponse с данными о производствах + + Raises: + CheckoValidationError: Не указан идентификатор + CheckoAPIError: Ошибка API + """ + if not (request.ogrn or request.inn): + raise CheckoValidationError("Необходимо указать ogrn или inn") + + data = self._request("/enforcements", request.to_params()) + return EnforcementsResponse( + data=self._parse_enforcements_data(data.get("data")), + meta=self._parse_meta(data), + ) + + def iter_enforcements(self, request: EnforcementsRequest) -> Iterator[Enforcement]: + """ + Итератор по всем производствам (автопагинация). + + Args: + request: Параметры запроса + + Yields: + Enforcement объекты + """ + page = 1 + while True: + request.page = page + response = self.get_enforcements(request) + + if response.data and response.data.enforcements: + yield from response.data.enforcements + + if ( + response.data.pagination + and page < response.data.pagination.total_pages + ): + page += 1 + else: + break + else: + break + + # ========================================================================= + # /legal-cases endpoint + # ========================================================================= + + def _parse_case_party(self, data: dict) -> CaseParty: + """Парсинг стороны дела.""" + return CaseParty( + name=data.get("name"), + inn=data.get("inn"), + ogrn=data.get("ogrn"), + role=data.get("role"), + ) + + def _parse_case_instance(self, data: dict) -> CaseInstance: + """Парсинг инстанции.""" + return CaseInstance( + number=data.get("number"), + court_name=data.get("court_name"), + judge=data.get("judge"), + result=data.get("result"), + date=data.get("date"), + ) + + def _parse_legal_case(self, data: dict) -> LegalCase: + """Парсинг арбитражного дела.""" + return LegalCase( + case_number=data.get("case_number", ""), + court_name=data.get("court_name"), + type=data.get("type"), + category=data.get("category"), + status=data.get("status"), + filing_date=data.get("filing_date"), + result_date=data.get("result_date"), + claim_amount=data.get("claim_amount"), + awarded_amount=data.get("awarded_amount"), + plaintiffs=tuple( + self._parse_case_party(p) for p in data.get("plaintiffs", []) + ), + defendants=tuple( + self._parse_case_party(d) for d in data.get("defendants", []) + ), + third_parties=tuple( + self._parse_case_party(t) for t in data.get("third_parties", []) + ), + instances=tuple( + self._parse_case_instance(i) for i in data.get("instances", []) + ), + url=data.get("url"), + ) + + def _parse_legal_cases_data(self, data: dict | None) -> LegalCasesData | None: + """Парсинг данных о делах.""" + if not data: + return None + return LegalCasesData( + cases=tuple(self._parse_legal_case(c) for c in data.get("cases", [])), + pagination=self._parse_pagination(data.get("pagination")), + total_claim_amount=data.get("total_claim_amount"), + ) + + def get_legal_cases(self, request: LegalCasesRequest) -> LegalCasesResponse: + """ + Получить арбитражные дела. + + Args: + request: Параметры запроса + + Returns: + LegalCasesResponse с данными о делах + + Raises: + CheckoValidationError: Не указан идентификатор + CheckoAPIError: Ошибка API + """ + if not (request.ogrn or request.inn): + raise CheckoValidationError("Необходимо указать ogrn или inn") + + data = self._request("/legal-cases", request.to_params()) + return LegalCasesResponse( + data=self._parse_legal_cases_data(data.get("data")), + meta=self._parse_meta(data), + ) + + def iter_legal_cases(self, request: LegalCasesRequest) -> Iterator[LegalCase]: + """ + Итератор по всем делам (автопагинация). + + Args: + request: Параметры запроса + + Yields: + LegalCase объекты + """ + page = 1 + while True: + request.page = page + response = self.get_legal_cases(request) + + if response.data and response.data.cases: + yield from response.data.cases + + if ( + response.data.pagination + and page < response.data.pagination.total_pages + ): + page += 1 + else: + break + else: + break + + # ========================================================================= + # /bank endpoint + # ========================================================================= + + def _parse_bank_data(self, data: dict | None) -> BankData | None: + """Парсинг данных о банке.""" + if not data: + return None + return BankData( + bic=data.get("bic", ""), + name=data.get("name"), + short_name=data.get("short_name"), + corr_account=data.get("corr_account"), + swift=data.get("swift"), + registration_number=data.get("registration_number"), + address=data.get("address"), + city=data.get("city"), + region_code=data.get("region_code"), + phone=data.get("phone"), + status=data.get("status"), + license_date=data.get("license_date"), + license_revoke_date=data.get("license_revoke_date"), + ) + + def get_bank(self, request: BankRequest) -> BankResponse: + """ + Получить информацию о банке. + + Args: + request: Параметры запроса + + Returns: + BankResponse с данными о банке + + Raises: + CheckoNotFoundError: Банк не найден + CheckoAPIError: Ошибка API + """ + data = self._request("/bank", request.to_params()) + return BankResponse( + data=self._parse_bank_data(data.get("data")), + meta=self._parse_meta(data), + ) + + # ========================================================================= + # Context manager + # ========================================================================= + + def close(self) -> None: + """Закрыть HTTP клиент.""" + self._http_client.close() + + def __enter__(self) -> "CheckoClient": + """Поддержка context manager.""" + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + """Закрытие при выходе из context manager.""" + self.close() diff --git a/src/apps/parsers/clients/checko/datasets/__init__.py b/src/apps/parsers/clients/checko/datasets/__init__.py new file mode 100644 index 0000000..f971065 --- /dev/null +++ b/src/apps/parsers/clients/checko/datasets/__init__.py @@ -0,0 +1,62 @@ +""" +Справочники для расшифровки данных Checko API. + +Справочники загружаются лениво при первом обращении и кэшируются. + +Использование: + from apps.parsers.clients.checko.datasets import OKVED2, OKFS, OKOPF, AccountCodes + + # ОКВЭД-2 + okved_name = OKVED2.get_name("62.01") + okved_item = OKVED2.get("62.01") + + # ОКФС + okfs_name = OKFS.get_name("16") + + # ОКОПФ + okopf_name = OKOPF.get_name("12300") + + # Коды финансовой отчетности + line_name = AccountCodes.get_name("1100") +""" + +from apps.parsers.clients.checko.datasets.account_codes import ( + AccountCodeItem, + AccountCodes, +) +from apps.parsers.clients.checko.datasets.base import BaseDataset, DatasetItem +from apps.parsers.clients.checko.datasets.okfs import OKFS, OkfsItem +from apps.parsers.clients.checko.datasets.okopf import OKOPF, OkopfItem +from apps.parsers.clients.checko.datasets.okpd import OKPD, OKPD2, OkpdItem +from apps.parsers.clients.checko.datasets.okved import OKVED2, OkvedItem +from apps.parsers.clients.checko.datasets.statuses import ( + CompanyStatuses, + EntrepreneurStatuses, + StatusItem, +) + +__all__ = [ + # Base + "BaseDataset", + "DatasetItem", + # ОКВЭД + "OKVED2", + "OkvedItem", + # ОКФС + "OKFS", + "OkfsItem", + # ОКОПФ + "OKOPF", + "OkopfItem", + # ОКПД + "OKPD", + "OKPD2", + "OkpdItem", + # Коды отчетности + "AccountCodes", + "AccountCodeItem", + # Статусы + "CompanyStatuses", + "EntrepreneurStatuses", + "StatusItem", +] diff --git a/src/apps/parsers/clients/checko/datasets/account_codes.py b/src/apps/parsers/clients/checko/datasets/account_codes.py new file mode 100644 index 0000000..45e12c8 --- /dev/null +++ b/src/apps/parsers/clients/checko/datasets/account_codes.py @@ -0,0 +1,91 @@ +""" +Коды строк финансовой (бухгалтерской) отчетности. + +Используются для расшифровки данных из /finances эндпоинта. + +Использование: + from apps.parsers.clients.checko.datasets import AccountCodes + + # Получить название строки по коду + name = AccountCodes.get_name("1100") # -> "Итого внеоборотных активов" + + # Получить полный объект + item = AccountCodes.get("2110") + + # Поиск по названию + items = AccountCodes.search("прибыль") +""" + +from dataclasses import dataclass +from typing import ClassVar + +from apps.parsers.clients.checko.datasets.base import BaseDataset + + +@dataclass(frozen=True) +class AccountCodeItem: + """Элемент справочника кодов строк отчетности.""" + + code: str + """Код строки (например: '1100', '2110').""" + + name: str + """Наименование строки.""" + + +class AccountCodes(BaseDataset[AccountCodeItem]): + """ + Справочник кодов строк финансовой отчетности. + + Коды соответствуют: + - Форма №1 (Бухгалтерский баланс): 1100-1700 + - Форма №2 (Отчет о финансовых результатах): 2100-2500 + - Форма №3 (Отчет об изменениях капитала): 3100-3600 + - Форма №4 (Отчет о движении денежных средств): 4100-4500 + - Форма №6 (Отчет о целевом использовании средств): 6100-6400 + """ + + _data: ClassVar[dict[str, AccountCodeItem] | None] = None + _json_filename: ClassVar[str] = "account_codes.json" + + @classmethod + def _parse_item(cls, raw: dict) -> AccountCodeItem: + return AccountCodeItem( + code=raw.get("code", ""), + name=raw.get("name", ""), + ) + + @classmethod + def get_form_codes(cls, form_number: int) -> list[AccountCodeItem]: + """ + Получить все коды указанной формы отчетности. + + Args: + form_number: Номер формы (1, 2, 3, 4 или 6). + + Returns: + Список кодов указанной формы. + """ + cls._ensure_loaded() + prefix = str(form_number) + return [i for i in cls._data.values() if i.code.startswith(prefix)] + + @classmethod + def get_balance_codes(cls) -> list[AccountCodeItem]: + """Получить коды Формы №1 (Бухгалтерский баланс).""" + return cls.get_form_codes(1) + + @classmethod + def get_profit_loss_codes(cls) -> list[AccountCodeItem]: + """Получить коды Формы №2 (Отчет о финансовых результатах).""" + return cls.get_form_codes(2) + + @classmethod + def get_capital_codes(cls) -> list[AccountCodeItem]: + """Получить коды Формы №3 (Отчет об изменениях капитала).""" + return cls.get_form_codes(3) + + @classmethod + def get_cash_flow_codes(cls) -> list[AccountCodeItem]: + """Получить коды Формы №4 (Отчет о движении денежных средств).""" + return cls.get_form_codes(4) diff --git a/src/apps/parsers/clients/checko/datasets/base.py b/src/apps/parsers/clients/checko/datasets/base.py new file mode 100644 index 0000000..541c774 --- /dev/null +++ b/src/apps/parsers/clients/checko/datasets/base.py @@ -0,0 +1,179 @@ +""" +Базовые классы для работы со справочниками Checko. + +Справочники загружаются лениво (lazy loading) при первом обращении. +Данные кэшируются на уровне класса (singleton pattern). +""" + +import json +from dataclasses import dataclass +from pathlib import Path +from typing import ClassVar, Generic, TypeVar + +T = TypeVar("T") + +# Путь к директории с JSON файлами +DATA_DIR = Path(__file__).parent / "data" + + +@dataclass(frozen=True) +class DatasetItem: + """Базовый элемент справочника.""" + + code: str + name: str + + +class BaseDataset(Generic[T]): + """ + Базовый класс для работы со справочниками. + + Реализует: + - Lazy loading данных при первом обращении + - Кэширование на уровне класса + - Поиск по коду и названию + + Наследники должны определить: + - _json_filename: имя JSON файла + - _parse_item: метод парсинга элемента из JSON + """ + + _data: ClassVar[dict[str, T] | None] = None + _json_filename: ClassVar[str] = "" + + @classmethod + def _get_json_path(cls) -> Path: + """Получить путь к JSON файлу.""" + return DATA_DIR / cls._json_filename + + @classmethod + def _parse_item(cls, raw: dict) -> T: + """Распарсить элемент из JSON. Переопределяется в наследниках.""" + raise NotImplementedError + + @classmethod + def _get_item_code(cls, raw: dict) -> str: + """Получить код элемента из JSON.""" + return raw.get("code", "") + + @classmethod + def _ensure_loaded(cls) -> None: + """Загрузить данные если ещё не загружены.""" + if cls._data is not None: + return + + json_path = cls._get_json_path() + if not json_path.exists(): + cls._data = {} + return + + with open(json_path, encoding="utf-8") as f: + raw_data = json.load(f) + + cls._data = {} + for raw in raw_data: + item = cls._parse_item(raw) + code = cls._get_item_code(raw) + cls._data[code] = item + + @classmethod + def get(cls, code: str) -> T | None: + """ + Получить элемент по коду. + + Args: + code: Код элемента справочника. + + Returns: + Элемент справочника или None если не найден. + """ + cls._ensure_loaded() + return cls._data.get(code) + + @classmethod + def get_name(cls, code: str) -> str | None: + """ + Получить название по коду. + + Args: + code: Код элемента справочника. + + Returns: + Название элемента или None если не найден. + """ + item = cls.get(code) + if item is None: + return None + return getattr(item, "name", None) or getattr(item, "full_name", None) + + @classmethod + def all(cls) -> list[T]: + """ + Получить все элементы справочника. + + Returns: + Список всех элементов. + """ + cls._ensure_loaded() + return list(cls._data.values()) + + @classmethod + def codes(cls) -> list[str]: + """ + Получить все коды справочника. + + Returns: + Список всех кодов. + """ + cls._ensure_loaded() + return list(cls._data.keys()) + + @classmethod + def search(cls, query: str) -> list[T]: + """ + Поиск по названию (регистронезависимый). + + Args: + query: Поисковый запрос. + + Returns: + Список найденных элементов. + """ + cls._ensure_loaded() + q = query.lower() + results = [] + for item in cls._data.values(): + name = getattr(item, "name", "") or getattr(item, "full_name", "") + if name and q in name.lower(): + results.append(item) + return results + + @classmethod + def exists(cls, code: str) -> bool: + """ + Проверить существование кода. + + Args: + code: Код элемента справочника. + + Returns: + True если код существует. + """ + return cls.get(code) is not None + + @classmethod + def count(cls) -> int: + """ + Получить количество элементов в справочнике. + + Returns: + Количество элементов. + """ + cls._ensure_loaded() + return len(cls._data) + + @classmethod + def reload(cls) -> None: + """Перезагрузить данные из JSON файла.""" + cls._data = None + cls._ensure_loaded() diff --git a/src/apps/parsers/clients/checko/datasets/okfs.py b/src/apps/parsers/clients/checko/datasets/okfs.py new file mode 100644 index 0000000..7540c36 --- /dev/null +++ b/src/apps/parsers/clients/checko/datasets/okfs.py @@ -0,0 +1,46 @@ +""" +ОКФС (ОК 027-99) - Общероссийский классификатор форм собственности. + +Использование: + from apps.parsers.clients.checko.datasets import OKFS + + # Получить название по коду + name = OKFS.get_name("16") # -> "Частная собственность" + + # Получить полный объект + item = OKFS.get("16") +""" + +from dataclasses import dataclass +from typing import ClassVar + +from apps.parsers.clients.checko.datasets.base import BaseDataset + + +@dataclass(frozen=True) +class OkfsItem: + """Элемент справочника ОКФС.""" + + code: str + """Код формы собственности.""" + + name: str + """Наименование формы собственности.""" + + +class OKFS(BaseDataset[OkfsItem]): + """ + Справочник ОКФС (формы собственности). + + Данные: ОК 027-99. + """ + + _data: ClassVar[dict[str, OkfsItem] | None] = None + _json_filename: ClassVar[str] = "okfs.json" + + @classmethod + def _parse_item(cls, raw: dict) -> OkfsItem: + return OkfsItem( + code=raw.get("code", ""), + name=raw.get("name", ""), + ) diff --git a/src/apps/parsers/clients/checko/datasets/okopf.py b/src/apps/parsers/clients/checko/datasets/okopf.py new file mode 100644 index 0000000..b8ea722 --- /dev/null +++ b/src/apps/parsers/clients/checko/datasets/okopf.py @@ -0,0 +1,76 @@ +""" +ОКОПФ (ОК 028-2012) - Общероссийский классификатор организационно-правовых форм. + +Использование: + from apps.parsers.clients.checko.datasets import OKOPF + + # Получить название по коду + name = OKOPF.get_name("12300") # -> "Акционерные общества" + + # Получить полный объект + item = OKOPF.get("12300") + + # Получить дочерние элементы + children = OKOPF.get_children("12300") +""" + +from dataclasses import dataclass +from typing import ClassVar + +from apps.parsers.clients.checko.datasets.base import BaseDataset + + +@dataclass(frozen=True) +class OkopfItem: + """Элемент справочника ОКОПФ.""" + + code: str + """Код ОПФ.""" + + full_name: str + """Полное наименование ОПФ.""" + + singular_name: str | None = None + """Наименование в единственном числе.""" + + parent_code: str | None = None + """Код родительского элемента.""" + + @property + def name(self) -> str: + """Алиас для full_name (для совместимости с BaseDataset).""" + return self.full_name + + +class OKOPF(BaseDataset[OkopfItem]): + """ + Справочник ОКОПФ (организационно-правовые формы). + + Данные: ОК 028-2012. + """ + + _data: ClassVar[dict[str, OkopfItem] | None] = None + _json_filename: ClassVar[str] = "okopf.json" + + @classmethod + def _parse_item(cls, raw: dict) -> OkopfItem: + return OkopfItem( + code=raw.get("code", ""), + full_name=raw.get("full_name", ""), + singular_name=raw.get("singular_name"), + parent_code=raw.get("parent_code") or None, + ) + + @classmethod + def get_children(cls, code: str) -> list[OkopfItem]: + """ + Получить дочерние элементы. + + Args: + code: Код родительского элемента. + + Returns: + Список дочерних элементов. + """ + cls._ensure_loaded() + return [i for i in cls._data.values() if i.parent_code == code] diff --git a/src/apps/parsers/clients/checko/datasets/okpd.py b/src/apps/parsers/clients/checko/datasets/okpd.py new file mode 100644 index 0000000..2c40801 --- /dev/null +++ b/src/apps/parsers/clients/checko/datasets/okpd.py @@ -0,0 +1,116 @@ +""" +ОКПД и ОКПД-2 - Общероссийские классификаторы продукции по видам экономической деятельности. + +ОКПД (ОК 005-93) - старый классификатор. +ОКПД-2 (ОК 034-2014) - актуальный классификатор. + +Использование: + from apps.parsers.clients.checko.datasets import OKPD, OKPD2 + + # Получить название по коду + name = OKPD2.get_name("62.01.1") + + # Получить полный объект + item = OKPD2.get("62.01.1") + + # Поиск по названию + items = OKPD2.search("программное") + + # Получить дочерние коды + children = OKPD2.get_children("62.01") +""" + +from dataclasses import dataclass +from typing import ClassVar + +from apps.parsers.clients.checko.datasets.base import BaseDataset + + +@dataclass(frozen=True) +class OkpdItem: + """Элемент справочника ОКПД/ОКПД-2.""" + + code: str + """Код продукции.""" + + name: str + """Наименование продукции.""" + + parent_code: str | None = None + """Код родительского элемента.""" + + comment: str | None = None + """Пояснения к коду.""" + + +class OKPD(BaseDataset[OkpdItem]): + """ + Справочник ОКПД (классификатор продукции, ОК 005-93). + + Устаревший справочник, используйте OKPD2. + """ + + _data: ClassVar[dict[str, OkpdItem] | None] = None + _json_filename: ClassVar[str] = "okpd.json" + + @classmethod + def _parse_item(cls, raw: dict) -> OkpdItem: + return OkpdItem( + code=raw.get("code", ""), + name=raw.get("name", ""), + parent_code=raw.get("parent_code") or None, + comment=raw.get("comment") or None, + ) + + @classmethod + def get_children(cls, code: str) -> list[OkpdItem]: + """Получить дочерние элементы.""" + cls._ensure_loaded() + return [i for i in cls._data.values() if i.parent_code == code] + + +class OKPD2(BaseDataset[OkpdItem]): + """ + Справочник ОКПД-2 (классификатор продукции, ОК 034-2014). + + Актуальный справочник для госзакупок. + """ + + _data: ClassVar[dict[str, OkpdItem] | None] = None + _json_filename: ClassVar[str] = "okpd_2.json" + + @classmethod + def _parse_item(cls, raw: dict) -> OkpdItem: + return OkpdItem( + code=raw.get("code", ""), + name=raw.get("name", ""), + parent_code=raw.get("parent_code") or None, + comment=raw.get("comment") or None, + ) + + @classmethod + def get_children(cls, code: str) -> list[OkpdItem]: + """Получить дочерние элементы.""" + cls._ensure_loaded() + return [i for i in cls._data.values() if i.parent_code == code] + + @classmethod + def get_parent(cls, code: str) -> OkpdItem | None: + """Получить родительский элемент.""" + item = cls.get(code) + if item and item.parent_code: + return cls.get(item.parent_code) + return None + + @classmethod + def get_hierarchy(cls, code: str) -> list[OkpdItem]: + """Получить иерархию от корня до указанного кода.""" + result = [] + current = cls.get(code) + while current: + result.insert(0, current) + if current.parent_code: + current = cls.get(current.parent_code) + else: + break + return result diff --git a/src/apps/parsers/clients/checko/datasets/okved.py b/src/apps/parsers/clients/checko/datasets/okved.py new file mode 100644 index 0000000..27061d4 --- /dev/null +++ b/src/apps/parsers/clients/checko/datasets/okved.py @@ -0,0 +1,132 @@ +""" +ОКВЭД-2 (ОК 029-2014) - Общероссийский классификатор видов экономической деятельности. + +Использование: + from apps.parsers.clients.checko.datasets import OKVED2 + + # Получить название по коду + name = OKVED2.get_name("62.01") + + # Получить полный объект + item = OKVED2.get("62.01") + + # Поиск по названию + items = OKVED2.search("программное") + + # Получить все коды раздела + section_j = OKVED2.get_section("J") + + # Получить дочерние коды + children = OKVED2.get_children("62") +""" + +from dataclasses import dataclass +from typing import ClassVar + +from apps.parsers.clients.checko.datasets.base import BaseDataset + + +@dataclass(frozen=True) +class OkvedItem: + """Элемент справочника ОКВЭД-2.""" + + code: str + """Код ОКВЭД (например: '62.01').""" + + name: str + """Наименование вида деятельности.""" + + section: str | None = None + """Раздел классификатора (A-U).""" + + parent_code: str | None = None + """Код родительского элемента.""" + + comment: str | None = None + """Пояснения к коду.""" + + +class OKVED2(BaseDataset[OkvedItem]): + """ + Справочник ОКВЭД-2 (виды экономической деятельности). + + Данные: ОК 029-2014 (КДЕС Ред. 2). + """ + + _data: ClassVar[dict[str, OkvedItem] | None] = None + _json_filename: ClassVar[str] = "okved_2.json" + + @classmethod + def _parse_item(cls, raw: dict) -> OkvedItem: + return OkvedItem( + code=raw.get("code", ""), + name=raw.get("name", ""), + section=raw.get("section"), + parent_code=raw.get("parent_code"), + comment=raw.get("comment"), + ) + + @classmethod + def get_section(cls, section: str) -> list[OkvedItem]: + """ + Получить все коды раздела. + + Args: + section: Код раздела (A-U). + + Returns: + Список элементов раздела. + """ + cls._ensure_loaded() + return [i for i in cls._data.values() if i.section == section.upper()] + + @classmethod + def get_children(cls, code: str) -> list[OkvedItem]: + """ + Получить дочерние коды. + + Args: + code: Родительский код ОКВЭД. + + Returns: + Список дочерних элементов. + """ + cls._ensure_loaded() + return [i for i in cls._data.values() if i.parent_code == code] + + @classmethod + def get_parent(cls, code: str) -> OkvedItem | None: + """ + Получить родительский элемент. + + Args: + code: Код ОКВЭД. + + Returns: + Родительский элемент или None. + """ + item = cls.get(code) + if item and item.parent_code: + return cls.get(item.parent_code) + return None + + @classmethod + def get_hierarchy(cls, code: str) -> list[OkvedItem]: + """ + Получить иерархию от корня до указанного кода. + + Args: + code: Код ОКВЭД. + + Returns: + Список от корневого раздела до указанного кода. + """ + result = [] + current = cls.get(code) + while current: + result.insert(0, current) + if current.parent_code: + current = cls.get(current.parent_code) + else: + break + return result diff --git a/src/apps/parsers/clients/checko/datasets/statuses.py b/src/apps/parsers/clients/checko/datasets/statuses.py new file mode 100644 index 0000000..55af739 --- /dev/null +++ b/src/apps/parsers/clients/checko/datasets/statuses.py @@ -0,0 +1,139 @@ +""" +СЮЛСТ и СИПСТ - справочники статусов юридических лиц и индивидуальных предпринимателей. + +Используются для расшифровки кодов статусов из ЕГРЮЛ/ЕГРИП. + +Использование: + from apps.parsers.clients.checko.datasets import CompanyStatuses, EntrepreneurStatuses + + # Получить название статуса ЮЛ по коду + name = CompanyStatuses.get_name("1") # -> "Действующее" + + # Получить название статуса ИП по коду + name = EntrepreneurStatuses.get_name("65") +""" + +from dataclasses import dataclass +from typing import ClassVar + +from apps.parsers.clients.checko.datasets.base import BaseDataset + + +@dataclass(frozen=True) +class StatusItem: + """Элемент справочника статусов.""" + + code: str + """Код статуса.""" + + name: str + """Наименование статуса.""" + + +class CompanyStatuses(BaseDataset[StatusItem]): + """ + Справочник СЮЛСТ - статусы юридических лиц. + + Основные статусы: + - 1: Действующее + - 3: Ликвидировано + - 4: Реорганизовано + - 5: Исключено из ЕГРЮЛ + """ + + _data: ClassVar[dict[str, StatusItem] | None] = None + _json_filename: ClassVar[str] = "statuses_company.json" + + # Встроенные данные (fallback если JSON не найден) + _builtin_data: ClassVar[dict[str, str]] = { + "1": "Действующее", + "2": "В процессе ликвидации", + "3": "Ликвидировано", + "4": "Реорганизовано", + "5": "Исключено из ЕГРЮЛ по решению регистрирующего органа", + "6": "В процессе реорганизации в форме присоединения к нему другого юридического лица", + "7": "В процессе реорганизации в форме слияния", + "8": "В процессе реорганизации в форме разделения", + "9": "В процессе реорганизации в форме выделения", + "10": "В процессе реорганизации в форме преобразования", + "11": "В процессе реорганизации в форме присоединения к другому юридическому лицу", + "12": "В процессе банкротства", + "20": "Признано несостоятельным (банкротом)", + "21": "Находится в стадии ликвидации", + "30": "Прекращение деятельности", + "40": "Деятельность не осуществляется", + "50": "Сведения признаны недостоверными", + "60": "На стадии исключения из ЕГРЮЛ", + } + + @classmethod + def _ensure_loaded(cls) -> None: + """Загрузить данные или использовать встроенные.""" + if cls._data is not None: + return + + json_path = cls._get_json_path() + if json_path.exists(): + super()._ensure_loaded() + else: + # Использовать встроенные данные + cls._data = { + code: StatusItem(code=code, name=name) + for code, name in cls._builtin_data.items() + } + + @classmethod + def _parse_item(cls, raw: dict) -> StatusItem: + return StatusItem( + code=raw.get("code", ""), + name=raw.get("name", ""), + ) + + +class EntrepreneurStatuses(BaseDataset[StatusItem]): + """ + Справочник СИПСТ - статусы индивидуальных предпринимателей. + + Основные статусы: + - 1: Действующее + - 3: Прекратил деятельность + """ + + _data: ClassVar[dict[str, StatusItem] | None] = None + _json_filename: ClassVar[str] = "statuses_entrepreneur.json" + + # Встроенные данные (fallback если JSON не найден) + _builtin_data: ClassVar[dict[str, str]] = { + "1": "Действующее", + "2": "В процессе прекращения деятельности", + "3": "Прекратил деятельность", + "4": "Прекратил деятельность по решению регистрирующего органа", + "5": "Прекратил деятельность в связи со смертью", + "10": "Признан несостоятельным (банкротом)", + "20": "Сведения признаны недостоверными", + "60": "На стадии исключения из ЕГРИП", + "65": "Прекращение деятельности ИП в связи с признанием несостоятельным", + } + + @classmethod + def _ensure_loaded(cls) -> None: + """Загрузить данные или использовать встроенные.""" + if cls._data is not None: + return + + json_path = cls._get_json_path() + if json_path.exists(): + super()._ensure_loaded() + else: + # Использовать встроенные данные + cls._data = { + code: StatusItem(code=code, name=name) + for code, name in cls._builtin_data.items() + } + + @classmethod + def _parse_item(cls, raw: dict) -> StatusItem: + return StatusItem( + code=raw.get("code", ""), + name=raw.get("name", ""), + ) diff --git a/src/apps/parsers/clients/checko/enums.py b/src/apps/parsers/clients/checko/enums.py new file mode 100644 index 0000000..d966fc1 --- /dev/null +++ b/src/apps/parsers/clients/checko/enums.py @@ -0,0 +1,88 @@ +""" +Enum'ы для параметров API Checko.ru. + +Используются для типизации запросов к API. +""" + +from enum import Enum + + +class SearchType(str, Enum): + """Тип поиска для /search эндпоинта.""" + + NAME = "name" + """Поиск по наименованию организации или ФИО предпринимателя.""" + + FOUNDER_NAME = "founder-name" + """Поиск по ФИО учредителя или наименованию компании-учредителя.""" + + LEADER_NAME = "leader-name" + """Поиск по ФИО руководителя.""" + + OKVED = "okved" + """Поиск по коду ОКВЭД-2 основного вида деятельности.""" + + REG_DATE = "reg-date" + """Поиск по дате регистрации (формат YYYY-MM-DD).""" + + UPD_DATE = "upd-date" + """Поиск по дате обновления выписки ЕГРЮЛ или ЕГРИП.""" + + +class ObjectType(str, Enum): + """Область поиска для /search эндпоинта.""" + + ORGANIZATION = "org" + """Поиск по организациям.""" + + ENTREPRENEUR = "ent" + """Поиск по индивидуальным предпринимателям.""" + + +class ContractLaw(str, Enum): + """Федеральный закон для /contracts эндпоинта.""" + + FZ44 = "44" + """44-ФЗ - О контрактной системе в сфере закупок.""" + + FZ94 = "94" + """94-ФЗ - О размещении заказов (старый закон).""" + + FZ223 = "223" + """223-ФЗ - О закупках товаров, работ, услуг отдельными видами юрлиц.""" + + +class ContractRole(str, Enum): + """Роль организации в контракте.""" + + CUSTOMER = "customer" + """Заказчик.""" + + SUPPLIER = "supplier" + """Поставщик (исполнитель).""" + + +class CaseRole(str, Enum): + """Роль организации в арбитражном деле.""" + + PLAINTIFF = "plaintiff" + """Истец.""" + + DEFENDANT = "defendant" + """Ответчик.""" + + +class SortOrder(str, Enum): + """Порядок сортировки результатов.""" + + DATE_ASC = "date" + """По дате, по возрастанию.""" + + DATE_DESC = "-date" + """По дате, по убыванию.""" + + PRICE_ASC = "price" + """По цене, по возрастанию.""" + + PRICE_DESC = "-price" + """По цене, по убыванию.""" diff --git a/src/apps/parsers/clients/checko/exceptions.py b/src/apps/parsers/clients/checko/exceptions.py new file mode 100644 index 0000000..7b97eaa --- /dev/null +++ b/src/apps/parsers/clients/checko/exceptions.py @@ -0,0 +1,90 @@ +""" +Иерархия исключений для Checko API клиента. +""" + + +class CheckoError(Exception): + """Базовое исключение Checko API.""" + + pass + + +class CheckoAPIError(CheckoError): + """ + Ошибка API (status != 'ok'). + + Attributes: + message: Сообщение об ошибке от API. + status_code: HTTP код ответа (если применимо). + balance: Остаток баланса (если доступен). + request_count: Количество запросов за сегодня. + """ + + def __init__( + self, + message: str, + status_code: int | None = None, + balance: float | None = None, + request_count: int | None = None, + ): + self.message = message + self.status_code = status_code + self.balance = balance + self.request_count = request_count + super().__init__(message) + + def __str__(self) -> str: + parts = [self.message] + if self.status_code: + parts.append(f"status_code={self.status_code}") + if self.balance is not None: + parts.append(f"balance={self.balance}") + return ", ".join(parts) + + +class CheckoValidationError(CheckoError): + """ + Ошибка валидации запроса. + + Возникает при некорректных параметрах запроса. + """ + + pass + + +class CheckoRateLimitError(CheckoAPIError): + """ + Превышен лимит запросов. + + Возникает при исчерпании лимита бесплатных запросов + или недостаточном балансе. + """ + + pass + + +class CheckoNotFoundError(CheckoAPIError): + """ + Организация/ИП/физлицо не найдены. + + Возникает когда API не нашёл данных по указанным идентификаторам. + """ + + pass + + +class CheckoConnectionError(CheckoError): + """ + Ошибка соединения с API. + + Возникает при сетевых проблемах, таймаутах и т.д. + """ + + def __init__(self, message: str, url: str | None = None): + self.url = url + super().__init__(message) + + def __str__(self) -> str: + if self.url: + return f"{self.args[0]} (url={self.url})" + return self.args[0] diff --git a/src/apps/parsers/clients/checko/schemas/__init__.py b/src/apps/parsers/clients/checko/schemas/__init__.py new file mode 100644 index 0000000..4674b26 --- /dev/null +++ b/src/apps/parsers/clients/checko/schemas/__init__.py @@ -0,0 +1,162 @@ +""" +Схемы данных для Checko API. + +Экспортирует все request/response модели. +""" + +from apps.parsers.clients.checko.schemas.common import ( + ApiMeta, + BankruptcyMessage, + CompanyShort, + EntrepreneurShort, + FundRegistration, + License, + MspSupport, + Okved, + PaginationInfo, + Region, + TaxAuthority, + Trademark, + UnfairSupplierRecord, +) +from apps.parsers.clients.checko.schemas.requests import ( + BankRequest, + CompanyRequest, + ContractsRequest, + EnforcementsRequest, + EntrepreneurRequest, + FinancesRequest, + InspectionsRequest, + LegalCasesRequest, + PersonRequest, + SearchRequest, +) +from apps.parsers.clients.checko.schemas.responses import ( + Address, + BankData, + BankResponse, + Branch, + Capital, + CaseInstance, + CaseParty, + CompanyData, + CompanyResponse, + CompanyStatistics, + CompanyStatus, + Contract, + ContractParty, + ContractsData, + ContractsResponse, + Disqualification, + Enforcement, + EnforcementsData, + EnforcementsResponse, + EntrepreneurData, + EntrepreneurResponse, + EntrepreneurStatus, + FinanceReport, + FinanceReportLine, + FinancesData, + FinancesResponse, + FinanceSummary, + Founder, + Inspection, + InspectionsData, + InspectionsResponse, + Leader, + LegalCase, + LegalCasesData, + LegalCasesResponse, + MspCategory, + OkvedInfo, + PersonCompany, + PersonData, + PersonEntrepreneur, + PersonResponse, + Predecessor, + RegistrarInfo, + RelatedCompany, + SearchData, + SearchResponse, + Successor, + TaxDebt, + TaxPenalty, +) + +__all__ = [ + # Common models + "ApiMeta", + "BankruptcyMessage", + "CompanyShort", + "EntrepreneurShort", + "FundRegistration", + "License", + "MspSupport", + "Okved", + "PaginationInfo", + "Region", + "TaxAuthority", + "Trademark", + "UnfairSupplierRecord", + # Request models + "BankRequest", + "CompanyRequest", + "ContractsRequest", + "EnforcementsRequest", + "EntrepreneurRequest", + "FinancesRequest", + "InspectionsRequest", + "LegalCasesRequest", + "PersonRequest", + "SearchRequest", + # Response models + "Address", + "BankData", + "BankResponse", + "Branch", + "Capital", + "CaseInstance", + "CaseParty", + "CompanyData", + "CompanyResponse", + "CompanyStatistics", + "CompanyStatus", + "Contract", + "ContractParty", + "ContractsData", + "ContractsResponse", + "Disqualification", + "Enforcement", + "EnforcementsData", + "EnforcementsResponse", + "EntrepreneurData", + "EntrepreneurResponse", + "EntrepreneurStatus", + "FinanceReport", + "FinanceReportLine", + "FinancesData", + "FinancesResponse", + "FinanceSummary", + "Founder", + "Inspection", + "InspectionsData", + "InspectionsResponse", + "Leader", + "LegalCase", + "LegalCasesData", + "LegalCasesResponse", + "MspCategory", + "OkvedInfo", + "PersonCompany", + "PersonData", + "PersonEntrepreneur", + "PersonResponse", + "Predecessor", + "RegistrarInfo", + "RelatedCompany", + "SearchData", + "SearchResponse", + "Successor", + "TaxDebt", + "TaxPenalty", +] diff --git a/src/apps/parsers/clients/checko/schemas/common.py b/src/apps/parsers/clients/checko/schemas/common.py new file mode 100644 index 0000000..2ac4b3d --- /dev/null +++ b/src/apps/parsers/clients/checko/schemas/common.py @@ -0,0 +1,294 @@ +""" +Общие dataclass модели для Checko API. + +Содержит модели, используемые в нескольких эндпоинтах. +""" + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ApiMeta: + """ + Метаинформация ответа API. + + Присутствует в каждом ответе API. + """ + + status: str + """Статус ответа: 'ok' или 'error'.""" + + today_request_count: int + """Количество запросов за сегодня.""" + + balance: float + """Остаток баланса (руб.).""" + + message: str | None = None + """Сообщение (при ошибке или особых условиях).""" + + +@dataclass(frozen=True) +class Region: + """Регион РФ.""" + + code: str + """Код региона (2 цифры).""" + + name: str + """Наименование региона.""" + + +@dataclass(frozen=True) +class Okved: + """Код ОКВЭД.""" + + code: str + """Код ОКВЭД.""" + + name: str + """Наименование вида деятельности.""" + + version: str | None = None + """Версия справочника: '2001' или '2014'.""" + + +@dataclass(frozen=True) +class TaxAuthority: + """Налоговый орган.""" + + code: str + """Код налогового органа.""" + + name: str + """Наименование.""" + + address: str | None = None + """Адрес.""" + + date: str | None = None + """Дата постановки на учёт.""" + + +@dataclass(frozen=True) +class FundRegistration: + """Регистрация в фонде (ПФР/ФСС).""" + + reg_date: str | None = None + """Дата регистрации.""" + + reg_number: str | None = None + """Регистрационный номер.""" + + code: str | None = None + """Код территориального органа.""" + + name: str | None = None + """Наименование территориального органа.""" + + +@dataclass(frozen=True) +class CompanyShort: + """ + Краткая информация об организации. + + Используется в связях, списках и результатах поиска. + """ + + ogrn: str + """ОГРН.""" + + inn: str + """ИНН.""" + + kpp: str | None = None + """КПП.""" + + short_name: str | None = None + """Сокращенное наименование.""" + + full_name: str | None = None + """Полное наименование.""" + + reg_date: str | None = None + """Дата регистрации.""" + + status: str | None = None + """Статус.""" + + liquidation_date: str | None = None + """Дата ликвидации.""" + + region_code: str | None = None + """Код региона.""" + + legal_address: str | None = None + """Юридический адрес.""" + + okved: str | None = None + """Код основного ОКВЭД.""" + + +@dataclass(frozen=True) +class EntrepreneurShort: + """ + Краткая информация об ИП. + + Используется в связях, списках и результатах поиска. + """ + + ogrnip: str + """ОГРНИП.""" + + inn: str + """ИНН.""" + + full_name: str | None = None + """ФИО.""" + + type_name: str | None = None + """Наименование типа ИП.""" + + reg_date: str | None = None + """Дата регистрации.""" + + status: str | None = None + """Статус.""" + + termination_date: str | None = None + """Дата прекращения деятельности.""" + + region_code: str | None = None + """Код региона.""" + + okved: str | None = None + """Код основного ОКВЭД.""" + + +@dataclass(frozen=True) +class License: + """Лицензия.""" + + number: str | None = None + """Номер лицензии.""" + + date: str | None = None + """Дата выдачи.""" + + start_date: str | None = None + """Дата начала действия.""" + + end_date: str | None = None + """Дата окончания действия.""" + + authority: str | None = None + """Орган, выдавший лицензию.""" + + activities: tuple[str, ...] = () + """Виды лицензируемой деятельности.""" + + +@dataclass(frozen=True) +class Trademark: + """Товарный знак.""" + + id: int + """Номер государственной регистрации.""" + + url: str | None = None + """Ссылка на страницу Роспатента.""" + + reg_date: str | None = None + """Дата регистрации.""" + + expiry_date: str | None = None + """Дата истечения срока действия.""" + + +@dataclass(frozen=True) +class BankruptcyMessage: + """Сообщение из реестра банкротств (ЕФРСБ).""" + + type: str + """Тип сообщения.""" + + date: str + """Дата публикации.""" + + case_number: str | None = None + """Номер арбитражного дела.""" + + +@dataclass(frozen=True) +class UnfairSupplierRecord: + """Запись реестра недобросовестных поставщиков.""" + + registry_number: str + """Реестровый номер.""" + + publish_date: str | None = None + """Дата публикации.""" + + approval_date: str | None = None + """Дата утверждения.""" + + customer_short_name: str | None = None + """Сокращенное наименование заказчика.""" + + customer_full_name: str | None = None + """Полное наименование заказчика.""" + + customer_inn: str | None = None + """ИНН заказчика.""" + + customer_kpp: str | None = None + """КПП заказчика.""" + + purchase_number: str | None = None + """Номер закупки.""" + + purchase_description: str | None = None + """Описание закупки.""" + + contract_price: int | None = None + """Цена контракта (руб.).""" + + +@dataclass(frozen=True) +class MspSupport: + """Поддержка МСП.""" + + date: str | None = None + """Дата оказания поддержки.""" + + type: str | None = None + """Тип поддержки.""" + + form: str | None = None + """Форма поддержки.""" + + org_name: str | None = None + """Наименование организации, оказавшей поддержку.""" + + org_inn: str | None = None + """ИНН организации.""" + + amount: str | None = None + """Размер поддержки.""" + + violation: bool = False + """Признак нарушений.""" + + +@dataclass(frozen=True) +class PaginationInfo: + """Информация о пагинации.""" + + total_records: int + """Общее количество записей.""" + + total_pages: int + """Количество страниц.""" + + current_page: int + """Текущая страница.""" diff --git a/src/apps/parsers/clients/checko/schemas/requests.py b/src/apps/parsers/clients/checko/schemas/requests.py new file mode 100644 index 0000000..5f3b74a --- /dev/null +++ b/src/apps/parsers/clients/checko/schemas/requests.py @@ -0,0 +1,423 @@ +""" +Request dataclass модели для Checko API. + +Содержит модели для формирования запросов к каждому эндпоинту. +""" + +from dataclasses import dataclass + +from apps.parsers.clients.checko.enums import ( + CaseRole, + ContractLaw, + ContractRole, + ObjectType, + SearchType, + SortOrder, +) + + +@dataclass +class CompanyRequest: + """ + Запрос информации о юридическом лице (/company). + + Должен быть указан хотя бы один идентификатор: ogrn, inn или okpo. + """ + + ogrn: str | None = None + """ОГРН организации.""" + + inn: str | None = None + """ИНН организации.""" + + kpp: str | None = None + """КПП организации (уточняет inn).""" + + okpo: str | None = None + """Код ОКПО.""" + + source: bool = False + """Включить исходные данные ЕГРЮЛ.""" + + def to_params(self) -> dict[str, str]: + """Преобразовать в параметры запроса.""" + params = {} + if self.ogrn: + params["ogrn"] = self.ogrn + if self.inn: + params["inn"] = self.inn + if self.kpp: + params["kpp"] = self.kpp + if self.okpo: + params["okpo"] = self.okpo + if self.source: + params["source"] = "true" + return params + + +@dataclass +class EntrepreneurRequest: + """ + Запрос информации об ИП (/entrepreneur). + + Должен быть указан хотя бы один идентификатор: ogrn, inn или okpo. + """ + + ogrn: str | None = None + """ОГРНИП предпринимателя.""" + + inn: str | None = None + """ИНН предпринимателя.""" + + okpo: str | None = None + """Код ОКПО.""" + + source: bool = False + """Включить исходные данные ЕГРИП.""" + + def to_params(self) -> dict[str, str]: + """Преобразовать в параметры запроса.""" + params = {} + if self.ogrn: + params["ogrn"] = self.ogrn + if self.inn: + params["inn"] = self.inn + if self.okpo: + params["okpo"] = self.okpo + if self.source: + params["source"] = "true" + return params + + +@dataclass +class PersonRequest: + """Запрос информации о физическом лице (/person).""" + + inn: str + """ИНН физического лица (обязательный).""" + + def to_params(self) -> dict[str, str]: + """Преобразовать в параметры запроса.""" + return {"inn": self.inn} + + +@dataclass +class SearchRequest: + """ + Запрос поиска организаций и ИП (/search). + + Обязательные поля: by, obj, query. + """ + + by: SearchType + """Тип поиска.""" + + obj: ObjectType + """Область поиска (организации или ИП).""" + + query: str + """Поисковый запрос (мин. 4 символа для текстового поиска).""" + + region: str | None = None + """Код региона (2 цифры).""" + + okved: str | None = None + """Код ОКВЭД (не для by=okved).""" + + opf: str | None = None + """Код ОКОПФ (не для obj=ent).""" + + active: bool = False + """Только активные организации/ИП.""" + + limit: int = 100 + """Количество результатов на страницу (макс. 100).""" + + page: int = 1 + """Номер страницы.""" + + def to_params(self) -> dict[str, str]: + """Преобразовать в параметры запроса.""" + params = { + "by": self.by.value, + "obj": self.obj.value, + "query": self.query, + } + if self.region: + params["region"] = self.region + if self.okved: + params["okved"] = self.okved + if self.opf: + params["opf"] = self.opf + if self.active: + params["active"] = "true" + if self.limit != 100: + params["limit"] = str(self.limit) + if self.page != 1: + params["page"] = str(self.page) + return params + + +@dataclass +class FinancesRequest: + """ + Запрос финансовой отчетности (/finances). + + Должен быть указан хотя бы один идентификатор: ogrn или inn. + """ + + ogrn: str | None = None + """ОГРН организации.""" + + inn: str | None = None + """ИНН организации.""" + + kpp: str | None = None + """КПП организации.""" + + extended: bool = False + """Расширенная версия отчетности.""" + + def to_params(self) -> dict[str, str]: + """Преобразовать в параметры запроса.""" + params = {} + if self.ogrn: + params["ogrn"] = self.ogrn + if self.inn: + params["inn"] = self.inn + if self.kpp: + params["kpp"] = self.kpp + if self.extended: + params["extended"] = "true" + return params + + +@dataclass +class ContractsRequest: + """ + Запрос контрактов по госзакупкам (/contracts). + + Должен быть указан хотя бы один идентификатор: ogrn или inn. + """ + + law: ContractLaw + """Федеральный закон (44, 94 или 223).""" + + ogrn: str | None = None + """ОГРН организации или ОГРНИП.""" + + inn: str | None = None + """ИНН организации или ИП.""" + + kpp: str | None = None + """КПП организации.""" + + role: ContractRole | None = None + """Роль: заказчик или поставщик.""" + + limit: int = 100 + """Количество результатов на страницу (макс. 100).""" + + page: int = 1 + """Номер страницы.""" + + sort: SortOrder | None = None + """Сортировка.""" + + def to_params(self) -> dict[str, str]: + """Преобразовать в параметры запроса.""" + params = {"law": self.law.value} + if self.ogrn: + params["ogrn"] = self.ogrn + if self.inn: + params["inn"] = self.inn + if self.kpp: + params["kpp"] = self.kpp + if self.role: + params["role"] = self.role.value + if self.limit != 100: + params["limit"] = str(self.limit) + if self.page != 1: + params["page"] = str(self.page) + if self.sort: + params["sort"] = self.sort.value + return params + + +@dataclass +class InspectionsRequest: + """ + Запрос проверок (/inspections). + + Должен быть указан хотя бы один идентификатор: ogrn или inn. + """ + + ogrn: str | None = None + """ОГРН организации или ОГРНИП.""" + + inn: str | None = None + """ИНН организации или ИП.""" + + kpp: str | None = None + """КПП организации.""" + + limit: int = 100 + """Количество результатов на страницу (макс. 100).""" + + page: int = 1 + """Номер страницы.""" + + sort: SortOrder | None = None + """Сортировка по дате.""" + + def to_params(self) -> dict[str, str]: + """Преобразовать в параметры запроса.""" + params = {} + if self.ogrn: + params["ogrn"] = self.ogrn + if self.inn: + params["inn"] = self.inn + if self.kpp: + params["kpp"] = self.kpp + if self.limit != 100: + params["limit"] = str(self.limit) + if self.page != 1: + params["page"] = str(self.page) + if self.sort: + params["sort"] = self.sort.value + return params + + +@dataclass +class EnforcementsRequest: + """ + Запрос исполнительных производств (/enforcements). + + Должен быть указан хотя бы один идентификатор: ogrn или inn. + """ + + ogrn: str | None = None + """ОГРН организации.""" + + inn: str | None = None + """ИНН организации.""" + + kpp: str | None = None + """КПП организации.""" + + limit: int = 100 + """Количество результатов на страницу (макс. 100).""" + + page: int = 1 + """Номер страницы.""" + + sort: SortOrder | None = None + """Сортировка по дате.""" + + def to_params(self) -> dict[str, str]: + """Преобразовать в параметры запроса.""" + params = {} + if self.ogrn: + params["ogrn"] = self.ogrn + if self.inn: + params["inn"] = self.inn + if self.kpp: + params["kpp"] = self.kpp + if self.limit != 100: + params["limit"] = str(self.limit) + if self.page != 1: + params["page"] = str(self.page) + if self.sort: + params["sort"] = self.sort.value + return params + + +@dataclass +class LegalCasesRequest: + """ + Запрос арбитражных дел (/legal-cases). + + Должен быть указан хотя бы один идентификатор: ogrn или inn. + """ + + ogrn: str | None = None + """ОГРН организации или ОГРНИП.""" + + inn: str | None = None + """ИНН организации или ИП.""" + + kpp: str | None = None + """КПП организации.""" + + role: CaseRole | None = None + """Роль: истец или ответчик.""" + + actual: bool = False + """Только актуальные дела (без отклонённых и т.д.).""" + + active: bool = False + """Только активные (незавершённые) дела.""" + + date_from: str | None = None + """Дата начала периода (YYYY-MM-DD).""" + + date_to: str | None = None + """Дата конца периода (YYYY-MM-DD).""" + + claim_amount_from: int | None = None + """Минимальная сумма исковых требований.""" + + claim_amount_to: int | None = None + """Максимальная сумма исковых требований.""" + + limit: int = 100 + """Количество результатов на страницу (макс. 100).""" + + page: int = 1 + """Номер страницы.""" + + sort: SortOrder | None = None + """Сортировка по дате.""" + + def to_params(self) -> dict[str, str]: # noqa: C901 + """Преобразовать в параметры запроса.""" + params = {} + if self.ogrn: + params["ogrn"] = self.ogrn + if self.inn: + params["inn"] = self.inn + if self.kpp: + params["kpp"] = self.kpp + if self.role: + params["role"] = self.role.value + if self.actual: + params["actual"] = "true" + if self.active: + params["active"] = "true" + if self.date_from: + params["date_from"] = self.date_from + if self.date_to: + params["date_to"] = self.date_to + if self.claim_amount_from is not None: + params["claim_amount_from"] = str(self.claim_amount_from) + if self.claim_amount_to is not None: + params["claim_amount_to"] = str(self.claim_amount_to) + if self.limit != 100: + params["limit"] = str(self.limit) + if self.page != 1: + params["page"] = str(self.page) + if self.sort: + params["sort"] = self.sort.value + return params + + +@dataclass +class BankRequest: + """Запрос информации о банке (/bank).""" + + bic: str + """БИК банка (обязательный).""" + + def to_params(self) -> dict[str, str]: + """Преобразовать в параметры запроса.""" + return {"bic": self.bic} diff --git a/src/apps/parsers/clients/checko/schemas/responses.py b/src/apps/parsers/clients/checko/schemas/responses.py new file mode 100644 index 0000000..a5c96cc --- /dev/null +++ b/src/apps/parsers/clients/checko/schemas/responses.py @@ -0,0 +1,1149 @@ +""" +Response dataclass модели для Checko API (frozen). + +Содержит модели ответов для всех 10 эндпоинтов API v2. +""" + +from dataclasses import dataclass + +from apps.parsers.clients.checko.schemas.common import ( + ApiMeta, + BankruptcyMessage, + CompanyShort, + EntrepreneurShort, + FundRegistration, + License, + MspSupport, + Okved, + PaginationInfo, + Region, + TaxAuthority, + Trademark, + UnfairSupplierRecord, +) + +# ============================================================================= +# Company Response (/company) +# ============================================================================= + + +@dataclass(frozen=True) +class CompanyStatus: + """Статус организации.""" + + restricted_access: bool + """Ограниченный доступ к данным.""" + + code: str | None = None + """Код статуса.""" + + name: str | None = None + """Наименование статуса.""" + + record_date: str | None = None + """Дата записи о статусе.""" + + +@dataclass(frozen=True) +class Address: + """Адрес организации.""" + + restricted_access: bool + """Ограниченный доступ к данным.""" + + full_address: str | None = None + """Полный адрес.""" + + region: Region | None = None + """Регион.""" + + city: str | None = None + """Город.""" + + street: str | None = None + """Улица.""" + + building: str | None = None + """Дом.""" + + apartment: str | None = None + """Квартира/офис.""" + + postal_code: str | None = None + """Почтовый индекс.""" + + is_unreliable: bool = False + """Адрес признан недостоверным.""" + + is_mass_address: bool = False + """Адрес массовой регистрации.""" + + +@dataclass(frozen=True) +class Leader: + """Руководитель организации.""" + + restricted_access: bool + """Ограниченный доступ к данным.""" + + full_name: str | None = None + """ФИО.""" + + inn: str | None = None + """ИНН руководителя.""" + + position_type: str | None = None + """Код типа должности.""" + + position_name: str | None = None + """Наименование должности.""" + + date: str | None = None + """Дата назначения.""" + + is_unreliable: bool = False + """ФИО признано недостоверным.""" + + is_mass_leader: bool = False + """Массовый руководитель.""" + + is_disqualified: bool = False + """Дисквалифицирован.""" + + +@dataclass(frozen=True) +class Founder: + """Учредитель организации.""" + + restricted_access: bool + """Ограниченный доступ к данным.""" + + founder_type: str | None = None + """Тип учредителя: 'org' или 'person'.""" + + full_name: str | None = None + """ФИО (для физ. лица) или наименование (для юр. лица).""" + + inn: str | None = None + """ИНН.""" + + ogrn: str | None = None + """ОГРН (для юр. лица).""" + + share: float | None = None + """Доля в уставном капитале (%).""" + + capital_amount: int | None = None + """Размер вклада (руб.).""" + + date: str | None = None + """Дата внесения сведений.""" + + is_unreliable: bool = False + """Сведения признаны недостоверными.""" + + +@dataclass(frozen=True) +class Capital: + """Уставный капитал.""" + + value: int | None = None + """Размер (руб.).""" + + type_name: str | None = None + """Вид уставного капитала.""" + + date: str | None = None + """Дата.""" + + +@dataclass(frozen=True) +class OkvedInfo: + """Информация о видах деятельности.""" + + main: Okved | None = None + """Основной вид деятельности.""" + + additional: tuple[Okved, ...] = () + """Дополнительные виды деятельности.""" + + +@dataclass(frozen=True) +class RegistrarInfo: + """Информация о держателе реестра акционеров.""" + + ogrn: str | None = None + inn: str | None = None + full_name: str | None = None + + +@dataclass(frozen=True) +class Predecessor: + """Правопредшественник.""" + + ogrn: str | None = None + inn: str | None = None + kpp: str | None = None + full_name: str | None = None + + +@dataclass(frozen=True) +class Successor: + """Правопреемник.""" + + ogrn: str | None = None + inn: str | None = None + kpp: str | None = None + full_name: str | None = None + + +@dataclass(frozen=True) +class Branch: + """Филиал или представительство.""" + + name: str | None = None + """Наименование.""" + + address: str | None = None + """Адрес.""" + + type: str | None = None + """Тип: 'branch' или 'representative_office'.""" + + country_code: str | None = None + """Код страны (для зарубежных).""" + + +@dataclass(frozen=True) +class TaxDebt: + """Задолженность по налогам.""" + + total: int | None = None + """Общая задолженность (руб.).""" + + date: str | None = None + """Дата сведений.""" + + +@dataclass(frozen=True) +class TaxPenalty: + """Штрафы и пени.""" + + penalties: int | None = None + """Пени (руб.).""" + + fines: int | None = None + """Штрафы (руб.).""" + + date: str | None = None + """Дата сведений.""" + + +@dataclass(frozen=True) +class RelatedCompany: + """Связанная компания.""" + + ogrn: str + inn: str + kpp: str | None = None + short_name: str | None = None + full_name: str | None = None + relation_type: str | None = None + """Тип связи: 'founder', 'manager', 'same_address', etc.""" + + +@dataclass(frozen=True) +class CompanyStatistics: + """Статистика по компании.""" + + contracts_44_customer_count: int | None = None + contracts_44_supplier_count: int | None = None + contracts_223_customer_count: int | None = None + contracts_223_supplier_count: int | None = None + legal_cases_plaintiff_count: int | None = None + legal_cases_defendant_count: int | None = None + inspections_count: int | None = None + enforcements_count: int | None = None + + +@dataclass(frozen=True) +class MspCategory: + """Категория в реестре МСП.""" + + category: str | None = None + """Категория: 'micro', 'small', 'medium'.""" + + include_date: str | None = None + """Дата включения.""" + + type: str | None = None + """Тип субъекта.""" + + +@dataclass(frozen=True) +class CompanyData: + """Полные данные об организации.""" + + ogrn: str + """ОГРН.""" + + inn: str + """ИНН.""" + + kpp: str | None = None + """КПП.""" + + okpo: str | None = None + """Код ОКПО.""" + + reg_date: str | None = None + """Дата регистрации.""" + + short_name: str | None = None + """Сокращенное наименование.""" + + full_name: str | None = None + """Полное наименование.""" + + status: CompanyStatus | None = None + """Статус организации.""" + + legal_address: Address | None = None + """Юридический адрес.""" + + leader: Leader | None = None + """Руководитель.""" + + founders: tuple[Founder, ...] = () + """Учредители.""" + + capital: Capital | None = None + """Уставный капитал.""" + + okved: OkvedInfo | None = None + """Виды деятельности.""" + + opf_code: str | None = None + """Код ОКОПФ.""" + + opf_name: str | None = None + """Наименование ОКОПФ.""" + + okfs_code: str | None = None + """Код ОКФС.""" + + okfs_name: str | None = None + """Наименование ОКФС.""" + + okogu_code: str | None = None + """Код ОКОГУ.""" + + okogu_name: str | None = None + """Наименование ОКОГУ.""" + + region: Region | None = None + """Регион.""" + + tax_authority: TaxAuthority | None = None + """Регистрирующая налоговая.""" + + tax_authority_local: TaxAuthority | None = None + """Местная налоговая.""" + + pfr: FundRegistration | None = None + """Регистрация в ПФР.""" + + fss: FundRegistration | None = None + """Регистрация в ФСС.""" + + registrar: RegistrarInfo | None = None + """Держатель реестра акционеров.""" + + predecessors: tuple[Predecessor, ...] = () + """Правопредшественники.""" + + successors: tuple[Successor, ...] = () + """Правопреемники.""" + + branches: tuple[Branch, ...] = () + """Филиалы и представительства.""" + + licenses: tuple[License, ...] = () + """Лицензии.""" + + trademarks: tuple[Trademark, ...] = () + """Товарные знаки.""" + + tax_debt: TaxDebt | None = None + """Задолженность по налогам.""" + + tax_penalty: TaxPenalty | None = None + """Штрафы и пени.""" + + msp: MspCategory | None = None + """Категория МСП.""" + + msp_support: tuple[MspSupport, ...] = () + """История поддержки МСП.""" + + bankruptcy: tuple[BankruptcyMessage, ...] = () + """Сообщения о банкротстве.""" + + unfair_supplier: tuple[UnfairSupplierRecord, ...] = () + """Реестр недобросовестных поставщиков.""" + + related_companies: tuple[RelatedCompany, ...] = () + """Связанные организации.""" + + statistics: CompanyStatistics | None = None + """Статистика.""" + + employees_count: int | None = None + """Среднесписочная численность.""" + + employees_count_date: str | None = None + """Дата сведений о численности.""" + + liquidation_date: str | None = None + """Дата ликвидации.""" + + +@dataclass(frozen=True) +class CompanyResponse: + """Ответ эндпоинта /company.""" + + data: CompanyData | None + """Данные организации.""" + + meta: ApiMeta + """Метаинформация ответа.""" + + source_data: dict | None = None + """Исходные данные ЕГРЮЛ (если source=true).""" + + +# ============================================================================= +# Entrepreneur Response (/entrepreneur) +# ============================================================================= + + +@dataclass(frozen=True) +class EntrepreneurStatus: + """Статус ИП.""" + + code: str | None = None + """Код статуса.""" + + name: str | None = None + """Наименование статуса.""" + + record_date: str | None = None + """Дата записи о статусе.""" + + +@dataclass(frozen=True) +class EntrepreneurData: + """Полные данные об ИП.""" + + ogrnip: str + """ОГРНИП.""" + + inn: str + """ИНН.""" + + okpo: str | None = None + """Код ОКПО.""" + + full_name: str | None = None + """ФИО предпринимателя.""" + + type_code: str | None = None + """Код типа ИП.""" + + type_name: str | None = None + """Наименование типа ИП.""" + + citizenship_code: str | None = None + """Код гражданства.""" + + citizenship_name: str | None = None + """Наименование гражданства.""" + + reg_date: str | None = None + """Дата регистрации.""" + + reg_authority_code: str | None = None + """Код регистрирующего органа.""" + + reg_authority_name: str | None = None + """Наименование регистрирующего органа.""" + + status: EntrepreneurStatus | None = None + """Статус ИП.""" + + termination_date: str | None = None + """Дата прекращения деятельности.""" + + termination_method_code: str | None = None + """Код способа прекращения.""" + + termination_method_name: str | None = None + """Наименование способа прекращения.""" + + region: Region | None = None + """Регион регистрации.""" + + tax_authority: TaxAuthority | None = None + """Регистрирующая налоговая.""" + + tax_authority_local: TaxAuthority | None = None + """Местная налоговая.""" + + pfr: FundRegistration | None = None + """Регистрация в ПФР.""" + + fss: FundRegistration | None = None + """Регистрация в ФСС.""" + + okved: OkvedInfo | None = None + """Виды деятельности.""" + + licenses: tuple[License, ...] = () + """Лицензии.""" + + bankruptcy: tuple[BankruptcyMessage, ...] = () + """Сообщения о банкротстве.""" + + msp: MspCategory | None = None + """Категория МСП.""" + + +@dataclass(frozen=True) +class EntrepreneurResponse: + """Ответ эндпоинта /entrepreneur.""" + + data: EntrepreneurData | None + """Данные ИП.""" + + meta: ApiMeta + """Метаинформация ответа.""" + + source_data: dict | None = None + """Исходные данные ЕГРИП (если source=true).""" + + +# ============================================================================= +# Person Response (/person) +# ============================================================================= + + +@dataclass(frozen=True) +class PersonCompany: + """Компания, связанная с физическим лицом.""" + + ogrn: str + inn: str + kpp: str | None = None + short_name: str | None = None + full_name: str | None = None + role: str | None = None + """Роль: 'leader', 'founder'.""" + position_name: str | None = None + share: float | None = None + status: str | None = None + + +@dataclass(frozen=True) +class PersonEntrepreneur: + """ИП, связанный с физическим лицом.""" + + ogrnip: str + inn: str + full_name: str | None = None + status: str | None = None + reg_date: str | None = None + termination_date: str | None = None + + +@dataclass(frozen=True) +class Disqualification: + """Информация о дисквалификации.""" + + date_from: str | None = None + """Дата начала.""" + + date_to: str | None = None + """Дата окончания.""" + + reason: str | None = None + """Причина дисквалификации.""" + + authority: str | None = None + """Орган, принявший решение.""" + + +@dataclass(frozen=True) +class PersonData: + """Данные о физическом лице.""" + + inn: str + """ИНН физического лица.""" + + full_name: str | None = None + """ФИО.""" + + is_mass_leader: bool = False + """Массовый руководитель.""" + + is_disqualified: bool = False + """Дисквалифицирован.""" + + disqualifications: tuple[Disqualification, ...] = () + """История дисквалификаций.""" + + companies_as_leader: tuple[PersonCompany, ...] = () + """Компании, где является руководителем.""" + + companies_as_founder: tuple[PersonCompany, ...] = () + """Компании, где является учредителем.""" + + entrepreneurs: tuple[PersonEntrepreneur, ...] = () + """Статус ИП.""" + + +@dataclass(frozen=True) +class PersonResponse: + """Ответ эндпоинта /person.""" + + data: PersonData | None + """Данные о физическом лице.""" + + meta: ApiMeta + """Метаинформация ответа.""" + + +# ============================================================================= +# Search Response (/search) +# ============================================================================= + + +@dataclass(frozen=True) +class SearchData: + """Результаты поиска.""" + + organizations: tuple[CompanyShort, ...] = () + """Найденные организации (если obj=org).""" + + entrepreneurs: tuple[EntrepreneurShort, ...] = () + """Найденные ИП (если obj=ent).""" + + pagination: PaginationInfo | None = None + """Информация о пагинации.""" + + +@dataclass(frozen=True) +class SearchResponse: + """Ответ эндпоинта /search.""" + + data: SearchData | None + """Результаты поиска.""" + + meta: ApiMeta + """Метаинформация ответа.""" + + +# ============================================================================= +# Finances Response (/finances) +# ============================================================================= + + +@dataclass(frozen=True) +class FinanceReportLine: + """Строка финансового отчёта.""" + + code: str + """Код строки.""" + + name: str | None = None + """Наименование строки.""" + + current: int | None = None + """Значение за текущий период.""" + + previous: int | None = None + """Значение за предыдущий период.""" + + +@dataclass(frozen=True) +class FinanceReport: + """Финансовый отчёт за год.""" + + year: int + """Год отчёта.""" + + report_date: str | None = None + """Дата сдачи отчёта.""" + + correction_number: int | None = None + """Номер корректировки.""" + + balance: tuple[FinanceReportLine, ...] = () + """Бухгалтерский баланс.""" + + profit_loss: tuple[FinanceReportLine, ...] = () + """Отчёт о прибылях и убытках.""" + + capital_changes: tuple[FinanceReportLine, ...] = () + """Отчёт об изменениях капитала.""" + + cash_flow: tuple[FinanceReportLine, ...] = () + """Отчёт о движении денежных средств.""" + + targeted_use: tuple[FinanceReportLine, ...] = () + """Отчёт о целевом использовании средств.""" + + +@dataclass(frozen=True) +class FinanceSummary: + """Сводные финансовые показатели.""" + + revenue: int | None = None + """Выручка.""" + + profit: int | None = None + """Чистая прибыль.""" + + assets: int | None = None + """Активы.""" + + equity: int | None = None + """Собственный капитал.""" + + +@dataclass(frozen=True) +class FinancesData: + """Финансовые данные организации.""" + + ogrn: str + """ОГРН.""" + + inn: str + """ИНН.""" + + kpp: str | None = None + """КПП.""" + + reports: tuple[FinanceReport, ...] = () + """Финансовые отчёты по годам.""" + + summary: FinanceSummary | None = None + """Сводные показатели за последний год.""" + + +@dataclass(frozen=True) +class FinancesResponse: + """Ответ эндпоинта /finances.""" + + data: FinancesData | None + """Финансовые данные.""" + + meta: ApiMeta + """Метаинформация ответа.""" + + +# ============================================================================= +# Contracts Response (/contracts) +# ============================================================================= + + +@dataclass(frozen=True) +class ContractParty: + """Сторона контракта.""" + + ogrn: str | None = None + inn: str | None = None + kpp: str | None = None + name: str | None = None + region_code: str | None = None + + +@dataclass(frozen=True) +class Contract: + """Государственный контракт.""" + + registry_number: str + """Реестровый номер контракта.""" + + publish_date: str | None = None + """Дата публикации.""" + + sign_date: str | None = None + """Дата подписания.""" + + execution_date: str | None = None + """Дата исполнения.""" + + price: int | None = None + """Цена контракта (руб.).""" + + currency_code: str | None = None + """Код валюты.""" + + status: str | None = None + """Статус контракта.""" + + subject: str | None = None + """Предмет контракта.""" + + law: str | None = None + """Закон: '44', '94', '223'.""" + + purchase_number: str | None = None + """Номер закупки.""" + + customer: ContractParty | None = None + """Заказчик.""" + + suppliers: tuple[ContractParty, ...] = () + """Поставщики.""" + + url: str | None = None + """Ссылка на zakupki.gov.ru.""" + + +@dataclass(frozen=True) +class ContractsData: + """Данные о контрактах.""" + + contracts: tuple[Contract, ...] = () + """Список контрактов.""" + + pagination: PaginationInfo | None = None + """Информация о пагинации.""" + + total_sum: int | None = None + """Общая сумма контрактов.""" + + +@dataclass(frozen=True) +class ContractsResponse: + """Ответ эндпоинта /contracts.""" + + data: ContractsData | None + """Данные о контрактах.""" + + meta: ApiMeta + """Метаинформация ответа.""" + + +# ============================================================================= +# Inspections Response (/inspections) +# ============================================================================= + + +@dataclass(frozen=True) +class Inspection: + """Проверка.""" + + id: str | None = None + """Идентификатор проверки.""" + + erp_id: str | None = None + """Идентификатор в ЕРП.""" + + plan_date_from: str | None = None + """Плановая дата начала.""" + + plan_date_to: str | None = None + """Плановая дата окончания.""" + + actual_date_from: str | None = None + """Фактическая дата начала.""" + + actual_date_to: str | None = None + """Фактическая дата окончания.""" + + type: str | None = None + """Тип проверки: 'scheduled', 'unscheduled'.""" + + form: str | None = None + """Форма проверки: 'documentary', 'field'.""" + + status: str | None = None + """Статус проверки.""" + + authority_name: str | None = None + """Наименование контролирующего органа.""" + + authority_ogrn: str | None = None + """ОГРН контролирующего органа.""" + + subject: str | None = None + """Предмет проверки.""" + + result: str | None = None + """Результат проверки.""" + + violations_found: bool = False + """Выявлены нарушения.""" + + +@dataclass(frozen=True) +class InspectionsData: + """Данные о проверках.""" + + inspections: tuple[Inspection, ...] = () + """Список проверок.""" + + pagination: PaginationInfo | None = None + """Информация о пагинации.""" + + +@dataclass(frozen=True) +class InspectionsResponse: + """Ответ эндпоинта /inspections.""" + + data: InspectionsData | None + """Данные о проверках.""" + + meta: ApiMeta + """Метаинформация ответа.""" + + +# ============================================================================= +# Enforcements Response (/enforcements) +# ============================================================================= + + +@dataclass(frozen=True) +class Enforcement: + """Исполнительное производство.""" + + number: str + """Номер производства.""" + + date: str | None = None + """Дата возбуждения.""" + + subject: str | None = None + """Предмет исполнения.""" + + department: str | None = None + """Отдел судебных приставов.""" + + bailiff: str | None = None + """ФИО пристава.""" + + status: str | None = None + """Статус производства.""" + + end_date: str | None = None + """Дата окончания.""" + + end_reason: str | None = None + """Основание окончания.""" + + debt_amount: int | None = None + """Сумма задолженности (руб.).""" + + recovered_amount: int | None = None + """Взыскано (руб.).""" + + +@dataclass(frozen=True) +class EnforcementsData: + """Данные об исполнительных производствах.""" + + enforcements: tuple[Enforcement, ...] = () + """Список производств.""" + + pagination: PaginationInfo | None = None + """Информация о пагинации.""" + + total_debt: int | None = None + """Общая сумма задолженности.""" + + +@dataclass(frozen=True) +class EnforcementsResponse: + """Ответ эндпоинта /enforcements.""" + + data: EnforcementsData | None + """Данные о производствах.""" + + meta: ApiMeta + """Метаинформация ответа.""" + + +# ============================================================================= +# Legal Cases Response (/legal-cases) +# ============================================================================= + + +@dataclass(frozen=True) +class CaseParty: + """Сторона судебного дела.""" + + name: str | None = None + """Наименование/ФИО.""" + + inn: str | None = None + """ИНН.""" + + ogrn: str | None = None + """ОГРН.""" + + role: str | None = None + """Роль в деле: 'plaintiff', 'defendant', 'third_party'.""" + + +@dataclass(frozen=True) +class CaseInstance: + """Инстанция судебного дела.""" + + number: int | None = None + """Номер инстанции (1-4).""" + + court_name: str | None = None + """Наименование суда.""" + + judge: str | None = None + """ФИО судьи.""" + + result: str | None = None + """Результат рассмотрения.""" + + date: str | None = None + """Дата решения.""" + + +@dataclass(frozen=True) +class LegalCase: + """Арбитражное дело.""" + + case_number: str + """Номер дела.""" + + court_name: str | None = None + """Наименование суда.""" + + type: str | None = None + """Тип дела.""" + + category: str | None = None + """Категория дела.""" + + status: str | None = None + """Статус дела.""" + + filing_date: str | None = None + """Дата подачи иска.""" + + result_date: str | None = None + """Дата решения.""" + + claim_amount: int | None = None + """Сумма исковых требований (руб.).""" + + awarded_amount: int | None = None + """Присуждённая сумма (руб.).""" + + plaintiffs: tuple[CaseParty, ...] = () + """Истцы.""" + + defendants: tuple[CaseParty, ...] = () + """Ответчики.""" + + third_parties: tuple[CaseParty, ...] = () + """Третьи лица.""" + + instances: tuple[CaseInstance, ...] = () + """Инстанции.""" + + url: str | None = None + """Ссылка на kad.arbitr.ru.""" + + +@dataclass(frozen=True) +class LegalCasesData: + """Данные об арбитражных делах.""" + + cases: tuple[LegalCase, ...] = () + """Список дел.""" + + pagination: PaginationInfo | None = None + """Информация о пагинации.""" + + total_claim_amount: int | None = None + """Общая сумма требований.""" + + +@dataclass(frozen=True) +class LegalCasesResponse: + """Ответ эндпоинта /legal-cases.""" + + data: LegalCasesData | None + """Данные о делах.""" + + meta: ApiMeta + """Метаинформация ответа.""" + + +# ============================================================================= +# Bank Response (/bank) +# ============================================================================= + + +@dataclass(frozen=True) +class BankData: + """Данные о банке.""" + + bic: str + """БИК банка.""" + + name: str | None = None + """Наименование банка.""" + + short_name: str | None = None + """Сокращенное наименование.""" + + corr_account: str | None = None + """Корреспондентский счёт.""" + + swift: str | None = None + """Код SWIFT.""" + + registration_number: str | None = None + """Регистрационный номер ЦБ.""" + + address: str | None = None + """Юридический адрес.""" + + city: str | None = None + """Город.""" + + region_code: str | None = None + """Код региона.""" + + phone: str | None = None + """Телефон.""" + + status: str | None = None + """Статус: 'active', 'liquidated'.""" + + license_date: str | None = None + """Дата лицензии.""" + + license_revoke_date: str | None = None + """Дата отзыва лицензии.""" + + +@dataclass(frozen=True) +class BankResponse: + """Ответ эндпоинта /bank.""" + + data: BankData | None + """Данные о банке.""" + + meta: ApiMeta + """Метаинформация ответа.""" diff --git a/src/apps/parsers/tests/run_checko_e2e.py b/src/apps/parsers/tests/run_checko_e2e.py new file mode 100644 index 0000000..6972f86 --- /dev/null +++ b/src/apps/parsers/tests/run_checko_e2e.py @@ -0,0 +1,119 @@ +#!/usr/bin/env python +""" +Standalone E2E test for Checko API client. + +Usage: + PYTHONPATH=src python src/apps/parsers/tests/run_checko_e2e.py +""" + +import os +import sys + +import django +from apps.parsers.clients.checko import ( + CheckoClient, + CompanyRequest, + ContractLaw, + ContractsRequest, + FinancesRequest, + LegalCasesRequest, + ObjectType, + SearchRequest, + SearchType, +) +from django.conf import settings + +sys.path.insert(0, ".") + +# Setup Django settings +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.development") + +django.setup() + + +API_KEY = os.environ.get("CHECKO_API_KEY") or settings.CHECKO_API_KEY +if not API_KEY: + print("ERROR: CHECKO_API_KEY not configured") + print("Set it in .env or as environment variable") + sys.exit(1) + +TEST_INN = "7727060950" # АО "ЭКОС" + + +def main(): + client = CheckoClient(api_key=API_KEY) + + print("=" * 60) + print("E2E TEST: Get Company by INN") + print("=" * 60) + response = client.get_company(CompanyRequest(inn=TEST_INN)) + print(f"Status: {response.meta.status}") + print(f"Balance: {response.meta.balance}") + print(f"Requests today: {response.meta.today_request_count}") + print(f"Company: {response.data.short_name}") + print(f"Full name: {response.data.full_name}") + print(f"INN: {response.data.inn}") + print(f"OGRN: {response.data.ogrn}") + print(f"Status: {response.data.status.name if response.data.status else None}") + print( + f"Address: {response.data.legal_address.full_address if response.data.legal_address else None}" + ) + + print() + print("=" * 60) + print("E2E TEST: Search by name") + print("=" * 60) + response = client.search( + SearchRequest( + by=SearchType.NAME, obj=ObjectType.ORGANIZATION, query="Ростелеком", limit=5 + ) + ) + print(f"Found: {len(response.data.organizations)} organizations") + for org in response.data.organizations[:3]: + print(f" - {org.inn}: {org.short_name}") + + print() + print("=" * 60) + print("E2E TEST: Get Finances") + print("=" * 60) + response = client.get_finances(FinancesRequest(inn=TEST_INN)) + print(f"Reports count: {len(response.data.reports)}") + if response.data.reports: + for r in response.data.reports[:2]: + print(f" - Year {r.year}: {len(r.balance)} balance lines") + + print() + print("=" * 60) + print("E2E TEST: Get Contracts (44-ФЗ)") + print("=" * 60) + response = client.get_contracts( + ContractsRequest(inn=TEST_INN, law=ContractLaw.FZ44, limit=5) + ) + print(f"Contracts: {len(response.data.contracts)}") + if response.data.pagination: + print(f"Total: {response.data.pagination.total_records}") + if response.data.contracts: + c = response.data.contracts[0] + print(f"First: {c.registry_number}, price={c.price}") + + print() + print("=" * 60) + print("E2E TEST: Get Legal Cases") + print("=" * 60) + response = client.get_legal_cases(LegalCasesRequest(inn=TEST_INN, limit=5)) + print(f"Cases: {len(response.data.cases)}") + if response.data.pagination: + print(f"Total: {response.data.pagination.total_records}") + if response.data.cases: + c = response.data.cases[0] + print(f"First: {c.case_number}, claim={c.claim_amount}") + + client.close() + print() + print("=" * 60) + print("ALL E2E TESTS PASSED!") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/src/apps/parsers/tests/test_checko_e2e.py b/src/apps/parsers/tests/test_checko_e2e.py new file mode 100644 index 0000000..7f40e2f --- /dev/null +++ b/src/apps/parsers/tests/test_checko_e2e.py @@ -0,0 +1,438 @@ +""" +E2E тесты для Checko API клиента. + +Тесты с реальными HTTP запросами к api.checko.ru. + +Для запуска E2E тестов с реальными данными: + RUN_E2E_TESTS=1 uv run python manage.py test apps.parsers.tests.test_checko_e2e + +Тесты пропускаются по умолчанию, чтобы не нагружать API +и не тратить баланс в обычных тестовых прогонах. +""" + +import os +import unittest + +from django.test import TestCase + +# Флаг для запуска E2E тестов +RUN_E2E_TESTS = os.environ.get("RUN_E2E_TESTS", "").lower() in ("1", "true", "yes") + +# API ключ для тестов (из переменной окружения) +CHECKO_API_KEY = os.environ.get("CHECKO_API_KEY", "") + +# Тестовый ИНН: ПАО "Ростелеком" +TEST_INN = "7707049388" +TEST_OGRN = "1027700198767" + + +@unittest.skipUnless(RUN_E2E_TESTS, "E2E tests disabled. Set RUN_E2E_TESTS=1 to enable") +class CheckoClientE2ETestCase(TestCase): + """ + E2E тесты клиента CheckoClient. + + Выполняют реальные HTTP запросы к api.checko.ru. + """ + + @classmethod + def setUpClass(cls): + """Подготовка класса.""" + super().setUpClass() + from apps.parsers.clients.checko import CheckoClient + + cls.client = CheckoClient(api_key=CHECKO_API_KEY, timeout=60) + + @classmethod + def tearDownClass(cls): + """Очистка класса.""" + cls.client.close() + super().tearDownClass() + + def test_get_company_by_inn(self): + """Получение информации о компании по ИНН.""" + from apps.parsers.clients.checko import CompanyRequest + + response = self.client.get_company(CompanyRequest(inn=TEST_INN)) + + # Проверка мета-информации + self.assertEqual(response.meta.status, "ok") + self.assertIsNotNone(response.meta.balance) + self.assertGreaterEqual(response.meta.today_request_count, 1) + + # Проверка данных компании + self.assertIsNotNone(response.data) + self.assertEqual(response.data.inn, TEST_INN) + self.assertEqual(response.data.ogrn, TEST_OGRN) + self.assertIsNotNone(response.data.full_name) + self.assertIn("РОСТЕЛЕКОМ", response.data.full_name.upper()) + + # Проверка статуса + self.assertIsNotNone(response.data.status) + self.assertFalse(response.data.status.restricted_access) + + # Проверка адреса + self.assertIsNotNone(response.data.legal_address) + + # Вывод для отладки + print(f"\n[E2E] Company: {response.data.short_name}") + print(f"[E2E] INN: {response.data.inn}, OGRN: {response.data.ogrn}") + print(f"[E2E] Status: {response.data.status.name}") + print(f"[E2E] Balance: {response.meta.balance}") + + def test_get_company_by_ogrn(self): + """Получение информации о компании по ОГРН.""" + from apps.parsers.clients.checko import CompanyRequest + + response = self.client.get_company(CompanyRequest(ogrn=TEST_OGRN)) + + self.assertEqual(response.meta.status, "ok") + self.assertIsNotNone(response.data) + self.assertEqual(response.data.ogrn, TEST_OGRN) + self.assertEqual(response.data.inn, TEST_INN) + + def test_get_company_with_source(self): + """Получение данных компании с исходными данными ЕГРЮЛ.""" + from apps.parsers.clients.checko import CompanyRequest + + response = self.client.get_company(CompanyRequest(inn=TEST_INN, source=True)) + + self.assertEqual(response.meta.status, "ok") + # source_data может быть None или dict + print(f"\n[E2E] Source data present: {response.source_data is not None}") + + def test_search_by_name(self): + """Поиск организаций по наименованию.""" + from apps.parsers.clients.checko import ( + ObjectType, + SearchRequest, + SearchType, + ) + + response = self.client.search( + SearchRequest( + by=SearchType.NAME, + obj=ObjectType.ORGANIZATION, + query="Ростелеком", + limit=10, + ) + ) + + self.assertEqual(response.meta.status, "ok") + self.assertIsNotNone(response.data) + self.assertGreater(len(response.data.organizations), 0) + + # Должен найти ПАО Ростелеком + inns = [org.inn for org in response.data.organizations] + print(f"\n[E2E] Search 'Ростелеком': found {len(inns)} organizations") + print(f"[E2E] First 5 INNs: {inns[:5]}") + + def test_search_active_only(self): + """Поиск только активных организаций.""" + from apps.parsers.clients.checko import ( + ObjectType, + SearchRequest, + SearchType, + ) + + response = self.client.search( + SearchRequest( + by=SearchType.NAME, + obj=ObjectType.ORGANIZATION, + query="Ростелеком", + active=True, + limit=5, + ) + ) + + self.assertEqual(response.meta.status, "ok") + self.assertIsNotNone(response.data) + + # Все найденные должны быть активными + for org in response.data.organizations: + if org.status: + # Статусы активных: "Действующее", код 100 и т.д. + print(f"[E2E] Org {org.inn}: status={org.status}") + + def test_get_finances(self): + """Получение финансовой отчетности.""" + from apps.parsers.clients.checko import FinancesRequest + + response = self.client.get_finances(FinancesRequest(inn=TEST_INN)) + + self.assertEqual(response.meta.status, "ok") + self.assertIsNotNone(response.data) + self.assertEqual(response.data.inn, TEST_INN) + + # Должны быть финансовые отчёты + if response.data.reports: + print(f"\n[E2E] Found {len(response.data.reports)} financial reports") + for report in response.data.reports[:3]: + print(f"[E2E] Year {report.year}: balance lines={len(report.balance)}") + + # Сводные показатели + if response.data.summary: + print(f"[E2E] Summary: revenue={response.data.summary.revenue}") + + def test_get_contracts_fz44(self): + """Получение контрактов по 44-ФЗ.""" + from apps.parsers.clients.checko import ContractLaw, ContractsRequest + + response = self.client.get_contracts( + ContractsRequest( + inn=TEST_INN, + law=ContractLaw.FZ44, + limit=10, + ) + ) + + self.assertEqual(response.meta.status, "ok") + self.assertIsNotNone(response.data) + + print(f"\n[E2E] Found {len(response.data.contracts)} contracts (44-ФЗ)") + if response.data.pagination: + print(f"[E2E] Total records: {response.data.pagination.total_records}") + if response.data.total_sum: + print(f"[E2E] Total sum: {response.data.total_sum:,} руб.") + + # Проверка первого контракта + if response.data.contracts: + contract = response.data.contracts[0] + self.assertIsNotNone(contract.registry_number) + print(f"[E2E] First contract: {contract.registry_number}") + + def test_get_contracts_fz223(self): + """Получение контрактов по 223-ФЗ.""" + from apps.parsers.clients.checko import ContractLaw, ContractsRequest + + response = self.client.get_contracts( + ContractsRequest( + inn=TEST_INN, + law=ContractLaw.FZ223, + limit=10, + ) + ) + + self.assertEqual(response.meta.status, "ok") + print(f"\n[E2E] Found {len(response.data.contracts)} contracts (223-ФЗ)") + + def test_get_inspections(self): + """Получение проверок.""" + from apps.parsers.clients.checko import InspectionsRequest + + response = self.client.get_inspections( + InspectionsRequest(inn=TEST_INN, limit=10) + ) + + self.assertEqual(response.meta.status, "ok") + self.assertIsNotNone(response.data) + + print(f"\n[E2E] Found {len(response.data.inspections)} inspections") + if response.data.pagination: + print(f"[E2E] Total inspections: {response.data.pagination.total_records}") + + def test_get_enforcements(self): + """Получение исполнительных производств.""" + from apps.parsers.clients.checko import EnforcementsRequest + + response = self.client.get_enforcements( + EnforcementsRequest(inn=TEST_INN, limit=10) + ) + + self.assertEqual(response.meta.status, "ok") + self.assertIsNotNone(response.data) + + print(f"\n[E2E] Found {len(response.data.enforcements)} enforcements") + if response.data.total_debt: + print(f"[E2E] Total debt: {response.data.total_debt:,} руб.") + + def test_get_legal_cases(self): + """Получение арбитражных дел.""" + from apps.parsers.clients.checko import LegalCasesRequest + + response = self.client.get_legal_cases( + LegalCasesRequest(inn=TEST_INN, limit=10) + ) + + self.assertEqual(response.meta.status, "ok") + self.assertIsNotNone(response.data) + + print(f"\n[E2E] Found {len(response.data.cases)} legal cases") + if response.data.pagination: + print(f"[E2E] Total cases: {response.data.pagination.total_records}") + if response.data.total_claim_amount: + print(f"[E2E] Total claims: {response.data.total_claim_amount:,} руб.") + + # Проверка первого дела + if response.data.cases: + case = response.data.cases[0] + self.assertIsNotNone(case.case_number) + print(f"[E2E] First case: {case.case_number}") + + def test_get_legal_cases_filtered(self): + """Получение активных арбитражных дел.""" + from apps.parsers.clients.checko import LegalCasesRequest + + response = self.client.get_legal_cases( + LegalCasesRequest( + inn=TEST_INN, + actual=True, + active=True, + limit=5, + ) + ) + + self.assertEqual(response.meta.status, "ok") + print(f"\n[E2E] Found {len(response.data.cases)} active/actual cases") + + +@unittest.skipUnless(RUN_E2E_TESTS, "E2E tests disabled. Set RUN_E2E_TESTS=1 to enable") +class CheckoClientIteratorsE2ETestCase(TestCase): + """ + E2E тесты итераторов с автопагинацией. + """ + + @classmethod + def setUpClass(cls): + """Подготовка класса.""" + super().setUpClass() + from apps.parsers.clients.checko import CheckoClient + + cls.client = CheckoClient(api_key=CHECKO_API_KEY, timeout=60) + + @classmethod + def tearDownClass(cls): + """Очистка класса.""" + cls.client.close() + super().tearDownClass() + + def test_iter_contracts_pagination(self): + """Итератор контрактов с пагинацией.""" + from apps.parsers.clients.checko import ContractLaw, ContractsRequest + + contracts = [] + for contract in self.client.iter_contracts( + ContractsRequest(inn=TEST_INN, law=ContractLaw.FZ44, limit=5) + ): + contracts.append(contract) + if len(contracts) >= 15: # Ограничиваем для теста + break + + print(f"\n[E2E] Iterated over {len(contracts)} contracts") + self.assertGreater(len(contracts), 0) + + def test_iter_legal_cases_pagination(self): + """Итератор арбитражных дел с пагинацией.""" + from apps.parsers.clients.checko import LegalCasesRequest + + cases = [] + for case in self.client.iter_legal_cases( + LegalCasesRequest(inn=TEST_INN, limit=5) + ): + cases.append(case) + if len(cases) >= 15: # Ограничиваем для теста + break + + print(f"\n[E2E] Iterated over {len(cases)} legal cases") + + +@unittest.skipUnless(RUN_E2E_TESTS, "E2E tests disabled. Set RUN_E2E_TESTS=1 to enable") +class CheckoClientErrorE2ETestCase(TestCase): + """ + E2E тесты обработки ошибок. + """ + + @classmethod + def setUpClass(cls): + """Подготовка класса.""" + super().setUpClass() + from apps.parsers.clients.checko import CheckoClient + + cls.client = CheckoClient(api_key=CHECKO_API_KEY, timeout=60) + + @classmethod + def tearDownClass(cls): + """Очистка класса.""" + cls.client.close() + super().tearDownClass() + + def test_company_not_found(self): + """Компания не найдена.""" + from apps.parsers.clients.checko import CheckoNotFoundError, CompanyRequest + + # Несуществующий ИНН + try: + self.client.get_company(CompanyRequest(inn="0000000000")) + self.fail("Expected CheckoNotFoundError") + except CheckoNotFoundError as e: + print(f"\n[E2E] Expected error: {e}") + self.assertIn("не найден", str(e).lower()) + + def test_invalid_api_key(self): + """Некорректный API ключ.""" + from apps.parsers.clients.checko import ( + CheckoAPIError, + CheckoClient, + CompanyRequest, + ) + + bad_client = CheckoClient(api_key="invalid_key_12345") + + try: + bad_client.get_company(CompanyRequest(inn=TEST_INN)) + self.fail("Expected CheckoAPIError") + except CheckoAPIError as e: + print(f"\n[E2E] Expected error: {e}") + finally: + bad_client.close() + + +@unittest.skipUnless(RUN_E2E_TESTS, "E2E tests disabled. Set RUN_E2E_TESTS=1 to enable") +class CheckoDatasetsE2ETestCase(TestCase): + """ + E2E тесты справочников. + + Проверяют загрузку и работу справочников с реальными данными. + """ + + def test_okved2_load_and_search(self): + """Загрузка и поиск в ОКВЭД2.""" + from apps.parsers.clients.checko.datasets import OKVED2 + + # Получение по коду + item = OKVED2.get("62.01") + self.assertIsNotNone(item) + self.assertEqual(item.code, "62.01") + print(f"\n[E2E] OKVED2 62.01: {item.name}") + + # Поиск + results = OKVED2.search("программ") + self.assertGreater(len(results), 0) + print(f"[E2E] Search 'программ': {len(results)} results") + + # Иерархия + children = OKVED2.get_children("62") + print(f"[E2E] Children of 62: {len(children)} items") + + def test_okfs_load(self): + """Загрузка ОКФС.""" + from apps.parsers.clients.checko.datasets import OKFS + + items = OKFS.all() + self.assertGreater(len(items), 0) + print(f"\n[E2E] OKFS: {len(items)} items") + + def test_okopf_load(self): + """Загрузка ОКОПФ.""" + from apps.parsers.clients.checko.datasets import OKOPF + + items = OKOPF.all() + self.assertGreater(len(items), 0) + print(f"\n[E2E] OKOPF: {len(items)} items") + + def test_account_codes_load(self): + """Загрузка кодов строк отчётности.""" + from apps.parsers.clients.checko.datasets import AccountCodes + + item = AccountCodes.get("1100") + self.assertIsNotNone(item) + print(f"\n[E2E] Account code 1100: {item.name}") diff --git a/src/config/settings/base.py b/src/config/settings/base.py index 7e967bf..5bd3cad 100644 --- a/src/config/settings/base.py +++ b/src/config/settings/base.py @@ -4,6 +4,7 @@ Base settings for Django project. Generated by 'django-admin startproject' using Django 3.2.25. """ +from datetime import timedelta from pathlib import Path from decouple import Config, RepositoryEnv @@ -311,7 +312,6 @@ REST_FRAMEWORK = { } # JWT settings -from datetime import timedelta SIMPLE_JWT = { "ACCESS_TOKEN_LIFETIME": timedelta(minutes=60), @@ -418,3 +418,10 @@ FNS_PROCESSED_DIRECTORY = BASE_DIR / "input" / "fns" / "processed" # Directory for failed files (moved after failed processing) FNS_FAILED_DIRECTORY = BASE_DIR / "input" / "fns" / "failed" + +# ============================================================================= +# Checko API Settings (checko.ru) +# ============================================================================= + +# API key for Checko.ru service +CHECKO_API_KEY = get_env("CHECKO_API_KEY", "") diff --git a/tests/apps/parsers/test_checko.py b/tests/apps/parsers/test_checko.py new file mode 100644 index 0000000..de5ae5b --- /dev/null +++ b/tests/apps/parsers/test_checko.py @@ -0,0 +1,631 @@ +"""Tests for Checko API client.""" + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +from django.test import SimpleTestCase, tag + +from apps.parsers.clients.checko import ( + CheckoClient, + CheckoAPIError, + CheckoNotFoundError, + CheckoRateLimitError, + CheckoValidationError, + CompanyRequest, + ContractsRequest, + EntrepreneurRequest, + FinancesRequest, + LegalCasesRequest, + PersonRequest, + SearchRequest, + SearchType, + ObjectType, + ContractLaw, +) +from apps.parsers.clients.checko.datasets import ( + OKVED2, + OKFS, + OKOPF, + OKPD, + OKPD2, + AccountCodes, + CompanyStatuses, + EntrepreneurStatuses, +) + + +class CheckoClientInitTest(SimpleTestCase): + """Tests for CheckoClient initialization.""" + + def test_client_initialization_default(self): + """Test client initializes with defaults.""" + client = CheckoClient(api_key="test_key") + + self.assertEqual(client.api_key, "test_key") + self.assertEqual(client.base_url, "https://api.checko.ru/v2") + self.assertEqual(client.timeout, 30) + self.assertIsNone(client.proxies) + + def test_client_initialization_custom(self): + """Test client initializes with custom params.""" + client = CheckoClient( + api_key="test_key", + base_url="https://custom.api.com", + timeout=60, + proxies=["http://proxy:8080"], + ) + + self.assertEqual(client.base_url, "https://custom.api.com") + self.assertEqual(client.timeout, 60) + self.assertEqual(client.proxies, ["http://proxy:8080"]) + + def test_context_manager(self): + """Test client works as context manager.""" + with CheckoClient(api_key="test_key") as client: + self.assertIsInstance(client, CheckoClient) + + +class CheckoClientValidationTest(SimpleTestCase): + """Tests for request validation.""" + + def setUp(self): + self.client = CheckoClient(api_key="test_key") + + def test_company_request_requires_identifier(self): + """Test CompanyRequest requires at least one identifier.""" + with self.assertRaises(CheckoValidationError) as context: + self.client.get_company(CompanyRequest()) + + self.assertIn("ogrn", str(context.exception).lower()) + + def test_entrepreneur_request_requires_identifier(self): + """Test EntrepreneurRequest requires at least one identifier.""" + with self.assertRaises(CheckoValidationError) as context: + self.client.get_entrepreneur(EntrepreneurRequest()) + + self.assertIn("ogrn", str(context.exception).lower()) + + def test_search_request_min_query_length(self): + """Test SearchRequest validates query length.""" + with self.assertRaises(CheckoValidationError) as context: + self.client.search( + SearchRequest( + by=SearchType.NAME, + obj=ObjectType.ORGANIZATION, + query="abc", # Too short + ) + ) + + self.assertIn("4", str(context.exception)) + + def test_finances_request_requires_identifier(self): + """Test FinancesRequest requires at least one identifier.""" + with self.assertRaises(CheckoValidationError) as context: + self.client.get_finances(FinancesRequest()) + + self.assertIn("ogrn", str(context.exception).lower()) + + +class CheckoClientApiTest(SimpleTestCase): + """Tests for API requests with mocked responses.""" + + def setUp(self): + self.client = CheckoClient(api_key="test_key") + + @patch.object(CheckoClient, "_request") + def test_get_company_success(self, mock_request): + """Test successful company retrieval.""" + mock_request.return_value = { + "data": { + "ogrn": "1027700132195", + "inn": "7707083893", + "kpp": "773601001", + "full_name": "ПУБЛИЧНОЕ АКЦИОНЕРНОЕ ОБЩЕСТВО \"СБЕРБАНК РОССИИ\"", + "short_name": "ПАО Сбербанк", + "reg_date": "1991-06-20", + "status": { + "restricted_access": False, + "code": "100", + "name": "Действующее", + }, + "legal_address": { + "restricted_access": False, + "full_address": "г Москва, ул Вавилова, д 19", + }, + }, + "meta": { + "status": "ok", + "today_request_count": 1, + "balance": 99.90, + }, + } + + response = self.client.get_company(CompanyRequest(inn="7707083893")) + + self.assertEqual(response.meta.status, "ok") + self.assertEqual(response.data.inn, "7707083893") + self.assertEqual(response.data.short_name, "ПАО Сбербанк") + self.assertEqual(response.data.status.code, "100") + + @patch.object(CheckoClient, "_request") + def test_get_company_not_found(self, mock_request): + """Test company not found error.""" + mock_request.side_effect = CheckoNotFoundError( + message="Организация не найдена", + balance=99.0, + ) + + with self.assertRaises(CheckoNotFoundError): + self.client.get_company(CompanyRequest(inn="0000000000")) + + @patch.object(CheckoClient, "_request") + def test_get_entrepreneur_success(self, mock_request): + """Test successful entrepreneur retrieval.""" + mock_request.return_value = { + "data": { + "ogrnip": "304770000000001", + "inn": "770100000001", + "full_name": "Иванов Иван Иванович", + "reg_date": "2010-01-15", + "status": { + "code": "100", + "name": "Действующий", + }, + }, + "meta": { + "status": "ok", + "today_request_count": 2, + "balance": 99.80, + }, + } + + response = self.client.get_entrepreneur( + EntrepreneurRequest(inn="770100000001") + ) + + self.assertEqual(response.data.ogrnip, "304770000000001") + self.assertEqual(response.data.full_name, "Иванов Иван Иванович") + + @patch.object(CheckoClient, "_request") + def test_search_organizations(self, mock_request): + """Test organization search.""" + mock_request.return_value = { + "data": { + "organizations": [ + { + "ogrn": "1027700132195", + "inn": "7707083893", + "short_name": "ПАО Сбербанк", + "status": "Действующее", + }, + { + "ogrn": "1027700000000", + "inn": "7700000000", + "short_name": "Сбербанк Капитал", + "status": "Действующее", + }, + ], + "pagination": { + "total_records": 2, + "total_pages": 1, + "current_page": 1, + }, + }, + "meta": { + "status": "ok", + "today_request_count": 3, + "balance": 99.70, + }, + } + + response = self.client.search( + SearchRequest( + by=SearchType.NAME, + obj=ObjectType.ORGANIZATION, + query="Сбербанк", + ) + ) + + self.assertEqual(len(response.data.organizations), 2) + self.assertEqual(response.data.organizations[0].inn, "7707083893") + self.assertEqual(response.data.pagination.total_records, 2) + + @patch.object(CheckoClient, "_request") + def test_get_finances(self, mock_request): + """Test financial data retrieval.""" + mock_request.return_value = { + "data": { + "ogrn": "1027700132195", + "inn": "7707083893", + "reports": [ + { + "year": 2023, + "balance": [ + {"code": "1100", "current": 1000000, "previous": 900000}, + {"code": "1200", "current": 500000, "previous": 450000}, + ], + "profit_loss": [ + {"code": "2110", "current": 2000000, + "previous": 1800000}, + ], + } + ], + "summary": { + "revenue": 2000000, + "profit": 500000, + "assets": 1500000, + }, + }, + "meta": { + "status": "ok", + "today_request_count": 4, + "balance": 99.60, + }, + } + + response = self.client.get_finances(FinancesRequest(inn="7707083893")) + + self.assertEqual(len(response.data.reports), 1) + self.assertEqual(response.data.reports[0].year, 2023) + self.assertEqual(response.data.summary.revenue, 2000000) + + @patch.object(CheckoClient, "_request") + def test_get_contracts(self, mock_request): + """Test contracts retrieval.""" + mock_request.return_value = { + "data": { + "contracts": [ + { + "registry_number": "0123456789012345", + "publish_date": "2024-01-15", + "price": 1000000, + "status": "Исполнение", + "subject": "Поставка оборудования", + "law": "44", + } + ], + "pagination": { + "total_records": 1, + "total_pages": 1, + "current_page": 1, + }, + "total_sum": 1000000, + }, + "meta": { + "status": "ok", + "today_request_count": 5, + "balance": 99.50, + }, + } + + response = self.client.get_contracts( + ContractsRequest(inn="7707083893", law=ContractLaw.FZ44) + ) + + self.assertEqual(len(response.data.contracts), 1) + self.assertEqual(response.data.contracts[0].price, 1000000) + self.assertEqual(response.data.total_sum, 1000000) + + @patch.object(CheckoClient, "_request") + def test_get_legal_cases(self, mock_request): + """Test legal cases retrieval.""" + mock_request.return_value = { + "data": { + "cases": [ + { + "case_number": "А40-12345/2024", + "court_name": "Арбитражный суд г. Москвы", + "claim_amount": 5000000, + "status": "Рассмотрение дела", + "plaintiffs": [{"name": "ООО Истец", "inn": "1234567890"}], + "defendants": [{"name": "ООО Ответчик", "inn": "0987654321"}], + } + ], + "pagination": { + "total_records": 1, + "total_pages": 1, + "current_page": 1, + }, + "total_claim_amount": 5000000, + }, + "meta": { + "status": "ok", + "today_request_count": 6, + "balance": 99.40, + }, + } + + response = self.client.get_legal_cases( + LegalCasesRequest(inn="7707083893")) + + self.assertEqual(len(response.data.cases), 1) + self.assertEqual(response.data.cases[0].case_number, "А40-12345/2024") + self.assertEqual(len(response.data.cases[0].plaintiffs), 1) + + +class CheckoClientErrorHandlingTest(SimpleTestCase): + """Tests for error handling.""" + + def setUp(self): + self.client = CheckoClient(api_key="test_key") + + @patch("apps.parsers.clients.checko.client.BaseHTTPClient.get_json") + def test_api_error_handling(self, mock_get_json): + """Test API error response handling.""" + mock_get_json.return_value = { + "meta": { + "status": "error", + "message": "Invalid API key", + "balance": 0, + "today_request_count": 0, + } + } + + with self.assertRaises(CheckoAPIError) as context: + self.client.get_company(CompanyRequest(inn="7707083893")) + + self.assertIn("Invalid API key", str(context.exception)) + + @patch("apps.parsers.clients.checko.client.BaseHTTPClient.get_json") + def test_rate_limit_error_handling(self, mock_get_json): + """Test rate limit error detection.""" + mock_get_json.return_value = { + "meta": { + "status": "error", + "message": "Превышен лимит запросов", + "balance": 0, + "today_request_count": 100, + } + } + + with self.assertRaises(CheckoRateLimitError): + self.client.get_company(CompanyRequest(inn="7707083893")) + + @patch("apps.parsers.clients.checko.client.BaseHTTPClient.get_json") + def test_not_found_error_handling(self, mock_get_json): + """Test not found error detection.""" + mock_get_json.return_value = { + "meta": { + "status": "error", + "message": "Организация не найдена", + "balance": 99.0, + "today_request_count": 1, + } + } + + with self.assertRaises(CheckoNotFoundError): + self.client.get_company(CompanyRequest(inn="0000000000")) + + +class CheckoRequestModelsTest(SimpleTestCase): + """Tests for request dataclass models.""" + + def test_company_request_to_params(self): + """Test CompanyRequest.to_params().""" + request = CompanyRequest(inn="7707083893", source=True) + params = request.to_params() + + self.assertEqual(params["inn"], "7707083893") + self.assertEqual(params["source"], "true") + self.assertNotIn("ogrn", params) + + def test_search_request_to_params(self): + """Test SearchRequest.to_params().""" + request = SearchRequest( + by=SearchType.NAME, + obj=ObjectType.ORGANIZATION, + query="Сбербанк", + region="77", + active=True, + limit=50, + page=2, + ) + params = request.to_params() + + self.assertEqual(params["by"], "name") + self.assertEqual(params["obj"], "org") + self.assertEqual(params["query"], "Сбербанк") + self.assertEqual(params["region"], "77") + self.assertEqual(params["active"], "true") + self.assertEqual(params["limit"], "50") + self.assertEqual(params["page"], "2") + + def test_contracts_request_to_params(self): + """Test ContractsRequest.to_params().""" + request = ContractsRequest( + inn="7707083893", + law=ContractLaw.FZ44, + ) + params = request.to_params() + + self.assertEqual(params["inn"], "7707083893") + self.assertEqual(params["law"], "44") + + def test_legal_cases_request_to_params(self): + """Test LegalCasesRequest.to_params().""" + request = LegalCasesRequest( + inn="7707083893", + actual=True, + active=True, + date_from="2024-01-01", + claim_amount_from=1000000, + ) + params = request.to_params() + + self.assertEqual(params["inn"], "7707083893") + self.assertEqual(params["actual"], "true") + self.assertEqual(params["active"], "true") + self.assertEqual(params["date_from"], "2024-01-01") + self.assertEqual(params["claim_amount_from"], "1000000") + + +@tag("datasets") +class CheckoDatasetsTest(SimpleTestCase): + """Tests for reference datasets.""" + + def test_okved2_get(self): + """Test OKVED2 dataset get by code.""" + item = OKVED2.get("62.01") + + self.assertIsNotNone(item) + self.assertEqual(item.code, "62.01") + self.assertIn("программ", item.name.lower()) + + def test_okved2_get_name(self): + """Test OKVED2 dataset get_name.""" + name = OKVED2.get_name("62.01") + + self.assertIsNotNone(name) + self.assertIn("программ", name.lower()) + + def test_okved2_search(self): + """Test OKVED2 search functionality.""" + results = OKVED2.search("программ") + + self.assertGreater(len(results), 0) + for item in results: + self.assertIn("программ", item.name.lower()) + + def test_okved2_exists(self): + """Test OKVED2 exists check.""" + self.assertTrue(OKVED2.exists("62.01")) + self.assertFalse(OKVED2.exists("99.99.99")) + + def test_okved2_get_children(self): + """Test OKVED2 hierarchy - get children.""" + children = OKVED2.get_children("62") + + self.assertGreater(len(children), 0) + for child in children: + self.assertTrue(child.code.startswith("62.")) + + def test_okfs_get(self): + """Test OKFS dataset.""" + item = OKFS.get("12") + + self.assertIsNotNone(item) + self.assertEqual(item.code, "12") + + def test_okfs_get_name(self): + """Test OKFS get_name.""" + name = OKFS.get_name("12") + + self.assertIsNotNone(name) + + def test_okopf_get(self): + """Test OKOPF dataset.""" + # Check for common OPF code + item = OKOPF.get("12300") # ООО + + if item: + self.assertEqual(item.code, "12300") + + def test_account_codes_get(self): + """Test AccountCodes dataset.""" + item = AccountCodes.get("1100") + + self.assertIsNotNone(item) + self.assertEqual(item.code, "1100") + + def test_company_statuses_get(self): + """Test CompanyStatuses dataset.""" + # Should return builtin value if no JSON + name = CompanyStatuses.get_name("100") + + # May be None if no data, but shouldn't raise + self.assertTrue(name is None or isinstance(name, str)) + + def test_entrepreneur_statuses_get(self): + """Test EntrepreneurStatuses dataset.""" + name = EntrepreneurStatuses.get_name("100") + + # May be None if no data, but shouldn't raise + self.assertTrue(name is None or isinstance(name, str)) + + def test_okpd_get(self): + """Test OKPD dataset.""" + # Check all() works + items = OKPD.all() + + self.assertIsInstance(items, list) + + def test_okpd2_get(self): + """Test OKPD2 dataset.""" + items = OKPD2.all() + + self.assertIsInstance(items, list) + + +class CheckoClientIteratorsTest(SimpleTestCase): + """Tests for paginated iterators.""" + + def setUp(self): + self.client = CheckoClient(api_key="test_key") + + @patch.object(CheckoClient, "_request") + def test_iter_contracts_pagination(self, mock_request): + """Test contracts iterator handles pagination.""" + # First page + mock_request.side_effect = [ + { + "data": { + "contracts": [ + {"registry_number": "0001", "price": 100}, + {"registry_number": "0002", "price": 200}, + ], + "pagination": { + "total_records": 4, + "total_pages": 2, + "current_page": 1, + }, + }, + "meta": {"status": "ok", "today_request_count": 1, "balance": 99}, + }, + # Second page + { + "data": { + "contracts": [ + {"registry_number": "0003", "price": 300}, + {"registry_number": "0004", "price": 400}, + ], + "pagination": { + "total_records": 4, + "total_pages": 2, + "current_page": 2, + }, + }, + "meta": {"status": "ok", "today_request_count": 2, "balance": 98}, + }, + ] + + contracts = list( + self.client.iter_contracts( + ContractsRequest(inn="7707083893", law=ContractLaw.FZ44) + ) + ) + + self.assertEqual(len(contracts), 4) + self.assertEqual(contracts[0].registry_number, "0001") + self.assertEqual(contracts[3].registry_number, "0004") + + @patch.object(CheckoClient, "_request") + def test_iter_legal_cases_empty(self, mock_request): + """Test legal cases iterator handles empty results.""" + mock_request.return_value = { + "data": { + "cases": [], + "pagination": { + "total_records": 0, + "total_pages": 0, + "current_page": 1, + }, + }, + "meta": {"status": "ok", "today_request_count": 1, "balance": 99}, + } + + cases = list( + self.client.iter_legal_cases(LegalCasesRequest(inn="0000000000")) + ) + + self.assertEqual(len(cases), 0)