Проверка на данном этапе
This commit is contained in:
commit
626ac21b41
9 files changed
+1986
No files matched your search
@@ -0,0 +1,6 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
*.pyc
|
||||
.env
|
||||
+1501
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,37 @@
|
||||
# Журнал работы с ИИ
|
||||
|
||||
## 12 ноября 2026 — осмотр проекта
|
||||
|
||||
**Запрос пользователя.** Подготовить по этапам ETL-пайплайн для очистки CSV и агрегации выручки по дням и категориям в SQLite. Сохранять исходный CSV, учитывать причины отбраковки, проверить идемпотентность, подготовить три аналитических запроса с результатами, README и готовую базу. Вести журнал по фактически выполненной работе. Проверки писать на pytest. Оставить `main.py` в корне для объединения этапов, функции разместить в `src/`, пути вычислять от корня проекта.
|
||||
|
||||
**Проверки.** Осмотр корня проекта через PowerShell показал `.git/` и `.gitignore`. Прочитан `.gitignore`, проверено состояние Git. Исходный CSV, код и тесты отсутствовали. Файл `AGENTS.md` в корне проекта и родительских каталогах не обнаружен. Дополнительная команда `rg --files` завершилась ошибкой канала запуска. Список содержимого корня получен через `Get-ChildItem -Force`.
|
||||
|
||||
**Решение.** Первый этап реализации пользователь пока не указал. Работа ограничена осмотром и созданием журнала. Схема CSV и правила расчёта выручки пока не определены. Тесты не запускались.
|
||||
|
||||
## 12 ноября 2026 — план реализации
|
||||
|
||||
**Запрос пользователя.** Изучить проект и подготовить краткий план без кода пайплайна. Зафиксировать заданную модель заказа, денежные ограничения, порядок обработки дублей, нумерацию записей CSV, интерфейсы модулей, полную транзакционную перезагрузку, критерии готовности и неоднозначности.
|
||||
|
||||
**Принятые решения.** В [плане реализации](implementation_plan.md) описаны пять полей заказа, точный разбор цены через Decimal, хранение денег в копейках и проверка диапазона INTEGER. Точные дубли определяются по исходным полям до валидации. Конфликты ID проверяются по всей группе валидных заказов до выбора представителя. Для отчёта у каждой отброшенной записи одна причина и логический номер после заголовка, начиная с единицы.
|
||||
|
||||
**Разделение работы.** Чтение, очистка и загрузка распределены между тремя модулями в src/. Дневные агрегаты строятся в SQLite при полной перезагрузке двух таблиц в одной транзакции. В плане отдельно отмечены предлагаемые параметры CSV и определения аналитических запросов.
|
||||
|
||||
**Фактические проверки и изменения.** Повторный осмотр через rg и Git показал только .gitignore и ранее созданный журнал. Прочитан журнал, проверено отсутствие AGENTS.md в каталогах проекта и документации и по пути к корню диска. Добавлен план и обновлён журнал. Код пайплайна не создавался, pytest не запускался.
|
||||
|
||||
## 12 ноября 2026 — генератор синтетических заказов
|
||||
|
||||
**Запрос пользователя.** Создать воспроизводимый CSV на 1500 записей с seed=42, заданными дублями и ошибками. Запустить генератор и тесты количества записей и воспроизводимости на временных файлах.
|
||||
|
||||
**Решения.** В `src/generate_data.py` используется локальный `Random(42)`. Корректные даты выбираются из диапазона 01.01.2024–31.03.2024 включительно. Цены формируются из целых копеек, категории выбираются из фиксированного списка. Для трёх заказов добавляется по одной копии — точной, с другой записью той же даты и с изменённым количеством. Для остальных заданных случаев изменено по одному полю в отдельных заказах. Записи перемешиваются тем же генератором. CSV синтетический, без персональных данных. Кодировка UTF-8 и перевод строки LF заданы явно.
|
||||
|
||||
**Окружение.** В доступном Python 3.14.4 отсутствовал pytest. Создана локальная .venv. Первая попытка установки не нашла дистрибутив, затем из PyPI установлен pytest 9.2.0.
|
||||
|
||||
**Проверки.** Команда `python -m src.generate_data` выполнена дважды. SHA-256 файла `data/raw_orders.csv` после обоих запусков совпал — `c4b9ffd2eb24c6c79b6a3e2436f3bd348ba86696a1aeb9b7e3083af65c799c67`. Чтение через csv.DictReader подтвердило пять заданных колонок, 1500 записей, один точный дубль и по одному пустому значению в каждом поле. ID 1, 2 и 3 встретились по два раза. Запуск `.\.venv\Scripts\python.exe -m pytest -q` завершился результатом `2 passed in 0.08s`. Оба теста используют tmp_path.
|
||||
|
||||
## 12 ноября 2026 — чтение и очистка
|
||||
|
||||
**Запрос пользователя.** Реализовать extract.py и transform.py, сохранить номер и исходные значения отклонённой записи, вернуть все обнаруженные ошибки полей, проверить дубли, конфликты, точность расчётов и сохранность CSV.
|
||||
|
||||
**Решения и изменения.** csv.reader в строгом режиме читает UTF-8 с поддержкой BOM и нумерует логические записи после заголовка. Нечитаемый файл, ошибка разбора CSV или неверный заголовок прерывают обработку. Для читаемой записи неверной ширины сохраняются все исходные поля. Точный дубль получает только причину повтора. Для остальных структурно корректных записей собираются ошибки независимых полей. Выручка проверяется при пригодных для расчёта цене и количестве. Среди валидных заказов сначала определяется наличие конфликта во всей группе ID. Номер источника сохраняется и у принятого заказа, но исключён из сравнения нормализованных значений. План обновлён согласно уточнению о нескольких причинах.
|
||||
|
||||
**Фактические проверки.** Полный запуск pytest дал 32 passed. На синтетическом raw_orders.csv обработаны 1500 записей, приняты 1482, отброшены 18. Из них 14 имели ошибки значений, одна оказалась точным дублем, одна — нормализованным, две отклонены из-за конфликта ID. Проверено, что номера от 1 до 1500 встречаются в совокупном результате ровно по одному разу. Байты CSV до и после обработки совпали, SHA-256 — `c4b9ffd2eb24c6c79b6a3e2436f3bd348ba86696a1aeb9b7e3083af65c799c67`.
|
||||
@@ -0,0 +1,51 @@
|
||||
# План реализации ETL
|
||||
|
||||
## Модель данных
|
||||
|
||||
Одна запись CSV описывает один завершённый заказ с одним товаром. Возвраты, скидки, бесплатные товары и несколько валют вне модели. Все пять колонок обязательны в заголовке. Пустое значение допустимо только в `category`.
|
||||
|
||||
| Поле | Принятое решение |
|
||||
|---|---|
|
||||
| `order_id` | Положительное целое число. После разбора ведущие нули не влияют на идентичность заказа. |
|
||||
| `order_date` | Только календарно корректные даты строго вида `YYYY-MM-DD` или `DD.MM.YYYY`, включая ведущие нули. В базе хранить текст `YYYY-MM-DD`. |
|
||||
| `category` | Удалить внешние пробельные символы, каждую внутреннюю последовательность пробельных символов заменить одним пробелом. Первую букву строки привести к верхнему регистру, остальные к нижнему. Пустое значение заменить на «Не указана». |
|
||||
| `quantity` | Строго положительное целое количество единиц товара. |
|
||||
| `unit_price` | Строго положительная цена единицы в рублях. Принимать целую часть из цифр и необязательную дробную часть из одной или двух цифр после точки или запятой. Разбирать через `Decimal`, переводить в целые копейки точно, без округления. |
|
||||
|
||||
Для числовых полей принимаем десятичные цифры ASCII, для всех полей при нормализации убираем внешние пробелы. Знаки, экспоненциальная запись, разделители тысяч и неконечные значения, включая NaN и Infinity, отклоняются. Цена `1,230` отклоняется даже при нуле в третьем разряде. Выручка заказа в копейках равна количеству, умноженному на цену единицы в копейках. ID, количество, цена в копейках и произведение должны умещаться в знаковый INTEGER SQLite с максимумом 9 223 372 036 854 775 807. Переполнение отдельного заказа является причиной отбраковки записи.
|
||||
|
||||
## Порядок обработки
|
||||
|
||||
1. Читать CSV стандартным модулем `csv`, открывая файл с `newline=''`. Считать логические записи после заголовка с единицы, включая отклонённые записи. Поле в кавычках с переводом строки входит в одну запись. Сохранять номер у принятого заказа и в отчёте для каждой отброшенной записи.
|
||||
2. До очистки сравнить кортежи исходных значений полей, полученных от CSV-парсера. Для каждого кортежа оставить первое вхождение, последующие отметить как точные дубли. Это правило действует и для повторов невалидного заказа.
|
||||
3. Проверить число полей. Для записи правильной ширины собрать все ошибки независимых полей в порядке таблицы. Проверить произведение, только если количество и цена прошли проверку. Сохранять один итоговый статус и список фактически установленных причин на запись.
|
||||
4. Сгруппировать только валидные записи по нормализованному ID. Сравнивать дату, категорию, количество и цену в копейках. Если значения совпадают у всей группы, принять запись с наименьшим номером, остальные отметить как нормализованные дубли. Если есть хотя бы два варианта значений, отклонить всю валидную группу как конфликт ID, включая повторившиеся варианты. Уже отброшенные точные дубли сохраняют исходную причину.
|
||||
5. Вывести количество прочитанных, принятых и отброшенных записей, разбивку по причинам и номера отклонённых записей. Число прочитанных должно равняться сумме принятых и отброшенных. Сумма счётчиков причин может быть больше числа отброшенных записей.
|
||||
|
||||
## Модули и загрузка
|
||||
|
||||
| Модуль | Предлагаемый интерфейс и ответственность |
|
||||
|---|---|
|
||||
| `src/extract.py` | `read_records(path) -> Iterator[RawRecord]`. Передаёт номер записи и исходные значения, проверяет заголовок и ширину записи. |
|
||||
| `src/transform.py` | `clean_orders(records) -> tuple[list[Order], list[RejectedRecord]]`. Рассчитывает выручку одного заказа и возвращает принятые заказы и отбраковку с причинами. |
|
||||
| `src/storage.py` | `reload_database(path, orders) -> None`. Обновляет обе таблицы атомарно и рассчитывает дневные агрегаты в SQLite. |
|
||||
| `main.py` | Определяет корень по расположению самого файла, собирает пути к ресурсам, вызывает этапы и выводит отчёт. |
|
||||
|
||||
В `orders` хранить одну принятую запись на `order_id` (первичный ключ), включая номер записи, дату, категорию, количество, цену и выручку в копейках. В `daily_revenue` хранить сумму выручки в копейках с составным первичным ключом из даты и категории.
|
||||
|
||||
В одной явной транзакции создать таблицы при первом запуске, очистить обе, загрузить принятые заказы и заполнить `daily_revenue` запросом с `SUM` и `GROUP BY order_date, category` из `orders`. Это единственное место построения дневной витрины. Зафиксировать транзакцию после успешного завершения всех операций. Ошибка загрузки или переполнение суммы группы отменяет транзакцию целиком, сохраняя предыдущие данные.
|
||||
|
||||
## Неоднозначности и рабочие допущения
|
||||
|
||||
- Исходного CSV пока нет. Предлагаемый формат — UTF-8 с поддержкой BOM, запятая между полями, двойные кавычки для значений с запятой или переводом строки. Для синтетического набора явно указать происхождение в README. Исходный файл открывать только на чтение.
|
||||
- Колонки сопоставлять по именам. Отсутствующее, повторное или лишнее имя в заголовке считать ошибкой файла. Неверное число полей в читаемой записи считать ошибкой записи. Если из-за повреждения CSV границы записей определить невозможно, остановить запуск до загрузки.
|
||||
- Файл с корректным заголовком и нулём принятых заказов считать пустым снимком. При полной перезагрузке обе таблицы станут пустыми.
|
||||
- Для аналитики предлагаются три запроса к витрине. Топ — пять категорий по суммарной выручке с сортировкой по имени при равенстве. Динамика — сумма по дате и изменение в копейках к предыдущей доступной дате. Аномалия — дневная выручка категории более чем вдвое выше предыдущего наблюдения той же категории. Первое наблюдение пропускается, пропущенные даты нулями не заполняются. Порог — рабочее допущение для тестового задания.
|
||||
|
||||
## Критерии готовности реализации
|
||||
|
||||
- На pytest проверить оба формата дат, невозможные даты, пробелы, пустые поля, дробное количество, варианты цены и переполнение на границе INTEGER. Проверить нумерацию на CSV с многострочным полем.
|
||||
- На размеченном примере сверить статус каждой исходной записи, причины и баланс счётчиков. Включить повтор невалидной записи, одинаковые нормализованные заказы, конфликт из трёх записей с двумя совпадающими вариантами и валидную запись рядом с невалидной того же ID.
|
||||
- Сверить копейки по известному примеру. Две единицы по 10,25 рубля и одна за 0,50 рубля в тот же день и в той же категории должны дать одну строку витрины на 2100 копеек.
|
||||
- Запустить пайплайн дважды с тем же CSV, в том числе из другой рабочей папки. Сравнить содержимое обеих таблиц, отчёты и контрольную сумму исходного файла. На изменённом снимке проверить удаление устаревших заказов и агрегатов. На переполнении суммы группы проверить откат обеих таблиц.
|
||||
- Перед сдачей подготовить готовую SQLite-базу, три SQL-запроса с фактическими результатами, README с командами запуска и тестов и журнал фактически выполненных проверок.
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Чтение логических записей CSV с сохранением исходных значений."""
|
||||
|
||||
import csv
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
FIELDS = ("order_id", "order_date", "category", "quantity", "unit_price")
|
||||
DEFAULT_INPUT = Path(__file__).resolve().parents[1] / "data" / "raw_orders.csv"
|
||||
|
||||
|
||||
class CSVFileError(ValueError):
|
||||
"""Файл целиком нельзя использовать как источник заказов."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RawRecord:
|
||||
record_number: int
|
||||
values: tuple[str, ...]
|
||||
header: tuple[str, ...]
|
||||
structure_error: str | None = None
|
||||
|
||||
|
||||
def read_records(path: Path = DEFAULT_INPUT) -> Iterator[RawRecord]:
|
||||
"""Нумерация начинается с 1 после заголовка, включая пустые записи."""
|
||||
try:
|
||||
with path.open(encoding="utf-8-sig", newline="") as source:
|
||||
reader = csv.reader(source, strict=True)
|
||||
header = tuple(next(reader, ()))
|
||||
if sorted(header) != sorted(FIELDS):
|
||||
raise CSVFileError(
|
||||
f"{path}: неверный заголовок. Ожидались колонки {FIELDS}, получены {header}"
|
||||
)
|
||||
for number, row in enumerate(reader, start=1):
|
||||
error = None
|
||||
if not row:
|
||||
error = "Пустая запись CSV"
|
||||
elif len(row) != len(header):
|
||||
error = f"Число полей: ожидалось {len(header)}, получено {len(row)}"
|
||||
yield RawRecord(number, tuple(row), header, error)
|
||||
except csv.Error as exc:
|
||||
raise CSVFileError(
|
||||
f"{path}: ошибка структуры CSV около физической строки {reader.line_num}: {exc}"
|
||||
) from exc
|
||||
except UnicodeDecodeError as exc:
|
||||
raise CSVFileError(f"{path}: файл не удалось прочитать как UTF-8") from exc
|
||||
except FileNotFoundError as exc:
|
||||
raise CSVFileError(f"{path}: файл не найден") from exc
|
||||
except OSError as exc:
|
||||
raise CSVFileError(f"{path}: ошибка чтения: {exc}") from exc
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Генерация синтетических заказов для тестового ETL."""
|
||||
|
||||
import csv
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from random import Random
|
||||
|
||||
OUTPUT_PATH = Path(__file__).resolve().parents[1] / "data" / "raw_orders.csv"
|
||||
FIELDS = ("order_id", "order_date", "category", "quantity", "unit_price")
|
||||
CATEGORIES = ("Книги", "Товары для дома", "Одежда", "Спорт", "Электроника")
|
||||
START_DATE = date(2024, 1, 1)
|
||||
END_DATE = date(2024, 3, 31)
|
||||
RECORD_COUNT = 1500
|
||||
|
||||
# Каждая подстановка применяется к отдельному заказу.
|
||||
FIELD_CASES = (
|
||||
("order_id", ""),
|
||||
("order_date", ""),
|
||||
("category", ""),
|
||||
("quantity", ""),
|
||||
("unit_price", ""),
|
||||
("order_date", "2024-02-30"),
|
||||
("order_date", "03/15/2024"),
|
||||
("category", " тОВАРЫ для ДОМА "),
|
||||
("quantity", "1.5"),
|
||||
("quantity", "0"),
|
||||
("quantity", "-2"),
|
||||
("quantity", "три"),
|
||||
("unit_price", "0"),
|
||||
("unit_price", "-12.50"),
|
||||
("unit_price", "бесплатно"),
|
||||
("unit_price", "12.340"),
|
||||
("unit_price", "12,50"),
|
||||
)
|
||||
|
||||
|
||||
def generate_data(path: Path = OUTPUT_PATH) -> None:
|
||||
rng = Random(42)
|
||||
days = (END_DATE - START_DATE).days
|
||||
orders = []
|
||||
# Три места зарезервированы для копий первых трёх заказов.
|
||||
for order_id in range(1, RECORD_COUNT - 3 + 1):
|
||||
order_date = START_DATE + timedelta(days=rng.randint(0, days))
|
||||
price_kopecks = rng.randint(1, 500_000)
|
||||
orders.append({
|
||||
"order_id": str(order_id),
|
||||
"order_date": order_date.isoformat(),
|
||||
"category": rng.choice(CATEGORIES),
|
||||
"quantity": str(rng.randint(1, 8)),
|
||||
"unit_price": f"{price_kopecks // 100}.{price_kopecks % 100:02d}",
|
||||
})
|
||||
|
||||
for index, (field, value) in enumerate(FIELD_CASES, start=3):
|
||||
orders[index][field] = value
|
||||
|
||||
same_date = date.fromisoformat(orders[1]["order_date"])
|
||||
orders.extend((
|
||||
orders[0].copy(),
|
||||
dict(orders[1], order_date=same_date.strftime("%d.%m.%Y")),
|
||||
dict(orders[2], quantity=str(int(orders[2]["quantity"]) + 1)),
|
||||
))
|
||||
rng.shuffle(orders)
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8", newline="") as output:
|
||||
writer = csv.DictWriter(output, fieldnames=FIELDS, lineterminator="\n")
|
||||
writer.writeheader()
|
||||
writer.writerows(orders)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
generate_data()
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Проверка заказов и обработка дублей до загрузки в SQLite."""
|
||||
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
|
||||
from .extract import RawRecord
|
||||
|
||||
MAX_INTEGER = 2**63 - 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Order:
|
||||
order_id: int
|
||||
order_date: str
|
||||
category: str
|
||||
quantity: int
|
||||
unit_price_kopecks: int
|
||||
revenue_kopecks: int
|
||||
record_number: int = field(compare=False)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RejectedRecord:
|
||||
source: RawRecord
|
||||
reasons: list[str]
|
||||
|
||||
|
||||
def _positive_integer(value: str) -> int:
|
||||
if not re.fullmatch(r"[0-9]+", value):
|
||||
raise ValueError("ожидается положительное целое число")
|
||||
digits = value.lstrip("0")
|
||||
if not digits:
|
||||
raise ValueError("значение должно быть больше нуля")
|
||||
if len(digits) > 19 or int(digits) > MAX_INTEGER:
|
||||
raise ValueError("превышен предел INTEGER SQLite")
|
||||
return int(digits)
|
||||
|
||||
|
||||
def _price_kopecks(value: str) -> int:
|
||||
match = re.fullmatch(r"([0-9]+)(?:[.,]([0-9]+))?", value)
|
||||
if not match:
|
||||
raise ValueError("ожидается положительная десятичная цена в рублях")
|
||||
whole, fraction = match.groups(default="")
|
||||
if len(fraction) > 2:
|
||||
raise ValueError("больше двух дробных знаков")
|
||||
if len(whole.lstrip("0")) > 17:
|
||||
raise ValueError("цена в копейках превышает предел INTEGER SQLite")
|
||||
numerator, denominator = Decimal(value.replace(",", ".")).as_integer_ratio()
|
||||
kopecks = numerator * 100 // denominator
|
||||
if kopecks == 0:
|
||||
raise ValueError("цена должна быть больше нуля")
|
||||
if kopecks > MAX_INTEGER:
|
||||
raise ValueError("цена в копейках превышает предел INTEGER SQLite")
|
||||
return kopecks
|
||||
|
||||
|
||||
def _order_date(value: str) -> str:
|
||||
if re.fullmatch(r"[0-9]{2}\.[0-9]{2}\.[0-9]{4}", value):
|
||||
day, month, year = value.split(".")
|
||||
value = f"{year}-{month}-{day}"
|
||||
elif not re.fullmatch(r"[0-9]{4}-[0-9]{2}-[0-9]{2}", value):
|
||||
raise ValueError("ожидается YYYY-MM-DD или DD.MM.YYYY")
|
||||
try:
|
||||
return date.fromisoformat(value).isoformat()
|
||||
except ValueError as exc:
|
||||
raise ValueError("несуществующая календарная дата") from exc
|
||||
|
||||
|
||||
def _validate(record: RawRecord) -> Order | RejectedRecord:
|
||||
values = dict(zip(record.header, record.values, strict=True))
|
||||
reasons = []
|
||||
|
||||
def parse(name, parser):
|
||||
value = values[name].strip()
|
||||
if not value:
|
||||
reasons.append(f"{name}: пропущено обязательное значение")
|
||||
return None
|
||||
try:
|
||||
return parser(value)
|
||||
except ValueError as exc:
|
||||
reasons.append(f"{name}: {exc}")
|
||||
return None
|
||||
|
||||
order_id = parse("order_id", _positive_integer)
|
||||
order_date = parse("order_date", _order_date)
|
||||
category = " ".join(values["category"].split()).capitalize() or "Не указана"
|
||||
quantity = parse("quantity", _positive_integer)
|
||||
price = parse("unit_price", _price_kopecks)
|
||||
if quantity is not None and price is not None:
|
||||
revenue = quantity * price
|
||||
if revenue > MAX_INTEGER:
|
||||
reasons.append("revenue_kopecks: превышен предел INTEGER SQLite")
|
||||
|
||||
if reasons:
|
||||
return RejectedRecord(record, reasons)
|
||||
return Order(order_id, order_date, category, quantity, price, revenue, record.record_number)
|
||||
|
||||
|
||||
def clean_orders(records: Iterable[RawRecord]) -> tuple[list[Order], list[RejectedRecord]]:
|
||||
"""Вернуть принятые и отброшенные записи в исходном порядке внутри списков."""
|
||||
seen = set()
|
||||
groups = defaultdict(list)
|
||||
rejected = []
|
||||
for record in records:
|
||||
if record.values in seen:
|
||||
rejected.append(RejectedRecord(record, ["Точный дубль"]))
|
||||
continue
|
||||
seen.add(record.values)
|
||||
if record.structure_error:
|
||||
rejected.append(RejectedRecord(record, [record.structure_error]))
|
||||
continue
|
||||
result = _validate(record)
|
||||
if isinstance(result, RejectedRecord):
|
||||
rejected.append(result)
|
||||
else:
|
||||
groups[result.order_id].append((record, result))
|
||||
|
||||
accepted = []
|
||||
for group in groups.values():
|
||||
first = group[0][1]
|
||||
if any(order != first for _, order in group):
|
||||
rejected.extend(RejectedRecord(raw, ["Конфликт order_id"]) for raw, _ in group)
|
||||
else:
|
||||
accepted.append(first)
|
||||
rejected.extend(RejectedRecord(raw, ["Нормализованный дубль"]) for raw, _ in group[1:])
|
||||
rejected.sort(key=lambda item: item.source.record_number)
|
||||
return accepted, rejected
|
||||
@@ -0,0 +1,118 @@
|
||||
import csv
|
||||
|
||||
import pytest
|
||||
|
||||
from src.extract import CSVFileError, FIELDS, read_records
|
||||
from src.transform import MAX_INTEGER, clean_orders
|
||||
|
||||
|
||||
def order(**changes):
|
||||
return dict(order_id="1", order_date="2024-02-29", category="Книги",
|
||||
quantity="3", unit_price="0,07") | changes
|
||||
|
||||
|
||||
def process(tmp_path, *rows, header=FIELDS):
|
||||
path = tmp_path / "orders.csv"
|
||||
with path.open("w", encoding="utf-8-sig", newline="") as output:
|
||||
writer = csv.writer(output)
|
||||
writer.writerow(header)
|
||||
for row in rows:
|
||||
writer.writerow([row[name] for name in header] if isinstance(row, dict) else row)
|
||||
before = path.read_bytes()
|
||||
accepted, rejected = clean_orders(read_records(path))
|
||||
assert path.read_bytes() == before
|
||||
numbers = [item.record_number for item in accepted]
|
||||
numbers.extend(item.source.record_number for item in rejected)
|
||||
assert sorted(numbers) == list(range(1, len(rows) + 1))
|
||||
return accepted, rejected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["2024-02-29", " 29.02.2024 "])
|
||||
def test_normalization_and_exact_money(tmp_path, value):
|
||||
accepted, rejected = process(tmp_path, order(order_date=value, category=" "), header=FIELDS[::-1])
|
||||
assert not rejected
|
||||
item, = accepted
|
||||
assert (item.order_date, item.category) == ("2024-02-29", "Не указана")
|
||||
assert (item.unit_price_kopecks, item.revenue_kopecks) == (7, 21)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("name", "value"), [
|
||||
("order_date", "2023-02-29"), ("order_date", "1.2.2024"), ("order_date", "20240229"),
|
||||
("quantity", "1.2"), ("quantity", "0"), ("quantity", "-2"), ("order_id", "0"),
|
||||
("unit_price", "NaN"), ("unit_price", "Infinity"), ("unit_price", "-Infinity"),
|
||||
("unit_price", "0"), ("unit_price", "-1"), ("unit_price", "1,230"),
|
||||
("unit_price", "1 000.20"), ("unit_price", "1e2"),
|
||||
("unit_price", "92233720368547758.08"), ("quantity", str(MAX_INTEGER + 1)),
|
||||
("order_id", "9" * 5000),
|
||||
])
|
||||
def test_invalid_field(tmp_path, name, value):
|
||||
accepted, rejected = process(tmp_path, order(**{name: value}))
|
||||
assert not accepted
|
||||
reason, = rejected[0].reasons
|
||||
assert reason.startswith(name + ":")
|
||||
|
||||
|
||||
def test_sqlite_boundary_and_product_overflow(tmp_path):
|
||||
price = "92233720368547758.07"
|
||||
accepted, rejected = process(tmp_path, order(quantity="1", unit_price=price),
|
||||
order(quantity="2", unit_price=price))
|
||||
assert accepted[0].revenue_kopecks == MAX_INTEGER
|
||||
assert rejected[0].reasons == ["revenue_kopecks: превышен предел INTEGER SQLite"]
|
||||
|
||||
|
||||
def test_multiple_missing_fields_and_exact_repeat(tmp_path):
|
||||
bad = order(order_id=" ", order_date="", quantity="", unit_price="")
|
||||
_, rejected = process(tmp_path, bad, bad)
|
||||
assert rejected[0].source.values == tuple(bad.values())
|
||||
assert [s.split(":")[0] for s in rejected[0].reasons] == [
|
||||
"order_id", "order_date", "quantity", "unit_price"
|
||||
]
|
||||
assert rejected[1].reasons == ["Точный дубль"]
|
||||
|
||||
|
||||
def test_duplicate_stages_and_conflict(tmp_path):
|
||||
first = order(category=" ТоВаРы\n ДЛЯ ДоМа ")
|
||||
equivalent = first | {"order_date": "29.02.2024", "order_id": "001",
|
||||
"category": "товары для дома", "unit_price": "0.07"}
|
||||
conflict = order(order_id="2")
|
||||
accepted, rejected = process(
|
||||
tmp_path, first, first, equivalent, conflict, conflict | {"quantity": "03"},
|
||||
conflict | {"quantity": "4"}, order(quantity="много")
|
||||
)
|
||||
assert [(item.record_number, item.category) for item in accepted] == [(1, "Товары для дома")]
|
||||
assert [(r.source.record_number, r.reasons) for r in rejected] == [
|
||||
(2, ["Точный дубль"]), (3, ["Нормализованный дубль"]),
|
||||
(4, ["Конфликт order_id"]), (5, ["Конфликт order_id"]), (6, ["Конфликт order_id"]),
|
||||
(7, ["quantity: ожидается положительное целое число"]),
|
||||
]
|
||||
|
||||
|
||||
def test_empty_short_and_long_records(tmp_path):
|
||||
accepted, rejected = process(tmp_path, [], ["1"], list(order().values()) + ["лишнее"], order())
|
||||
assert accepted[0].record_number == 4
|
||||
assert [r.reasons for r in rejected] == [
|
||||
["Пустая запись CSV"], ["Число полей: ожидалось 5, получено 1"],
|
||||
["Число полей: ожидалось 5, получено 6"],
|
||||
]
|
||||
|
||||
|
||||
def test_header_only(tmp_path):
|
||||
assert process(tmp_path) == ([], [])
|
||||
|
||||
|
||||
@pytest.mark.parametrize("header", ["", "order_id,quantity", ",".join((*FIELDS, "extra")),
|
||||
",".join((*FIELDS[:-1], "quantity"))])
|
||||
def test_invalid_header(tmp_path, header):
|
||||
path = tmp_path / "orders.csv"
|
||||
path.write_text(header, encoding="utf-8")
|
||||
with pytest.raises(CSVFileError, match="неверный заголовок"):
|
||||
clean_orders(read_records(path))
|
||||
|
||||
|
||||
def test_missing_or_damaged_file(tmp_path):
|
||||
path = tmp_path / "orders.csv"
|
||||
with pytest.raises(CSVFileError, match="файл не найден"):
|
||||
clean_orders(read_records(path))
|
||||
path.write_text(",".join(FIELDS) + '\n1,2024-02-29,"незакрытое поле', encoding="utf-8")
|
||||
with pytest.raises(CSVFileError, match="ошибка структуры CSV"):
|
||||
clean_orders(read_records(path))
|
||||
@@ -0,0 +1,20 @@
|
||||
import csv
|
||||
|
||||
from src.generate_data import generate_data
|
||||
|
||||
|
||||
def test_record_count_and_header(tmp_path):
|
||||
path = tmp_path / "orders.csv"
|
||||
generate_data(path)
|
||||
with path.open(encoding="utf-8", newline="") as source:
|
||||
records = csv.reader(source)
|
||||
assert next(records) == ["order_id", "order_date", "category", "quantity", "unit_price"]
|
||||
assert sum(1 for _ in records) == 1500
|
||||
|
||||
|
||||
def test_repeated_generation_is_byte_identical(tmp_path):
|
||||
path = tmp_path / "orders.csv"
|
||||
generate_data(path)
|
||||
first_run = path.read_bytes()
|
||||
generate_data(path)
|
||||
assert path.read_bytes() == first_run
|
||||
Reference in new issue
Block a user