Skip to content

ТЗ — Курсы валют (ЦБ РФ) (cms/cbr-rates)

Слой: 🟡 функц-модуль · Зрелость доноров: ★ · Донор: — Статус: ТЗ к разработке

Назначение и возможности

Автообновление курсов валют ЦБ РФ по расписанию с хранением истории и fallback на последний известный курс при недоступности источника.

  • Автообновление курсов по расписанию (планировщик, ежедневно после публикации ЦБ)
  • Хранение истории курсов по датам, не только текущего значения
  • Получение курса на произвольную дату — для пересчёта старых заказов/возвратов (курс на дату заказа, не текущий)
  • provides: rates-provider — потребитель cms/commerce-currencies
  • Fallback на последний известный курс при недоступности источника ЦБ РФ
  • Алерт администратору при недоступности источника дольше порога
  • Ручное обновление курса из Filament (на случай экстренной правки)

Зависимости и выключение

requires: cms/integrations-bus · provides: rates-provider (потребитель cms/commerce-currencies)

Поведение при выключении: cms/commerce-currencies использует последний сохранённый курс без обновлений — цены в неосновной валюте не пересчитываются автоматически, пока модуль не включён снова.

Экстренная остановка автообновления без выключения модуля целиком — настройка cbr-rates.kill_switch: ручной ввод курса из Filament остаётся доступен, что позволяет временно управлять курсами вручную, не выключая модуль и не теряя историю/API.

Модель данных

ТаблицаКлючевые поляПримечание
cms_cbr_rates_historyid, currency_code, rate, rate_date, is_manual, edited_by (nullable), sourceистория курсов по датам, уникальность на currency_code+rate_date; is_manual — защита от автоперезаписи

Индексы: уникальный индекс на currency_code+rate_date; rate — integer minor units (копейки) с currency_code, float запрещён; таблица append-only по датам (ручная правка существующей даты — upsert по тому же ключу, не отдельная версия строки; конфликт с автообновлением логируется — см. «Крайние случаи»), BRIN по rate_date.

ПДн-паспорт: ПДн не храню — курсы валют не персональные данные, участие в «выгрузить всё по субъекту»/«забыть по запросу» не применимо.

Входные и выходные данные

Входы:

ИсточникПоляВалидация
Планировщик (update_schedule_cron)нет пользовательского ввода
POST /api/v1/admin/cbr-rates/refreshcurrencies[] (опционально, иначе все tracked_currencies)whitelist из tracked_currencies
GET /api/v1/admin/cbr-rates/historyfilter[currency_code], filter[date_from], filter[date_to]whitelist полей фильтра, cursor=
GET /api/v1/cbr-rates/ratecurrency (route/query), datecurrencytracked_currencies, date — валидная дата, не в будущем
Filament: ручной ввод курсаcurrency_code, rate, rate_dateте же правила, помечает is_manual=true

Выходы:

КудаФормат
200 current{"data": {"USD": {…}, "EUR": {…}}, "meta": {"as_of": rate_date}}
200 current при деградации{"data": {…последний известный…}, "meta": {"degraded": true, "degraded_reason": "source_unavailable"|"stale"|"no_data_yet"}}
200/404 rate (курс на дату)найден — {"data": {rate, rate_date, is_manual}}; не найден — 404 {"code": "rate_not_found_for_date"}
Событие CbrRatesUpdatedcurrencies, rate_date
Событие CbrRatesStalelast_success_at
Событие CbrRateManuallyOverriddencurrency_code, rate_date, previous_rate, new_rate, edited_by

Настройки (группа cbr-rates)

КлючТипДефолтaffectsPageCacheОписание
cbr-rates.tracked_currenciesarray["USD","EUR"]нетОтслеживаемые валюты
cbr-rates.update_schedule_cronstring"0 12 * * *"нетРасписание обновления курсов (после публикации ЦБ ~13:00 МСК на завтра — с запасом)
cbr-rates.stale_alert_hoursint36нетПорог «курс не обновлялся» для алерта, считается от ожидаемой даты публикации с учётом календаря ЦБ
cbr-rates.kill_switchboolfalseнетАварийная остановка автообновления; ручной ввод курса остаётся доступен

