feat(parser): шапка ОСВ и схема группировок (§1.1, §1.5)

- header.py: parse_doc_header (организация, счёт, период, data_type, валюта, вариант шапки,
  предупреждения на русском) и build_group_schema (вариант 1 — метки в text_col, вариант 2 —
  «субконто: …»); has_inn по ячейке ИНН в строке метки (отдельной или в тексте метки)
- periods.yaml: end — конец текста периода («г.», «год»); PeriodRules.end
- линтер координат: Literal[1, 2] не считается индексацией ячейки
- fixtures-тест на 66 файлах: счёт по имени файла одним правилом без исключений

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-05 13:12:20 +04:00
parent 5868b348a2
commit 83dd241a00
9 changed files with 813 additions and 5 deletions

View File

@ -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`) |
## Как писать паттерны

View File

@ -21,3 +21,7 @@ rules:
# Год периода: четыре цифры 19xx/20xx, не часть длинного числа (ИНН, номер)
year: '(?<!\d)(19|20)\d{2}(?!\d)'
# Конец текста периода в строке шапки: «за 3 квартал 2024 г. (по подразделениям)» -> «3 квартал 2024 г.»;
# без совпадения период идёт до конца строки
end: '\bг\.|\bгода?\b'

305
api/src/parser/header.py Normal file
View File

@ -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

View File

@ -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 — от специфичных к общим."""

113
api/tests/fixtures/test_header_real.py vendored Normal file
View File

@ -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<year>\d{4})-(?P<kind>FY|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"

View File

@ -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 == ""

View File

@ -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) == []

View File

@ -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)`.
---

View File

@ -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` совпадает с кодом в имени каталога/файла по регулярке и период распознан.