Перейти к содержимому
Поиск
Php Разработка

PHP: полный мануал с примерами, функциями и ООП

Практический мануал PHP: от переменных и читаемого кода до безопасных форм, интеграций, базы данных и ООП на примере магазина. Учебный маршрут, поиск функций, подробные примеры, разбор ошибок и исходники для скачивания.

Александр НекрасовИзменено:
Практический мануал PHP: от переменных и функций к объектной модели интернет-магазина
PHP 8.5.11468 глав6256 карточек справочника
Дерево руководства

Глава 115 · После SELECT, связей и GROUP BY

MySQL: последние события, лидеры и накопительные отчёты

ROW_NUMBER, RANK/DENSE_RANK, SUM OVER, LAG, точные границы DATETIME и календарь пропущенных дней на самостоятельных данных.

От строк истории к понятному отчёту

У истории заказов и платежей много строк, а экрану нужны осмысленные ответы: последний статус, лидеры категории, итог после каждой операции. SQL может вернуть нужные строки вместе с рассчитанными признаками. Не надо получать весь архив в PHP, чтобы затем искать каждую предыдущую запись.

ИнструментЧто делает со строкамиПолезный результат
GROUP BY accountСобирает много операций в одну строку на счётОбщая сумма A=1300
SUM(...) OVER (...)Сохраняет операции и добавляет вычисленную колонкуИстория 1000 → 1500 → 1300
ROW_NUMBER() OVER (...)Даёт номер внутри выбранной группыПоследняя запись каждого заказа
LAG(...) OVER (...)Добавляет значение соседней предыдущей строкиИзменение показаний
WITH ... AS (...)Называет промежуточный результат одного запросаПосчитать номера, затем выбрать rn=1
Модель окна на истории заказа 101
  1. Исходные строки 1, 2, 3, 7 относятся к одному order_id
  2. SQL задаёт порядок по времени и ID: 3, 2, 1, 7
  3. Рядом с исходными строками появляются номера rn: 1, 2, 3, 4
  4. Наружный запрос оставляет rn=1: событие 3 — delivered

Слово «окно» означает набор строк, доступный вычислению для текущей строки. PARTITION BY разделяет данные, например по заказу или счёту. Это не разделы физического хранения таблицы. ORDER BY внутри OVER задаёт порядок расчёта; ROWS/RANGE уточняет рамку агрегата. У ROW_NUMBER/LAG свой смысл: рамка SUM не является инструкцией для их нумерации или смещения.

Начальная запись из первого примера только добавляет номер. У событий заказа 101 в порядке времени и ID получатся номера 1, 2, 3, 4; поля истории сохранятся:

SELECT order_id, id,
       ROW_NUMBER() OVER (
           PARTITION BY order_id ORDER BY happened_at DESC, id DESC
       ) AS rn
FROM mysql110_w_events ORDER BY order_id, rn;

SELECT выбирает поля; круглые скобки после ROW_NUMBER — вызов SQL-функции без аргументов; OVER описывает её окно; AS rn даёт имя новой колонке. Пустой OVER() означал бы одно окно всех строк запроса. До копирования фрагмента нужна таблица событий: полный архив первого примера создаёт её сам.

Полезные предпосылки — таблицы, типы и связи, SELECT и группировка, подключение PDO и параметры. Если нужен только признак наличия или одна сводка на клиента, начните с EXISTS и безопасных итогов по связанным данным. Здесь продолжаем до упорядоченных отчётов, не меняя канонические карточки PHP.

Запустить комплект и увидеть таблицы результата

Примеры рассчитаны на MySQL 8.4 и PHP 8.4+ с pdo_mysql/mysqlnd; в полном комплекте используются PDO::connect и драйверные константы Pdo\Mysql. Контрольный стенд — PHP 8.5.11 / MySQL 8.4.13. SQL-функции из этой главы есть в MySQL 8.0+, но это не подтверждение запуска комплектов на всех старых версиях или других СУБД.

Установите локальный сервер и создайте отдельную учебную базу с ограниченным пользователем по инструкции подключения. В окружении терминала задайте PHPMAN_MYSQL_HOST, PHPMAN_MYSQL_PORT, PHPMAN_MYSQL_DATABASE, PHPMAN_MYSQL_USER, PHPMAN_MYSQL_PASSWORD. На стенде сервер слушает 127.0.0.1:33070. Пароль не входит ни в архив, ни в публичный результат.

Распакуйте один ZIP и выполните php example.php. Helper и README уже вложены; программа создаёт свои TEMPORARY-таблицы и заполняет их указанными ниже строками. Они принадлежат одному соединению и исчезают после его завершения. Постоянные таблицы/схемы не меняются. Откройте result.html; result.json содержит те же таблицы, metrics.json — фактические версии и проверки. Это запуск PHP CLI и SQL, а не проверка веб-формы.

Helper передаёт SQL через PDO::prepare(), вызывает PDOStatement::execute(), читает строки через PDOStatement::fetch() и освобождает результат через closeCursor(). Числа для таблиц отчёта переводятся в строки, NULL остаётся null. Это формат сохранённого результата, а не заявление, что все типы PDO всегда строковые.

Получить последний статус каждого заказа, сохранив связанную строку

На экране заказов нужна одна строка: какой статус действует последним. История хранит все события, причём импортированное позднее событие может относиться к более раннему времени. Для заказа 101 ответом должна стать строка 3 — delivered, а не строка с наибольшим ID 7.

История заказа

idorder_idhappened_atstatus
11012026-01-10 10:00:00created
21012026-01-10 12:00:00packed
31012026-01-10 12:00:00delivered
41022026-01-10 09:00:00created
51022026-01-10 11:00:00cancelled
61032026-01-10 08:00:00created
71012026-01-10 09:00:00imported
Как формируется результат
  1. Заказ 101: события 1, 2, 3 и поздно загруженное событие 7
  2. PARTITION BY order_id разделяет истории заказов
  3. Внутри 101: время убывает; равное время разрешает id DESC → 3, 2, 1, 7
  4. ROW_NUMBER присваивает 1, 2, 3, 4 → наружный WHERE rn=1 сохраняет строку 3

Номера внутри заказа

order_ididstatushappened_atrn
1013delivered2026-01-10 12:00:001
1012packed2026-01-10 12:00:002
1011created2026-01-10 10:00:003
1017imported2026-01-10 09:00:004
1025cancelled2026-01-10 11:00:001
1024created2026-01-10 09:00:002
1036created2026-01-10 08:00:001