API

МетодПутьДоступНазначение
GET/api/v1/cbr-rates/currentпубличныйТекущие курсы отслеживаемых валют; 200 + meta.degraded при недоступности источника/отсутствии данных
GET/api/v1/cbr-rates/rateпубличныйКурс валюты на произвольную дату (для пересчёта возврата по старому заказу)
GET/api/v1/admin/cbr-rates/historyadmin (cbr-rates.view)История курсов с фильтром по дате, keyset-пагинация
POST/api/v1/admin/cbr-rates/refreshadmin (cbr-rates.manage)Ручное обновление курсов
POST/api/v1/admin/cbr-rates/backfilladmin (cbr-rates.manage)Догрузка исторического курса на отсутствующую дату (через очередь)

История курсов — keyset-пагинация, не OFFSET.

Компоненты

Filament: таблица истории курсов (бейдж is_manual на строках, защищённых от автообновления), кнопка ручного обновления, действие «догрузить курс на дату» при обращении к rate без результата. Команды: cms:cbr-rates:update --json, cms:cbr-rates:backfill --currency=<код> --date=<дата> --json.

События и обмен

СобытиеКогдаPayload
CbrRatesUpdatedкурсы успешно обновлены из источникаcurrencies, rate_date
CbrRatesStaleисточник недоступен дольше порога stale_alert_hourslast_success_at
CbrRateManuallyOverriddenадминистратор вручную изменил курс за дату, где уже есть значениеcurrency_code, rate_date, previous_rate, new_rate, edited_by

Взаимодействия:

Сущность/модульКаналНаправлениеЧто происходит
cms/commerce-currencies3 — provides: rates-providercbr-rates → commerce-currenciesРезолв DI активного rates-provider, пересчёт цен в неосновной валюте. Провайдер не активен/модуль выключен → работа на последнем сохранённом курсе, без обновлений, без ошибки на витрине
cms/commerce-orders (возвраты)4 — requirescommerce-orders → cbr-ratesЗапрос курса на дату оформления заказа через GET rate/сервис-метод для пересчёта возврата
cms/integrations-bus4 — requirescbr-rates → шинаЕдинственный выход к источнику ЦБ РФ (XML/API), всегда из очереди
cms/health1 — событиеcbr-rates → healthCbrRatesStale и сбой парсера агрегируются в health-отчёт, алерт разработчику/админу
Filament (ручная правка)внутренний, часть публичного контракта модуляадмин → cbr-ratesПравка помечает строку is_manual=true, публикует CbrRateManuallyOverridden; следующее автообновление не перезаписывает такую строку молча (см. «Крайние случаи»)

Фоновая работа

Обновление курсов — по расписанию (update_schedule_cron), именованная очередь cbr-rates. Весь внешний вызов источника ЦБ РФ — только из очереди, никогда синхронно на горячем пути (в отличие от cms/dadata, где допустимо синхронное исключение для подсказок форм — §9 стандарта: cbr-rates под это исключение не подпадает, курс не нужен «прямо сейчас, чтобы продолжить» рендер страницы).

Алгоритм ожидаемой даты публикации учитывает календарь ЦБ РФ (выходные/праздники — курс на нерабочий день не публикуется, курс на завтра публикуется заранее ~13:00 МСК): stale_alert_hours считается от последней ожидаемой даты публикации, а не от буквального «не обновилось за N часов от последнего успеха» — иначе штатные выходные дают ложный алерт каждую неделю.

Ретраи с backoff при недоступности источника; при исчерпании ретраев — fallback на последний известный курс без падения, алерт CbrRatesStale публикуется один раз за инцидент (не при каждой неудачной попытке).

