From 2ec0b38d95b81073518e66fca39e0628711a13b4 Mon Sep 17 00:00:00 2001 From: Vladlav064 Date: Thu, 12 Nov 2026 08:52:15 +0300 Subject: [PATCH] =?UTF-8?q?=D0=AD=D1=82=D0=B0=D0=BF:=20=D0=90=D0=BD=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D1=82=D0=B8=D1=87=D0=B5=D1=81=D0=BA=D0=B8=D0=B5=20?= =?UTF-8?q?=D0=B7=D0=B0=D0=BF=D1=80=D0=BE=D1=81=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ai_work_log.md | 10 +++ main.py | 3 +- outputs/orders.db | Bin 102400 -> 102400 bytes outputs/query_results.md | 140 +++++++++++++++++++++++++++++++++++++++ sql/anomalies.sql | 33 +++++++++ sql/daily_revenue.sql | 16 +++++ sql/top_categories.sql | 7 ++ src/database.py | 48 ++++++++++++++ tests/test_pipeline.py | 4 ++ tests/test_queries.py | 55 +++++++++++++++ 10 files changed, 315 insertions(+), 1 deletion(-) create mode 100644 outputs/query_results.md create mode 100644 sql/anomalies.sql create mode 100644 sql/daily_revenue.sql create mode 100644 sql/top_categories.sql create mode 100644 tests/test_queries.py diff --git a/docs/ai_work_log.md b/docs/ai_work_log.md index 06f6f4b..2797b34 100644 --- a/docs/ai_work_log.md +++ b/docs/ai_work_log.md @@ -67,3 +67,13 @@ **Фактические проверки.** В существующей .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 совпал с исходным замером. Аналитические запросы остаются следующим этапом. + +## 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 совпал с ранее зафиксированным. diff --git a/main.py b/main.py index 10744aa..8aca6ed 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,7 @@ import sqlite3 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.quality import OUTPUT_DIR, build_report, save_reports from src.transform import clean_orders @@ -19,6 +19,7 @@ def main() -> int: try: save_reports(accepted, rejected, report) + save_query_results(OUTPUT_DIR / "orders.db", OUTPUT_DIR / "query_results.md") except Exception as exc: exc.add_note("База уже обновлена. Сохранение файлов отчётов не завершено.") raise diff --git a/outputs/orders.db b/outputs/orders.db index a47ff2f631dcb9ea9ec2355d101a794127b71289..ec179eec568787427177608b16de6bbda338f240 100644 GIT binary patch delta 60 zcmZozz}B#UZGto-%S0JxMwX2UF&}u?nG`JA4D4_I>~JRS-|*&fAd|1SeETpt&GRG E0MdIAvH$=8 diff --git a/outputs/query_results.md b/outputs/query_results.md new file mode 100644 index 0000000..321c334 --- /dev/null +++ b/outputs/query_results.md @@ -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 | + +Аномалия служит сигналом для проверки. Само отклонение не доказывает ошибку. На короткой выборке оценка нестабильна. Выбросы влияют на среднее и дисперсию, а метод не учитывает сезонность. diff --git a/sql/anomalies.sql b/sql/anomalies.sql new file mode 100644 index 0000000..1f15777 --- /dev/null +++ b/sql/anomalies.sql @@ -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; diff --git a/sql/daily_revenue.sql b/sql/daily_revenue.sql new file mode 100644 index 0000000..eeca6f0 --- /dev/null +++ b/sql/daily_revenue.sql @@ -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; diff --git a/sql/top_categories.sql b/sql/top_categories.sql new file mode 100644 index 0000000..71cc320 --- /dev/null +++ b/sql/top_categories.sql @@ -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; diff --git a/src/database.py b/src/database.py index beabae7..b633ed4 100644 --- a/src/database.py +++ b/src/database.py @@ -3,7 +3,9 @@ import sqlite3 from collections.abc import Iterable from contextlib import closing +from html import escape from pathlib import Path +from tempfile import TemporaryDirectory from .transform import Order @@ -31,3 +33,49 @@ def reload_database(path: Path, orders: Iterable[Order]) -> None: FROM orders 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("|", "|") + 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) diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py index 9982434..caafc2f 100644 --- a/tests/test_pipeline.py +++ b/tests/test_pipeline.py @@ -73,6 +73,10 @@ def test_full_run_and_repeat(project): assert (len(amounts), sum(amounts)) == (count, revenue) 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: clean = list(csv.DictReader(source)) assert sorted((int(r["order_id"]), r["order_date"], r["category"], int(r["quantity"]), diff --git a/tests/test_queries.py b/tests/test_queries.py new file mode 100644 index 0000000..63ed3e1 --- /dev/null +++ b/tests/test_queries.py @@ -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")