Последний статус

order_ididstatushappened_at
1013delivered2026-01-10 12:00:00
1025cancelled2026-01-10 11:00:00
1036created2026-01-10 08:00:00

Почему одного MAX(id) недостаточно

order_idMAX(id)
1017
1025
1036

Короткая запись действия

WITH ranked AS (
    SELECT order_id, id, status, happened_at,
           ROW_NUMBER() OVER (
               PARTITION BY order_id
               ORDER BY happened_at DESC, id DESC
           ) AS rn
    FROM mysql110_w_events
)
SELECT order_id, id, status, happened_at
FROM ranked WHERE rn=1 ORDER BY order_id;

Это объясняющий SQL-фрагмент на уже подготовленных таблицах, не отдельная дополнительная программа. Полный пример ниже содержит создание данных, все сравниваемые запросы и сохранение результата. В календаре короткий фрагмент показывает первый шаг CTE; полная версия добавляет суммы и LEFT JOIN.

ROW_NUMBER() присваивает последовательный номер каждой строке раздела окна, начиная с 1. PARTITION BY order_id начинает нумерацию заново для другого заказа. ORDER BY happened_at DESC выбирает сначала более позднее время. У событий 2 и 3 время одинаковое, поэтому id DESC задаёт явный второй критерий: здесь бизнес-правило считает более высокий ID более поздней записью при равном времени.

WITH ranked AS (...) даёт имя результату вложенного SELECT в пределах одного SQL-запроса. Это CTE: не постоянная таблица и не обещание создать отдельный файл. Внутри ranked сохраняются поля исходного события и вычисляется rn. Во внешнем SELECT условие rn=1 выбирает ровно одну связанную строку, поэтому status и happened_at относятся к тому же id.

В одном уровне SELECT нельзя поместить оконную функцию в WHERE или ссылаться там на её псевдоним rn: отбор WHERE происходит раньше вычисления окна. Поэтому сначала считаем номера в CTE, затем фильтруем его результат. Последний ORDER BY order_id отдельно определяет порядок строк готового экрана; порядок внутри OVER сам по себе этого не обещает.

Сравнительный запрос MAX(id) возвращает для заказа 101 число 7. Это корректный максимум номера, но не ответ на вопрос о времени. MAX(happened_at) даст только дату; он тоже не выбирает остальные поля нужной строки. Выбор максимальной даты и произвольного status рядом с ней — ошибочный договор отчёта, а при строгой группировке может завершиться отказом.

Границы и частые ошибки

Время здесь NOT NULL, а ID уникален. Если у источника другое правило последовательности, используйте его номер события, а не автоматически наш id. ROW_NUMBER всегда разрывает ничью; если нужны все события с одинаковым последним временем, договор и запрос будут другими. Статус created означает «создан», packed — «упакован», delivered — «доставлен», cancelled — «отменён»; imported здесь учебная запись истории. Заказ вообще без истории не появится: для него нужна отдельная таблица заказов и LEFT JOIN к выбранным событиям.

Полная самостоятельная программа и файлы для запуска
Получить последний статус каждого заказа, сохранив связанную строкуСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/mysql110-window-helper.php';
$db=window110Connect();
window110Create($db,'mysql110_w_events','id INT PRIMARY KEY, order_id INT NOT NULL, happened_at DATETIME NOT NULL, status VARCHAR(20) NOT NULL');
$db->exec("INSERT INTO mysql110_w_events VALUES (1,101,'2026-01-10 10:00:00','created'),(2,101,'2026-01-10 12:00:00','packed'),(3,101,'2026-01-10 12:00:00','delivered'),(4,102,'2026-01-10 09:00:00','created'),(5,102,'2026-01-10 11:00:00','cancelled'),(6,103,'2026-01-10 08:00:00','created'),(7,101,'2026-01-10 09:00:00','imported')");
$ranked="SELECT order_id,id,status,happened_at,ROW_NUMBER() OVER (PARTITION BY order_id ORDER BY happened_at DESC,id DESC) AS rn FROM mysql110_w_events";
$all=window110Read($db,$ranked.' ORDER BY order_id,rn');
$latest=window110Read($db,'WITH ranked AS ('.$ranked.') SELECT order_id,id,status,happened_at FROM ranked WHERE rn=1 ORDER BY order_id');
$maxIds=window110Read($db,'SELECT order_id,MAX(id) AS biggest_id FROM mysql110_w_events GROUP BY order_id ORDER BY order_id');
$tables=[window110Table('Номера внутри заказа',['order_id','id','status','happened_at','rn'],$all),window110Table('Последний статус',['order_id','id','status','happened_at'],$latest),window110Table('Почему одного MAX(id) недостаточно',['order_id','MAX(id)'],$maxIds)];
$checks=['one_row_per_order'=>count($latest)===3,'timestamp_tie_resolved'=>$latest[0]===['101','3','delivered','2026-01-10 12:00:00'],'late_import_not_latest'=>$maxIds[0]===['101','7'] && $latest[0][1]==='3','original_row_preserved'=>$latest[1]===['102','5','cancelled','2026-01-10 11:00:00']];
window110Report('mysql110-latest-status',$tables,$checks,$db);
$db=null;
echo 'mysql110-latest-status: saved result.html and result.json
';
Ожидаемый результат
mysql110-latest-status: saved result.html and result.json

Полезный результат — показанные выше таблицы, сохранённые в result.html и result.json. Архив включает исходные данные внутри PHP, helper и README; соседние примеры запускать не требуется. Проверяется PHP CLI с MySQL.

Открыть фактический HTML-результат · Скачать таблицы JSON

Попробуйте сами. Добавьте заказу 103 событие id 8 с более поздним временем и статусом packed. В итоговой таблице по-прежнему будет три заказа, но у 103 появится id 8. Затем добавьте событие id 9 с более ранним временем: оно не должно заменить id 8. Решение — сохранить сортировку по времени перед ID.

Практический вопрос: Последняя запись группы, когда дата повторяется. Нужны полные последние строки клиентов; одинаковая дата требует второго критерия row_id. Наша история заказов дополнительно показывает поздний импорт старого события. Это источник ситуации; решение и учебные данные написаны отдельно, правила сверены с MySQL/PHP.

Выбрать лидеров каждой категории и договориться о равных местах