Догрузка отсутствующей исторической даты (cms:cbr-rates:backfill) — именованная очередь cbr-rates, идемпотентна (повторный запрос той же даты не дублирует запись, upsert по currency_code+rate_date).

Производительность и кеш

  • Объёмы: единицы отслеживаемых валют (tracked_currencies), одно обновление в сутки — история растёт на N_валют × 365 строк в год, BRIN по rate_date достаточно.
  • Горячий путь — публичный GET /current: тег кеша cbr-rates:current, инвалидируется событием CbrRatesUpdated, page-cache hit — 0 запросов к БД.
  • Внешний вызов к ЦБ РФ — исключительно из очереди по расписанию; на горячем пути публичного эндпоинта запроса к внешнему API нет никогда (см. «Фоновая работа»).
  • Индексы: уникальный currency_code+rate_date; поиск курса на дату — по тому же уникальному ключу, без сканирования истории.

Безопасность

  • Повторное обновление за тот же день не создаёт дублирующую запись истории (идемпотентность по уникальному индексу currency_code+rate_date).

  • Публичные эндпоинты current/rate — read-only, без побочных эффектов.

  • Конфликт автообновления и ручной правки: автообновление не перезаписывает строку с is_manual=true молча — правило разрешения конфликта: последнее явное действие администратора побеждает до следующей ручной правки; попытка автообновления такой даты логируется (пропуск записывается в лог модуля), не 500 и не тихая перезапись.

  • ПДн-паспорт: ПДн не храню (явная декларация) — курсы валют обезличены, «забыть по запросу»/«выгрузить по субъекту» не применимо.

  • Матрица ролей:

    РольТекущие курсы (публично)ИсторияРучное обновление/backfillРучная правка курса
    посетитель
    редактор
    менеджер
    админ
    studio

    Права: cbr-rates.view (публично + история), cbr-rates.manage (ручное обновление, backfill, ручная правка курса — необратимо влияет на цены, повышенная роль).

  • Kill-switch: cbr-rates.kill_switch останавливает автообновление по расписанию; ручной режим (Filament) остаётся доступен для точечных правок.

UX-требования

Админ:

  • Устаревший курс — заметный индикатор в Filament («курс не обновлялся N часов», бейдж), понятное сообщение при недоступности источника: «ЦБ РФ временно недоступен, используется курс от <дата>», не техническая ошибка.
  • Ручная правка даты, уже защищённой is_manual, — предупреждение «эта дата уже правилась вручную <когда>, перезаписать?» перед подтверждением.
  • Действие «догрузить курс на дату» при обращении к rate без результата — доступно прямо из истории, не требует отдельной консольной команды для админа.

Посетитель:

  • Цена в неосновной валюте при meta.degraded/устаревшем курсе показывается по последнему известному курсу без ошибки — покупка не блокируется.
  • Отсутствие курса на дату (новая, только что добавленная валюта) не ломает витрину — товар остаётся в базовой валюте до первого успешного обновления.

