feat(parsers): добавлен API клиент для checko.ru

- Реализован CheckoClient с поддержкой всех 10 эндпоинтов API v2
- Frozen dataclass модели для запросов и ответов
- Справочники ОКВЭД2, ОКФС, ОКОПФ, ОКПД, статусы компаний
- Маппинг русских полей API на английские имена
- Unit тесты с моками
- E2E тесты с реальными запросами
- Настройка CHECKO_API_KEY в settings.py
This commit is contained in:
2026-02-03 17:00:19 +01:00
parent 5c88c6466d
commit c36c7b9ba9
22 changed files with 5943 additions and 4 deletions

View File

@@ -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",
]

View File

@@ -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)

View File

@@ -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()

View File

@@ -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", ""),
)

View File

@@ -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]

View File

@@ -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

View File

@@ -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

View File

@@ -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", ""),
)