# -*- coding: utf-8 -*- import os import pandas as pd import numpy as np import requests import io TYPE_META = { "Доставка за 1 час": {"short": "1 час", "color": "#22c55e", "order": 0}, "Доставка за 2 час": {"short": "2 часа", "color": "#f59e0b", "order": 1}, "Доставка за 30 мин": {"short": "30 мин", "color": "#a855f7", "order": 2}, "Экспресс": {"short": "Экспресс", "color": "#ec4899", "order": 3}, "Остальное": {"short": "Остальное","color": "#f97316", "order": 4}, } def load_orders(file) -> pd.DataFrame: """Читает xlsx/csv, возвращает: date, type, count.""" if hasattr(file, "name"): name = file.name else: name = str(file) if name.endswith(".xlsx") or name.endswith(".xls"): df = pd.read_excel(file, skiprows=2, header=0) else: df = pd.read_csv(file) df.columns = ["date", "count", "type"] df = df.dropna(subset=["date"]).copy() df["date"] = pd.to_datetime(df["date"]).dt.normalize() df["count"] = pd.to_numeric(df["count"], errors="coerce").fillna(0).astype(int) df["type"] = df["type"].str.replace("до", "за", regex=False).str.strip() return df def fetch_weather(start: str, end: str, lat: float = 55.7558, lon: float = 37.6176) -> pd.DataFrame: url = "https://archive-api.open-meteo.com/v1/archive" r = requests.get(url, params={ "latitude": lat, "longitude": lon, "start_date": start, "end_date": end, "daily": "temperature_2m_max,precipitation_sum,snowfall_sum", "timezone": "Europe/Moscow", }, timeout=20) r.raise_for_status() d = r.json()["daily"] return pd.DataFrame({ "date": pd.to_datetime(d["time"]), "temp_max": pd.array(d["temperature_2m_max"], dtype="Float64").fillna(0), "precipitation": pd.array(d["precipitation_sum"], dtype="Float64").fillna(0), "snowfall": pd.array(d["snowfall_sum"], dtype="Float64").fillna(0), }) def fetch_weather_forecast(days: int = 16, lat: float = 55.7558, lon: float = 37.6176) -> pd.DataFrame: url = "https://api.open-meteo.com/v1/forecast" r = requests.get(url, params={ "latitude": lat, "longitude": lon, "daily": "temperature_2m_max,precipitation_sum,snowfall_sum", "forecast_days": days, "timezone": "Europe/Moscow", }, timeout=20) r.raise_for_status() d = r.json()["daily"] return pd.DataFrame({ "date": pd.to_datetime(d["time"]), "temp_max": pd.array(d["temperature_2m_max"], dtype="Float64").fillna(0), "precipitation": pd.array(d["precipitation_sum"], dtype="Float64").fillna(0), "snowfall": pd.array(d["snowfall_sum"], dtype="Float64").fillna(0), }) def fetch_weather_hourly(start: str, end: str, lat: float = 55.7558, lon: float = 37.6176) -> pd.DataFrame: """Почасовая температура/осадки/снег из архива Open-Meteo. Колонки: timestamp, temp, precip, snow.""" url = "https://archive-api.open-meteo.com/v1/archive" r = requests.get(url, params={ "latitude": lat, "longitude": lon, "start_date": start, "end_date": end, "hourly": "temperature_2m,precipitation,snowfall", "timezone": "Europe/Moscow", }, timeout=30) r.raise_for_status() h = r.json()["hourly"] return pd.DataFrame({ "timestamp": pd.to_datetime(h["time"]), "temp": pd.array(h["temperature_2m"], dtype="Float64").fillna(0), "precip": pd.array(h["precipitation"], dtype="Float64").fillna(0), "snow": pd.array(h["snowfall"], dtype="Float64").fillna(0), }) def fetch_weather_forecast_hourly(days: int = 16, lat: float = 55.7558, lon: float = 37.6176) -> pd.DataFrame: """Почасовой прогноз погоды Open-Meteo. Колонки: timestamp, temp, precip, snow.""" url = "https://api.open-meteo.com/v1/forecast" r = requests.get(url, params={ "latitude": lat, "longitude": lon, "hourly": "temperature_2m,precipitation,snowfall", "forecast_days": days, "timezone": "Europe/Moscow", }, timeout=30) r.raise_for_status() h = r.json()["hourly"] return pd.DataFrame({ "timestamp": pd.to_datetime(h["time"]), "temp": pd.array(h["temperature_2m"], dtype="Float64").fillna(0), "precip": pd.array(h["precipitation"], dtype="Float64").fillna(0), "snow": pd.array(h["snowfall"], dtype="Float64").fillna(0), }) # ── производственный календарь: API → локальный кэш → офлайн-реконструкция ────── # isdayoff.ru недоступен из-под VPN (проверено 31.07.2026), а прогноз без календаря # не считается вообще. Поэтому: удачный ответ API кэшируем на диск, при отказе # читаем кэш, и только если кэша нет — собираем календарь сами. _CAL_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.dirname( os.path.abspath(__file__)))), "data", "calendar") # нерабочие дни сверх выходных (ст. 112 ТК + переносы правительства). # 2027 заполнен только до апреля — дальше горизонта прогноза нет. _HOLIDAYS = { 2024: ["01-01", "01-02", "01-03", "01-04", "01-05", "01-08", "02-23", "03-08", "04-29", "04-30", "05-01", "05-09", "05-10", "06-12", "11-04", "12-30", "12-31"], 2025: ["01-01", "01-02", "01-03", "01-06", "01-07", "01-08", "02-24", "03-08", "05-01", "05-02", "05-08", "05-09", "06-12", "06-13", "11-03", "11-04", "12-31"], 2026: ["01-01", "01-02", "01-05", "01-06", "01-07", "01-08", "02-23", "03-09", "05-01", "05-11", "06-12", "11-04", "12-31"], 2027: ["01-01", "01-04", "01-05", "01-06", "01-07", "01-08", "02-23", "03-08", "05-03", "05-10", "06-14", "11-04"], } # предпраздничные сокращённые дни _SHORT = { 2024: ["02-22", "03-07", "04-28", "05-08", "06-11", "11-02", "12-28"], 2025: ["03-07", "04-30", "06-11", "11-01", "12-30"], 2026: ["02-20", "03-06", "04-30", "05-08", "06-11", "11-03", "12-30"], 2027: ["02-22", "03-05", "04-30", "05-07", "06-11", "11-03"], } def _calendar_offline(year: int) -> pd.DataFrame: """Реконструкция: выходные + праздники из таблицы. is_holiday в isdayoff — это ЛЮБОЙ нерабочий день, включая субботы и воскресенья, поэтому они тоже = 1.""" dates = pd.date_range(f"{year}-01-01", f"{year}-12-31", freq="D") md = dates.strftime("%m-%d") hol = set(_HOLIDAYS.get(year, [])) sht = set(_SHORT.get(year, [])) is_hol = ((dates.dayofweek >= 5) | pd.Index(md).isin(hol)).astype(int) is_short = (pd.Index(md).isin(sht) & (is_hol == 0)).astype(int) return pd.DataFrame({"date": dates, "is_holiday": is_hol, "is_short": is_short}) def fetch_ru_calendar(year: int) -> pd.DataFrame: cache = os.path.join(_CAL_DIR, f"ru_{year}.csv") try: r = requests.get(f"https://isdayoff.ru/api/getdata?year={year}", timeout=10) r.raise_for_status() data_str = r.text.strip() if not set(data_str) <= {"0", "1", "2", "4"} or len(data_str) < 365: raise ValueError(f"неожиданный ответ isdayoff для {year}: {data_str[:40]!r}") dates, holidays, shorts = [], [], [] date = pd.Timestamp(f"{year}-01-01") for ch in data_str: dates.append(date) holidays.append(1 if ch == "1" else 0) shorts.append(1 if ch == "2" else 0) date += pd.Timedelta(days=1) df = pd.DataFrame({"date": dates, "is_holiday": holidays, "is_short": shorts}) os.makedirs(_CAL_DIR, exist_ok=True) df.to_csv(cache, index=False) return df except Exception as e: if os.path.exists(cache): print(f" календарь {year}: API недоступен ({type(e).__name__}), взят кэш {cache}") df = pd.read_csv(cache) df["date"] = pd.to_datetime(df["date"]) return df print(f" ⚠ календарь {year}: API недоступен ({type(e).__name__}) и кэша нет → " "офлайн-реконструкция (выходные + праздники по таблице)") df = _calendar_offline(year) os.makedirs(_CAL_DIR, exist_ok=True) df.to_csv(cache, index=False) return df def build_calendar(start: pd.Timestamp, end: pd.Timestamp) -> pd.DataFrame: years = range(start.year, end.year + 1) frames = [fetch_ru_calendar(y) for y in years] cal = pd.concat(frames).reset_index(drop=True) dates = pd.date_range(start, end, freq="D") return cal[cal["date"].isin(dates)].reset_index(drop=True) def build_series(orders_df, dtype, weather_df, cal_df, start, end, item_id): full_dates = pd.date_range(start, end, freq="D") agg = orders_df[orders_df["type"] == dtype].groupby("date")["count"].sum() agg = agg.reindex(full_dates, fill_value=0).reset_index() agg.columns = ["date", "target"] cov_cols = ["temp_max", "precipitation", "snowfall", "is_holiday", "is_short"] agg = agg.merge(weather_df, on="date", how="left") agg = agg.merge(cal_df[["date", "is_holiday", "is_short"]], on="date", how="left") agg[cov_cols] = agg[cov_cols].fillna(0) agg["item_id"] = item_id agg.rename(columns={"date": "timestamp"}, inplace=True) return agg