Крайние случаи и типовые баги

  • ЦБ не опубликовал курс (выходной/праздничный день) → используется последний рабочий день, НЕ считается staleness-инцидентом: алгоритм ожидаемых дат публикации учитывает календарь, а не факт «не обновилось за N часов».
  • Парсинг XML сломался (ЦБ изменил формат, добавил/убрал поле, сменил кодировку) → изолированный сбой парсера, алерт разработчику/админу, частично распарсенный курс не используется; stale_alert_hours продолжает отсчитываться от последнего успешного парсинга, не от неудачной попытки.
  • Исторические курсы для возврата по заказу месячной давностиGET rate/сервис- метод возвращает курс на дату заказа, не текущий; append-only история уже это поддерживает. Недостающая дата → cms:cbr-rates:backfill догружает её через очередь, либо публичный GET rate отвечает 404 rate_not_found_for_date (без синхронной догрузки на горячем пути).
  • Повторное автообновление в день после ручной правки → не перезаписывает молча: строка с is_manual=true защищена, конфликт логируется, побеждает последнее явное действие администратора (правило зафиксировано в «Безопасность»).
  • Валюта временно не публикуется ЦБ (санкции/приостановка котировки) → деградация на последний известный курс конкретно по этой валюте (остальные tracked_currencies продолжают обновляться штатно), алерт по этой валюте отдельно.
  • provides: rates-provider резолвится, но модуль выключенcommerce-currencies работает на последнем сохранённом курсе без обновлений, без ошибки на витрине.
  • Задвоение при параллельном cron и ручном refresh → уникальный индекс currency_code+rate_date + upsert предотвращают дубль записи, конфликт разрешается без 500.
  • Первый запуск на новом сайте — истории ещё нетcms:cbr-rates:update без предыдущей истории не падает; GET current до первого прогона отвечает 200 + meta.degraded: true, meta.degraded_reason: "no_data_yet".
  • Новая валюта добавлена в tracked_currencies посреди дня → следующий плановый запуск подхватывает её штатно; до первого успешного обновления GET current для этой валюты — meta.degraded: true, meta.degraded_reason: "no_data_yet", для остальных валют деградации нет (частичная деградация по конкретной валюте, не по всему ответу).
  • cbr-rates.kill_switch включён во время инцидента у источника → плановые job'ы не ставятся в очередь вовсе (не «падают с ошибкой» — намеренно не запускаются); health-чек модуля явно показывает «автообновление остановлено вручную», отличая это состояние от сбоя источника (разные алерты — ручная остановка не эскалируется).

Донорский код

Донор: — (новая разработка). Legacy-импорт (§16 стандарта): если у клиента есть донор с историей курсов (экспорт из 1С/старой CMS/предыдущего сайта) — cms:cbr-rates:import-legacy --source=<профиль> маппит старые записи на cms_cbr_rates_history по ключу currency_code+rate_date, идемпотентна (повторный прогон обновляет, не дублирует), поддерживает --dry-run с отчётом расхождений. При отсутствии донора — не применимо: история собирается автообновлением с момента установки модуля, обратная история до установки не требуется.

Тесты и приёмка

  • [ ] Мок API ЦБ РФ: тест обновления курсов и сохранения истории по дате
  • [ ] При недоступности источника используется последний известный курс (тест fallback)
  • [ ] Алерт CbrRatesStale публикуется один раз за инцидент, не при каждой неудачной попытке
  • [ ] Повторное обновление за тот же день не создаёт дублирующую запись истории (идемпотентность)
  • [ ] При выключении модуля cms/commerce-currencies продолжает работать на последнем курсе
  • [ ] Права cbr-rates.manage разграничивают просмотр истории и ручное обновление
  • [ ] Выходной/праздничный день не порождает ложный CbrRatesStale (тест календаря публикаций)
  • [ ] Сбой парсинга XML (мок с битым/изменённым форматом) — изолированный алерт, частично распарсенный курс не сохраняется, stale_alert_hours отсчитывается от последнего успеха
  • [ ] GET rate на дату возврата возвращает исторический курс, не текущий; отсутствующая дата — 404 rate_not_found_for_date либо успешный backfill
  • [ ] Ручная правка (is_manual=true) не перезаписывается автообновлением того же дня; конфликт логируется, событие CbrRateManuallyOverridden издаётся
  • [ ] GET current без источника данных отвечает 200 + meta.degraded: true/degraded_reason: no_data_yet, не 5xx (контрактный тест ревизии ядра п.2)
  • [ ] cbr-rates.kill_switch=true останавливает автообновление, ручная правка остаётся доступна
  • [ ] Контрактный набор cms-testing зелёный, пакет тестируется в testbench-изоляции
  • [ ] Feature-тест на каждый роут; тестовая БД только cbr_rates_test, migrate:fresh запрещён

Внутренняя база знаний студии