"""Производственный календарь (D30): разбор файлов xmlcalendar.ru за 2025 и 2026 (сохранены как есть), предыдущий рабочий день, загрузка в SQLite.""" from __future__ import annotations import json from datetime import date from pathlib import Path import pytest from app.db.base import run_db from app.imports.xmlcalendar import CalendarFormatError, parse_calendar_json from app.services import business_calendar from app.services.dates import BusinessCalendar, previous_business_day USER = "analyst@rshb.ru" def _file(fixtures_dir: Path, year: int) -> bytes: return (fixtures_dir / f"xmlcalendar_ru_{year}.json").read_bytes() def _calendar(fixtures_dir: Path, *years: int) -> BusinessCalendar: days = set() for year in years: days |= set(parse_calendar_json(_file(fixtures_dir, year)).non_working) return BusinessCalendar(frozenset(years), frozenset(days)) # --- разбор файла ------------------------------------------------------------ def test_parse_2026(fixtures_dir: Path) -> None: parsed = parse_calendar_json(_file(fixtures_dir, 2026)) assert parsed.year == 2026 assert len(parsed.non_working) == 118 # = statistic.holidays в файле assert parsed.weekday_holidays == ( date(2026, 1, 1), date(2026, 1, 2), date(2026, 1, 5), date(2026, 1, 6), date(2026, 1, 7), date(2026, 1, 8), date(2026, 1, 9), # перенос с 03.01 date(2026, 2, 23), date(2026, 3, 9), # 08.03 — воскресенье date(2026, 5, 1), date(2026, 5, 11), date(2026, 6, 12), date(2026, 11, 4), date(2026, 12, 31), # перенос с 04.01 ) assert parsed.working_weekends == () assert date(2026, 4, 30) in parsed.shortened assert date(2026, 4, 30) not in parsed.non_working def test_parse_2025_working_saturday(fixtures_dir: Path) -> None: """01.11.2025 — рабочая суббота: в файле «1*», как сокращённый день.""" parsed = parse_calendar_json(_file(fixtures_dir, 2025)) assert len(parsed.non_working) == 118 assert parsed.working_weekends == (date(2025, 11, 1),) assert date(2025, 11, 3) in parsed.non_working def _json(**changes) -> bytes: # noqa: ANN003 data = { "year": 2026, "months": [{"month": m, "days": "1"} for m in range(1, 13)], } data.update(changes) return json.dumps(data).encode() @pytest.mark.parametrize( ("content", "message"), [ (b"", "не читается как JSON"), (b"[]", "нет года и месяцев"), (_json(year="2026"), "нет года"), (_json(months=[{"month": 1, "days": "1"}]), "12 месяцев"), ( _json(months=[{"month": 1, "days": "1"} for _ in range(12)]), "месяц 1 дважды", ), ( _json(months=[{"month": 13, "days": "1"} for _ in range(12)]), "номер месяца", ), ( _json(months=[{"month": m, "days": "1,x"} for m in range(1, 13)]), "непонятный день «x»", ), ( _json(months=[{"month": m, "days": "31"} for m in range(1, 13)]), "нет даты 31.02", ), (_json(statistic={"holidays": 13}), "в списке 12, а в итоге файла 13"), ], ) def test_parse_errors(content: bytes, message: str) -> None: with pytest.raises(CalendarFormatError, match=message): parse_calendar_json(content) def test_parse_accepts_bom_and_spaces() -> None: content = "".encode() + _json( months=[{"month": m, "days": " 1, 2+ ,3*"} for m in range(1, 13)] ) parsed = parse_calendar_json(content) assert len(parsed.non_working) == 24 assert len(parsed.shortened) == 12 # --- предыдущий рабочий день ------------------------------------------------- @pytest.mark.parametrize( ("today", "expected"), [ (date(2026, 1, 12), date(2025, 12, 30)), # 31.12.2025 — перенос (date(2026, 2, 24), date(2026, 2, 20)), # 23.02 — праздник (date(2026, 3, 10), date(2026, 3, 6)), # 09.03 — перенос (date(2026, 5, 12), date(2026, 5, 8)), # 08.05 — сокращённый, рабочий (date(2025, 11, 2), date(2025, 11, 1)), # рабочая суббота (date(2025, 11, 5), date(2025, 11, 1)), # 03.11 и 04.11 — выходные (date(2026, 10, 27), date(2026, 10, 26)), ], ) def test_previous_business_day_by_calendar( fixtures_dir: Path, today: date, expected: date ) -> None: calendar = _calendar(fixtures_dir, 2025, 2026) assert previous_business_day(today, calendar) == expected def test_year_without_calendar_skips_weekends_only(fixtures_dir: Path) -> None: only_2026 = _calendar(fixtures_dir, 2026) # 2025 не загружен: 31.12.2025 — среда, считается рабочим. assert previous_business_day(date(2026, 1, 12), only_2026) == date(2025, 12, 31) assert previous_business_day(date(2027, 1, 4), only_2026) == date(2027, 1, 1) # --- загрузка и дата отчёта (SQLite) ---------------------------------------- def test_save_year_and_report_date(session_local, fixtures_dir: Path) -> None: assert run_db(business_calendar.list_years()) == [] assert run_db(business_calendar.report_date(date(2026, 1, 12))) == date(2026, 1, 9) for year in (2025, 2026): parsed = parse_calendar_json(_file(fixtures_dir, year)) run_db(business_calendar.save_year(parsed, USER)) assert run_db(business_calendar.report_date(date(2026, 1, 12))) == date( 2025, 12, 30 ) assert run_db(business_calendar.report_date(date(2025, 11, 2))) == date(2025, 11, 1) rows = run_db(business_calendar.list_years()) assert [ (r.year, r.non_working, r.weekday_holidays, r.working_weekends, r.updated_by) for r in rows ] == [(2026, 118, 14, 0, USER), (2025, 118, 15, 1, USER)] def test_reload_replaces_year(session_local, fixtures_dir: Path) -> None: run_db( business_calendar.save_year( parse_calendar_json(_file(fixtures_dir, 2026)), USER ) ) fixed = parse_calendar_json( _json(months=[{"month": m, "days": "1"} for m in range(1, 13)]) ) run_db(business_calendar.save_year(fixed, "other@rshb.ru")) [row] = run_db(business_calendar.list_years()) assert (row.year, row.non_working, row.updated_by) == (2026, 12, "other@rshb.ru") # 09.01.2026 теперь рабочий, 01.01 — нет. assert run_db(business_calendar.report_date(date(2026, 1, 10))) == date(2026, 1, 9)