В каждой категории надо показать двух лидеров продаж. Но фраза «два лучших» неоднозначна: ровно две карточки, два места с ничьей или два разных значения продаж. На одинаковых исходных данных получаются 4, 5 и 6 строк — каждая выдача правильна только для своего требования.

Продажи за выбранный период

idcategoryskuunits
1teaA100
2teaB100
3teaC90
4teaD80
5coffeeE70
6coffeeF60
7coffeeG60
8coffeeH50
Как формируется результат
  1. В tea: A=100, B=100, C=90, D=80; в coffee: E=70, F=60, G=60, H=50
  2. ROW_NUMBER: отдельная позиция каждому товару, ID разрешает равенство
  3. RANK: одинаковые продажи получают одно место; следующее место учитывает число предыдущих строк
  4. DENSE_RANK: одинаковые продажи получают один уровень; пропусков между уровнями нет

Три значения места

categoryidskuunitsROW_NUMBERRANKDENSE_RANK
coffee5E70111
coffee6F60222
coffee7G60322
coffee8H50443
tea1A100111
tea2B100211
tea3C90332
tea4D80443

Ровно две строки, если они есть

categoryskuunits
coffeeE70
coffeeF60
teaA100
teaB100

Спортивные места 1 и 2 с ничьей

categoryskuunits
coffeeE70
coffeeF60
coffeeG60
teaA100
teaB100

Два разных уровня результата

categoryskuunits
coffeeE70
coffeeF60
coffeeG60
teaA100
teaB100
teaC90

Короткая запись действия

WITH ranked AS (
    SELECT category, id, sku, units,
           ROW_NUMBER() OVER (
               PARTITION BY category ORDER BY units DESC, id
           ) AS position_no
    FROM mysql110_w_leaders
)
SELECT category, sku, units FROM ranked
WHERE position_no<=2 ORDER BY category, position_no;

Это объясняющий SQL-фрагмент на уже подготовленных таблицах, не отдельная дополнительная программа. Полный пример ниже содержит создание данных, все сравниваемые запросы и сохранение результата. В календаре короткий фрагмент показывает первый шаг CTE; полная версия добавляет суммы и LEFT JOIN.

В исходной таблице units — уже посчитанные продажи одного товара за согласованный период. Здесь не надо суммировать неизвестные сырые заказы. Если исходник содержит несколько строк на товар, сначала получите нужный итог через GROUP BY, и только затем ранжируйте. Так число мест относится к товарам, а не к отдельным позициям заказов.

ПолитикаКак считатьЧто делать при равенстве
Не больше двух карточек категорииROW_NUMBER по units DESC,id; position_no<=2Выбрать по стабильному второму критерию
Места 1 и 2 с разделённым местомRANK по units DESC; rank_no<=2В coffee показать F и G на втором месте
Два различных уровня продажDENSE_RANK по units DESC; dense_no<=2В tea показать 100 и 90, включая A, B, C

Для tea значения RANK равны 1,1,3,4: два товара заняли первое место, поэтому следующая позиция — третья. DENSE_RANK даёт 1,1,2,3. В coffee это 1,2,2,4 и 1,2,2,3. ROW_NUMBER даёт 1,2,3,4 в каждой категории.

Обратите внимание на разные ORDER BY. Для ROW_NUMBER нужен ID, чтобы выбор между F и G был воспроизводим. Для RANK/DENSE_RANK в окне оставлены только продажи: если добавить уникальный ID, равных сочетаний уже не будет и ничья исчезнет. Внешний ORDER BY с ID можно сохранить: он упорядочивает показ, не меняя вычисленные места.

Обычный LIMIT 2 после всего запроса оставил бы два товара из общего результата. Он не означает «по два в каждом разделе». Нужны PARTITION BY category и внешний фильтр номера, уже вычисленного внутри каждой категории.

Границы и частые ошибки

Место — вычисляемое значение данного отчёта; не сохраняйте его как постоянный атрибут товара без правила обновления. Не обещайте ровно две строки, если в категории один товар. Для запроса пользователя N передавайте проверенным параметром либо выбирайте из допустимого набора; имя поля сортировки нельзя безопасно подставить обычным параметром. Предыдущая глава о разрешённых сортировках объясняет выбор такого поля.

Полная самостоятельная программа и файлы для запуска
Выбрать лидеров каждой категории и договориться о равных местахСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/mysql110-window-helper.php';
$db=window110Connect();
window110Create($db,'mysql110_w_leaders','id INT PRIMARY KEY, category VARCHAR(20) NOT NULL, sku VARCHAR(20) NOT NULL, units INT NOT NULL');
$db->exec("INSERT INTO mysql110_w_leaders VALUES (1,'tea','A',100),(2,'tea','B',100),(3,'tea','C',90),(4,'tea','D',80),(5,'coffee','E',70),(6,'coffee','F',60),(7,'coffee','G',60),(8,'coffee','H',50)");
$ranked='SELECT category,id,sku,units,ROW_NUMBER() OVER (PARTITION BY category ORDER BY units DESC,id) AS position_no,RANK() OVER (PARTITION BY category ORDER BY units DESC) AS rank_no,DENSE_RANK() OVER (PARTITION BY category ORDER BY units DESC) AS dense_no FROM mysql110_w_leaders';
$all=window110Read($db,$ranked.' ORDER BY category,position_no');
$two=window110Read($db,'WITH ranked AS ('.$ranked.') SELECT category,sku,units FROM ranked WHERE position_no<=2 ORDER BY category,position_no');
$rank=window110Read($db,'WITH ranked AS ('.$ranked.') SELECT category,sku,units FROM ranked WHERE rank_no<=2 ORDER BY category,units DESC,id');
$dense=window110Read($db,'WITH ranked AS ('.$ranked.') SELECT category,sku,units FROM ranked WHERE dense_no<=2 ORDER BY category,units DESC,id');
$tables=[window110Table('Три значения места',['category','id','sku','units','ROW_NUMBER','RANK','DENSE_RANK'],$all),window110Table('Ровно две строки, если они есть',['category','sku','units'],$two),window110Table('Спортивные места 1 и 2 с ничьей',['category','sku','units'],$rank),window110Table('Два разных уровня результата',['category','sku','units'],$dense)];
$checks=['exact_two_per_group'=>count($two)===4,'ties_extend_rank'=>count($rank)===5,'dense_includes_second_value'=>count($dense)===6,'stable_tie_choice'=>$two===[['coffee','E','70'],['coffee','F','60'],['tea','A','100'],['tea','B','100']]];
window110Report('mysql110-top-per-group',$tables,$checks,$db);
$db=null;
echo 'mysql110-top-per-group: saved result.html and result.json
';
Ожидаемый результат
mysql110-top-per-group: saved result.html and result.json

