Собесов

Finstar SQL — сумма и длительность текущей просрочки по кредиту

SQLСложная агрегацияСложнаяMiddle

Условие

Таблица pdcl(date, customer, deal, currency, sum) — события по просрочке кредита: вынос непогашенных сумм («+») и погашения («−»).

Для каждой пары (клиент, кредит) с непогашенной задолженностью на текущий момент рассчитать:

  1. Сумму текущего просроченного долга.
  2. Дату начала текущей просрочки = первая дата непрерывного периода, где накопленная сумма > 0.
  3. Кол-во дней текущей просрочки.

«Текущая просрочка» — последний из (возможно нескольких) непрерывных периодов положительного баланса.

Пример

12.12.2009 +12000   →  cum=12000  (start=12.12)
25.12.2009  +5000   →  cum=17000
12.01.2010 −10100   →  cum=6900   (всё ещё в просрочке)

Долг 6900, начало 12.12.2009.

Решение

Подход

Это смесь running sum и gaps and islands:

  1. Считаем накопительный баланс по (клиент, кредит) во времени.
  2. Помечаем «нулевые точки» (cum_sum = 0 после погашения).
  3. Каждое погашение в 0 закрывает остров; следующая «+»-запись открывает новый остров.
  4. Для последнего острова с положительным финальным балансом — наш ответ.

Реализация (PostgreSQL)

WITH running AS (
  SELECT
    customer, deal, currency, date, sum,
    SUM(sum) OVER (PARTITION BY customer, deal, currency ORDER BY date
                   ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS cum_sum
  FROM pdcl
),
flagged AS (
  -- Маркер начала острова: предыдущая cum=0 (или NULL) И текущая >0
  SELECT *,
    CASE WHEN cum_sum > 0
         AND COALESCE(LAG(cum_sum) OVER (PARTITION BY customer, deal, currency ORDER BY date), 0) <= 0
         THEN 1 ELSE 0 END AS is_island_start
  FROM running
),
islands AS (
  SELECT *,
    SUM(is_island_start) OVER (PARTITION BY customer, deal, currency ORDER BY date) AS island_id
  FROM flagged
  WHERE cum_sum > 0  -- только дни просрочки
),
last_island AS (
  -- Берём последний остров для каждого (customer, deal)
  SELECT customer, deal, currency, MAX(island_id) AS island_id
  FROM islands
  GROUP BY customer, deal, currency
)
SELECT
  i.customer,
  i.deal,
  i.currency,
  MIN(i.date)                                                    AS overdue_start,
  CURRENT_DATE - MIN(i.date)                                     AS overdue_days,
  -- Финальный cum_sum в этом острове = сумма просрочки сейчас
  MAX(i.cum_sum) FILTER (WHERE i.date = (SELECT MAX(date) FROM islands i2
                                          WHERE i2.customer = i.customer
                                            AND i2.deal     = i.deal
                                            AND i2.island_id = li.island_id))
                                                                 AS overdue_amount
FROM islands i
JOIN last_island li
  ON li.customer = i.customer AND li.deal = i.deal
 AND li.island_id = i.island_id
GROUP BY i.customer, i.deal, i.currency, li.island_id;

Упрощённая версия — на «снимок текущего момента»:

WITH running AS (
  SELECT customer, deal, date, sum,
         SUM(sum) OVER (PARTITION BY customer, deal ORDER BY date) AS cum_sum
  FROM pdcl
),
overdue_now AS (
  -- Текущая просрочка только если последний cum_sum > 0
  SELECT customer, deal, MAX(cum_sum) FILTER (WHERE rn=1) AS curr_balance
  FROM (SELECT *, ROW_NUMBER() OVER (PARTITION BY customer, deal ORDER BY date DESC) rn FROM running) t
  GROUP BY customer, deal
  HAVING MAX(cum_sum) FILTER (WHERE rn=1) > 0
),
-- Найти последнюю дату, на которой cum_sum=0 ИЛИ cum_sum<0 (т.е. перед текущим островом)
last_zero AS (
  SELECT customer, deal, MAX(date) AS zero_date
  FROM running
  WHERE cum_sum <= 0
  GROUP BY customer, deal
)
SELECT
  o.customer,
  o.deal,
  o.curr_balance                                                 AS overdue_amount,
  COALESCE(
    (SELECT MIN(r.date) FROM running r
     WHERE r.customer = o.customer AND r.deal = o.deal
       AND r.date > COALESCE(lz.zero_date, '1900-01-01')
       AND r.cum_sum > 0),
    NULL
  ) AS overdue_start,
  CURRENT_DATE -
    (SELECT MIN(r.date) FROM running r
     WHERE r.customer = o.customer AND r.deal = o.deal
       AND r.date > COALESCE(lz.zero_date, '1900-01-01')
       AND r.cum_sum > 0)                                        AS overdue_days
FROM overdue_now o
LEFT JOIN last_zero lz USING (customer, deal);

Подводные камни

  1. «Просрочка» определяется по знаку накопленной суммы, не по знаку отдельной строки. Платёж −10000 при долге +12000 не закрывает просрочку, а только уменьшает.
  2. Несколько периодов. В примере про deal 222221 есть две просрочки разных лет; нужна именно последняя.
  3. Граница «cum_sum = 0» — закрытие просрочки. Следующая «+» — новый остров.
  4. Точное равенство нулю. Если хоть на копейку клиент перебил долг (cum_sum<0), это всё ещё закрытие острова. С отрицательными суммами (переплата) обращайтесь как с «закрытием».
  5. Несколько кредитов у клиента — обязательная партиция по (customer, deal), иначе мешаем долги.
  6. Часовой пояс / порядок при равных датах. Если несколько событий за один день, нужен tie-breaker (id, ts, secondary key).
  7. Валюта. В таблице есть currency. Если у одного кредита разные валюты — это аномалия, обычно нет. Но в группировке безопаснее включить.

Эталонный ответ

Running sum по (customer, deal, date) → острова непрерывного cum_sum > 0 → берём последний остров → MIN(date) острова = начало просрочки, MAX(cum_sum) at last date = сумма долга, CURRENT_DATE − start = days.

Хочешь увидеть разбор?

Зарегистрируйся бесплатно — откроется развёрнутое решение этой задачи и ещё 4 на выбор.

Зарегистрироваться и увидеть разбор
Уже есть аккаунт? Войти