Этап: Аналитические запросы

This commit is contained in:
vladslav064 committed 2026-11-12 08:52:15 +03:00
1 parent d90f1f30b2
commit 2ec0b38d95
10 files changed
+315 -1

No files matched your search

+10
View File
@@ -67,3 +67,13 @@
**Фактические проверки.** В существующей .venv прошли все 45 тестов. Пять новых интеграционных тестов запускали копию проекта во временном каталоге из другой рабочей папки. Переполнение суммы группы откатило изменения обеих таблиц. Испорченный CSV сохранил прежний снимок. Набор без принятых заказов очистил обе таблицы. Сбой экспорта после фиксации базы завершил CLI с ненулевым кодом и сообщением об обновлённой БД. **Фактические проверки.** В существующей .venv прошли все 45 тестов. Пять новых интеграционных тестов запускали копию проекта во временном каталоге из другой рабочей папки. Переполнение суммы группы откатило изменения обеих таблиц. Испорченный CSV сохранил прежний снимок. Набор без принятых заказов очистил обе таблицы. Сбой экспорта после фиксации базы завершил CLI с ненулевым кодом и сообщением об обновлённой БД.
На рабочем синтетическом CSV команда python main.py создала outputs/orders.db. Вход — 1500 записей, приняты 1482, отброшены 18. Получено 438 групп по дате и категории. Суммарная выручка в обеих таблицах составила 1 687 705 899 копеек. Содержимое обеих таблиц сравнили до и после повторного запуска, заказы сверили по всем шести полям с clean_orders.csv. SHA-256 исходного CSV совпал с исходным замером. Аналитические запросы остаются следующим этапом. На рабочем синтетическом CSV команда python main.py создала outputs/orders.db. Вход — 1500 записей, приняты 1482, отброшены 18. Получено 438 групп по дате и категории. Суммарная выручка в обеих таблицах составила 1 687 705 899 копеек. Содержимое обеих таблиц сравнили до и после повторного запуска, заказы сверили по всем шести полям с clean_orders.csv. SHA-256 исходного CSV совпал с исходным замером. Аналитические запросы остаются следующим этапом.
## 12 ноября 2026 — SQL-аналитика и Markdown-отчёт
**Запрос пользователя.** Подключить три запроса к main.py, сохранить фактические результаты и пояснения в outputs/query_results.md, проверить запросы на временных базах.
**Решения.** Запросы читают дневную витрину. Рейтинг ограничен пятью категориями согласно плану. Динамика включает календарные пропуски и изменение общей выручки к предыдущему дню. Для аномалий календарь общий, среднее и дисперсия отдельные для каждой категории. Перед AVG вычитается минимум категории, чтобы лучше сохранять небольшие различия при большой выручке. Метод сравнивает квадрат отклонения с четырьмя дисперсиями. Все три результата получаются в одной транзакции чтения, денежные колонки отчёта выражены в копейках.
**Фактические проверки.** В существующей .venv прошёл 51 тест. На временных базах проверены сортировка при равной выручке, пропущенный день, рост и падение по отдельным категориям, постоянный ряд, короткий период, точная граница в два стандартных отклонения и пустой снимок. Контрольная сумма рабочей базы до и после тестов совпала.
**Запуск на данных проекта.** При первом запуске Windows отказала в замене clean_orders.csv после фиксации БД. После разрешённого повторного запуска python main.py завершился успешно. Для чтения созданного отчёта при проверке также потребовался доступ вне песочницы. Все строки трёх результатов сверены с Markdown. Получены 5 категорий, 91 календарный день и 18 аномальных пар «дата и категория». SHA-256 исходного CSV совпал с ранее зафиксированным.
+2 -1
View File
@@ -1,7 +1,7 @@
import sqlite3 import sqlite3
import sys import sys
from src.database import reload_database from src.database import reload_database, save_query_results
from src.extract import CSVFileError, read_records from src.extract import CSVFileError, read_records
from src.quality import OUTPUT_DIR, build_report, save_reports from src.quality import OUTPUT_DIR, build_report, save_reports
from src.transform import clean_orders from src.transform import clean_orders
@@ -19,6 +19,7 @@ def main() -> int:
try: try:
save_reports(accepted, rejected, report) save_reports(accepted, rejected, report)
save_query_results(OUTPUT_DIR / "orders.db", OUTPUT_DIR / "query_results.md")
except Exception as exc: except Exception as exc:
exc.add_note("База уже обновлена. Сохранение файлов отчётов не завершено.") exc.add_note("База уже обновлена. Сохранение файлов отчётов не завершено.")
raise raise
BIN
View File
Binary file not shown.
+140
View File
@@ -0,0 +1,140 @@
# Выручка по синтетическим заказам
Период охватывает даты от первого до последнего принятого заказа включительно. Нулевой день внутри диапазона считаем днём без заказов. Полнота исходных данных для таких дней не подтверждена.
## Топ категорий
Пять категорий с наибольшей суммарной выручкой. При равенстве порядок определяется названием.
| Категория | Заказы, шт. | Выручка, коп. |
| --- | --- | --- |
| Электроника | 310 | 391080190 |
| Товары для дома | 293 | 349440300 |
| Спорт | 302 | 338855762 |
| Одежда | 295 | 307648084 |
| Книги | 281 | 299951820 |
## Динамика общей выручки
Сумма по всем категориям за календарный день и изменение к предыдущему дню. Для первой даты изменение не определено.
| Дата | Выручка, коп. | Изменение, коп. |
| --- | --- | --- |
| 2024-01-01 | 5234207 | — |
| 2024-01-02 | 15959605 | 10725398 |
| 2024-01-03 | 13252392 | -2707213 |
| 2024-01-04 | 17152115 | 3899723 |
| 2024-01-05 | 17877952 | 725837 |
| 2024-01-06 | 15339640 | -2538312 |
| 2024-01-07 | 19892270 | 4552630 |
| 2024-01-08 | 25490951 | 5598681 |
| 2024-01-09 | 14691064 | -10799887 |
| 2024-01-10 | 9513666 | -5177398 |
| 2024-01-11 | 14198281 | 4684615 |
| 2024-01-12 | 22484473 | 8286192 |
| 2024-01-13 | 19967837 | -2516636 |
| 2024-01-14 | 19375849 | -591988 |
| 2024-01-15 | 25760629 | 6384780 |
| 2024-01-16 | 32761991 | 7001362 |
| 2024-01-17 | 17439350 | -15322641 |
| 2024-01-18 | 25226964 | 7787614 |
| 2024-01-19 | 16038444 | -9188520 |
| 2024-01-20 | 13482523 | -2555921 |
| 2024-01-21 | 25309562 | 11827039 |
| 2024-01-22 | 25405924 | 96362 |
| 2024-01-23 | 16170985 | -9234939 |
| 2024-01-24 | 19929525 | 3758540 |
| 2024-01-25 | 13812648 | -6116877 |
| 2024-01-26 | 20075426 | 6262778 |
| 2024-01-27 | 13037503 | -7037923 |
| 2024-01-28 | 22508862 | 9471359 |
| 2024-01-29 | 16052452 | -6456410 |
| 2024-01-30 | 18644889 | 2592437 |
| 2024-01-31 | 11619914 | -7024975 |
| 2024-02-01 | 20804046 | 9184132 |
| 2024-02-02 | 9864238 | -10939808 |
| 2024-02-03 | 29199916 | 19335678 |
| 2024-02-04 | 17149356 | -12050560 |
| 2024-02-05 | 15187448 | -1961908 |
| 2024-02-06 | 18078285 | 2890837 |
| 2024-02-07 | 15343818 | -2734467 |
| 2024-02-08 | 19468051 | 4124233 |
| 2024-02-09 | 16459694 | -3008357 |
| 2024-02-10 | 14071437 | -2388257 |
| 2024-02-11 | 22626032 | 8554595 |
| 2024-02-12 | 15600384 | -7025648 |
| 2024-02-13 | 20147931 | 4547547 |
| 2024-02-14 | 23307304 | 3159373 |
| 2024-02-15 | 12665127 | -10642177 |
| 2024-02-16 | 16561242 | 3896115 |
| 2024-02-17 | 15303081 | -1258161 |
| 2024-02-18 | 26452474 | 11149393 |
| 2024-02-19 | 21636025 | -4816449 |
| 2024-02-20 | 28618838 | 6982813 |
| 2024-02-21 | 17006115 | -11612723 |
| 2024-02-22 | 11692493 | -5313622 |
| 2024-02-23 | 28585480 | 16892987 |
| 2024-02-24 | 14715781 | -13869699 |
| 2024-02-25 | 18760056 | 4044275 |
| 2024-02-26 | 17345781 | -1414275 |
| 2024-02-27 | 15714988 | -1630793 |
| 2024-02-28 | 27629837 | 11914849 |
| 2024-02-29 | 23000428 | -4629409 |
| 2024-03-01 | 15387167 | -7613261 |
| 2024-03-02 | 11285080 | -4102087 |
| 2024-03-03 | 14710174 | 3425094 |
| 2024-03-04 | 10967689 | -3742485 |
| 2024-03-05 | 18435191 | 7467502 |
| 2024-03-06 | 18962554 | 527363 |
| 2024-03-07 | 11197837 | -7764717 |
| 2024-03-08 | 15393970 | 4196133 |
| 2024-03-09 | 22885538 | 7491568 |
| 2024-03-10 | 15339096 | -7546442 |
| 2024-03-11 | 28717903 | 13378807 |
| 2024-03-12 | 20028289 | -8689614 |
| 2024-03-13 | 28862996 | 8834707 |
| 2024-03-14 | 23501759 | -5361237 |
| 2024-03-15 | 7788128 | -15713631 |
| 2024-03-16 | 16635114 | 8846986 |
| 2024-03-17 | 22454061 | 5818947 |
| 2024-03-18 | 8894345 | -13559716 |
| 2024-03-19 | 25480556 | 16586211 |
| 2024-03-20 | 22016371 | -3464185 |
| 2024-03-21 | 19594771 | -2421600 |
| 2024-03-22 | 14913116 | -4681655 |
| 2024-03-23 | 20186036 | 5272920 |
| 2024-03-24 | 16282631 | -3903405 |
| 2024-03-25 | 20217864 | 3935233 |
| 2024-03-26 | 17567169 | -2650695 |
| 2024-03-27 | 28243586 | 10676417 |
| 2024-03-28 | 25251572 | -2992014 |
| 2024-03-29 | 15348968 | -9902604 |
| 2024-03-30 | 19370836 | 4021868 |
| 2024-03-31 | 15081953 | -4288883 |
## Аномалии по категориям
Для каждой категории берём все дни периода, включая нулевые. Вычисляем среднюю дневную выручку и дисперсию генеральной совокупности как средний квадрат отклонения от среднего. День попадает в таблицу, если квадрат его отклонения строго больше четырёх дисперсий. Учитываются рост и падение. При периоде короче семи дней или нулевой дисперсии категория не даёт аномалий.
| Дата | Категория | Выручка, коп. |
| --- | --- | --- |
| 2024-01-02 | Товары для дома | 9424949 |
| 2024-01-05 | Одежда | 8751658 |
| 2024-01-16 | Книги | 9093093 |
| 2024-01-16 | Товары для дома | 10344579 |
| 2024-01-18 | Электроника | 12813010 |
| 2024-01-22 | Одежда | 9872917 |
| 2024-01-28 | Одежда | 8850850 |
| 2024-02-03 | Одежда | 12467037 |
| 2024-02-11 | Электроника | 10399062 |
| 2024-02-23 | Электроника | 11543519 |
| 2024-02-25 | Товары для дома | 9834762 |
| 2024-02-28 | Товары для дома | 9481484 |
| 2024-03-12 | Не указана | 729743 |
| 2024-03-19 | Книги | 8433237 |
| 2024-03-23 | Спорт | 10383417 |
| 2024-03-25 | Одежда | 8569495 |
| 2024-03-26 | Спорт | 8244141 |
| 2024-03-27 | Электроника | 13178994 |
Аномалия служит сигналом для проверки. Само отклонение не доказывает ошибку. На короткой выборке оценка нестабильна. Выбросы влияют на среднее и дисперсию, а метод не учитывает сезонность.
+33
View File
@@ -0,0 +1,33 @@
WITH RECURSIVE calendar(day, last_day) AS (
SELECT MIN(order_date), MAX(order_date) FROM daily_category_revenue
HAVING COUNT(*) > 0
UNION ALL
SELECT date(day, '+1 day'), last_day FROM calendar WHERE day < last_day
),
categories AS (
SELECT DISTINCT category FROM daily_category_revenue
),
daily AS (
SELECT day, c.category, COALESCE(r.revenue_kopecks, 0) AS revenue_kopecks
FROM calendar CROSS JOIN categories AS c
LEFT JOIN daily_category_revenue AS r ON r.order_date = day AND r.category = c.category
),
-- Вычитаем общий для категории минимум в целых копейках до перехода AVG к REAL.
-- Такой сдвиг сохраняет отклонения и различия в копейку при большом базовом уровне.
shifted AS (
SELECT *, revenue_kopecks - MIN(revenue_kopecks) OVER (PARTITION BY category) AS amount
FROM daily
),
deviations AS (
SELECT *, amount - AVG(amount) OVER (PARTITION BY category) AS deviation
FROM shifted
),
stats AS (
SELECT *, COUNT(*) OVER (PARTITION BY category) AS days,
AVG(deviation * deviation) OVER (PARTITION BY category) AS variance
FROM deviations
)
SELECT day AS order_date, category, revenue_kopecks
FROM stats
WHERE days >= 7 AND variance > 0 AND deviation * deviation > 4 * variance
ORDER BY day, category;
+16
View File
@@ -0,0 +1,16 @@
WITH RECURSIVE calendar(day, last_day) AS (
SELECT MIN(order_date), MAX(order_date) FROM daily_category_revenue
HAVING COUNT(*) > 0
UNION ALL
SELECT date(day, '+1 day'), last_day FROM calendar WHERE day < last_day
),
daily AS (
SELECT day AS order_date, COALESCE(SUM(r.revenue_kopecks), 0) AS revenue_kopecks
FROM calendar
LEFT JOIN daily_category_revenue AS r ON r.order_date = day
GROUP BY day
)
SELECT order_date, revenue_kopecks,
revenue_kopecks - LAG(revenue_kopecks) OVER (ORDER BY order_date) AS change_kopecks
FROM daily
ORDER BY order_date;
+7
View File
@@ -0,0 +1,7 @@
SELECT category,
SUM(order_count) AS order_count,
SUM(revenue_kopecks) AS revenue_kopecks
FROM daily_category_revenue
GROUP BY category
ORDER BY revenue_kopecks DESC, category
LIMIT 5;
+48
View File
@@ -3,7 +3,9 @@
import sqlite3 import sqlite3
from collections.abc import Iterable from collections.abc import Iterable
from contextlib import closing from contextlib import closing
from html import escape
from pathlib import Path from pathlib import Path
from tempfile import TemporaryDirectory
from .transform import Order from .transform import Order
@@ -31,3 +33,49 @@ def reload_database(path: Path, orders: Iterable[Order]) -> None:
FROM orders FROM orders
GROUP BY order_date, category GROUP BY order_date, category
""") """)
def save_query_results(path: Path, destination: Path) -> None:
"""Выполнить три запроса на одном снимке и заменить готовый Markdown-отчёт."""
sections = (
("top_categories", "Топ категорий",
"Пять категорий с наибольшей суммарной выручкой. При равенстве порядок определяется названием.",
("Категория", "Заказы, шт.", "Выручка, коп.")),
("daily_revenue", "Динамика общей выручки",
"Сумма по всем категориям за календарный день и изменение к предыдущему дню. "
"Для первой даты изменение не определено.",
("Дата", "Выручка, коп.", "Изменение, коп.")),
("anomalies", "Аномалии по категориям",
"Для каждой категории берём все дни периода, включая нулевые. Вычисляем среднюю дневную "
"выручку и дисперсию генеральной совокупности как средний квадрат отклонения от среднего. "
"День попадает в таблицу, если квадрат его отклонения строго больше четырёх дисперсий. "
"Учитываются рост и падение. При периоде короче семи дней или нулевой дисперсии "
"категория не даёт аномалий.",
("Дата", "Категория", "Выручка, коп.")),
)
lines = ["# Выручка по синтетическим заказам", "",
"Период охватывает даты от первого до последнего принятого заказа включительно. "
"Нулевой день внутри диапазона считаем днём без заказов. Полнота исходных данных "
"для таких дней не подтверждена."]
with closing(sqlite3.connect(path.resolve().as_uri() + "?mode=ro", uri=True)) as db:
db.execute("BEGIN")
for name, title, description, columns in sections:
sql = SCHEMA.with_name(name + ".sql").read_text(encoding="utf-8")
rows = db.execute(sql).fetchall()
lines.extend(("", f"## {title}", "", description, ""))
if not rows:
lines.append("Аномалий не обнаружено." if name == "anomalies" else "Нет принятых заказов.")
continue
lines.append("| " + " | ".join(columns) + " |")
lines.append("| " + " | ".join("---" for _ in columns) + " |")
for row in rows:
cells = ["—" if value is None else escape(str(value)).replace("|", "&#124;")
for value in row]
lines.append("| " + " | ".join(cells) + " |")
lines.extend(("", "Аномалия служит сигналом для проверки. Само отклонение не доказывает ошибку. "
"На короткой выборке оценка нестабильна. Выбросы влияют на среднее и дисперсию, "
"а метод не учитывает сезонность."))
with TemporaryDirectory(dir=destination.parent) as staging:
report = Path(staging) / destination.name
report.write_text("\n".join(lines) + "\n", encoding="utf-8")
report.replace(destination)
+4
View File
@@ -73,6 +73,10 @@ def test_full_run_and_repeat(project):
assert (len(amounts), sum(amounts)) == (count, revenue) assert (len(amounts), sum(amounts)) == (count, revenue)
outputs = project / "outputs" outputs = project / "outputs"
markdown = (outputs / "query_results.md").read_text(encoding="utf-8")
assert "| Книги | 3 | 2600 |" in markdown
assert "| 2024-01-02 | 500 | -1900 |" in markdown
assert "Аномалий не обнаружено." in markdown
with (outputs / "clean_orders.csv").open(encoding="utf-8-sig", newline="") as source: with (outputs / "clean_orders.csv").open(encoding="utf-8-sig", newline="") as source:
clean = list(csv.DictReader(source)) clean = list(csv.DictReader(source))
assert sorted((int(r["order_id"]), r["order_date"], r["category"], int(r["quantity"]), assert sorted((int(r["order_id"]), r["order_date"], r["category"], int(r["quantity"]),
+55
View File
@@ -0,0 +1,55 @@
import sqlite3
from contextlib import closing
import pytest
from src.database import SCHEMA, reload_database, save_query_results
from src.transform import Order
def make_database(tmp_path, sales):
path = tmp_path / "orders.db"
orders = [
Order(i, f"2024-01-{day:02}", category, 1, amount, amount, i)
for i, (day, category, amount) in enumerate(sales, 1)
]
reload_database(path, orders)
return path
def query(path, name):
with closing(sqlite3.connect(path)) as db:
return db.execute(SCHEMA.with_name(name + ".sql").read_text(encoding="utf-8")).fetchall()
def test_ranking_and_calendar_gap(tmp_path):
db = make_database(tmp_path, [(1, "B", 50), (1, "B", 50), (3, "A", 100), (3, "C", 1)])
assert query(db, "top_categories") == [("A", 1, 100), ("B", 2, 100), ("C", 1, 1)]
assert query(db, "daily_revenue") == [
("2024-01-01", 100, None), ("2024-01-02", 0, -100), ("2024-01-03", 101, 101),
]
def test_separate_categories_and_zero_days(tmp_path):
sales = [(day, "Постоянная", 50) for day in range(1, 8)]
sales += [(day, "Падение", 100) for day in range(1, 8) if day != 4]
sales += [(4, "Рост", 100)]
db = make_database(tmp_path, sales)
assert query(db, "anomalies") == [
("2024-01-04", "Падение", 0), ("2024-01-04", "Рост", 100),
]
@pytest.mark.parametrize("amounts", [[10] * 7, [1] * 5 + [100], [10] * 8 + [20] * 2])
def test_constant_short_or_exactly_two_standard_deviations(tmp_path, amounts):
db = make_database(tmp_path, [(i, "A", value) for i, value in enumerate(amounts, 1)])
assert query(db, "anomalies") == []
def test_empty_queries_and_report(tmp_path):
db = make_database(tmp_path, [])
for name in ("top_categories", "daily_revenue", "anomalies"):
assert query(db, name) == []
destination = tmp_path / "query_results.md"
save_query_results(db, destination)
assert "Аномалий не обнаружено." in destination.read_text(encoding="utf-8")