Полезный результат — показанные выше таблицы, сохранённые в result.html и result.json. Архив включает исходные данные внутри PHP, helper и README; соседние примеры запускать не требуется. Проверяется PHP CLI с MySQL.

Открыть фактический HTML-результат · Скачать таблицы JSON

Попробуйте сами. Измените требование на «первые два уровня продаж независимо от ничьих». Решение уже есть в третьей выдаче: фильтр dense_no<=2. Если нужны ровно две карточки, оставьте position_no<=2 и заранее договоритесь, почему меньший ID выигрывает ничью.

Показать каждую операцию, накопительный итог и сумму по счёту

В истории счёта нужно оставить каждое пополнение и возврат, рядом показать итог после операции и итог всего выбранного набора. У A после трёх операций получится 1000 → 1500 → 1300 копеек; отдельный счёт B начнётся со своих 700. Общая сумма GROUP BY не заменяет такую историю.

Операции, суммы в копейках

idaccounthappened_atamount_cents
1A2026-02-01 09:00:001000
2A2026-02-01 09:00:00500
3A2026-02-01 10:00:00-200
4B2026-02-01 09:00:00700
Как формируется результат
  1. GROUP BY account: четыре операции превращаются в две суммы A=1300, B=700
  2. SUM OVER: четыре строки сохраняются и получают дополнительные вычисленные поля
  3. ROWS от начала до текущей строки: 1000; 1000+500; 1000+500−200
  4. Без ORDER BY в SUM OVER: итог раздела 1300 повторяется возле каждой операции A

GROUP BY: одна строка на счёт

accounttotal_cents
A1300
B700

Окно: каждая операция остаётся

accountidamount_centsrunning_centstotal_centsdefault_range_cents
A11000100013001500
A2500150013001500
A3-200130013001300
B4700700700700

Короткая запись действия

SELECT account, id, amount_cents,
       SUM(amount_cents) OVER (
           PARTITION BY account ORDER BY happened_at, id
           ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
       ) AS running_cents,
       SUM(amount_cents) OVER (PARTITION BY account) AS total_cents
FROM mysql110_w_ledger ORDER BY account, happened_at, id;

Это объясняющий SQL-фрагмент на уже подготовленных таблицах, не отдельная дополнительная программа. Полный пример ниже содержит создание данных, все сравниваемые запросы и сохранение результата. В календаре короткий фрагмент показывает первый шаг CTE; полная версия добавляет суммы и LEFT JOIN.

SUM(amount_cents) остаётся знакомой суммой. OVER меняет способ применения: сумма рассчитывается для окна строк и записывается рядом с текущей строкой, не сворачивая историю в одну запись. PARTITION BY account не позволяет смешать операции разных счетов. ORDER BY happened_at,id определяет последовательность; ID делает порядок однозначным, даже если время совпало.

Часть рамкиПростой смысл
ROWSСчитать по позициям строк в заданном порядке
UNBOUNDED PRECEDINGНачать с первой строки этого раздела
CURRENT ROWЗакончить текущей строкой
BETWEEN ... AND ...Указать обе границы рамки

В полной программе есть намеренное сравнение с SUM OVER (PARTITION BY account ORDER BY happened_at) без рамки. По умолчанию при ORDER BY используется RANGE до текущего значения, включая строки с таким же значением сортировки. Поэтому у двух событий в 09:00 сразу показано 1500 и 1500. Это не потерянная строка и не ошибка арифметики: запрос выбрал группировку равного времени. Явный ROWS вместе с однозначным порядком даёт требуемые последовательные шаги 1000 и 1500.

Сумма OVER (PARTITION BY account) без ORDER BY берёт весь раздел: у A 1300 во всех трёх строках. Знак минус у возврата сохраняется; иначе отчёт завысит итог. Копейки здесь целые числа и одна валюта. Это сумма показанных операций, а не автоматически настоящий остаток счёта: для остатка нужен ещё согласованный начальный баланс.

Границы и частые ошибки

WHERE ограничивает строки до вычисления окна. Если оставить только февраль, накопительный итог начнётся с февраля, а не с начала истории. Для «остатка на дату» нужно включить прежние операции или отдельно вычислить начальный баланс. ROWS не означает календарный день и не заполняет дни без событий. На больших данных окно может требовать сортировки/временного результата сервера; уменьшайте исходный набор осмысленно и исследуйте план, не обещайте ускорение одним словом OVER.

Полная самостоятельная программа и файлы для запуска
Показать каждую операцию, накопительный итог и сумму по счётуСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/mysql110-window-helper.php';
$db=window110Connect();
window110Create($db,'mysql110_w_ledger','id INT PRIMARY KEY, account VARCHAR(10) NOT NULL, happened_at DATETIME NOT NULL, amount_cents INT NOT NULL');
$db->exec("INSERT INTO mysql110_w_ledger VALUES (1,'A','2026-02-01 09:00:00',1000),(2,'A','2026-02-01 09:00:00',500),(3,'A','2026-02-01 10:00:00',-200),(4,'B','2026-02-01 09:00:00',700)");
$groups=window110Read($db,'SELECT account,SUM(amount_cents) AS total_cents FROM mysql110_w_ledger GROUP BY account ORDER BY account');
$rows=window110Read($db,'SELECT account,id,amount_cents,SUM(amount_cents) OVER (PARTITION BY account ORDER BY happened_at,id ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_cents,SUM(amount_cents) OVER (PARTITION BY account) AS total_cents,SUM(amount_cents) OVER (PARTITION BY account ORDER BY happened_at) AS default_range_cents FROM mysql110_w_ledger ORDER BY account,happened_at,id');
$tables=[window110Table('GROUP BY: одна строка на счёт',['account','total_cents'],$groups),window110Table('Окно: каждая операция остаётся',['account','id','amount_cents','running_cents','total_cents','default_range_cents'],$rows)];
$checks=['groups_collapse'=>count($groups)===2,'window_preserves_rows'=>count($rows)===4,'same_time_separate_steps'=>$rows[0][3]==='1000' && $rows[1][3]==='1500','default_frame_includes_peers'=>$rows[0][5]==='1500' && $rows[1][5]==='1500','refund_reduces_total'=>$rows[2][3]==='1300' && $rows[2][4]==='1300','partitions_independent'=>$rows[3]===['B','4','700','700','700','700']];
window110Report('mysql110-running-total',$tables,$checks,$db);
$db=null;
echo 'mysql110-running-total: saved result.html and result.json
';
Ожидаемый результат
mysql110-running-total: saved result.html and result.json

