Валютная выручка в SQL: курс на дату операции вместо сегодняшнего

Магазин продаёт в долларах, а управленческий отчёт строит в рублях. Аналитик умножает весь месячный оборот на последний курс и получает сумму, которая меняется каждый раз при обновлении справочника. Руководитель спрашивает, выросли продажи или просто изменился пересчёт. Чтобы ответить, сначала нужно закрепить правило оценки каждой операции.

Выбрать нужную дату и нужный курс

Для учебного отчёта возьмём правило: продажа оценивается по последнему доступному курсу, дата которого не позже бизнес-даты продажи. В выходной нового значения может не быть, поэтому обычный JOIN по равенству даты потеряет строку. Но и ближайший по абсолютной разнице курс не подходит: он может взять значение из будущего.

Это модель управленческого пересчёта, а не универсальное правило бухгалтерского признания. Компания отдельно утверждает дату операции, источник курса и обработку выходных. Банковский курс фактической конвертации отвечает на другой вопрос: сколько рублей действительно поступило после обмена и комиссий. Его полезно хранить отдельной величиной, не подменяя им выбранную оценку выручки.

Найти последнее допустимое значение

Коррелированный подзапрос выбирает курсы нужной валюты не позже продажи, сортирует даты по убыванию и берёт одну строку. В SQLite это простой способ показать правило поиска. Стоимость продажи хранится в центах, курс — в рублях за один доллар. Поэтому произведение делится на сто. Значение курса в примере целое, чтобы не смешивать выбор даты с вопросом десятичного округления.

Пример выполняется в SQLite; функции и синтаксис ниже относятся к этому диалекту.

WITH sales(id,day,currency,minor) AS (VALUES
 (1,'2026-10-03','USD',10000),(2,'2026-10-05','USD',5000)),
rates(currency,day,rub_per_unit) AS (VALUES
 ('USD','2026-10-02',90),('USD','2026-10-05',92)),
resolved AS (
 SELECT s.*, (SELECT r.rub_per_unit FROM rates r
  WHERE r.currency=s.currency AND r.day<=s.day ORDER BY r.day DESC LIMIT 1) AS rate
 FROM sales s
)
SELECT id,day,rate,minor*rate/100.0 AS rub_amount
FROM resolved ORDER BY id;

Продажа 1 от 3 октября получает курс 90 за 2 октября и оценку 9000 рублей. Продажа 2 от 5 октября получает курс 92 и оценку 4600. Суммарно это 13600, тогда как пересчёт всех 150 долларов по последнему курсу дал бы 13800. Разница в двести рублей возникает из-за метода оценки, а не из-за новой продажи.

Не позволить пропуску исчезнуть в SUM

Если до даты продажи нет ни одного курса, подзапрос вернёт NULL. Последующее SUM обычно пропустит такую строку, и отчёт получится меньше без явной ошибки. Поэтому перед агрегированием выведите строки с rate IS NULL и заблокируйте итог либо явно обозначьте неполное покрытие. Заменять пропуск единицей можно только для валюты самого отчёта, для которой единица действительно является курсом.

Для каждого источника проверьте номинал. Значение «90 рублей за единицу» и «90 рублей за сто единиц» различается в сто раз. В справочнике полезно хранить исходный номинал и нормализованный курс. Уникальный ключ включает валюту, дату и источник; если банк и официальный справочник попали в одну таблицу, сортировка только по дате не выберет правильную запись.

Отделить валютный эффект от продажи

При сравнении периодов можно дополнительно оценить оба оборота в фиксированных курсах. Это покажет динамику в постоянной валюте по выбранной методике. Но такой ряд должен идти рядом с фактической оценкой, с указанием базовых курсов. Незаметная замена исторического пересчёта постоянным лишает пользователя возможности понять причину расхождения с финансовым отчётом.

Для большого потока индекс по валюте и дате помогает искать последнюю строку, но план выполнения нужно проверить на своей базе. Другой вариант — заранее превратить историю курсов в интервалы действия и выполнить интервальное соединение. После любой оптимизации количество продаж обязано сохраниться: одному id соответствует ровно один выбранный курс.

В финальном наборе проверки нужны выходной день, операция в день изменения курса, отсутствующая история, разные валюты и дубли одного курса. Для денежных сумм согласуйте точность и этап округления. Сохраняйте rate_day и версию источника в витрине: это позволит объяснить старое значение даже после исправления исторического справочника и отделить технический пересчёт от изменения бизнеса.

Следующий шаг

Следующий шаг — построить интервалы действия курсов, сохранить дату выбранной записи и сверить объёмы до и после соединения. В платном «Продвинутом SQL» есть исторические JOIN, оконные функции и проверка многошаговых расчётов. Это поможет перейти от демонстрационного подзапроса к отчёту, который можно воспроизвести за прошлый период. Откройте программу курса.

Продолжить на практике