diff --git a/api/rules/README.md b/api/rules/README.md index a79c56d..86184a5 100644 --- a/api/rules/README.md +++ b/api/rules/README.md @@ -12,7 +12,7 @@ AST-линтер `tests/unit/parser/test_no_coordinates.py`. |---|---| | `osv_headers.yaml` | маркер листа ОСВ и листов форм ББ/ОФР; группы сумм (начало / обороты / конец); «Дебет»/«Кредит»; служебные колонки (ИНН, КПП, код, количество, валюта); строка «Итого»; метки шапки (счёт, период, выводимые данные, валюта, параметры, субконто) | | `osv_groups.yaml` | название группировки из шапки -> `ACCOUNT` / `DEPARTMENT` / `COUNTERPARTY` / `CONTRACT` / `OTHER` (`default` — для всего прочего) | -| `periods.yaml` | текст периода -> `period_quarter` и `is_cumulative` (§1.5); `year` — паттерн года | +| `periods.yaml` | текст периода -> `period_quarter` и `is_cumulative` (§1.5); `year` — паттерн года, `end` — конец текста периода в строке шапки («г.», «год») | | `data_types.yaml` | «Выводимые данные» -> `BU` / `NU` / `SUM`; строки «Вал.» / «Кол.»; коды валют ISO 4217; синонимы валюты (`руб.` -> `RUB`) | ## Как писать паттерны diff --git a/api/rules/periods.yaml b/api/rules/periods.yaml index e4dfe51..0f4e7dc 100644 --- a/api/rules/periods.yaml +++ b/api/rules/periods.yaml @@ -21,3 +21,7 @@ rules: # Год периода: четыре цифры 19xx/20xx, не часть длинного числа (ИНН, номер) year: '(? «3 квартал 2024 г.»; +# без совпадения период идёт до конца строки +end: '\bг\.|\bгода?\b' diff --git a/api/src/parser/header.py b/api/src/parser/header.py new file mode 100644 index 0000000..fdd54a9 --- /dev/null +++ b/api/src/parser/header.py @@ -0,0 +1,305 @@ +"""Шапка документа и схема группировок ОСВ (`05-domain-rules.md` §1.1, §1.5; spec «Шапка документа и схема…»). + +Область шапки — строки выше заголовка таблицы. Организация, счёт, период, тип данных и валюта берутся по меткам +из `rules/osv_headers.yaml` (`header_markers`), квартал и признак нарастающего итога — по `rules/periods.yaml`, +тип группировки — по `rules/osv_groups.yaml`. Ни одна позиция ячейки заранее не известна; отсутствие поля — +пустая строка (для полей контракта-строк) или `None` и предупреждение на русском. +""" + +import bisect +import re +from collections.abc import Iterable +from dataclasses import dataclass +from typing import Any, Literal + +from src.contract.common import DataType +from src.contract.result import OsvGroup +from src.parser.detect import TableHeader +from src.parser.grid import Sheet +from src.parser.rules import GroupType, Pattern, Rules, first_match, normalize_text + +__all__ = ["DocHeader", "build_group_schema", "parse_doc_header"] + +Quarter = Literal["Q1", "Q2", "Q3", "Q4"] +HeaderVariant = Literal[1, 2] + +# Код счёта начинается с цифры и идёт до пробела или запятой: «60», «58.03», «76.07.1» +_ACCOUNT_CODE = re.compile(r"\d[^\s,;]*") +# Метка отделяется от значения двоеточием («Валюта БУ, НУ: RUB»); перечисление субконто — запятыми +_LABEL_SEPARATOR = ":" +_LIST_SEPARATOR = "," +# Знаки, которыми код счёта может заканчиваться в тексте, но которые кодом не являются +_CODE_TRAILING = ".,;:" + + +@dataclass(frozen=True) +class DocHeader: + """Поля шапки ОСВ (`osvDocument` контракта); `warnings` — что не найдено или не распознано.""" + + organization_name: str + account_code_raw: str + period_text: str + period_year: int | None + period_quarter: Quarter | None + is_cumulative: bool | None + data_type: DataType | None + currency: str | None + header_variant: HeaderVariant + warnings: tuple[str, ...] + + +def _group(group_type: GroupType, name: str | None = None, *, has_inn: bool = False) -> OsvGroup: + """Группировка контракта; необязательные поля без значения опускаются (явный `null` схема не допускает).""" + fields: dict[str, Any] = {"type": group_type} + if name: + fields["name"] = name + if has_inn: + fields["has_inn"] = True + return OsvGroup(**fields) + + +@dataclass(frozen=True) +class _HeadCell: + row: int + col: int + text: str + + +# --- Текст --- + + +def _source_offset(text: str, normalized_offset: int) -> int: + """Смещение в исходном тексте, с которого начинается хвост нормализованного текста с позиции `normalized_offset`. + + Позиции `Match` относятся к `normalize_text(text)` (нижний регистр, схлопнутые пробелы), а в результат нужен + исходный текст («3 квартал 2024 г.», не «3 квартал 2024 г.» в нижнем регистре). Нормализация префикса — префикс + нормализации целого, поэтому длина нормализованного префикса монотонна по длине исходного. + """ + # Пробелы сразу после совпадения (`\s+` в метке) нормализация префикса отбрасывает — не считаем их в позиции + target = len(normalize_text(text)[:normalized_offset].rstrip()) + return bisect.bisect_left(range(len(text) + 1), target, key=lambda size: len(normalize_text(text[:size]))) + + +def _tail(text: str, match: re.Match[str]) -> str: + """Исходный текст после совпадения (позиции `match` — в нормализованном тексте).""" + return text[_source_offset(text, match.end()) :].strip() + + +def _find(patterns: Iterable[Pattern], text: str) -> re.Match[str] | None: + for pattern in patterns: + match = pattern.search(text) + if match is not None: + return match + return None + + +def _value_after_colon(tail: str) -> str: + """Значение метки: текст после двоеточия, а без двоеточия — весь хвост («Валюта БУ, НУ: RUB» -> «RUB»).""" + _, separator, value = tail.partition(_LABEL_SEPARATOR) + return (value if separator else tail).strip() + + +# --- Область шапки --- + + +def _head_cells(sheet: Sheet, header: TableHeader) -> list[_HeadCell]: + """Текстовые ячейки выше заголовка таблицы по строкам сверху вниз, слева направо (числа и даты — не текст).""" + return [ + _HeadCell(row, col, cell.text) + for row in range(1, header.first_row) + for col, cell in sheet.row_cells(row) + if cell.kind == "text" + ] + + +def _is_marker(cell: _HeadCell, rules: Rules) -> bool: + patterns = [ + *rules.headers.sheet_markers, + *(pattern for group in rules.headers.header_markers.values() for pattern in group), + ] + return _find(patterns, cell.text) is not None + + +def _organization(cells: list[_HeadCell], rules: Rules) -> _HeadCell | None: + return next((cell for cell in cells if not _is_marker(cell, rules)), None) + + +def _account_code(cells: list[_HeadCell], rules: Rules) -> str | None: + for cell in cells: + match = _find(rules.headers.header_markers["account"], cell.text) + if match is None: + continue + code = _ACCOUNT_CODE.match(_tail(cell.text, match)) + if code is not None: + return code.group(0).rstrip(_CODE_TRAILING) + return None + + +def _period_text(cells: list[_HeadCell], skip: _HeadCell | None, rules: Rules) -> str | None: + """Текст после метки периода («за …», «Период: …») до «г.» включительно или до конца строки.""" + for cell in cells: + if cell is skip: + continue + match = _find(rules.headers.header_markers["period"], cell.text) + if match is None: + continue + tail = _tail(cell.text, match) + end = rules.periods.end.search(tail) + if end is not None: + tail = tail[: _source_offset(tail, end.end())].strip() + if tail: + return tail + return None + + +def _labelled_value(sheet: Sheet, cells: list[_HeadCell], patterns: list[Pattern]) -> str | None: + """Значение строки с меткой: после двоеточия в той же ячейке, а если там пусто — в следующей ячейке строки.""" + for cell in cells: + match = _find(patterns, cell.text) + if match is None: + continue + value = _value_after_colon(_tail(cell.text, match)) + if value: + return value + neighbours = [other.text for col, other in sheet.row_cells(cell.row) if col > cell.col] + return neighbours[0] if neighbours else "" + return None + + +def _subkonto_items(cells: list[_HeadCell], rules: Rules) -> list[str] | None: + """Вариант 2: «Детализация по субсчетам, субконто: Контрагенты, Договоры» -> `["Контрагенты", "Договоры"]`.""" + for cell in cells: + if _find(rules.headers.header_markers["subkonto"], cell.text) is None or _LABEL_SEPARATOR not in cell.text: + continue + _, _, listing = cell.text.partition(_LABEL_SEPARATOR) + items = [item.strip() for item in listing.split(_LIST_SEPARATOR) if item.strip()] + if items: + return items + return None + + +# --- Шапка документа --- + + +@dataclass(frozen=True) +class _Period: + year: int | None + quarter: Quarter | None + is_cumulative: bool | None + + +def _parse_period(text: str, rules: Rules, warnings: list[str]) -> _Period: + rule = rules.periods.match(text) + year = rules.periods.year.search(text) + if rule is None: + warnings.append(f"период «{text}»: квартал и признак нарастающего итога не определены по словарю периодов") + if year is None: + warnings.append(f"период «{text}»: год не найден") + # ASSUMPTION(OQ-02): is_cumulative=false для Q2–Q4 определяется по тексту «N квартал»; ядро может переопределить + # по данным (opening Q3 == opening Q1 -> нарастающая) + return _Period( + year=None if year is None else int(year.group(0)), + quarter=None if rule is None else rule.quarter, + is_cumulative=None if rule is None else rule.is_cumulative, + ) + + +def _parse_data_type(sheet: Sheet, cells: list[_HeadCell], rules: Rules, warnings: list[str]) -> DataType | None: + value = _labelled_value(sheet, cells, rules.headers.header_markers["data_type"]) + if value is None: + warnings.append("тип выводимых данных (БУ/НУ/сумма) в шапке не найден") + return None + data_type = first_match(rules.data_types.header, value) + if data_type is None: + warnings.append(f"тип выводимых данных «{value}» не распознан") + return data_type + + +def _parse_currency(sheet: Sheet, cells: list[_HeadCell], rules: Rules, warnings: list[str]) -> str | None: + value = _labelled_value(sheet, cells, rules.headers.header_markers["currency"]) + if value is None: + warnings.append("валюта в шапке не найдена") + return None + code = rules.data_types.currency_code(value) + if code is None: + warnings.append(f"валюта «{value}» не распознана") + return code + + +def parse_doc_header(sheet: Sheet, header: TableHeader, rules: Rules) -> DocHeader: + """Шапка документа по области выше заголовка таблицы; что не найдено — `""`/`None` и предупреждение.""" + cells = _head_cells(sheet, header) + warnings: list[str] = [] + + organization = _organization(cells, rules) + if organization is None: + warnings.append("наименование организации в шапке не найдено") + account = _account_code(cells, rules) + if account is None: + warnings.append("код счёта (после «по счету») в шапке не найден") + period_text = _period_text(cells, organization, rules) + if period_text is None: + warnings.append("период (после «за» или «Период:») в шапке не найден") + period = _Period(None, None, None) + else: + period = _parse_period(period_text, rules, warnings) + + data_type = _parse_data_type(sheet, cells, rules, warnings) + currency = _parse_currency(sheet, cells, rules, warnings) + variant: HeaderVariant = 1 if _subkonto_items(cells, rules) is None else 2 + return DocHeader( + organization_name="" if organization is None else organization.text, + account_code_raw=account or "", + period_text=period_text or "", + period_year=period.year, + period_quarter=period.quarter, + is_cumulative=period.is_cumulative, + data_type=data_type, + currency=currency, + header_variant=variant, + warnings=tuple(warnings), + ) + + +# --- Схема группировок --- + + +def _row_has_inn(sheet: Sheet, row: int, rules: Rules) -> bool: + """В строке блока заголовка есть ячейка «ИНН» (отдельная или в тексте метки: «Контрагенты, ИНН»).""" + patterns = rules.headers.service_columns["inn"] + return any(_find(patterns, cell.text) is not None for _, cell in sheet.row_cells(row)) + + +def _first_group_name(sheet: Sheet, header: TableHeader) -> str | None: + if not header.group_label_rows: + return None + return sheet.text_at(header.group_label_rows[0], header.text_col) or None + + +def _variant1_groups(sheet: Sheet, header: TableHeader, rules: Rules) -> list[OsvGroup]: + groups: list[OsvGroup] = [] + for index, row in enumerate(header.group_label_rows): + label = sheet.text_at(row, header.text_col) + # Первая группировка — всегда ACCOUNT, как бы она ни называлась («Счет», «Подразделение», «Субконто») + group_type: GroupType = "ACCOUNT" if index == 0 else rules.groups.classify(label) + groups.append(_group(group_type, label, has_inn=_row_has_inn(sheet, row, rules))) + return groups or [_group("ACCOUNT")] + + +def _variant2_groups(sheet: Sheet, header: TableHeader, rules: Rules, items: list[str]) -> list[OsvGroup]: + groups = [_group("ACCOUNT", _first_group_name(sheet, header))] + inn_pending = header.inn_col is not None + for item in items: + group_type = rules.groups.classify(item) + has_inn = inn_pending and group_type == "COUNTERPARTY" + inn_pending = inn_pending and not has_inn + groups.append(_group(group_type, item, has_inn=has_inn)) + return groups + + +def build_group_schema(sheet: Sheet, header: TableHeader, rules: Rules) -> tuple[list[OsvGroup], HeaderVariant]: + """Группировки в порядке вложенности (первая — ACCOUNT) и вариант шапки (1 — строки блока, 2 — «субконто: …»).""" + items = _subkonto_items(_head_cells(sheet, header), rules) + if items is None: + return _variant1_groups(sheet, header, rules), 1 + return _variant2_groups(sheet, header, rules, items), 2 diff --git a/api/src/parser/rules.py b/api/src/parser/rules.py index 1aba286..a3c3788 100644 --- a/api/src/parser/rules.py +++ b/api/src/parser/rules.py @@ -165,6 +165,7 @@ class PeriodRule(_Model): class PeriodRules(_Model): rules: list[PeriodRule] year: Pattern + end: Pattern def match(self, text: str) -> PeriodRule | None: """Первое правило, паттерн которого совпал с текстом периода; порядок в YAML — от специфичных к общим.""" diff --git a/api/tests/fixtures/test_header_real.py b/api/tests/fixtures/test_header_real.py new file mode 100644 index 0000000..d813936 --- /dev/null +++ b/api/tests/fixtures/test_header_real.py @@ -0,0 +1,113 @@ +"""Шапка и схема группировок на реальных ОСВ из ../fixtures: общие свойства на всех 66 файлах. + +Фикстуры — регрессия, не оракул: ожидаемые значения выводятся из имени файла и каталога одним общим правилом (без +таблицы исключений), а парсер читает только содержимое. Правило имени файла — `expected_account`. +""" + +import re +from pathlib import Path +from typing import cast + +import pytest +from fixtures_helpers import osv_fixture_files + +from src.contract import FileType +from src.parser.detect import SheetKind, classify_sheet, find_table_header +from src.parser.grid import Sheet +from src.parser.header import build_group_schema, parse_doc_header +from src.parser.readers import read_book +from src.parser.rules import Rules, load_rules + +# Первое число имени файла — счёт: «ОСВ_58 за 2024», «сч 01», «по счету 62 за 3 квартал»; «583» — три цифры — +# счёт и субсчёт без точки («58», субсчёт «3» -> «58.03») +ACCOUNT_IN_NAME = re.compile(r"\d{2,3}(?:\.\d{1,2})?") +SUBACCOUNT_WITHOUT_DOT = 3 # цифр в коде «счёт + субсчёт» без точки +ACCOUNT_DIGITS = 2 # цифр в корне счёта +SUBACCOUNT_DIGITS = 2 # цифр в записи субсчёта: «58.03» +PERIOD_DIR = re.compile(r"^(?P\d{4})-(?PFY|Q[1-4])$") +EXPECTED_FILES = 66 + + +@pytest.fixture(scope="module") +def rules() -> Rules: + return load_rules() + + +def expected_account(path: Path) -> str: + """Код счёта из имени файла: первое число; три цифры без точки — счёт и субсчёт («583» -> «58.03»).""" + found = ACCOUNT_IN_NAME.search(path.stem) + assert found is not None, path.name + code = found.group(0) + if "." not in code and len(code) == SUBACCOUNT_WITHOUT_DOT: + return f"{code[:ACCOUNT_DIGITS]}.{code[ACCOUNT_DIGITS:].zfill(SUBACCOUNT_DIGITS)}" + return code + + +def _osv_sheet(path: Path, rules: Rules) -> Sheet: + sheets, _ = read_book(path.read_bytes(), cast(FileType, path.suffix.lstrip(".").upper())) + osv = [sheet for sheet in sheets if classify_sheet(sheet, rules) is SheetKind.OSV] + assert len(osv) == 1 + return osv[0] + + +@pytest.mark.fixtures +def test_every_real_osv_header_and_group_schema(osv_file: Path, rules: Rules) -> None: + sheet = _osv_sheet(osv_file, rules) + table = find_table_header(sheet, rules) + + doc = parse_doc_header(sheet, table, rules) + groups, variant = build_group_schema(sheet, table, rules) + + assert doc.organization_name + assert doc.account_code_raw == expected_account(osv_file) + assert doc.period_text + assert doc.period_quarter is not None + assert doc.period_year is not None + assert doc.is_cumulative is not None + assert doc.data_type == "BU" + assert doc.currency == "RUB" + assert doc.header_variant == variant == 1 + assert doc.warnings == () + assert groups[0].type == "ACCOUNT" + assert len(groups) == len(table.group_label_rows) + + +@pytest.mark.fixtures +def test_real_osv_period_matches_its_directory(osv_file: Path, rules: Rules) -> None: + sheet = _osv_sheet(osv_file, rules) + doc = parse_doc_header(sheet, find_table_header(sheet, rules), rules) + + directory = PERIOD_DIR.fullmatch(osv_file.parent.name) + assert directory is not None, osv_file.parent.name + year, kind = directory.group("year"), directory.group("kind") + assert doc.period_year == int(year) + if kind == "FY": + assert (doc.period_quarter, doc.is_cumulative) == ("Q4", True) + else: + assert doc.period_quarter == kind + if kind == "Q3": + assert doc.is_cumulative is False # ASSUMPTION(OQ-02): квартальная ведомость, не нарастающая + + +@pytest.mark.fixtures +def test_group_schemas_of_known_files(fixtures_dir: Path, rules: Rules) -> None: + schastl_62 = fixtures_dir / "osv/schastl/2024-Q3/Оборотносальдовая ведомость по счету 62 за 3 квартал 2024 г..xls" + sheet = _osv_sheet(schastl_62, rules) + groups, variant = build_group_schema(sheet, find_table_header(sheet, rules), rules) + + assert variant == 1 + assert [(g.type, g.has_inn) for g in groups] == [("ACCOUNT", None), ("DEPARTMENT", None), ("COUNTERPARTY", True)] + doc = parse_doc_header(sheet, find_table_header(sheet, rules), rules) + assert doc.period_text == "3 квартал 2024 г." + + +@pytest.mark.fixtures +def test_all_66_real_files_are_covered(fixtures_dir: Path) -> None: + assert len(osv_fixture_files(fixtures_dir)) == EXPECTED_FILES + + +def test_expected_account_rule() -> None: + assert expected_account(Path("сч 583_3 кв 2024.xlsx")) == "58.03" + assert expected_account(Path("ОСВ_01_ОС по субсчетам.xlsx")) == "01" + assert expected_account(Path("ОСВ_58 за 2024.xls")) == "58" + assert expected_account(Path("сч 66_3 кв 2024.xlsx")) == "66" diff --git a/api/tests/unit/parser/test_header.py b/api/tests/unit/parser/test_header.py new file mode 100644 index 0000000..c472039 --- /dev/null +++ b/api/tests/unit/parser/test_header.py @@ -0,0 +1,378 @@ +"""Шапка документа и схема группировок (spec «Шапка документа и схема группировок», `05-domain-rules.md` §1.1, §1.5). + +Сетки собираются `gridbuilder`; шапка и заголовок таблицы находятся по содержимому, позиции в сетках произвольные. +""" + +import pytest +from gridbuilder import M, at, sheet_from_rows + +from src.parser.detect import TableHeader, find_table_header +from src.parser.grid import Sheet +from src.parser.header import DocHeader, build_group_schema, parse_doc_header +from src.parser.rules import Rules, load_rules + +OPENING = "Сальдо на начало периода" +TURNOVER = "Обороты за период" +CLOSING = "Сальдо на конец периода" +MISSING_FIELDS_WARNINGS = 5 # организация, счёт, период, тип данных, валюта +VARIANT_2 = 2 +DATA_ROW = at({1: "60", 5: 100, 8: 20, 10: 30, 12: 40, 15: 50, 17: 60}) +SUBHEAD = ["Дебет", None, None, "Кредит", None, "Дебет", "Кредит", "Дебет", None, "Кредит"] +GROUPS_ROW = [M("Счет", 4), M(OPENING, 5), M(TURNOVER, 5), M(CLOSING, 3)] + + +@pytest.fixture(scope="module") +def rules() -> Rules: + return load_rules() + + +def _table(*label_rows: list[object]) -> list[list[object]]: + """Заголовок таблицы варианта 1: строка групп, подзаголовки сумм в строке первой метки, остальные метки ниже.""" + first, *rest = label_rows + return [ + GROUPS_ROW, + [*first, *at({5: "Дебет", 8: "Кредит", 10: "Дебет", 12: "Кредит", 15: "Дебет", 17: "Кредит"})[len(first) :]], + *rest, + DATA_ROW, + ] + + +def _parse(rows: list[list[object]], rules: Rules) -> tuple[Sheet, TableHeader, DocHeader]: + sheet = sheet_from_rows(rows) + header = find_table_header(sheet, rules) + return sheet, header, parse_doc_header(sheet, header, rules) + + +def _variant1( + rules: Rules, *, org: object = 'АО "Ромашка"', title: str, params: object = "Валюта БУ, НУ: RUB" +) -> DocHeader: + rows: list[list[object]] = [ + [org], + [title], + [], + ["Выводимые данные: БУ (данные бухгалтерского учета)"], + [], + ["Параметры:", None, params], + [], + *_table(["Контрагенты"]), + ] + return _parse(rows, rules)[2] + + +def _title(period: str, account: str = "60") -> str: + return f"Оборотно-сальдовая ведомость по счету {account} за {period}" + + +# --- Вариант 1: организация, счёт, период, тип данных, валюта --- + + +def test_variant1_header_fields(rules: Rules) -> None: + header = _variant1(rules, title=_title("3 квартал 2024 г."), params="Валюта БУ, НУ: руб.") + + assert header == DocHeader( + organization_name='АО "Ромашка"', + account_code_raw="60", + period_text="3 квартал 2024 г.", + period_year=2024, + period_quarter="Q3", + is_cumulative=False, + data_type="BU", + currency="RUB", + header_variant=1, + warnings=(), + ) + + +def test_account_with_subaccount_is_kept_as_in_file(rules: Rules) -> None: + header = _variant1(rules, title=_title("3 квартал 2024 г.", "58.03")) + + assert header.account_code_raw == "58.03" + + +@pytest.mark.parametrize("currency", ["RUB", "rub", "руб.", "Руб", "рублей"]) +def test_currency_spellings_give_rub(rules: Rules, currency: str) -> None: + header = _variant1(rules, title=_title("2024 г."), params=f"Валюта БУ, НУ: {currency}") + + assert header.currency == "RUB" + assert not any("алют" in warning for warning in header.warnings) + + +def test_other_currency_code_is_kept(rules: Rules) -> None: + header = _variant1(rules, title=_title("2024 г."), params="Валюта БУ, НУ: USD") + + assert header.currency == "USD" + + +def test_currency_value_in_neighbour_cell(rules: Rules) -> None: + rows: list[list[object]] = [ + ["Организация"], + [_title("2024 г.")], + ["Параметры:", None, "Валюта БУ, НУ:", None, "RUB"], + *_table(["Контрагенты"]), + ] + + assert _parse(rows, rules)[2].currency == "RUB" + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("БУ (данные бухгалтерского учета)", "BU"), + ("НУ (данные налогового учета)", "NU"), + ("сумма", "SUM"), + ], +) +def test_data_type_values(rules: Rules, text: str, expected: str) -> None: + rows: list[list[object]] = [ + ["Организация"], + [_title("2024 г.")], + [f"Выводимые данные: {text}"], + *_table(["Контрагенты"]), + ] + + assert _parse(rows, rules)[2].data_type == expected + + +# --- Период: все строки таблицы §1.5 --- + + +@pytest.mark.parametrize( + ("period", "quarter", "cumulative"), + [ + ("1 квартал 2025 г.", "Q1", True), + ("I кв. 2025 г.", "Q1", True), + ("3 месяца 2025 г.", "Q1", True), + ("январь-март 2025 г.", "Q1", True), + ("01.01.2025 - 31.03.2025", "Q1", True), + ("2 квартал 2025 г.", "Q2", False), + ("II кв. 2025 г.", "Q2", False), + ("полугодие 2025 г.", "Q2", True), + ("1 полугодие 2025 г.", "Q2", True), + ("6 месяцев 2025 г.", "Q2", True), + ("январь-июнь 2025 г.", "Q2", True), + ("3 квартал 2025 г.", "Q3", False), + ("III кв. 2025 г.", "Q3", False), + ("9 месяцев 2025 г.", "Q3", True), + ("январь-сентябрь 2025 г.", "Q3", True), + ("4 квартал 2025 г.", "Q4", False), + ("IV кв. 2025 г.", "Q4", False), + ("2025 год", "Q4", True), + ("2025 г.", "Q4", True), + ("12 месяцев 2025 г.", "Q4", True), + ("январь-декабрь 2025 г.", "Q4", True), + ("2025 год (годовая)", "Q4", True), + ], +) +def test_period_table_of_domain_rules(rules: Rules, period: str, quarter: str, cumulative: bool) -> None: + header = _variant1(rules, title=_title(period)) + + assert (header.period_quarter, header.is_cumulative, header.period_year) == (quarter, cumulative, 2025) + assert header.warnings == () + + +def test_period_text_ends_at_year_marker(rules: Rules) -> None: + header = _variant1(rules, title=_title("1 квартал 2025 г. (по подразделениям)")) + + assert header.period_text == "1 квартал 2025 г." + + +def test_period_without_year_marker_runs_to_line_end(rules: Rules) -> None: + header = _variant1(rules, title=_title("с 01.01.2025 по 31.03.2025")) + + assert header.period_text == "с 01.01.2025 по 31.03.2025" + assert header.period_quarter == "Q1" + + +def test_unrecognised_period_text_gives_none_and_warning(rules: Rules) -> None: + header = _variant1(rules, title=_title("неделю")) + + assert header.period_text == "неделю" + assert (header.period_quarter, header.is_cumulative, header.period_year) == (None, None, None) + assert any("период" in warning for warning in header.warnings) + + +# --- Отсутствующие поля шапки --- + + +def test_missing_organization_gives_empty_string_and_warning(rules: Rules) -> None: + rows: list[list[object]] = [[_title("2024 г.")], ["Выводимые данные: БУ"], *_table(["Контрагенты"])] + + header = _parse(rows, rules)[2] + + assert header.organization_name == "" + assert any("организац" in warning for warning in header.warnings) + assert header.account_code_raw == "60" + + +def test_empty_head_area_gives_empty_strings_none_and_warnings(rules: Rules) -> None: + header = _parse(_table(["Контрагенты"]), rules)[2] + + assert header.organization_name == "" + assert header.account_code_raw == "" + assert header.period_text == "" + assert (header.period_year, header.period_quarter, header.is_cumulative) == (None, None, None) + assert (header.data_type, header.currency, header.header_variant) == (None, None, 1) + assert len(header.warnings) == MISSING_FIELDS_WARNINGS + assert all(warning for warning in header.warnings) + + +def test_numeric_and_date_cells_in_head_do_not_break_parsing(rules: Rules) -> None: + rows: list[list[object]] = [ + [45658], # xlsb: даты остаются числами + ["Организация", None, 45659], + [_title("2024 г.")], + *_table(["Контрагенты"]), + ] + + header = _parse(rows, rules)[2] + + assert header.organization_name == "Организация" + assert header.period_quarter == "Q4" + + +def test_organization_is_first_text_cell_that_is_not_a_marker(rules: Rules) -> None: + rows: list[list[object]] = [ + [], + ["Оборотно-сальдовая ведомость"], + ["Организация Б"], + ["Организация В"], + [_title("2024 г.")], + *_table(["Контрагенты"]), + ] + + assert _parse(rows, rules)[2].organization_name == "Организация Б" + + +# --- Схема группировок --- + + +def test_group_schema_variant1_account_counterparty_contract(rules: Rules) -> None: + sheet, header, doc = _parse( + [["Организация"], [_title("2024 г.")], *_table(["Контрагенты"], ["Договоры"])], + rules, + ) + + groups, variant = build_group_schema(sheet, header, rules) + + assert variant == doc.header_variant == 1 + assert [(g.type, g.name, g.has_inn) for g in groups] == [ + ("ACCOUNT", "Счет", None), + ("COUNTERPARTY", "Контрагенты", None), + ("CONTRACT", "Договоры", None), + ] + + +def test_group_schema_inn_in_separate_cell_of_counterparty_row(rules: Rules) -> None: + rows: list[list[object]] = [ + ["Организация"], + [_title("2024 г.")], + *_table(["Подразделение"], [M("Контрагенты", 2), "ИНН"]), + ] + sheet, header, _ = _parse(rows, rules) + + groups, _ = build_group_schema(sheet, header, rules) + + assert [(g.type, g.has_inn) for g in groups] == [("ACCOUNT", None), ("DEPARTMENT", None), ("COUNTERPARTY", True)] + + +def test_group_schema_inn_in_the_label_cell(rules: Rules) -> None: + rows: list[list[object]] = [["Организация"], [_title("2024 г.")], *_table(["Контрагенты, ИНН"])] + sheet, header, _ = _parse(rows, rules) + + groups, _ = build_group_schema(sheet, header, rules) + + assert [(g.type, g.name, g.has_inn) for g in groups] == [ + ("ACCOUNT", "Счет", None), + ("COUNTERPARTY", "Контрагенты, ИНН", True), + ] + + +def test_first_group_is_account_whatever_its_text_says(rules: Rules) -> None: + rows: list[list[object]] = [ + ["Организация"], + [_title("2024 г.")], + [M("Подразделение", 4), M(OPENING, 5), M(TURNOVER, 5), M(CLOSING, 3)], + ["Контрагенты", *at({5: "Дебет", 8: "Кредит", 10: "Дебет", 12: "Кредит", 15: "Дебет", 17: "Кредит"})[1:]], + DATA_ROW, + ] + sheet, header, _ = _parse(rows, rules) + + groups, _ = build_group_schema(sheet, header, rules) + + assert [(g.type, g.name) for g in groups] == [("ACCOUNT", "Подразделение"), ("COUNTERPARTY", "Контрагенты")] + + +def test_unknown_group_text_is_other(rules: Rules) -> None: + rows: list[list[object]] = [["Организация"], [_title("2024 г.")], *_table(["Основные средства"])] + sheet, header, _ = _parse(rows, rules) + + groups, _ = build_group_schema(sheet, header, rules) + + assert [g.type for g in groups] == ["ACCOUNT", "OTHER"] + + +def test_without_inn_cell_has_inn_stays_unset(rules: Rules) -> None: + rows: list[list[object]] = [["Организация"], [_title("2024 г.")], *_table(["Контрагенты"], ["Договоры"])] + sheet, header, _ = _parse(rows, rules) + + groups, _ = build_group_schema(sheet, header, rules) + + assert all(g.has_inn is None for g in groups) + + +# --- Вариант 2 --- + + +def _variant2_rows(detail: str = "Детализация по субсчетам, субконто: Контрагенты, Договоры") -> list[list[object]]: + return [ + ["ООО Вторая"], + ["Оборотно-сальдовая ведомость по счету 60"], + ["Период: 1 квартал 2026 г."], + [detail], + ["Выводимые данные: сумма"], + [M("Субконто", 4), *GROUPS_ROW[1:]], + ["", *at({5: "Дебет", 8: "Кредит", 10: "Дебет", 12: "Кредит", 15: "Дебет", 17: "Кредит"})[1:]], + DATA_ROW, + ] + + +def test_variant2_header(rules: Rules) -> None: + sheet, header, doc = _parse(_variant2_rows(), rules) + + groups, variant = build_group_schema(sheet, header, rules) + + assert variant == doc.header_variant == VARIANT_2 + assert [(g.type, g.name) for g in groups] == [ + ("ACCOUNT", "Субконто"), + ("COUNTERPARTY", "Контрагенты"), + ("CONTRACT", "Договоры"), + ] + assert doc.organization_name == "ООО Вторая" + assert doc.account_code_raw == "60" + assert (doc.period_text, doc.period_year, doc.period_quarter, doc.is_cumulative) == ( + "1 квартал 2026 г.", + 2026, + "Q1", + True, + ) + assert doc.data_type == "SUM" + assert doc.currency is None + assert any("алют" in warning for warning in doc.warnings) + + +def test_variant2_single_item(rules: Rules) -> None: + sheet, header, _ = _parse(_variant2_rows("Детализация по субсчетам, субконто: Контрагенты"), rules) + + groups, variant = build_group_schema(sheet, header, rules) + + assert variant == VARIANT_2 + assert [g.type for g in groups] == ["ACCOUNT", "COUNTERPARTY"] + + +def test_variant2_detail_row_is_not_taken_for_organization(rules: Rules) -> None: + rows = _variant2_rows() + rows[0] = [] + + doc = _parse(rows, rules)[2] + + assert doc.organization_name == "" diff --git a/api/tests/unit/parser/test_no_coordinates.py b/api/tests/unit/parser/test_no_coordinates.py index 1b8f48f..c863f51 100644 --- a/api/tests/unit/parser/test_no_coordinates.py +++ b/api/tests/unit/parser/test_no_coordinates.py @@ -134,6 +134,7 @@ def _violations_in_tree(path: Path, tree: ast.AST, allowed_strings: frozenset[st yield Violation(path, node.lineno, "сравнение номера строки/колонки с числовой константой") elif ( isinstance(node, ast.Subscript) + and not _is_literal_subscript(node) # Literal[1, 2] — значения типа контракта, не индекс ячейки and isinstance(node.slice, ast.Tuple) and len(node.slice.elts) == 2 # noqa: PLR2004 - пара (строка, колонка) and all(_is_int_constant(element) for element in node.slice.elts) @@ -305,3 +306,9 @@ def test_grid_py_and_readers_are_excluded_but_other_files_are_not(tmp_path: Path "classify.py", "nested/grid.py", ] + + +def test_int_literal_type_is_not_a_cell_subscript(tmp_path: Path) -> None: + root = _tree(tmp_path, {"detect.py": "from typing import Literal\nVariant = Literal[1, 2]\n"}) + + assert find_violations(root) == [] diff --git a/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md b/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md index 4c01d16..d3ed97e 100644 --- a/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md +++ b/docs/superpowers/plans/2026-10-05-slice-2a-osv-parser.md @@ -244,9 +244,9 @@ def build_group_schema(sheet: Sheet, header: TableHeader, rules: Rules) -> tuple ``` `parse_doc_header`: область — строки 1..header.first_row−1; организация — первая непустая текстовая ячейка, не матчащая ни один `header_markers` и `sheet_markers`; счёт — текст после `header_markers.account` до пробела/«за»; период — после `header_markers.period` до «г.»/конца строки (для варианта 2 — строка «Период: …»); отсутствие → `""` + предупреждение; `data_type` по `data_types.header`; валюта по `header_markers.currency` → код (`RUB` для «RUB», «руб», «рубл»); год — `periods.year`; квартал/нарастание — первый матч `periods.rules` (`# ASSUMPTION(OQ-02)`). `build_group_schema`: вариант 1 — тексты `text_col` в `group_label_rows` сверху вниз → первая всегда `ACCOUNT` (даже если текст «Счет»), остальные по `osv_groups`; `has_inn` — в той же строке есть ячейка `service_columns.inn`; вариант 2 — если в области шапки есть строка с `header_markers.subkonto`: `ACCOUNT` + элементы через запятую после двоеточия. -- [ ] **Step 1: Тесты.** Юнит: вариант 1 с «Счет/Контрагенты/Договоры», с «Подразделение/Контрагенты ИНН» → `has_inn` у COUNTERPARTY; вариант 2; шапка без организации → `""` + warning; счёт «58.03»; все строки таблицы §1.5 через `parse_doc_header` на мини-сетке; валюта «руб.» и «RUB» → `RUB`. `test_header_real.py` (`fixtures`): для всех 66 `account_code_raw` совпадает с кодом из имени файла (регулярка `(\d{2,3}(?:\.\d{1,2})?)` по имени; для `сч 583_` ожидать `58.03`, для `ОСВ_01_ОС` — `01`; таблица исключений в тесте запрещена — вместо неё нормализация имени каталога/файла одной регуляркой, 583 → `58.03` по правилу «три цифры = счёт+субсчёт»), `period_quarter` не `None`, для каталога `2024-FY` `is_cumulative is True`, для `*-Q3` — `False`; `organization_name` непустой; `group_schema[0].type == "ACCOUNT"`. -- [ ] **Step 2–4:** падает → реализация → тесты, `make check`. -- [ ] **Step 5: Commit** `feat(parser): шапка ОСВ и схема группировок (§1.1, §1.5)`. +- [x] **Step 1: Тесты.** Юнит: вариант 1 с «Счет/Контрагенты/Договоры», с «Подразделение/Контрагенты ИНН» → `has_inn` у COUNTERPARTY; вариант 2; шапка без организации → `""` + warning; счёт «58.03»; все строки таблицы §1.5 через `parse_doc_header` на мини-сетке; валюта «руб.» и «RUB» → `RUB`. `test_header_real.py` (`fixtures`): для всех 66 `account_code_raw` совпадает с кодом из имени файла (регулярка `(\d{2,3}(?:\.\d{1,2})?)` по имени; для `сч 583_` ожидать `58.03`, для `ОСВ_01_ОС` — `01`; таблица исключений в тесте запрещена — вместо неё нормализация имени каталога/файла одной регуляркой, 583 → `58.03` по правилу «три цифры = счёт+субсчёт»), `period_quarter` не `None`, для каталога `2024-FY` `is_cumulative is True`, для `*-Q3` — `False`; `organization_name` непустой; `group_schema[0].type == "ACCOUNT"`. +- [x] **Step 2–4:** падает → реализация → тесты, `make check`. +- [x] **Step 5: Commit** `feat(parser): шапка ОСВ и схема группировок (§1.1, §1.5)`. --- diff --git a/openspec/changes/slice-2a-osv-parser/tasks.md b/openspec/changes/slice-2a-osv-parser/tasks.md index b7257c8..2278f03 100644 --- a/openspec/changes/slice-2a-osv-parser/tasks.md +++ b/openspec/changes/slice-2a-osv-parser/tasks.md @@ -29,7 +29,7 @@ группой, свёрнутое сальдо, отсутствие «начала», `right_edge`, `text_col`, `inn_col` по заголовку и по доле 10/12 цифр, `extra`-колонки). Unit-тесты на сетках: заголовок 1/2/3/4 строки, merged, без «начала», свёрнутое сальдо, КПП между ИНН и суммами, мусор правее. `fixtures`-тест: `columns` для РБ `E/H/J/L/O/Q`, для Счастливочной xls `G/J/K/N/Q/S` + `inn=E`. -- [ ] 2.2 `header.py`: шапка документа (организация, счёт, период, data_type, валюта), `group_schema` вариант 1 и 2, `has_inn`, +- [x] 2.2 `header.py`: шапка документа (организация, счёт, период, data_type, валюта), `group_schema` вариант 1 и 2, `has_inn`, период → `period_year`, `period_quarter`, `is_cumulative` по `periods.yaml` (`ASSUMPTION(OQ-02)`). Unit-тесты: обе шапки, отсутствие полей → `null` + warning, субсчёт 58.03, все строки таблицы §1.5. `fixtures`-тест: у всех 66 файлов `account_code_raw` совпадает с кодом в имени каталога/файла по регулярке и период распознан.