Полезный результат — показанные выше таблицы, сохранённые в result.html и result.json. Архив включает исходные данные внутри PHP, helper и README; соседние примеры запускать не требуется. Проверяется PHP CLI с MySQL.

Открыть фактический HTML-результат · Скачать таблицы JSON

Попробуйте сами. Добавьте A ещё один возврат −100 после 10:00. Последний running_cents станет 1200, total_cents всех строк A — 1200; B останется 700. Если первым двум событиям оставить одинаковое время, ID продолжит задавать порядок.

Практический вопрос: Почему SUM OVER меняется при повторяющихся значениях сортировки. Автор ожидает прибавление каждой строки, но получает одинаковый итог у равных значений ORDER BY. Наш пример сравнивает ROWS и стандартный RANGE на двух платежах в одну секунду. Это источник ситуации; решение и учебные данные написаны отдельно, правила сверены с MySQL/PHP.

Сравнить показания с предыдущей записью, сохранив исходную точку

Сервис получает накопительные показания счётчика. Нужно показать, насколько они изменились относительно предыдущего измерения, даже если между измерениями был пропущен день. Скачок 1100 → 10 надо отправить на разбор замены/сброса, а не превращать в обычный расход.

Показания

idmetermeasured_atreading
1A2026-01-01 08:00:001000
2A2026-01-03 08:00:001040
3A2026-01-03 08:00:001100
4A2026-01-04 08:00:0010
5B2026-01-02 08:00:00500
6B2026-01-04 08:00:00560
Как формируется результат
  1. Счётчик A: 1000 → 1040 → 1100 → 10
  2. LAG читает предыдущее значение в порядке measured_at,id
  3. Разность: NULL, 40, 60, −1090; отрицательная разность помечается review_reset
  4. Фильтр периода после LAG сохраняет базу 1000 для первого показания периода 1040

Соседняя запись и решение

meteridreadingprevious_readingdeltadecision
A11000NULLNULLbaseline
A21040100040ok
A31100104060ok
A4101100-1090review_reset
B5500NULLNULLbaseline
B656050060ok

Период после вычисления LAG

meteridreadingprevious_readingdelta
A21040100040
A31100104060
A4101100-1090

Обрезали исходные данные до LAG

meteridreadingprevious_reading
A21040NULL
A311001040
A4101100

Короткая запись действия

SELECT meter, id, reading,
       LAG(reading) OVER (
           PARTITION BY meter ORDER BY measured_at, id
       ) AS previous_reading
FROM mysql110_w_readings ORDER BY meter, measured_at, id;

Это объясняющий SQL-фрагмент на уже подготовленных таблицах, не отдельная дополнительная программа. Полный пример ниже содержит создание данных, все сравниваемые запросы и сохранение результата. В календаре короткий фрагмент показывает первый шаг CTE; полная версия добавляет суммы и LEFT JOIN.

LAG(reading) возвращает reading предыдущей строки в этом разделе. По умолчанию смещение равно 1, а при отсутствии предыдущей строки получается NULL. PARTITION BY meter разделяет приборы; иначе первое показание B могло бы сравниваться с A. ORDER BY measured_at,id означает предыдущую запись в согласованном порядке, а не обязательно вчерашний день.

В полном запросе CTE compared сначала добавляет previous_reading. Наружный SELECT вычисляет reading-previous_reading и понятную пометку CASE: baseline — нет начального сравнения, ok — обычное неотрицательное изменение, review_reset — значение уменьшилось. CASE проверяет условия по очереди; первая подходящая ветка задаёт значение decision. NULL не заменяется нулём: иначе самое первое показание 1000 превратилось бы в выдуманный расход 1000.

Две следующие таблицы показывают влияние места фильтра. Если в исходном SELECT оставить только measured_at начиная с 3 января, запись 1 января исчезнет до LAG. У показания 1040 предыдущего значения тогда нет. Если рассчитать LAG на нужной истории и отфильтровать период во внешнем SELECT, сохранится предыдущее значение 1000 и разность 40. Для большого архива можно получить необходимую граничную запись отдельно и включить её в расчёт; просто отрезать её нельзя.

Здесь одинаковое время двух показаний разрешается ID. Это демонстрационный договор последовательности загрузки. В реальном устройстве часто есть собственный монотонный номер измерения; используйте его, если именно он определяет порядок.

Границы и частые ошибки

Отрицательная разность не доказывает сброс: возможны исправление данных, замена прибора и неверный порядок. Пример только помечает её для проверки и не начисляет деньги. LAG не интерполирует пропуски, не делит разность на число дней и не рассчитывает расход в единицу времени. NULL в самом reading потребует отдельной политики: пример запрещает его схемой, а отсутствие предыдущей строки оставляет NULL.

Полная самостоятельная программа и файлы для запуска
Сравнить показания с предыдущей записью, сохранив исходную точкуСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/mysql110-window-helper.php';
$db=window110Connect();
window110Create($db,'mysql110_w_readings','id INT PRIMARY KEY, meter VARCHAR(10) NOT NULL, measured_at DATETIME NOT NULL, reading INT NOT NULL');
$db->exec("INSERT INTO mysql110_w_readings VALUES (1,'A','2026-01-01 08:00:00',1000),(2,'A','2026-01-03 08:00:00',1040),(3,'A','2026-01-03 08:00:00',1100),(4,'A','2026-01-04 08:00:00',10),(5,'B','2026-01-02 08:00:00',500),(6,'B','2026-01-04 08:00:00',560)");
$lag='SELECT meter,id,measured_at,reading,LAG(reading) OVER (PARTITION BY meter ORDER BY measured_at,id) AS previous_reading FROM mysql110_w_readings';
$all=window110Read($db,"WITH compared AS ($lag) SELECT meter,id,reading,previous_reading,reading-previous_reading AS delta,CASE WHEN previous_reading IS NULL THEN 'baseline' WHEN reading<previous_reading THEN 'review_reset' ELSE 'ok' END AS decision FROM compared ORDER BY meter,measured_at,id");
$kept=window110Read($db,"WITH compared AS ($lag) SELECT meter,id,reading,previous_reading,reading-previous_reading AS delta FROM compared WHERE meter='A' AND measured_at>='2026-01-03 00:00:00' ORDER BY measured_at,id");
$cut=window110Read($db,"SELECT meter,id,reading,LAG(reading) OVER (PARTITION BY meter ORDER BY measured_at,id) AS previous_reading FROM mysql110_w_readings WHERE meter='A' AND measured_at>='2026-01-03 00:00:00' ORDER BY measured_at,id");
$tables=[window110Table('Соседняя запись и решение',['meter','id','reading','previous_reading','delta','decision'],$all),window110Table('Период после вычисления LAG',['meter','id','reading','previous_reading','delta'],$kept),window110Table('Обрезали исходные данные до LAG',['meter','id','reading','previous_reading'],$cut)];
$checks=['first_is_unknown'=>$all[0][3]===null && $all[0][4]===null,'gap_not_assumed_daily'=>$kept[0]===['A','2','1040','1000','40'],'reset_not_billed'=>$all[3][4]==='-1090' && $all[3][5]==='review_reset','where_order_visible'=>$cut[0][3]===null && $kept[0][3]==='1000','meters_separate'=>$all[4][3]===null && $all[5][4]==='60'];
window110Report('mysql110-previous-value',$tables,$checks,$db);
$db=null;
echo 'mysql110-previous-value: saved result.html and result.json
';
Ожидаемый результат
mysql110-previous-value: saved result.html and result.json

