Тема
ТЗ — Курсы валют (ЦБ РФ) (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_history | id, 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/refresh | currencies[] (опционально, иначе все tracked_currencies) | whitelist из tracked_currencies |
GET /api/v1/admin/cbr-rates/history | filter[currency_code], filter[date_from], filter[date_to] | whitelist полей фильтра, cursor= |
GET /api/v1/cbr-rates/rate | currency (route/query), date | currency ∈ tracked_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"} |
Событие CbrRatesUpdated | currencies, rate_date |
Событие CbrRatesStale | last_success_at |
Событие CbrRateManuallyOverridden | currency_code, rate_date, previous_rate, new_rate, edited_by |
Настройки (группа cbr-rates)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
cbr-rates.tracked_currencies | array | ["USD","EUR"] | нет | Отслеживаемые валюты |
cbr-rates.update_schedule_cron | string | "0 12 * * *" | нет | Расписание обновления курсов (после публикации ЦБ ~13:00 МСК на завтра — с запасом) |
cbr-rates.stale_alert_hours | int | 36 | нет | Порог «курс не обновлялся» для алерта, считается от ожидаемой даты публикации с учётом календаря ЦБ |
cbr-rates.kill_switch | bool | false | нет | Аварийная остановка автообновления; ручной ввод курса остаётся доступен |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/cbr-rates/current | публичный | Текущие курсы отслеживаемых валют; 200 + meta.degraded при недоступности источника/отсутствии данных |
| GET | /api/v1/cbr-rates/rate | публичный | Курс валюты на произвольную дату (для пересчёта возврата по старому заказу) |
| GET | /api/v1/admin/cbr-rates/history | admin (cbr-rates.view) | История курсов с фильтром по дате, keyset-пагинация |
| POST | /api/v1/admin/cbr-rates/refresh | admin (cbr-rates.manage) | Ручное обновление курсов |
| POST | /api/v1/admin/cbr-rates/backfill | admin (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_hours | last_success_at |
CbrRateManuallyOverridden | администратор вручную изменил курс за дату, где уже есть значение | currency_code, rate_date, previous_rate, new_rate, edited_by |
Взаимодействия:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
cms/commerce-currencies | 3 — provides: rates-provider | cbr-rates → commerce-currencies | Резолв DI активного rates-provider, пересчёт цен в неосновной валюте. Провайдер не активен/модуль выключен → работа на последнем сохранённом курсе, без обновлений, без ошибки на витрине |
cms/commerce-orders (возвраты) | 4 — requires | commerce-orders → cbr-rates | Запрос курса на дату оформления заказа через GET rate/сервис-метод для пересчёта возврата |
cms/integrations-bus | 4 — requires | cbr-rates → шина | Единственный выход к источнику ЦБ РФ (XML/API), всегда из очереди |
cms/health | 1 — событие | cbr-rates → health | CbrRatesStale и сбой парсера агрегируются в 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запрещён