Условие
Таблица pdcl(date, customer, deal, currency, sum) — события по просрочке кредита: вынос непогашенных сумм («+») и погашения («−»).
Для каждой пары (клиент, кредит) с непогашенной задолженностью на текущий момент рассчитать:
- Сумму текущего просроченного долга.
- Дату начала текущей просрочки = первая дата непрерывного периода, где накопленная сумма
> 0. - Кол-во дней текущей просрочки.
«Текущая просрочка» — последний из (возможно нескольких) непрерывных периодов положительного баланса.
Пример
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:
- Считаем накопительный баланс по (клиент, кредит) во времени.
- Помечаем «нулевые точки» (cum_sum = 0 после погашения).
- Каждое погашение в 0 закрывает остров; следующая «+»-запись открывает новый остров.
- Для последнего острова с положительным финальным балансом — наш ответ.
Реализация (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);Подводные камни
- «Просрочка» определяется по знаку накопленной суммы, не по знаку отдельной строки. Платёж −10000 при долге +12000 не закрывает просрочку, а только уменьшает.
- Несколько периодов. В примере про deal
222221есть две просрочки разных лет; нужна именно последняя. - Граница «cum_sum = 0» — закрытие просрочки. Следующая «+» — новый остров.
- Точное равенство нулю. Если хоть на копейку клиент перебил долг (cum_sum<0), это всё ещё закрытие острова. С отрицательными суммами (переплата) обращайтесь как с «закрытием».
- Несколько кредитов у клиента — обязательная партиция по
(customer, deal), иначе мешаем долги. - Часовой пояс / порядок при равных датах. Если несколько событий за один день, нужен tie-breaker (id, ts, secondary key).
- Валюта. В таблице есть
currency. Если у одного кредита разные валюты — это аномалия, обычно нет. Но в группировке безопаснее включить.
Эталонный ответ
Running sum по (customer, deal, date) → острова непрерывного cum_sum > 0 → берём последний остров → MIN(date) острова = начало просрочки, MAX(cum_sum) at last date = сумма долга, CURRENT_DATE − start = days.