Полезный результат — показанные выше таблицы, сохранённые в result.html и result.json. Архив включает исходные данные внутри PHP, helper и README; соседние примеры запускать не требуется. Проверяется PHP CLI с MySQL.

Открыть фактический HTML-результат · Скачать таблицы JSON

Попробуйте сами. Для отчёта с 3 января сохраните значение 40 у первой строки A. Решение — отбирать даты во внешнем запросе к compared. Затем добавьте новому прибору C одно показание: previous_reading и delta должны остаться NULL, decision — baseline.

Выбрать весь месяц, не потеряв дробные секунды и не захватив следующий

Нужны все платежи февраля. У колонки есть микросекунды, поэтому окончание «28 февраля 23:59:59» потеряет часть последней секунды, а BETWEEN до 1 марта захватит мартовскую запись. Полуоткрытый интервал сохраняет ровно ID 2, 3 и 4.

Платежи около границ

idpaid_at UTCamount_cents
12026-01-31 23:59:59.999999100
22026-02-01 00:00:00.000000200
32026-02-28 23:59:59.000000300
42026-02-28 23:59:59.999999400
52026-03-01 00:00:00.000000500
Как формируется результат
  1. Нижняя граница включена: 2026-02-01 00:00:00
  2. Верхняя граница исключена: 2026-03-01 00:00:00
  3. 23:59:59.999999 февраля меньше границы → включено
  4. 00:00:00 марта равно исключённой границе → не включено

Полный февраль

idpaid_at UTCamount_cents
22026-02-01 00:00:00.000000200
32026-02-28 23:59:59.000000300
42026-02-28 23:59:59.999999400

Ошибочно включена граница марта

idpaid_at UTCamount_cents
22026-02-01 00:00:00.000000200
32026-02-28 23:59:59.000000300
42026-02-28 23:59:59.999999400
52026-03-01 00:00:00.000000500

Потеряна дробная секунда

idpaid_at UTCamount_cents
22026-02-01 00:00:00.000000200
32026-02-28 23:59:59.000000300

Сравнение условий

УсловиеСтрок
>= начало; < следующий месяц3
BETWEEN до следующего месяца4
<= 23:59:59 последнего дня2

Короткая запись действия

SELECT id, paid_at, amount_cents FROM mysql110_w_period
WHERE paid_at >= :from_dt AND paid_at < :to_dt
ORDER BY paid_at, id;
-- :from_dt = 2026-02-01 00:00:00
-- :to_dt   = 2026-03-01 00:00:00

Это объясняющий SQL-фрагмент на уже подготовленных таблицах, не отдельная дополнительная программа. Полный пример ниже содержит создание данных, все сравниваемые запросы и сохранение результата. В календаре короткий фрагмент показывает первый шаг CTE; полная версия добавляет суммы и LEFT JOIN.

Условие paid_at>=:from_dt AND paid_at<:to_dt задаёт интервал [начало, конец): квадратная скобка означает включённую границу, круглая — исключённую. Не нужно угадывать последнюю допустимую дробь секунды. Соседний мартовский отчёт начинает свой интервал ровно там, где закончился февральский: запись на общей границе попадёт один раз.

DATETIME(6) хранит время с шестью цифрами дробной части секунды. В результатах DATE_FORMAT с %f только делает эти цифры явно видимыми. Фильтр применяется к самой paid_at, а не к отформатированной строке. BETWEEN включает обе границы, поэтому при верхней дате 1 марта получает четвёртую лишнюю строку. Условие до 23:59:59, напротив, оставляет лишь две строки и пропускает id4.

Маркеры :from_dt и :to_dt — значения для подготовленного запроса. Helper вызывает prepare, execute с массивом дат и читает готовые строки. Даты не приклеиваются к SQL строковой конкатенацией. Формат исходных границ однозначный YYYY-MM-DD HH:MM:SS; для значений из формы нужно дополнительно проверить формат, порядок и разрешённую длину периода.

Все учебные DATETIME интерпретируются как UTC по договору программы. Сам DATETIME не хранит имя часового пояса и не преобразует его так, как TIMESTAMP. Если отчёт нужен по местному календарному месяцу, сначала определите начало и начало следующего месяца в нужной зоне, затем переведите обе границы в формат хранения. В зонах с переходом времени «следующий день» не всегда означает добавить 86400 секунд.

Границы и частые ошибки

Условие по исходной колонке позволяет рассмотреть диапазонный доступ через индекс paid_at; конкретный план и выигрыш проверяют на своих данных. Наличие индекса в этой крошечной таблице не доказывает скорость. DATE(paid_at)=... может быть понятной записью, но не следует обещать тот же доступ по обычному индексу. Период фильтруется до SUM/окна, если это сумма только периода; полный накопительный баланс требует другой границы исходных данных.

Полная самостоятельная программа и файлы для запуска
Выбрать весь месяц, не потеряв дробные секунды и не захватив следующийСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/mysql110-window-helper.php';
$db=window110Connect();
window110Create($db,'mysql110_w_period','id INT PRIMARY KEY, paid_at DATETIME(6) NOT NULL, amount_cents INT NOT NULL, INDEX paid_at_idx(paid_at)');
$db->exec("INSERT INTO mysql110_w_period VALUES (1,'2026-01-31 23:59:59.999999',100),(2,'2026-02-01 00:00:00.000000',200),(3,'2026-02-28 23:59:59.000000',300),(4,'2026-02-28 23:59:59.999999',400),(5,'2026-03-01 00:00:00.000000',500)");
$start='2026-02-01 00:00:00'; $end='2026-03-01 00:00:00';
$projection="SELECT id,DATE_FORMAT(paid_at,'%Y-%m-%d %H:%i:%s.%f'),amount_cents FROM mysql110_w_period ";
$correct=window110Read($db,$projection.'WHERE paid_at>=:from_dt AND paid_at<:to_dt ORDER BY paid_at,id',['from_dt'=>$start,'to_dt'=>$end]);
$inclusive=window110Read($db,$projection.'WHERE paid_at BETWEEN :from_dt AND :to_dt ORDER BY paid_at,id',['from_dt'=>$start,'to_dt'=>$end]);
$lastSecond=window110Read($db,$projection.'WHERE paid_at>=:from_dt AND paid_at<=:last_dt ORDER BY paid_at,id',['from_dt'=>$start,'last_dt'=>'2026-02-28 23:59:59']);
$summary=[['>= начало; < следующий месяц',(string)count($correct)],['BETWEEN до следующего месяца',(string)count($inclusive)],['<= 23:59:59 последнего дня',(string)count($lastSecond)]];
$tables=[window110Table('Полный февраль',['id','paid_at UTC','amount_cents'],$correct),window110Table('Ошибочно включена граница марта',['id','paid_at UTC','amount_cents'],$inclusive),window110Table('Потеряна дробная секунда',['id','paid_at UTC','amount_cents'],$lastSecond),window110Table('Сравнение условий',['Условие','Строк'],$summary)];
$checks=['exact_february'=>array_column($correct,0)===['2','3','4'],'microsecond_preserved'=>$correct[2][1]==='2026-02-28 23:59:59.999999','inclusive_adds_march'=>array_column($inclusive,0)===['2','3','4','5'],'last_second_loses_row'=>array_column($lastSecond,0)===['2','3']];
window110Report('mysql110-period-boundaries',$tables,$checks,$db);
$db=null;
echo 'mysql110-period-boundaries: saved result.html and result.json
';
Ожидаемый результат
mysql110-period-boundaries: saved result.html and result.json

Полезный результат — показанные выше таблицы, сохранённые в result.html и result.json. Архив включает исходные данные внутри PHP, helper и README; соседние примеры запускать не требуется. Проверяется PHP CLI с MySQL.

Открыть фактический HTML-результат · Скачать таблицы JSON

Попробуйте сами. Смените границы на март: начало 2026-03-01, конец 2026-04-01. В текущих данных должна остаться только запись 5 на 500 копеек. Добавьте оплату ровно 1 апреля в 00:00:00 — она не должна попасть в март.

Добавить дни без продаж и сохранить накопительный итог

На графике должны быть пять последовательных дней, включая 2 и 4 февраля без продаж. Обычный GROUP BY возвращает только три существующих дня. Дополним календарь нулями и покажем, что накопительный итог на пропущенном дне остаётся прежним, а возврат уменьшает его.

Платежи и возврат

idpaid_at UTCamount_cents
12026-02-01 10:00:001000
22026-02-01 11:00:00500
32026-02-03 09:00:00700
42026-02-05 18:00:00-200
52026-02-06 00:00:009999
Как формируется результат
  1. RECURSIVE days создаёт 1, 2, 3, 4, 5 февраля; 6 февраля — исключённый конец
  2. daily сначала суммирует реальные операции каждого дня → 1500, 700, −200
  3. LEFT JOIN календаря к daily сохраняет дни без пары; COALESCE заменяет отсутствующую сумму на 0
  4. Окно по заполненным дням → 1500, 1500, 2200, 2200, 2000

Только дни с операциями

daytotal_cents
2026-02-011500
2026-02-03700
2026-02-05-200

Календарь отчёта

daytotal_centsrunning_cents
2026-02-0115001500
2026-02-0201500
2026-02-037002200
2026-02-0402200
2026-02-05-2002000

Отказ до рекурсивного запроса

startend exclusiveРезультат
2026-02-062026-02-01rejected_before_query
2026-02-012026-04-01rejected_before_query
2026-02-302026-03-01rejected_before_query

Короткая запись действия

WITH RECURSIVE days(day) AS (
    SELECT CAST(:calendar_start AS DATE)
    UNION ALL
    SELECT day + INTERVAL 1 DAY FROM days
    WHERE day + INTERVAL 1 DAY < CAST(:calendar_end AS DATE)
)
SELECT day FROM days ORDER BY day;
-- начало: 2026-02-01; конец исключён: 2026-02-06

Это объясняющий SQL-фрагмент на уже подготовленных таблицах, не отдельная дополнительная программа. Полный пример ниже содержит создание данных, все сравниваемые запросы и сохранение результата. В календаре короткий фрагмент показывает первый шаг CTE; полная версия добавляет суммы и LEFT JOIN.

Сначала короткий запрос создаёт только календарь. WITH RECURSIVE разрешает CTE ссылаться на себя. Первая часть SELECT CAST(:calendar_start AS DATE) — начальная дата. UNION ALL добавляет следующий день, пока day + INTERVAL 1 DAY меньше исключённого конца. Прибавляется один календарный день, а не число к строке даты. При периоде [1 февраля, 6 февраля) результат содержит пять дат.

Полная программа добавляет два понятных шага. CTE daily сначала группирует платежи по DATE(paid_at), то есть делает одну строку на день. CTE filled соединяет полный календарь с этими итогами через LEFT JOIN. Если продаж не было, правое значение NULL; COALESCE(daily.total_cents,0) выбирает 0. Это осмысленно именно для отсутствующей суммы операций, а не универсальное правило замены любого NULL.

Последний SUM OVER с явной ROWS-рамкой работает уже с готовым рядом дней. У дня без продаж сумма 0, поэтому накопительный итог не меняется. Возврат −200 учтён 5 февраля, итог становится 2000. Платёж 9999 ровно 6 февраля исключён из daily тем же полуоткрытым интервалом; иначе календарь и исходные суммы относились бы к разным периодам.

В PHP функция window110Period принимает даты формата YYYY-MM-DD. createFromFormat способен нормализовать несуществующую дату в следующий месяц, поэтому код сравнивает отформатированный результат с исходной строкой. Дополнительно проверяются конец после начала и длина не более 31 дня. Три отказа показываются до SQL: обратный диапазон, слишком длинный период и 30 февраля. Четыре места передачи дат имеют разные имена маркеров — это требуется native prepare, даже если две пары значений совпадают.

Границы и частые ошибки

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

Полная самостоятельная программа и файлы для запуска
Добавить дни без продаж и сохранить накопительный итогСамостоятельный пример
<?php
declare(strict_types=1);
require __DIR__.'/mysql110-window-helper.php';
$db=window110Connect();
window110Create($db,'mysql110_w_calendar_sales','id INT PRIMARY KEY, paid_at DATETIME NOT NULL, amount_cents INT NOT NULL');
$db->exec("INSERT INTO mysql110_w_calendar_sales VALUES (1,'2026-02-01 10:00:00',1000),(2,'2026-02-01 11:00:00',500),(3,'2026-02-03 09:00:00',700),(4,'2026-02-05 18:00:00',-200),(5,'2026-02-06 00:00:00',9999)");
function window110Period(string $start,string $end): array {
    $zone=new DateTimeZone('UTC'); $a=DateTimeImmutable::createFromFormat('!Y-m-d',$start,$zone); $b=DateTimeImmutable::createFromFormat('!Y-m-d',$end,$zone);
    if (!$a || !$b || $a->format('Y-m-d')!==$start || $b->format('Y-m-d')!==$end || $b<=$a || $a->diff($b)->days>31) { throw new InvalidArgumentException('Use a non-empty period of at most 31 days'); }
    return ['calendar_start'=>$start,'calendar_end'=>$end,'sales_start'=>$start.' 00:00:00','sales_end'=>$end.' 00:00:00'];
}
$params=window110Period('2026-02-01','2026-02-06');
$daily=window110Read($db,'SELECT DATE(paid_at) AS day,SUM(amount_cents) AS total_cents FROM mysql110_w_calendar_sales WHERE paid_at>=:sales_start AND paid_at<:sales_end GROUP BY DATE(paid_at) ORDER BY day',['sales_start'=>$params['sales_start'],'sales_end'=>$params['sales_end']]);
$sql=<<<'SQL'
WITH RECURSIVE days(day) AS (
    SELECT CAST(:calendar_start AS DATE)
    UNION ALL
    SELECT day + INTERVAL 1 DAY FROM days
    WHERE day + INTERVAL 1 DAY < CAST(:calendar_end AS DATE)
), daily AS (
    SELECT DATE(paid_at) AS day,SUM(amount_cents) AS total_cents
    FROM mysql110_w_calendar_sales
    WHERE paid_at>=:sales_start AND paid_at<:sales_end
    GROUP BY DATE(paid_at)
), filled AS (
    SELECT days.day,COALESCE(daily.total_cents,0) AS total_cents
    FROM days LEFT JOIN daily ON daily.day=days.day
)
SELECT day,total_cents,SUM(total_cents) OVER (
    ORDER BY day ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
) AS running_cents FROM filled ORDER BY day
SQL;
$filled=window110Read($db,$sql,$params);
$rejections=[];foreach ([['2026-02-06','2026-02-01'],['2026-02-01','2026-04-01'],['2026-02-30','2026-03-01']] as [$a,$b]) {
    try { window110Period($a,$b); throw new RuntimeException('Expected period rejection'); }
    catch (InvalidArgumentException $e) { $rejections[]=[$a,$b,'rejected_before_query']; }
}
$tables=[window110Table('Только дни с операциями',['day','total_cents'],$daily),window110Table('Календарь отчёта',['day','total_cents','running_cents'],$filled),window110Table('Отказ до рекурсивного запроса',['start','end exclusive','Результат'],$rejections)];
$checks=['five_days'=>count($filled)===5,'zero_day_visible'=>$filled[1]===['2026-02-02','0','1500'],'running_survives_gap'=>$filled[3]===['2026-02-04','0','2200'],'refund_counted'=>$filled[4]===['2026-02-05','-200','2000'],'outside_period_excluded'=>$daily===[['2026-02-01','1500'],['2026-02-03','700'],['2026-02-05','-200']],'invalid_periods_rejected'=>count($rejections)===3];
window110Report('mysql110-calendar-gaps',$tables,$checks,$db);
$db=null;
echo 'mysql110-calendar-gaps: saved result.html and result.json
';
Ожидаемый результат
mysql110-calendar-gaps: saved result.html and result.json

Полезный результат — показанные выше таблицы, сохранённые в result.html и result.json. Архив включает исходные данные внутри PHP, helper и README; соседние примеры запускать не требуется. Проверяется PHP CLI с MySQL.

Открыть фактический HTML-результат · Скачать таблицы JSON

Попробуйте сами. Сделайте период [2 февраля, 5 февраля). Календарь содержит 2, 3, 4 февраля; суммы 0, 700, 0; накопительные значения 0, 700, 700, поскольку это накопление только внутри нового периода. Если нужен итог с 1 февраля, вычисляйте его на более широком наборе, а отображаемые даты отберите позже.

Практический вопрос: Как получить итог и для дней без продаж. Нужна строка на каждую дату, даже когда продажи нет. Наш ограниченный календарь включает нулевые дни и возврат; входные даты проверяются до запроса. Это источник ситуации; решение и учебные данные написаны отдельно, правила сверены с MySQL/PHP.

Перенести отчёт в свой сервис

Сначала запишите: что означает одна строка ответа, какой набор строк участвует в расчёте, как разрешаются одинаковые значения, где границы периода и как трактуется отсутствие данных. Затем передайте проверенные параметры через PDO и сохраните ожидаемую маленькую таблицу для своего примера. Индексы и план исследуйте после проверки смысла результата; подход к выборке под экран уже разобран в главе SQL-запросов для экрана.

Источники и обсуждения

Код и руководство с собой

Скачайте самостоятельные примеры с исходными данными, инструкциями и ожидаемыми результатами. Примеры HTTP и фрагменты для проекта помечены отдельно: им нужно указанное окружение.

Код не исполняется на сайте. Скачайте нужный пример и запускайте его в своём локальном окружении.

Материалы сверены 2026-10-12. Примеры и объяснения написаны для этого руководства; источники и обсуждения доступны в каждой главе.