From 30f78c42e7a96ef92a42a0151a9088718fb0381e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 12:49:11 +0400 Subject: [PATCH 1/5] =?UTF-8?q?feat(osv):=20=D0=BF=D0=B5=D1=80=D0=B8=D0=BE?= =?UTF-8?q?=D0=B4-=D0=B4=D0=B8=D0=B0=D0=BF=D0=B0=D0=B7=D0=BE=D0=BD=20?= =?UTF-8?q?=D0=BC=D0=B5=D1=81=D1=8F=D1=86=D0=B5=D0=B2=20=C2=AB=D0=9E=D0=BA?= =?UTF-8?q?=D1=82=D1=8F=D0=B1=D1=80=D1=8C=202024=20=D0=B3.=20=E2=80=93=20?= =?UTF-8?q?=D0=A1=D0=B5=D0=BD=D1=82=D1=8F=D0=B1=D1=80=D1=8C=202025=20?= =?UTF-8?q?=D0=B3.=C2=BB=20(=D1=81=D0=BA=D0=BE=D0=BB=D1=8C=D0=B7=D1=8F?= =?UTF-8?q?=D1=89=D0=B8=D0=B9,=20=D0=BD=D0=B5=20=D1=81=20=D0=BD=D0=B0?= =?UTF-8?q?=D1=87=D0=B0=D0=BB=D0=B0=20=D0=B3=D0=BE=D0=B4=D0=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше первый месяц диапазона давал «2024 Q4, нарастающий». Теперь словарь periods.yaml (months, range) разбирает диапазон: период режется по году конца, январь–конец квартала — нарастающий, ровно квартал — квартальный, остальное (скользящие 12 месяцев) — год конца, квартал и is_cumulative = null + предупреждение «не с начала года». Контракт v1.1 не менялся. Refs #11 Co-Authored-By: Claude Sonnet 4.5 Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b --- api/rules/periods.yaml | 8 ++++ api/src/parser/header.py | 66 +++++++++++++++++++++++++++- api/src/parser/rules.py | 63 ++++++++++++++++++++++++++ api/tests/unit/parser/test_header.py | 51 +++++++++++++++++++++ api/tests/unit/parser/test_rules.py | 52 ++++++++++++++++++++++ 5 files changed, 238 insertions(+), 2 deletions(-) diff --git a/api/rules/periods.yaml b/api/rules/periods.yaml index 0f4e7dc..2362e93 100644 --- a/api/rules/periods.yaml +++ b/api/rules/periods.yaml @@ -25,3 +25,11 @@ year: '(? «3 квартал 2024 г.»; # без совпадения период идёт до конца строки end: '\bг\.|\bгода?\b' + +# Названия месяцев в любом падеже; имя группы — номер месяца (m01 … m12): по нему код узнаёт месяц без списка слов +months: '(?Pянвар)|(?Pфеврал)|(?Pмарт)|(?Pапрел)|(?Pма[йя])|(?Pиюн)|(?Pиюл)|(?Pавгуст)|(?Pсентябр)|(?Pоктябр)|(?Pноябр)|(?Pдекабр)' + +# Диапазон месяцев с годом конца: «Октябрь 2024 г. - Сентябрь 2025 г.», «с октября 2024 по сентябрь 2025 г.», +# «Апрель - Июнь 2025 г.». Группы: m1/y1 — месяц и год начала (год может отсутствовать), m2/y2 — месяц и год конца. +# Диапазон от января без года начала («январь-март 2025 г.») берут правила выше; здесь он не учитывается (код). +range: '(?P(январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр)\w*)\s*(?P(19|20)\d{2})?\s*(г\.?|года?)?\s*(по\s+|до\s+)?(?P(январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр)\w*)\s*(?P(19|20)\d{2})' diff --git a/api/src/parser/header.py b/api/src/parser/header.py index f645d79..e4ad61c 100644 --- a/api/src/parser/header.py +++ b/api/src/parser/header.py @@ -10,17 +10,18 @@ import bisect import re from collections.abc import Iterable from dataclasses import dataclass -from typing import Any, Literal +from typing import Any, Literal, get_args 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 +from src.parser.rules import GroupType, MonthRange, Pattern, Rules, first_match, normalize_text __all__ = ["DocHeader", "build_group_schema", "parse_doc_header"] Quarter = Literal["Q1", "Q2", "Q3", "Q4"] +QUARTERS: tuple[Quarter, ...] = get_args(Quarter) HeaderVariant = Literal[1, 2] # Код счёта начинается с цифры и идёт до пробела или запятой: «60», «58.03», «76.07.1» @@ -147,12 +148,33 @@ class _PeriodHit: text: str +def _cut_month_range(tail: str, rules: Rules) -> str | None: + """Период — диапазон месяцев с годами («Октябрь 2024 г. - Сентябрь 2025 г.»): до года конца и «г.» после него. + + Первое «г.» диапазона (после года начала) период не заканчивает: «Октябрь 2024 г.» — лишь его начало. + """ + if rules.periods.month_range(tail) is None: + return None + found = rules.periods.range.search(tail) + if found is None: + return None + stop = _source_offset(tail, found.end()) + rest = tail[stop:] + mark = rules.periods.end.search(rest) + if mark is not None and mark.start() == 0: + stop += _source_offset(rest, mark.end()) + return tail[:stop].strip() + + def _cut_period(tail: str, rules: Rules) -> str: """Конец периода — «г.»/«год» включительно: самое короткое начало хвоста, которое словарь периодов узнаёт. Диапазон дат «01.01.2025 г. - 31.03.2025 г.» первым «г.» не обрезается: «01.01.2025 г.» — не период. Если ни одно начало не узнано, период — весь хвост до конца строки. """ + ranged = _cut_month_range(tail, rules) + if ranged is not None: + return ranged start = 0 while start < len(tail): end = rules.periods.end.search(tail[start:]) @@ -252,7 +274,47 @@ class _Period: is_cumulative: bool | None +_MONTHS_IN_QUARTER = 3 + + +def _quarter_of_end(month: int) -> Quarter | None: + """Квартал, которым заканчивается период, если конец диапазона — конец квартала (3, 6, 9, 12-й месяц).""" + if month % _MONTHS_IN_QUARTER: + return None + return QUARTERS[month // _MONTHS_IN_QUARTER - 1] + + +def _month_range_period(span: MonthRange, text: str, warnings: list[str]) -> _Period: + """Период-диапазон месяцев: с января по конец квартала — нарастающий, ровно квартал — квартальный; остальное + (скользящие 12 месяцев, полгода с апреля) — квартал и признак не определены: год — год конца диапазона. + + Скользящий период нельзя выдать ни за квартальный, ни за нарастающий: ядро привяжет его к версии как не тот + документ. `null` + предупреждение оставляют решение аналитику (OQ-131). + """ + quarter = _quarter_of_end(span.end_month) + same_year = span.start_year == span.end_year + if quarter is not None and same_year and span.start_month == 1: + return _Period(span.end_year, quarter, True) + if quarter is not None and same_year and span.months == _MONTHS_IN_QUARTER: + return _Period(span.end_year, quarter, False) + if span.start_month != 1 or not same_year: + warnings.append( + f"период «{text}»: скользящий период {span.months} мес. " + f"(с {span.start_month:02d}.{span.start_year} по {span.end_month:02d}.{span.end_year}), не с начала года: " + "квартал и признак нарастающего итога не определены" + ) + else: + warnings.append( + f"период «{text}»: конец диапазона не совпадает с концом квартала: " + "квартал и признак нарастающего итога не определены" + ) + return _Period(span.end_year, None, None) + + def _parse_period(text: str, rules: Rules, warnings: list[str]) -> _Period: + span = rules.periods.month_range(text) + if span is not None: + return _month_range_period(span, text, warnings) rule = rules.periods.match(text) year = rules.periods.year.search(text) if rule is None: diff --git a/api/src/parser/rules.py b/api/src/parser/rules.py index a3c3788..603aab5 100644 --- a/api/src/parser/rules.py +++ b/api/src/parser/rules.py @@ -24,6 +24,7 @@ from src.contract.common import DataType __all__ = [ "DEFAULT_RULES_DIR", "DataTypeRules", + "MonthRange", "OsvGroupRules", "OsvHeaderRules", "Pattern", @@ -97,6 +98,11 @@ class Pattern: """Как `search`, но текст уже приведён `normalize_text`: ячейка нормализуется один раз на все паттерны.""" return self._regex.search(normalized) + @property + def group_names(self) -> frozenset[str]: + """Имена именованных групп паттерна: словарь требует их у записей месяцев и диапазона.""" + return frozenset(self._regex.groupindex) + def __repr__(self) -> str: return f"Pattern({self.source!r})" @@ -162,10 +168,67 @@ class PeriodRule(_Model): is_cumulative: bool +_MONTHS_IN_YEAR = 12 +_RANGE_GROUPS = frozenset({"m1", "y1", "m2", "y2"}) +_MONTH_GROUPS = frozenset(f"m{number:02d}" for number in range(1, _MONTHS_IN_YEAR + 1)) + + +@dataclass(frozen=True) +class MonthRange: + """Диапазон месяцев периода: «Октябрь 2024 г. – Сентябрь 2025 г.» -> (10, 2024, 9, 2025).""" + + start_month: int + start_year: int + end_month: int + end_year: int + + @property + def months(self) -> int: + """Число месяцев диапазона включительно: октябрь 2024 – сентябрь 2025 = 12.""" + return (self.end_year - self.start_year) * _MONTHS_IN_YEAR + self.end_month - self.start_month + 1 + + class PeriodRules(_Model): rules: list[PeriodRule] year: Pattern end: Pattern + # Названия месяцев: одна запись с именованными группами m01..m12; диапазон месяцев с годами — группы m1, y1, m2, y2 + months: Pattern + range: Pattern + + @model_validator(mode="after") + def _check_month_patterns(self) -> "PeriodRules": + if self.months.group_names != _MONTH_GROUPS: + raise ValueError("months: ожидаются именованные группы m01…m12 (по одной на месяц)") + missing = _RANGE_GROUPS - self.range.group_names + if missing: + raise ValueError(f"range: нет обязательных именованных групп {sorted(missing)}") + return self + + def month_number(self, text: str) -> int | None: + """Номер месяца по названию в любом падеже («октября» -> 10); не месяц — `None`.""" + found = self.months.search(text) + return None if found is None or found.lastgroup is None else int(found.lastgroup.removeprefix("m")) + + def month_range(self, text: str) -> MonthRange | None: + """Диапазон месяцев с годом конца: начало без года — в том же году, а если месяц начала позже месяца конца + (октябрь – сентябрь) — в предыдущем. + + Диапазон, который начинается с января и не называет год начала («январь-март 2025 г.»), словарь периодов + разбирает своими правилами (`match`), поэтому здесь это `None`. + """ + found = self.range.search(text) + if found is None: + return None + start, end = self.month_number(found.group("m1")), self.month_number(found.group("m2")) + if start is None or end is None: + return None + end_year = int(found.group("y2")) + year_text = found.group("y1") + if year_text is None and start == 1: + return None + start_year = int(year_text) if year_text is not None else end_year - (1 if start > end else 0) + return MonthRange(start, start_year, end, end_year) def match(self, text: str) -> PeriodRule | None: """Первое правило, паттерн которого совпал с текстом периода; порядок в YAML — от специфичных к общим.""" diff --git a/api/tests/unit/parser/test_header.py b/api/tests/unit/parser/test_header.py index 238fe7a..9993cfb 100644 --- a/api/tests/unit/parser/test_header.py +++ b/api/tests/unit/parser/test_header.py @@ -427,6 +427,57 @@ def test_date_range_with_year_marks_is_kept_whole(rules: Rules, period: str, qua assert header.warnings == () +# --- Диапазон месяцев с годами: скользящий период (issue #11 п. 1, OQ-131) --- + + +@pytest.mark.parametrize( + "period", + [ + "Октябрь 2024 г. - Сентябрь 2025 г.", + "с октября 2024 г. по сентябрь 2025 г.", + "октябрь 2024 - сентябрь 2025", + "Октябрь 2024 г. - Сентябрь 2025 г. (по подразделениям)", + ], +) +def test_rolling_period_is_not_read_as_first_month_quarter(rules: Rules, period: str) -> None: + header = _variant1(rules, title=_title(period)) + + assert header.period_text == period.removesuffix(" (по подразделениям)") + # Конец диапазона определяет год; квартал и «нарастающий» не определены: 12 месяцев — не квартал и не с начала года + assert (header.period_year, header.period_quarter, header.is_cumulative) == (2025, None, None) + assert any("не с начала года" in warning and "12 мес" in warning for warning in header.warnings) + + +def test_month_range_of_one_quarter_is_a_quarter_not_cumulative(rules: Rules) -> None: + header = _variant1(rules, title=_title("Апрель 2025 г. - Июнь 2025 г.")) + + assert header.period_text == "Апрель 2025 г. - Июнь 2025 г." + assert (header.period_year, header.period_quarter, header.is_cumulative) == (2025, "Q2", False) + assert header.warnings == () + + +def test_month_range_inside_year_not_from_january_is_not_a_quarter(rules: Rules) -> None: + header = _variant1(rules, title=_title("Апрель 2025 г. - Сентябрь 2025 г.")) + + assert (header.period_year, header.period_quarter, header.is_cumulative) == (2025, None, None) + assert any("не с начала года" in warning for warning in header.warnings) + + +def test_month_range_from_january_to_quarter_end_stays_cumulative(rules: Rules) -> None: + header = _variant1(rules, title=_title("Январь 2025 г. - Сентябрь 2025 г.")) + + assert header.period_text == "Январь 2025 г. - Сентябрь 2025 г." + assert (header.period_year, header.period_quarter, header.is_cumulative) == (2025, "Q3", True) + assert header.warnings == () + + +def test_month_range_from_january_to_mid_quarter_has_no_quarter(rules: Rules) -> None: + header = _variant1(rules, title=_title("Январь 2025 г. - Май 2025 г.")) + + assert (header.period_year, header.period_quarter, header.is_cumulative) == (2025, None, None) + assert any("период" in warning for warning in header.warnings) + + # --- Даты числами / датами (xlsb) (T5.5) --- diff --git a/api/tests/unit/parser/test_rules.py b/api/tests/unit/parser/test_rules.py index e914873..21c074c 100644 --- a/api/tests/unit/parser/test_rules.py +++ b/api/tests/unit/parser/test_rules.py @@ -184,6 +184,58 @@ def test_default_period_end_marker_cuts_at_year_abbreviation_and_year_word(rules assert end.search("с 01.01.2025 по 31.03.2025") is None +@pytest.mark.parametrize( + ("text", "expected"), + [("январь", 1), ("Мая", 5), ("мая", 5), ("октября", 10), ("Декабрь", 12), ("квартал", None), ("", None)], +) +def test_month_number_by_name_in_any_case(rules: Rules, text: str, expected: int | None) -> None: + assert rules.periods.month_number(text) == expected + + +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("Октябрь 2024 г. - Сентябрь 2025 г.", (10, 2024, 9, 2025, 12)), + ("с октября 2024 по сентябрь 2025 г.", (10, 2024, 9, 2025, 12)), + ("апрель - июнь 2025 г.", (4, 2025, 6, 2025, 3)), + ("сентябрь - май 2025", (9, 2024, 5, 2025, 9)), + ("Январь 2025 г. - Сентябрь 2025 г.", (1, 2025, 9, 2025, 9)), + ], +) +def test_month_range_with_years(rules: Rules, text: str, expected: tuple[int, int, int, int, int]) -> None: + found = rules.periods.month_range(text) + + assert found is not None + assert (found.start_month, found.start_year, found.end_month, found.end_year, found.months) == expected + + +@pytest.mark.parametrize("text", ["январь-март 2025 г.", "9 месяцев 2025 г.", "3 квартал 2025 г.", "2025 г.", ""]) +def test_month_range_is_none_for_other_period_texts(rules: Rules, text: str) -> None: + assert rules.periods.month_range(text) is None + + +def test_periods_months_without_all_month_groups_is_rejected(tmp_path: Path) -> None: + target = _copy_rules(tmp_path) + path = target / "periods.yaml" + data = yaml.safe_load(path.read_text(encoding="utf-8")) + data["months"] = "(?Pянвар)" + path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8") + + with pytest.raises(RuntimeError, match=r"periods\.yaml.*m01"): + load_rules(target) + + +def test_periods_range_without_named_groups_is_rejected(tmp_path: Path) -> None: + target = _copy_rules(tmp_path) + path = target / "periods.yaml" + data = yaml.safe_load(path.read_text(encoding="utf-8")) + data["range"] = "январ" + path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8") + + with pytest.raises(RuntimeError, match=r"periods\.yaml.*range"): + load_rules(target) + + def test_unknown_key_is_rejected_as_probable_typo(tmp_path: Path) -> None: target = _copy_rules(tmp_path) path = target / "periods.yaml" -- 2.47.2 From 6fd7adde22ab2d9635c646caa1600bd05653b5d4 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 12:50:35 +0400 Subject: [PATCH 2/5] =?UTF-8?q?feat(osv):=20=C2=AB=D0=98=D1=82=D0=BE=D0=B3?= =?UTF-8?q?=D0=BE=C2=BB=20=D1=81=D0=B2=D0=B5=D1=80=D1=8F=D0=B5=D1=82=D1=81?= =?UTF-8?q?=D1=8F=20=D1=81=20=D1=81=D1=83=D0=BC=D0=BC=D0=BE=D0=B9=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=BE=D0=BA=20=D1=83=D1=80=D0=BE=D0=B2=D0=BD=D1=8F?= =?UTF-8?q?=200;=20=C2=AB=D0=98=D1=82=D0=BE=D0=B3=D0=BE=20=D1=80=D0=B0?= =?UTF-8?q?=D0=B7=D0=B2=D0=B5=D1=80=D0=BD=D1=83=D1=82=D0=BE=D0=B5=C2=BB=20?= =?UTF-8?q?=E2=80=94=20=D0=B2=D1=82=D0=BE=D1=80=D0=B0=D1=8F=20=D0=B8=D1=82?= =?UTF-8?q?=D0=BE=D0=B3=D0=BE=D0=B2=D0=B0=D1=8F=20=D1=81=D1=82=D1=80=D0=BE?= =?UTF-8?q?=D0=BA=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - checks: «Итого» = первый корень ACCOUNT либо сумма всех строк уровня 0 (лист без строки корневого счёта, верхний уровень 58.01/58.03; OQ-106, OQ-133). Прежнее правило «= первому корню» остаётся достаточным; строки-показатели в сумму не входят; основная «Итого» — первая TOTAL листа. - classify/hierarchy: вторые итоговые строки сразу за «Итого» (osv_headers.yaml: total_row_secondary) остаются в таблице как TOTAL уровня 0, а не уходят в OTHER; без обычной «Итого» вторая служит единственной итоговой. Refs #11 Co-Authored-By: Claude Sonnet 4.5 Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b --- api/rules/osv_headers.yaml | 4 ++ api/src/parser/checks.py | 44 +++++++++++++++---- api/src/parser/classify.py | 39 +++++++++++++---- api/src/parser/hierarchy.py | 10 +++-- api/src/parser/rules.py | 1 + api/tests/unit/parser/test_checks.py | 58 ++++++++++++++++++++++++++ api/tests/unit/parser/test_classify.py | 46 ++++++++++++++++++++ 7 files changed, 182 insertions(+), 20 deletions(-) diff --git a/api/rules/osv_headers.yaml b/api/rules/osv_headers.yaml index bb7acf5..23e31ee 100644 --- a/api/rules/osv_headers.yaml +++ b/api/rules/osv_headers.yaml @@ -49,6 +49,10 @@ service_columns: total_row: - '^итого\b' +# Вторая итоговая строка после «Итого» («Итого развернутое» в 1С): тоже TOTAL, но «Итого» листа — основная, первая из них +total_row_secondary: + - '^итого\s+развернут' + # Метки шапки документа (область выше заголовка таблицы); ключи фиксированы header_markers: # «по счету 60 за …»: код счёта — сразу после совпадения diff --git a/api/src/parser/checks.py b/api/src/parser/checks.py index 2090570..265da86 100644 --- a/api/src/parser/checks.py +++ b/api/src/parser/checks.py @@ -9,8 +9,10 @@ по колонкам (развёрнутое сальдо) либо, если у родителя сальдо свёрнуто (не больше одной ненулевой стороны), по нетто `дебет − кредит`: 1С показывает счёт свёрнутым сальдо, а субсчета и контрагентов — развёрнутым (так во всех реальных файлах, где у счёта есть субсчета с разными сторонами сальдо); -3. строка «Итого» (TOTAL) = корневая строка ACCOUNT (первая строка уровня 0); корня нет (пустая ОСВ, R-13) — «Итого» - должна быть пустой или нулевой; «Итого» не найдена (R-20) — сверка невозможна, результат `false`. +3. строка «Итого» (TOTAL) = корневая строка ACCOUNT (первая строка уровня 0) либо сумма всех строк уровня 0, если их + несколько (лист без строки корневого счёта: верхний уровень — субсчета 58.01, 58.03; OQ-106, OQ-133); корня нет + (пустая ОСВ, R-13) — «Итого» должна быть пустой или нулевой; «Итого» не найдена (R-20) — сверка невозможна, + результат `false`. Строки-показатели (валюта, количество) в сумму уровня 0 не входят: это другие единицы. Любое нарушение делает результат `needs_check` (решает оркестратор `osv.py`); парсер структуру под сходимость не подгоняет. Номера строк — в `OsvChecks`, причины — тексты предупреждений. @@ -111,7 +113,8 @@ def parent_sums(rows: Sequence[ClassifiedRow]) -> list[int]: def _total(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None: - return next((item for item in reversed(rows) if item.group_type == "TOTAL"), None) + """Основная «Итого» листа — первая строка TOTAL (вторая итоговая строка идёт за ней и в сверку не входит).""" + return next((item for item in rows if item.group_type == "TOTAL"), None) def _root(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None: @@ -121,10 +124,28 @@ def _root(rows: Sequence[ClassifiedRow]) -> ClassifiedRow | None: ) -def total_matches_root(rows: Sequence[ClassifiedRow]) -> bool: - """«Итого» = первая корневая строка ACCOUNT по шести колонкам (±0.01). +def _level_zero(rows: Sequence[ClassifiedRow]) -> list[ClassifiedRow]: + """Строки уровня 0 без родителя, кроме «Итого» и строк-показателей, в порядке листа.""" + return [ + item + for item in rows + if item.level == _ROOT_LEVEL and item.parent is None and item.group_type != "TOTAL" and not _is_indicator(item) + ] - Нет «Итого» — `False` (R-20); нет корня — `True`, только если все суммы «Итого» пусты или нулевые (R-13). + +def _sum_of(rows: Iterable[ClassifiedRow]) -> tuple[Decimal, ...]: + totals = [_ZERO] * len(SUM_FIELDS) + for item in rows: + for index, value in enumerate(_values(item)): + totals[index] += value + return tuple(totals) + + +def total_matches_root(rows: Sequence[ClassifiedRow]) -> bool: + """«Итого» = первая корневая строка ACCOUNT или сумма строк уровня 0 по шести колонкам (±0.01). + + Нет «Итого» — `False` (R-20); нет корня — `True`, только если все суммы «Итого» пусты или нулевые (R-13). Сумма + уровня 0 нужна листу без строки корневого счёта: верхний уровень — несколько субсчетов, «Итого» равно их сумме. """ total = _total(rows) if total is None: @@ -132,7 +153,10 @@ def total_matches_root(rows: Sequence[ClassifiedRow]) -> bool: root = _root(rows) if root is None: return all(abs(value) <= TOLERANCE for value in _values(total)) - return not _differs(_values(total), _values(root)) + if not _differs(_values(total), _values(root)): + return True + roots = _level_zero(rows) + return len(roots) > 1 and not _differs(_values(total), _sum_of(roots)) def _rows_text(rows: Iterable[int]) -> str: @@ -150,7 +174,11 @@ def _total_warning(rows: Sequence[ClassifiedRow]) -> str: return TOTAL_NOT_FOUND_WARNING if root is None: return f"§1.4: строка «Итого» (строка {total.body.row}) не пуста, а корневого счёта нет" - return f"§1.4: строка «Итого» (строка {total.body.row}) ≠ корневому счёту (строка {root.body.row})" + message = f"§1.4: строка «Итого» (строка {total.body.row}) ≠ корневому счёту (строка {root.body.row})" + level_zero = _level_zero(rows) + if len(level_zero) > 1: + return f"{message} и ≠ сумме {len(level_zero)} строк уровня 0" + return message def run_checks(rows: Sequence[ClassifiedRow]) -> tuple[OsvChecks, list[str]]: diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index 3cb56ea..61dd630 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -112,6 +112,8 @@ def _find_total(sheet: Sheet, header: TableHeader, rows: Sequence[int], rules: R строкой «Итого»), поэтому «корневой уровень» проверяется по оформлению: outline level и отступ строки не больше, чем у любой другой строки таблицы. Так контрагент «Итого-Сервис» в середине данных не TOTAL (за ним есть настоящее «Итого»), а последний «Итого-Сервис» под счётом с outline/отступом — тоже не TOTAL (T3.3). + Вторая итоговая строка («Итого развернутое», `total_row_secondary`) основной не бывает: основная — «Итого» перед ней; + без обычной «Итого» вторая строка служит единственной итоговой. Известный риск: на листе без outline и отступов последняя строка-контрагент «Итого-…» при отсутствии настоящего «Итого» станет TOTAL; это ловит проверка «TOTAL = корень» (§1.4). """ @@ -119,14 +121,33 @@ def _find_total(sheet: Sheet, header: TableHeader, rows: Sequence[int], rules: R return None outline_floor = min(sheet.outline(row) for row in rows) indent_floor = min(_text_cell_style(sheet, row, header.text_col)[1] for row in rows) + only_secondary: int | None = None for row in reversed(rows): text = sheet.text_at(row, header.text_col) if not any(pattern.search(text) for pattern in rules.headers.total_row): continue _, indent = _text_cell_style(sheet, row, header.text_col) - is_root = sheet.outline(row) == outline_floor and indent == indent_floor - return row if is_root else None - return None + if sheet.outline(row) != outline_floor or indent != indent_floor: + return only_secondary + if _is_secondary_total(text, rules): + only_secondary = only_secondary or row + continue + return row + return only_secondary + + +def _is_secondary_total(text: str, rules: Rules) -> bool: + return any(pattern.search(text) for pattern in rules.headers.total_row_secondary) + + +def _trailing_totals(sheet: Sheet, header: TableHeader, rows: Sequence[int], total: int, rules: Rules) -> int: + """Номер последней из вторых итоговых строк, идущих в таблице сразу за основной «Итого» (она сама, если их нет).""" + last = total + for row in rows[rows.index(total) + 1 :]: + if not _is_secondary_total(sheet.text_at(row, header.text_col), rules): + break + last = row + return last def _split_collapsed(sums: dict[str, Decimal | None], columns: ColumnMap) -> None: @@ -179,7 +200,8 @@ def collect_body_rows( ) -> tuple[list[BodyRow], int | None, list[str]]: """Строки данных листа (включая «Итого»), номер строки «Итого» (`None` — не найдена) и предупреждения. - Строки после «Итого» — подвал (подписи, числа правее таблицы): ни в данные, ни в `extra` они не попадают (T4.5). + Строки после «Итого» — подвал (подписи, числа правее таблицы): ни в данные, ни в `extra` они не попадают (T4.5); + исключение — вторые итоговые строки («Итого развернутое») сразу за «Итого»: они остаются в таблице как TOTAL. """ warnings: list[str] = [] if header.columns.collapsed: @@ -187,7 +209,7 @@ def collect_body_rows( rows = _table_rows(sheet, header, rules, deadline) total_row = _find_total(sheet, header, rows, rules) if total_row is not None: - rows = rows[: rows.index(total_row) + 1] + rows = rows[: rows.index(_trailing_totals(sheet, header, rows, total_row, rules)) + 1] body: list[BodyRow] = [] for index, row in enumerate(rows, start=1): if index % DEADLINE_CHECK_EVERY_ROWS == 0: @@ -300,8 +322,8 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta ) -> list[ClassifiedRow]: """Классификация строк по дереву `levels` и схеме группировок. - - «Итого» (`total_row`) — TOTAL, уровень 0 без родителя; в `rows` остаётся (# ASSUMPTION(OQ-01): хранить ли - «Итого», решает ядро); + - «Итого» (`total_row`) и вторые итоговые строки после неё («Итого развернутое») — TOTAL, уровень 0 без родителя; + в `rows` остаются (# ASSUMPTION(OQ-01): хранить ли «Итого», решает ядро); - счёт (`ACCOUNT_RE`) — ACCOUNT, `account_code_raw` — код как в файле; дети наследуют код ветки; - строка-показатель («Вал.», «Кол.», код валюты) — `data_type` CURRENCY/QUANTITY, группа OTHER: это не уровень группировки, а вид сумм родителя; позицию в схеме она не занимает; @@ -313,7 +335,8 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta branches: dict[int, _Branch] = {} result: list[ClassifiedRow] = [] for item in rows: - if item.row == total_row: + if total_row is not None and item.row >= total_row: + # Всё от основной «Итого» и ниже — итоговые строки (подвал `collect_body_rows` уже отрезал) # ASSUMPTION(OQ-01): строка «Итого» остаётся в rows; хранить ли её, решает ядро kind, branch = _Kind("TOTAL", None, None, doc_data_type), _ROOT_BRANCH level, parent = 0, None diff --git a/api/src/parser/hierarchy.py b/api/src/parser/hierarchy.py index ab8310f..1ce8a1e 100644 --- a/api/src/parser/hierarchy.py +++ b/api/src/parser/hierarchy.py @@ -268,11 +268,13 @@ def parents(rows: Sequence[BodyRow], by_row: Mapping[int, int]) -> dict[int, int def resolve_levels(rows: Sequence[BodyRow], total_row: int | None, deadline: Deadline) -> Levels: """Первая стратегия каскада, давшая непротиворечивое дерево; иначе `InvalidFormatError`. - «Итого» (`total_row`) в стратегии не передаётся и получает уровень 0: это контрольная сумма документа, а не - ребёнок последнего счёта. Меньше двух строк, кроме «Итого», — тривиальное дерево корней `TRIVIAL_STRATEGY`. + «Итого» (`total_row`) и строки после неё (вторые итоговые) в стратегии не передаются и получают уровень 0: это + контрольная сумма документа, а не ребёнок последнего счёта. Меньше двух строк, кроме «Итого», — тривиальное дерево корней `TRIVIAL_STRATEGY`. """ - body = [item for item in rows if item.row != total_row] - pinned = {} if total_row is None else {total_row: ROOT_LEVEL} + # «Итого» и вторые итоговые строки за ней («Итого развернутое») — не данные: уровень 0 без участия стратегий + totals = [] if total_row is None else [item for item in rows if item.row >= total_row] + body = [item for item in rows if total_row is None or item.row < total_row] + pinned = {item.row: ROOT_LEVEL for item in totals} if len(body) < MIN_ROWS_FOR_CASCADE: by_row = {item.row: ROOT_LEVEL for item in rows} return Levels(by_row=by_row, parent=parents(rows, by_row), strategy=TRIVIAL_STRATEGY) diff --git a/api/src/parser/rules.py b/api/src/parser/rules.py index 603aab5..00f0e3a 100644 --- a/api/src/parser/rules.py +++ b/api/src/parser/rules.py @@ -136,6 +136,7 @@ class OsvHeaderRules(_Model): credit: list[Pattern] service_columns: dict[str, list[Pattern]] total_row: list[Pattern] + total_row_secondary: list[Pattern] header_markers: dict[HeaderMarker, list[Pattern]] @model_validator(mode="after") diff --git a/api/tests/unit/parser/test_checks.py b/api/tests/unit/parser/test_checks.py index 5910693..680dd41 100644 --- a/api/tests/unit/parser/test_checks.py +++ b/api/tests/unit/parser/test_checks.py @@ -228,6 +228,64 @@ def test_total_matches_first_root_account() -> None: assert total_matches_root(rows) is False +# Лист без строки корневого счёта (вариант 2, issue #11 п. 3, OQ-106/OQ-133): верхний уровень — субсчета 58.01, 58.03; +# «Итого» равно сумме строк уровня 0 +SUB_A: Sums = ("100", None, "10", "0", "110", None) +SUB_B: Sums = ("40", None, "30", "20", "50", None) +SUB_SUM: Sums = ("140", None, "40", "20", "160", None) + + +def multi_root_tree(total_sums: Sums = SUB_SUM) -> list[ClassifiedRow]: + return [ + account(7, SUB_A), + row(8, SUB_A, level=1, parent=7), + account(9, SUB_B), + row(10, SUB_B, level=1, parent=9), + total(11, total_sums), + ] + + +def test_total_matches_sum_of_level_zero_rows_when_there_are_several_roots() -> None: + rows = multi_root_tree() + + checks, warnings = run_checks(rows) + + assert total_matches_root(rows) is True + assert checks.total_row_matches_root is True + assert warnings == [] + + +def test_total_that_equals_neither_first_root_nor_sum_of_roots_mismatches() -> None: + rows = multi_root_tree(("140", None, "40", "20", "161", None)) + + checks, warnings = run_checks(rows) + + assert checks.total_row_matches_root is False + assert any("Итого" in warning and "11" in warning and "сумме 2 строк уровня 0" in warning for warning in warnings) + + +def test_sum_of_roots_ignores_indicator_rows_and_total_itself() -> None: + rows = multi_root_tree() + rows.insert(2, row(20, ("5", None, None, None, "5", None), group_type="OTHER", data_type="CURRENCY")) + + assert total_matches_root(rows) is True + + +def test_total_still_matches_single_root_even_if_other_level_zero_rows_exist() -> None: + """Прежнее поведение сохраняется: «Итого» = первый корень достаточно, лишние строки уровня 0 статус не портят.""" + rows = tree() + rows.insert(3, row(9, ("1", None, None, None, "1", None), group_type="OTHER")) + + assert total_matches_root(rows) is True + + +def test_second_total_row_does_not_replace_the_first_one() -> None: + """Вторая итоговая строка («Итого развернутое») в сверку с корнем не входит: берётся первая TOTAL листа.""" + rows = [*tree(), total(11, ("1", None, None, None, "1", None))] + + assert total_matches_root(rows) is True + + def test_empty_osv_without_root_matches_when_total_is_empty_or_zero() -> None: assert total_matches_root([total(8, EMPTY)]) is True assert total_matches_root([total(8, ("0", None, "0.00", None, None, "0"))]) is True diff --git a/api/tests/unit/parser/test_classify.py b/api/tests/unit/parser/test_classify.py index e310b52..1b1bf6d 100644 --- a/api/tests/unit/parser/test_classify.py +++ b/api/tests/unit/parser/test_classify.py @@ -567,6 +567,52 @@ def test_total_without_sums_in_account_pattern_sheet_stays_root(rules: Rules) -> assert unpinned.by_row[total.body.row] > 0 +def test_second_total_row_after_total_is_total_too(rules: Rules) -> None: + """Issue #11 п. 5: «Итого развернутое» после «Итого» — вторая итоговая строка (TOTAL, уровень 0), не OTHER.""" + sheet = osv( + r(0, "60.01", S100), + r(1, "ООО Поставщик", S100, inn="7701234567"), + r(None, "Итого", S100), + r(None, "Итого развернутое", S100), + [], + ["Главный бухгалтер", None, "Иванова"], + ) + + parsed = parse(sheet, rules) + + assert [row.body.text for row in parsed.rows] == ["60.01", "ООО Поставщик", "Итого", "Итого развернутое"] + assert parsed.total_row == parsed.one("Итого").body.row # основная — первая из подряд идущих итоговых + for text in ("Итого", "Итого развернутое"): + total = parsed.one(text) + assert (total.group_type, total.level, total.parent) == ("TOTAL", 0, None) + + +def test_second_total_row_alone_serves_as_total(rules: Rules) -> None: + """Если обычной «Итого» нет, «Итого развернутое» — единственная итоговая строка и сверяется с корнем.""" + sheet = osv( + r(0, "60", S100), + r(1, "ООО Поставщик", S100, inn="7701234567"), + r(None, "Итого развернутое", S100), + ) + + parsed = parse(sheet, rules) + + assert parsed.total_row == parsed.one("Итого развернутое").body.row + assert parsed.one("Итого развернутое").group_type == "TOTAL" + + +def test_text_after_total_that_is_not_a_second_total_stays_footer(rules: Rules) -> None: + sheet = osv( + r(0, "60", S100), + r(None, "Итого", S100), + r(None, "Руководитель", S100), + ) + + parsed = parse(sheet, rules) + + assert [row.body.text for row in parsed.rows] == ["60", "Итого"] + + def test_counterparty_named_like_total_in_the_middle(rules: Rules) -> None: """T3.3: «Итого-Сервис» в середине данных — контрагент; TOTAL — последняя строка «Итого».""" sheet = osv( -- 2.47.2 From 1c26e7810e840644b767a38aaa50ec5b59a8ad6d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 12:50:46 +0400 Subject: [PATCH 3/5] =?UTF-8?q?style(osv):=20=D0=B4=D0=BB=D0=B8=D0=BD?= =?UTF-8?q?=D0=B0=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=20=D0=B4=D0=BE=D0=BA?= =?UTF-8?q?=D1=81=D1=82=D1=80=D0=B8=D0=BD=D0=B3=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #11 Co-Authored-By: Claude Sonnet 4.5 Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b --- api/src/parser/classify.py | 4 ++-- api/src/parser/hierarchy.py | 3 ++- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index 61dd630..f768d7f 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -112,8 +112,8 @@ def _find_total(sheet: Sheet, header: TableHeader, rows: Sequence[int], rules: R строкой «Итого»), поэтому «корневой уровень» проверяется по оформлению: outline level и отступ строки не больше, чем у любой другой строки таблицы. Так контрагент «Итого-Сервис» в середине данных не TOTAL (за ним есть настоящее «Итого»), а последний «Итого-Сервис» под счётом с outline/отступом — тоже не TOTAL (T3.3). - Вторая итоговая строка («Итого развернутое», `total_row_secondary`) основной не бывает: основная — «Итого» перед ней; - без обычной «Итого» вторая строка служит единственной итоговой. + Вторая итоговая строка («Итого развернутое», `total_row_secondary`) основной не бывает: основная — «Итого» + перед ней; без обычной «Итого» вторая строка служит единственной итоговой. Известный риск: на листе без outline и отступов последняя строка-контрагент «Итого-…» при отсутствии настоящего «Итого» станет TOTAL; это ловит проверка «TOTAL = корень» (§1.4). """ diff --git a/api/src/parser/hierarchy.py b/api/src/parser/hierarchy.py index 1ce8a1e..fcc9ef6 100644 --- a/api/src/parser/hierarchy.py +++ b/api/src/parser/hierarchy.py @@ -269,7 +269,8 @@ def resolve_levels(rows: Sequence[BodyRow], total_row: int | None, deadline: Dea """Первая стратегия каскада, давшая непротиворечивое дерево; иначе `InvalidFormatError`. «Итого» (`total_row`) и строки после неё (вторые итоговые) в стратегии не передаются и получают уровень 0: это - контрольная сумма документа, а не ребёнок последнего счёта. Меньше двух строк, кроме «Итого», — тривиальное дерево корней `TRIVIAL_STRATEGY`. + контрольная сумма документа, а не ребёнок последнего счёта. Меньше двух строк, кроме «Итого», — тривиальное + дерево корней `TRIVIAL_STRATEGY`. """ # «Итого» и вторые итоговые строки за ней («Итого развернутое») — не данные: уровень 0 без участия стратегий totals = [] if total_row is None else [item for item in rows if item.row >= total_row] -- 2.47.2 From af8ec918c5c5120cf76c0ff28f850785be2a4363 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 13:04:31 +0400 Subject: [PATCH 4/5] =?UTF-8?q?feat(osv):=20=D1=88=D0=B0=D0=BF=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=9E=D0=A1=D0=92=20=D0=B1=D0=B5=D0=B7=20=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=BE=D0=BA=20=D0=B8=20=C2=AB=D0=A1=D1=87=D0=B5=D1=82:=20?= =?UTF-8?q?=D0=9A=D0=BE=D0=B4=20/=20=D0=9D=D0=B0=D0=B8=D0=BC=D0=B5=D0=BD?= =?UTF-8?q?=D0=BE=D0=B2=D0=B0=D0=BD=D0=B8=D0=B5=C2=BB=20(=D0=9E=D0=A1?= =?UTF-8?q?=D0=92=2063=20=D0=9D=D0=BE=D0=B2=D0=BE=D1=80=D0=BE=D1=81=D0=BC?= =?UTF-8?q?=D0=B5=D1=82=D0=B0=D0=BB=D0=BB),=20=C2=AB=D0=98=D1=82=D0=BE?= =?UTF-8?q?=D0=B3=D0=BE=C2=BB=20=E2=86=94=20=CE=A3=20=D1=83=D1=80=D0=BE?= =?UTF-8?q?=D0=B2=D0=BD=D1=8F=200=20=D0=BF=D0=BE=20=D0=BD=D0=B5=D1=82?= =?UTF-8?q?=D1=82=D0=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - header: период отдельной ячейкой (periods.yaml: standalone), тип данных отдельной ячейкой («Бухгалтерский», data_types.yaml: standalone_header), организация — хвост названия ведомости; код счёта — из первой строки-счёта таблицы (с предупреждением), если «по счету» в шапке нет. - classify/detect: текст строки может стоять правее колонки текста (колонка задаёт уровень), подписи «Код»/ «Наименование» (osv_headers.yaml: column_captions) — не группировки, строка сумм без подписи в конце таблицы — «Итого» (с предупреждением). - checks: сумма уровня 0 сверяется с «Итого» по правилу R-21 (свёрнутое сальдо «Итого» — по нетто), как у родителя. - tests: osv_dkb_files, покрытие словарей включает osv-dkb/**; README. Refs #11 Co-Authored-By: Claude Sonnet 4.5 Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b --- README.md | 10 +++ api/rules/README.md | 6 +- api/rules/data_types.yaml | 5 ++ api/rules/osv_headers.yaml | 4 ++ api/rules/periods.yaml | 5 ++ api/src/parser/checks.py | 7 ++- api/src/parser/classify.py | 68 +++++++++++++++++++-- api/src/parser/detect.py | 8 ++- api/src/parser/header.py | 66 +++++++++++++++++--- api/src/parser/hierarchy.py | 1 + api/src/parser/osv.py | 23 ++++++- api/src/parser/rules.py | 3 + api/tests/fixtures/test_rules_coverage.py | 7 ++- api/tests/fixtures_helpers.py | 13 ++++ api/tests/unit/parser/test_checks.py | 28 +++++++++ api/tests/unit/parser/test_osv_parser.py | 74 +++++++++++++++++++++++ api/tests/unit/test_fixtures_helpers.py | 14 +++++ 17 files changed, 316 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index 1272ef3..9acd09f 100644 --- a/README.md +++ b/README.md @@ -158,6 +158,16 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо показывает счёт нетто, а субсчета и контрагентов — развёрнуто (решение R-21, design №31). Остаточный риск: перестановка сторон при совпавшем нетто родителя этой проверкой не ловится — её ловят тождество строки и «Итого = корень». +**«Итого».** Основная итоговая строка — первая TOTAL листа; она сверяется с первым корнем ACCOUNT либо, если корней +уровня 0 несколько (лист без строки корневого счёта: верхний уровень — субсчета 58.01, 58.03), с суммой строк уровня 0 +(OQ-106, OQ-133; сальдо — по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя выше). Вторая итоговая +строка сразу за «Итого» («Итого развернутое», `total_row_secondary`) — тоже `TOTAL`, в сверку не входит. Строка сумм +без подписи в конце таблицы (1С без «Итого») принимается за итоговую с предупреждением. + +**Период-диапазон.** «Октябрь 2024 г. – Сентябрь 2025 г.» (`periods.yaml`: `months`, `range`): январь–конец квартала — +нарастающий, ровно квартал — квартальный, остальное (скользящие 12 месяцев) — `period_year` = год конца, `period_quarter` +и `is_cumulative` = `null` и предупреждение «не с начала года» (OQ-131: ядро такой документ к версии не привязывает). + ### Словари и как добавить синоним Словари — данные: `osv_headers.yaml` (признаки листов, группы сумм, «Дебет/Кредит», колонка ИНН, «Итого», метки шапки), diff --git a/api/rules/README.md b/api/rules/README.md index 83f5609..1e69637 100644 --- a/api/rules/README.md +++ b/api/rules/README.md @@ -10,10 +10,10 @@ AST-линтер `tests/unit/parser/test_no_coordinates.py`. | Файл | Ключи | Что описывает | |---|---|---| -| `osv_headers.yaml` | `sheet_markers`, `statement_markers`, `groups` (`opening`/`turnover`/`closing`), `debit`, `credit`, `service_columns` (только `inn`), `total_row`, `header_markers` (`account`, `period`, `data_type`, `currency`, `org_params`, `subkonto`) | признак листа ОСВ и листов форм ББ/ОФР; группы сумм (начало / обороты / конец); «Дебет»/«Кредит»; колонка ИНН (прочие колонки таблицы попадают в `rows[].extra` под своим заголовком и в словаре не нужны); строка «Итого»; метки шапки | +| `osv_headers.yaml` | `sheet_markers`, `statement_markers`, `groups` (`opening`/`turnover`/`closing`), `debit`, `credit`, `service_columns` (только `inn`), `total_row`, `total_row_secondary` («Итого развернутое»), `column_captions` («Код»/«Наименование» под названием группировки), `header_markers` (`account`, `period`, `data_type`, `currency`, `org_params`, `subkonto`) | признак листа ОСВ и листов форм ББ/ОФР; группы сумм (начало / обороты / конец); «Дебет»/«Кредит»; колонка ИНН (прочие колонки таблицы попадают в `rows[].extra` под своим заголовком и в словаре не нужны); строка «Итого»; метки шапки | | `osv_groups.yaml` | `mapping`, `default` | название группировки из шапки -> `ACCOUNT` / `DEPARTMENT` / `COUNTERPARTY` / `CONTRACT` / `OTHER` (`default` — для всего прочего) | -| `periods.yaml` | `rules` (`pattern`, `quarter`, `is_cumulative`), `year`, `end` | текст периода -> `period_quarter` и `is_cumulative` (§1.5, `ASSUMPTION(OQ-02)`); `year` — паттерн года, `end` — конец текста периода в строке шапки («г.», «год») | -| `data_types.yaml` | `header`, `row`, `currency_codes`, `currency_aliases` | «Выводимые данные» -> `BU` / `NU` / `SUM`; строки «Вал.» / «Кол.» -> `CURRENCY` / `QUANTITY`; коды валют ISO 4217 (строка, равная коду, — валютная); написания валюты в шапке (`руб.` -> `RUB`; ключ алиаса обязан быть в `currency_codes`) | +| `periods.yaml` | `rules` (`pattern`, `quarter`, `is_cumulative`), `year`, `end`, `months` (названия месяцев, группы `m01…m12`), `range` (диапазон месяцев с годами, группы `m1`, `y1`, `m2`, `y2`), `standalone` (период отдельной ячейкой без метки) | текст периода -> `period_quarter` и `is_cumulative` (§1.5, `ASSUMPTION(OQ-02)`); `year` — паттерн года, `end` — конец текста периода в строке шапки («г.», «год») | +| `data_types.yaml` | `header`, `standalone_header` (тип данных отдельной ячейкой без метки), `row`, `currency_codes`, `currency_aliases` | «Выводимые данные» -> `BU` / `NU` / `SUM`; строки «Вал.» / «Кол.» -> `CURRENCY` / `QUANTITY`; коды валют ISO 4217 (строка, равная коду, — валютная); написания валюты в шапке (`руб.` -> `RUB`; ключ алиаса обязан быть в `currency_codes`) | ## Как писать паттерны diff --git a/api/rules/data_types.yaml b/api/rules/data_types.yaml index 6d49572..8d1d1da 100644 --- a/api/rules/data_types.yaml +++ b/api/rules/data_types.yaml @@ -7,6 +7,11 @@ header: - ['\bну\b|налогов', NU] - ['\bсумм', SUM] +# Тип данных отдельной ячейкой шапки без метки «Выводимые данные:» («Бухгалтерский» в строке «Бухгалтерский | период»): +# ячейка целиком — название типа; ищется, только если метки нет. НУ отдельной ячейкой в файлах не встречался. +standalone_header: + - ['^бухгалтерск\w*(\s+учет\w*)?$', BU] + # Строки под контрагентом: «Вал.» и «Кол.» помечают строку как валютную / количественную row: - ['^вал\.?$', CURRENCY] diff --git a/api/rules/osv_headers.yaml b/api/rules/osv_headers.yaml index 23e31ee..04d4d25 100644 --- a/api/rules/osv_headers.yaml +++ b/api/rules/osv_headers.yaml @@ -53,6 +53,10 @@ total_row: total_row_secondary: - '^итого\s+развернут' +# Подписи колонок под названием группировки («Счет» -> «Код» | «Наименование»): это не группировки блока заголовка +column_captions: + - '^(код|наименование)$' + # Метки шапки документа (область выше заголовка таблицы); ключи фиксированы header_markers: # «по счету 60 за …»: код счёта — сразу после совпадения diff --git a/api/rules/periods.yaml b/api/rules/periods.yaml index 2362e93..5227689 100644 --- a/api/rules/periods.yaml +++ b/api/rules/periods.yaml @@ -33,3 +33,8 @@ months: '(?Pянвар)|(?Pфеврал)|(?Pмарт)|(?Pа # «Апрель - Июнь 2025 г.». Группы: m1/y1 — месяц и год начала (год может отсутствовать), m2/y2 — месяц и год конца. # Диапазон от января без года начала («январь-март 2025 г.») берут правила выше; здесь он не учитывается (код). range: '(?P(январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр)\w*)\s*(?P(19|20)\d{2})?\s*(г\.?|года?)?\s*(по\s+|до\s+)?(?P(январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр)\w*)\s*(?P(19|20)\d{2})' + +# Период отдельной ячейкой без метки «за»/«Период:» («9 месяцев 2025 г.» справа в строке «Бухгалтерский | период»): +# ячейка целиком — период с годом; ищется, только если периода по меткам нет +standalone: + - '^(\d{1,2}|[ivx]+)\s*(й|я|ый|ой)?\s*(квартал\w*|кв\b\.?|мес\w*)\s*(19|20)\d{2}\s*(г\b\.?|года?)?$' diff --git a/api/src/parser/checks.py b/api/src/parser/checks.py index 265da86..2c31a20 100644 --- a/api/src/parser/checks.py +++ b/api/src/parser/checks.py @@ -10,7 +10,8 @@ `дебет − кредит`: 1С показывает счёт свёрнутым сальдо, а субсчета и контрагентов — развёрнутым (так во всех реальных файлах, где у счёта есть субсчета с разными сторонами сальдо); 3. строка «Итого» (TOTAL) = корневая строка ACCOUNT (первая строка уровня 0) либо сумма всех строк уровня 0, если их - несколько (лист без строки корневого счёта: верхний уровень — субсчета 58.01, 58.03; OQ-106, OQ-133); корня нет + несколько (лист без строки корневого счёта: верхний уровень — субсчета 58.01, 58.03; OQ-106, OQ-133; сальдо — + по колонкам либо по нетто, если «Итого» свёрнуто, как у родителя в п. 2); корня нет (пустая ОСВ, R-13) — «Итого» должна быть пустой или нулевой; «Итого» не найдена (R-20) — сверка невозможна, результат `false`. Строки-показатели (валюта, количество) в сумму уровня 0 не входят: это другие единицы. @@ -156,7 +157,9 @@ def total_matches_root(rows: Sequence[ClassifiedRow]) -> bool: if not _differs(_values(total), _values(root)): return True roots = _level_zero(rows) - return len(roots) > 1 and not _differs(_values(total), _sum_of(roots)) + # Сумма уровня 0 — «ребёнок» итоговой строки: те же допуски, что у родителя и детей (R-21): 1С показывает «Итого» + # свёрнутым сальдо, а субсчета — развёрнутым. Обороты по-прежнему по колонкам. + return len(roots) > 1 and _sums_match(_values(total), _sum_of(roots)) def _rows_text(rows: Iterable[int]) -> str: diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index f768d7f..e35cafd 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -49,6 +49,10 @@ __all__ = [ # («60.01, Расчеты\nс поставщиками» — счёт, а не контрагент, который сдвинул бы group_type детей) ACCOUNT_RE = re.compile(r"^(\d{2,3}(?:\.(?:\d{1,2}|[А-ЯЁA-Z]{1,3})){0,2})(?:,\s*(.*))?$", re.ASCII | re.DOTALL) _SEGMENT_SEPARATOR = "." +# Сдвиг на одну колонку текста в «индентах» строки: больше любого отступа ячейки +LABEL_COLUMN_INDENT_STEP = 1000 +# Строка итога без подписи возможна, только если над ней есть хотя бы одна строка данных +MIN_ROWS_WITH_TOTAL = 2 # Кооперативный дедлайн (design D-8): проверка каждые N строк листа DEADLINE_CHECK_EVERY_ROWS = 500 # Шесть сумм строки в порядке полей `ColumnMap` и `OsvRow` контракта @@ -136,6 +140,14 @@ def _find_total(sheet: Sheet, header: TableHeader, rows: Sequence[int], rules: R return only_secondary +def _is_unlabelled_total(sheet: Sheet, header: TableHeader, rows: Sequence[int]) -> bool: + """Последняя строка таблицы — одни суммы, левее колонок сумм пусто (1С без подписи «Итого»); над ней есть данные.""" + if len(rows) < MIN_ROWS_WITH_TOTAL: + return False + first_sum = _first_sum_col(header) + return all(col >= first_sum for col, _ in sheet.row_cells(rows[-1])) + + def _is_secondary_total(text: str, rules: Rules) -> bool: return any(pattern.search(text) for pattern in rules.headers.total_row_secondary) @@ -171,10 +183,46 @@ def _sums(sheet: Sheet, row: int, columns: ColumnMap, warnings: list[str]) -> tu return tuple(sums.values()) -def _body_row(sheet: Sheet, row: int, header: TableHeader, warnings: list[str]) -> BodyRow: - text = sheet.text_at(row, header.text_col) +def _first_sum_col(header: TableHeader) -> int: + return min(header.columns.indexes().values()) + + +def _label_cols(sheet: Sheet, header: TableHeader, rows: Sequence[int]) -> tuple[int, ...]: + """Колонки текста строки: колонка текста таблицы и колонки правее неё (левее сумм, кроме ИНН), в которых текст стоит + вместо пустой ячейки колонки текста. + + Так выглядит «Счет: Код / Наименование» с контрагентами правее кода: уровень строки задаёт колонка, в которой + стоит её текст. У обычных таблиц текст всегда в колонке текста — вторых колонок нет. + """ + first_sum = _first_sum_col(header) + found: set[int] = set() + for row in rows: + if sheet.text_at(row, header.text_col): + continue + found.update( + col + for col, _ in sheet.row_cells(row) + if header.text_col < col < first_sum and col != header.inn_col and col <= header.right_edge + ) + return (header.text_col, *sorted(found)) + + +def _label_of(sheet: Sheet, row: int, label_cols: Sequence[int]) -> tuple[int, str]: + """Колонка и текст строки: первая непустая колонка текста слева направо (без текста — колонка текста таблицы).""" + for col in label_cols: + text = sheet.text_at(row, col) + if text: + return col, text + return label_cols[0], "" + + +def _body_row(sheet: Sheet, row: int, header: TableHeader, label_cols: Sequence[int], warnings: list[str]) -> BodyRow: + label_col, text = _label_of(sheet, row, label_cols) account = ACCOUNT_RE.match(text) - bold, indent = _text_cell_style(sheet, row, header.text_col) + bold, indent = _text_cell_style(sheet, row, label_col) + # Единицы отступа разные (alignment.indent, пробелы, колонки) — иерархия сравнивает только ранги: колонка глубже + # любого отступа ячейки + indent += label_cols.index(label_col) * LABEL_COLUMN_INDENT_STEP return BodyRow( row=row, text=text, @@ -184,6 +232,7 @@ def _body_row(sheet: Sheet, row: int, header: TableHeader, warnings: list[str]) indent=indent, outline=sheet.outline(row), sums=_sums(sheet, row, header.columns, warnings), + label_col=label_col, ) @@ -208,13 +257,19 @@ def collect_body_rows( warnings.append(_collapsed_warning(header.columns)) rows = _table_rows(sheet, header, rules, deadline) total_row = _find_total(sheet, header, rows, rules) + if total_row is None and _is_unlabelled_total(sheet, header, rows): + total_row = rows[-1] + warnings.append( + f"строка {total_row}: строка сумм без подписи в конце таблицы принята за «Итого» (подписи в файле нет)" + ) if total_row is not None: rows = rows[: rows.index(_trailing_totals(sheet, header, rows, total_row, rules)) + 1] + label_cols = _label_cols(sheet, header, rows) body: list[BodyRow] = [] for index, row in enumerate(rows, start=1): if index % DEADLINE_CHECK_EVERY_ROWS == 0: deadline.check("суммы строк") - body.append(_body_row(sheet, row, header, warnings)) + body.append(_body_row(sheet, row, header, label_cols, warnings)) return body, total_row, warnings @@ -228,8 +283,11 @@ def _extra_keys(sheet: Sheet, header: TableHeader, rows: Sequence[BodyRow]) -> d Одинаковые заголовки двух колонок различаются буквой колонки в скобках: «Кол-во (B)», «Кол-во (C)» (Review Focus 1); заголовок без повторов — как в файле. """ + label_cols = {item.label_col for item in rows} used = { - col: title for col, title in header.extra_cols.items() if any(sheet.text_at(item.row, col) for item in rows) + col: title + for col, title in header.extra_cols.items() + if col not in label_cols and any(sheet.text_at(item.row, col) for item in rows) } titles = list(used.values()) return {col: f"{title} ({column_letter(col)})" if titles.count(title) > 1 else title for col, title in used.items()} diff --git a/api/src/parser/detect.py b/api/src/parser/detect.py index 64f9ce4..201092c 100644 --- a/api/src/parser/detect.py +++ b/api/src/parser/detect.py @@ -392,11 +392,13 @@ def _block_title(sheet: Sheet, first_row: int, last_row: int, col: int) -> str | return None -def _group_label_rows(sheet: Sheet, first_row: int, last_row: int, text_col: int) -> list[int]: +def _group_label_rows(sheet: Sheet, first_row: int, last_row: int, text_col: int, rules: Rules) -> list[int]: + """Строки блока, где в колонке текста начинается название группировки; подписи колонок («Код») — не группировки.""" labels: list[int] = [] for row in range(first_row, last_row + 1): origin_row, origin_col = sheet.merged_origin(row, text_col) - if origin_row == row and sheet.cell(origin_row, origin_col).text: + text = sheet.cell(origin_row, origin_col).text + if origin_row == row and text and _search(rules.headers.column_captions, _normalized(text)) is None: labels.append(row) return labels @@ -464,7 +466,7 @@ def find_table_header(sheet: Sheet, rules: Rules, deadline: Deadline | None = No inn_col=inn_col, right_edge=right_edge, extra_cols=dict(sorted(extra_cols.items())), - group_label_rows=_group_label_rows(sheet, group_row, last_row, text_col), + group_label_rows=_group_label_rows(sheet, group_row, last_row, text_col, rules), ) diff --git a/api/src/parser/header.py b/api/src/parser/header.py index e4ad61c..7471d31 100644 --- a/api/src/parser/header.py +++ b/api/src/parser/header.py @@ -18,11 +18,12 @@ from src.parser.detect import TableHeader from src.parser.grid import Sheet from src.parser.rules import GroupType, MonthRange, Pattern, Rules, first_match, normalize_text -__all__ = ["DocHeader", "build_group_schema", "parse_doc_header"] +__all__ = ["ACCOUNT_NOT_FOUND_WARNING", "DocHeader", "build_group_schema", "parse_doc_header"] Quarter = Literal["Q1", "Q2", "Q3", "Q4"] QUARTERS: tuple[Quarter, ...] = get_args(Quarter) HeaderVariant = Literal[1, 2] +ACCOUNT_NOT_FOUND_WARNING = "код счёта (после «по счету») в шапке не найден" # Код счёта начинается с цифры и идёт до пробела или запятой: «60», «58.03», «76.07.1» _ACCOUNT_CODE = re.compile(r"\d[^\s,;]*") @@ -207,22 +208,65 @@ def _is_recognised(text: str, rules: Rules) -> bool: return rules.periods.match(text) is not None or rules.periods.year.search(text) is not None +def _standalone_period(cells: list[_HeadCell], rules: Rules) -> _PeriodHit | None: + """Период отдельной ячейкой без метки: ячейка целиком — «9 месяцев 2025 г.» (словарь `periods.standalone`).""" + cell = next((cell for cell in cells if _find(rules.periods.standalone, cell.text) is not None), None) + return None if cell is None else _PeriodHit(cell, cell.text) + + def _find_period(cells: list[_HeadCell], rules: Rules) -> _PeriodHit | None: - """Строка периода: сначала с опознанным периодом, затем строка названия ведомости или метка с двоеточием.""" + """Строка периода: сначала с опознанным периодом по метке, затем период отдельной ячейкой, затем строка названия + ведомости или метка с двоеточием. + """ hits = _period_candidates(cells, rules) recognised = next((hit for hit in hits if _is_recognised(hit.text, rules)), None) if recognised is not None: return recognised + standalone = _standalone_period(cells, rules) + if standalone is not None: + return standalone return next((hit for hit in hits if _is_title(hit.cell, rules) or _LABEL_SEPARATOR in hit.cell.text), None) -def _organization(cells: list[_HeadCell], rules: Rules, period: _PeriodHit | None) -> _HeadCell | None: - """Первая текстовая ячейка, не являющаяся меткой шапки, строкой периода или меткой валюты.""" - taken = [cell for cell in (None if period is None else period.cell, *_currency_cells(cells, rules)) if cell] - return next( +def _standalone_data_type(cells: list[_HeadCell], rules: Rules) -> tuple[_HeadCell, DataType] | None: + """Тип данных отдельной ячейкой без метки: ячейка целиком — «Бухгалтерский» (словарь `standalone_header`).""" + for cell in cells: + found = first_match(rules.data_types.standalone_header, cell.text) + if found is not None: + return cell, found + return None + + +def _title_tail_organization(cells: list[_HeadCell], rules: Rules) -> str | None: + """Организация в названии ведомости: «Оборотно-сальдовая ведомость НОВОРОСМЕТАЛЛ ООО» -> «НОВОРОСМЕТАЛЛ ООО». + + Хвост без метки счёта и периода («по счету 60 за …» — не организация); пустой хвост — `None`. + """ + markers = rules.headers.header_markers + for cell in cells: + match = _find(rules.headers.sheet_markers, cell.text) + if match is None: + continue + tail = _tail(cell.text, match).strip(" ,;:") + if tail and _find([*markers["account"], *markers["period"]], tail) is None: + return tail + return None + + +def _organization( + cells: list[_HeadCell], rules: Rules, period: _PeriodHit | None, data_type: _HeadCell | None +) -> str | None: + """Первая текстовая ячейка, не являющаяся меткой шапки, строкой периода, меткой валюты или типом данных; иначе + хвост названия ведомости. + """ + taken = [ + cell for cell in (None if period is None else period.cell, data_type, *_currency_cells(cells, rules)) if cell + ] + found = next( (cell for cell in cells if cell not in taken and not _is_structural_marker(cell, rules)), None, ) + return _title_tail_organization(cells, rules) if found is None else found.text def _account_code(cells: list[_HeadCell], rules: Rules) -> str | None: @@ -333,6 +377,9 @@ def _parse_period(text: str, rules: Rules, warnings: list[str]) -> _Period: 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: + standalone = _standalone_data_type(cells, rules) + if standalone is not None: + return standalone[1] warnings.append("тип выводимых данных (БУ/НУ/сумма) в шапке не найден") return None data_type = first_match(rules.data_types.header, value) @@ -361,12 +408,13 @@ def parse_doc_header(sheet: Sheet, header: TableHeader, rules: Rules) -> DocHead warnings: list[str] = [] period_hit = _find_period(cells, rules) - organization = _organization(cells, rules, period_hit) + standalone_type = _standalone_data_type(cells, rules) + organization = _organization(cells, rules, period_hit, None if standalone_type is None else standalone_type[0]) if organization is None: warnings.append("наименование организации в шапке не найдено") account = _account_code(cells, rules) if account is None: - warnings.append("код счёта (после «по счету») в шапке не найден") + warnings.append(ACCOUNT_NOT_FOUND_WARNING) period_text = None if period_hit is None else period_hit.text if period_text is None: warnings.append("период (после «за» или «Период:») в шапке не найден") @@ -378,7 +426,7 @@ def parse_doc_header(sheet: Sheet, header: TableHeader, rules: Rules) -> DocHead 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, + organization_name="" if organization is None else organization, account_code_raw=account or "", period_text=period_text or "", period_year=period.year, diff --git a/api/src/parser/hierarchy.py b/api/src/parser/hierarchy.py index fcc9ef6..e5866c1 100644 --- a/api/src/parser/hierarchy.py +++ b/api/src/parser/hierarchy.py @@ -58,6 +58,7 @@ class BodyRow: indent: int # alignment.indent или число ведущих пробелов (единицы разные — сравниваются только ранги) outline: int sums: tuple[Decimal | None, ...] + label_col: int | None = None # колонка, в которой стоит текст строки (она не всегда колонка текста таблицы) @dataclass(frozen=True) diff --git a/api/src/parser/osv.py b/api/src/parser/osv.py index 4eba4aa..bac75ca 100644 --- a/api/src/parser/osv.py +++ b/api/src/parser/osv.py @@ -15,7 +15,8 @@ """ import time -from collections.abc import Callable, Iterable +from collections.abc import Callable, Iterable, Sequence +from dataclasses import replace from loguru import logger @@ -28,7 +29,7 @@ from src.parser.deadline import Deadline from src.parser.detect import SheetKind, TableHeader, classify_sheet, find_table_header from src.parser.errors import InvalidDocumentError, InvalidFormatError, ParserError, UnsupportedFormatError from src.parser.grid import Sheet, column_letter -from src.parser.header import DocHeader, build_group_schema, parse_doc_header +from src.parser.header import ACCOUNT_NOT_FOUND_WARNING, DocHeader, build_group_schema, parse_doc_header from src.parser.hierarchy import resolve_levels from src.parser.readers import read_book from src.parser.readers._common import one_line_reason @@ -98,6 +99,23 @@ def _row(item: ClassifiedRow) -> OsvRow: ) +def _with_table_account(doc: DocHeader, rows: Sequence[ClassifiedRow]) -> DocHeader: + """Код счёта шапки, которой нет («по счету …» в названии нет), — код первой строки-счёта таблицы. + + «Счет: Код / Наименование»: код стоит в строке таблицы, а не в названии ведомости. Предупреждение «код счёта в + шапке не найден» заменяется пометкой, откуда код взят. + """ + if doc.account_code_raw: + return doc + accounts = (item.account_code_raw for item in rows if item.group_type == "ACCOUNT") + code = next((code for code in accounts if code), None) + if code is None: + return doc + warnings = [warning for warning in doc.warnings if warning != ACCOUNT_NOT_FOUND_WARNING] + warnings.append(f"код счёта «{code}» взят из первой строки-счёта таблицы: в шапке «по счету …» нет") + return replace(doc, account_code_raw=code, warnings=tuple(warnings)) + + def _is_ok(checks: OsvChecks) -> bool: return ( bool(checks.total_row_matches_root) @@ -185,6 +203,7 @@ class OsvParser: classified = classify_rows(body, levels, schema, header, sheet, doc.data_type, total_row, rules) except InvalidFormatError as exc: raise _invalid_format(sheet, step, exc) from exc + doc = _with_table_account(doc, classified) deadline.check(f"лист «{sheet.name}»: контрольные суммы") checks, check_warnings = run_checks(classified) document = self._document(sheet=sheet, header=header, doc=doc, schema=schema, rows=classified, checks=checks) diff --git a/api/src/parser/rules.py b/api/src/parser/rules.py index 00f0e3a..2990849 100644 --- a/api/src/parser/rules.py +++ b/api/src/parser/rules.py @@ -137,6 +137,7 @@ class OsvHeaderRules(_Model): service_columns: dict[str, list[Pattern]] total_row: list[Pattern] total_row_secondary: list[Pattern] + column_captions: list[Pattern] header_markers: dict[HeaderMarker, list[Pattern]] @model_validator(mode="after") @@ -196,6 +197,7 @@ class PeriodRules(_Model): # Названия месяцев: одна запись с именованными группами m01..m12; диапазон месяцев с годами — группы m1, y1, m2, y2 months: Pattern range: Pattern + standalone: list[Pattern] @model_validator(mode="after") def _check_month_patterns(self) -> "PeriodRules": @@ -241,6 +243,7 @@ class PeriodRules(_Model): class DataTypeRules(_Model): header: list[tuple[Pattern, DataType]] + standalone_header: list[tuple[Pattern, DataType]] row: list[tuple[Pattern, RowDataType]] currency_codes: list[str] currency_aliases: dict[str, list[Pattern]] diff --git a/api/tests/fixtures/test_rules_coverage.py b/api/tests/fixtures/test_rules_coverage.py index 6d84d60..9c71680 100644 --- a/api/tests/fixtures/test_rules_coverage.py +++ b/api/tests/fixtures/test_rules_coverage.py @@ -6,6 +6,9 @@ `currency_aliases`). Перечень кодов `currency_codes` — список значений, не синонимы: в покрытие не входит. Записи находятся обходом моделей `Rules`, поэтому новый паттерн в любом словаре попадает под проверку автоматически. +Файлы — реальные ОСВ `osv/**`, пакет ДКБ `osv-dkb/**` (диапазон месяцев, «Итого развернутое», шапка без меток) и +синтетика. + «Срабатывание» — `search`/`search_normalized` вернул совпадение. Счётчик — обёртка вокруг методов `Pattern` в тесте, боевой код не меняется. """ @@ -13,7 +16,7 @@ from pathlib import Path import pytest -from fixtures_helpers import osv_fixture_files, synthetic_files +from fixtures_helpers import osv_dkb_files, osv_fixture_files, synthetic_files from rules_coverage import DICTIONARIES, MatchCounter, iter_patterns, uncovered from src.parser import get_parser @@ -33,7 +36,7 @@ def coverage(fixtures_dir: Path) -> tuple[Rules, MatchCounter, int]: base = get_parser() assert isinstance(base, OsvParser) parser = OsvParser(rules, max_bytes=base.max_bytes, timeout_seconds=base.timeout_seconds) - files = [*osv_fixture_files(fixtures_dir), *synthetic_files(fixtures_dir)] + files = [*osv_fixture_files(fixtures_dir), *osv_dkb_files(fixtures_dir), *synthetic_files(fixtures_dir)] for file in files: parser.parse(file.read_bytes(), file.suffix.lstrip(".").upper()) # type: ignore[arg-type] return rules, counter, len(files) diff --git a/api/tests/fixtures_helpers.py b/api/tests/fixtures_helpers.py index 007db5b..4b86b87 100644 --- a/api/tests/fixtures_helpers.py +++ b/api/tests/fixtures_helpers.py @@ -6,6 +6,8 @@ from pathlib import Path FIXTURES_ENV = "ARM_KA_FIXTURES" OSV_EXTENSIONS = frozenset({".xls", ".xlsx", ".xlsb"}) _SYNTHETIC = "synthetic" +_OSV_DKB = "osv-dkb" +_ANALYSIS_REPORTS = "analysis-reports" # Не фикстуры: скрытые файлы и каталоги («.DS_Store», «.cache/») и lock-файлы Excel («~$книга.xlsx», пока она открыта) _IGNORED_PREFIXES = (".", "~$") @@ -52,6 +54,17 @@ def osv_fixture_files(root: Path) -> list[Path]: return sorted(path for path in osv.rglob("*") if _is_workbook(path, osv) and synthetic not in path.parents) +def osv_dkb_files(root: Path) -> list[Path]: + """Файлы Excel пакета ДКБ `root/osv-dkb/**` (многолистовые книги, статусы `needs_check`), кроме отчётов + «Анализ счета» (`analysis-reports/`: не ОСВ); отсортированы. Нет каталога — пусто.""" + directory = root / _OSV_DKB + if not directory.is_dir(): + return [] + reports = directory / _ANALYSIS_REPORTS + found = (path for path in directory.rglob("*") if _is_workbook(path, directory)) + return sorted(path for path in found if reports not in path.parents) + + def synthetic_files(root: Path) -> list[Path]: """Файлы Excel `root/osv/synthetic/*.xls|xlsx|xlsb` (скрытые, lock-файлы `~$…` и пояснения вроде `README.md` не считаются); отсортированы.""" diff --git a/api/tests/unit/parser/test_checks.py b/api/tests/unit/parser/test_checks.py index 680dd41..3f994e5 100644 --- a/api/tests/unit/parser/test_checks.py +++ b/api/tests/unit/parser/test_checks.py @@ -255,6 +255,34 @@ def test_total_matches_sum_of_level_zero_rows_when_there_are_several_roots() -> assert warnings == [] +def test_collapsed_total_matches_sum_of_level_zero_rows_by_net_balance() -> None: + """1С: «Итого» — свёрнутое сальдо (одна сторона), субсчета — развёрнутое; нетто сходится (R-21 для пары + «Итого» ↔ Σ уровня 0); обороты сверяются по колонкам. + """ + mixed_a: Sums = ("100", None, "10", "0", "110", None) + mixed_b: Sums = (None, "30", "30", "20", None, "20") + net_total: Sums = ("70", None, "40", "20", "90", None) + rows = [ + account(7, mixed_a), + account(8, mixed_b), + total(9, net_total), + ] + + assert total_matches_root(rows) is True + rows[-1] = total(9, ("70", None, "41", "20", "90", None)) + assert total_matches_root(rows) is False + + +def test_expanded_balance_total_is_not_matched_by_net_when_total_has_both_sides() -> None: + """Нетто допускается, только если сальдо «Итого» свёрнуто; «Итого» с обеими сторонами сверяется по колонкам.""" + mixed_a: Sums = ("100", None, "10", "0", "110", None) + mixed_b: Sums = (None, "30", "30", "20", None, "20") + both_sides: Sums = ("70", "1", "40", "20", "90", "1") + rows = [account(7, mixed_a), account(8, mixed_b), total(9, both_sides)] + + assert total_matches_root(rows) is False + + def test_total_that_equals_neither_first_root_nor_sum_of_roots_mismatches() -> None: rows = multi_root_tree(("140", None, "40", "20", "161", None)) diff --git a/api/tests/unit/parser/test_osv_parser.py b/api/tests/unit/parser/test_osv_parser.py index b3cf5e8..a69d163 100644 --- a/api/tests/unit/parser/test_osv_parser.py +++ b/api/tests/unit/parser/test_osv_parser.py @@ -260,6 +260,80 @@ def test_number_warnings_carry_row_numbers(rules: Rules) -> None: assert any(warning.startswith(f"строка {CONTRACTOR_ROW}:") for warning in warnings_of(outcome)) +# --- Шапка без меток и код/наименование счёта в двух колонках (issue #11 п. 4, OQ-134) --- + +NO_LABEL_ORG_SUMS = (None, 600, 100, None, None, 500) +NO_LABEL_SUPPLIER = (None, 400, None, None, None, 400) +NO_LABEL_CONTRACTOR = (None, 200, 100, None, None, 100) + + +def code_name_osv(*, total_label: str | None = None) -> list[Cells]: + """ОСВ 63 Новоросметалл: «Бухгалтерский | период» отдельной строкой, организация — в названии ведомости, колонки + «Счет: Код / Наименование», контрагенты — в колонке правее кода (уровень — сдвиг колонки), «Итого» без подписи. + """ + blank = [None] * 3 + sums_head = ["Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"] + + def line(label_cells: Sequence[object], sums: Sequence[object]) -> Cells: + return [*label_cells, *sums] + + return [ + ["Оборотно-сальдовая ведомость НОВОРОСМЕТАЛЛ ООО"], # 1 + [], # 2 + ["Бухгалтерский", *([None] * 10), "9 месяцев 2025 г."], # 3 + ["Счет", *([None] * 5), M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], # 4 + ["Код", *blank, "Наименование", None, *sums_head], # 5 + line([63, *blank, "Резервы по сомнительным долгам", None], NO_LABEL_ORG_SUMS), # 6 + line([None, "ООО Поставщик", None, None, None, None], NO_LABEL_SUPPLIER), # 7 + line([None, "ООО Подрядчик", None, None, None, None], NO_LABEL_CONTRACTOR), # 8 + line([total_label, None, None, None, None, None], NO_LABEL_ORG_SUMS), # 9 + ] + + +def test_header_without_labels_and_code_name_columns_are_found(rules: Rules) -> None: + outcome = parser(rules).parse(xlsx({"Лист1": code_name_osv()}), "XLSX") + + document = only_document(outcome) + header = document.header + assert header.organization_name == "НОВОРОСМЕТАЛЛ ООО" + assert header.account_code_raw == "63" + assert (header.period_text, header.period_year, header.period_quarter, header.is_cumulative) == ( + "9 месяцев 2025 г.", + 2025, + "Q3", + True, + ) + assert header.data_type == "BU" + warnings = warnings_of(outcome) + assert not any("период" in warning and "не найден" in warning for warning in warnings), warnings + assert not any("тип выводимых данных" in warning for warning in warnings), warnings + + +def test_code_name_columns_rows_are_account_and_children_by_label_column(rules: Rules) -> None: + outcome = parser(rules).parse(xlsx({"Лист1": code_name_osv(total_label="Итого")}), "XLSX") + + document = only_document(outcome) + assert [(row.row_value, row.level, row.group_type) for row in document.rows] == [ + ("63", 0, "ACCOUNT"), + ("ООО Поставщик", 1, "OTHER"), + ("ООО Подрядчик", 1, "OTHER"), + ("Итого", 0, "TOTAL"), + ] + assert document.rows[0].extra == {"Наименование": "Резервы по сомнительным долгам"} + assert outcome.status is ResultStatus.DONE + + +def test_total_row_without_caption_is_the_last_row_of_sums(rules: Rules) -> None: + outcome = parser(rules).parse(xlsx({"Лист1": code_name_osv()}), "XLSX") + + document = only_document(outcome) + assert document.rows[-1].group_type == "TOTAL" + assert document.checks is not None + assert document.checks.total_row_matches_root is True + assert outcome.status is ResultStatus.DONE + assert any("без подписи" in warning for warning in warnings_of(outcome)), warnings_of(outcome) + + # --- Несколько листов --- diff --git a/api/tests/unit/test_fixtures_helpers.py b/api/tests/unit/test_fixtures_helpers.py index ec30268..f59bc37 100644 --- a/api/tests/unit/test_fixtures_helpers.py +++ b/api/tests/unit/test_fixtures_helpers.py @@ -6,6 +6,7 @@ import pytest from fixtures_helpers import ( FIXTURES_ENV, fixtures_skip_reason, + osv_dkb_files, osv_fixture_files, resolve_fixtures_dir, synthetic_files, @@ -45,6 +46,19 @@ def test_synthetic_excluded_and_foreign_files_ignored(tmp_path: Path) -> None: assert synthetic_files(tmp_path) == [osv / "synthetic" / "s.xlsx"] +def test_osv_dkb_files_skip_analysis_reports_and_hidden_files(tmp_path: Path) -> None: + dkb = tmp_path / "osv-dkb" + (dkb / "nm" / "2025-Q3").mkdir(parents=True) + (dkb / "analysis-reports").mkdir() + (dkb / "nm" / "2025-Q3" / "a.xlsx").write_bytes(b"") + (dkb / "nm" / "2025-Q3" / "~$a.xlsx").write_bytes(b"") + (dkb / "nm" / "README.md").write_bytes(b"") + (dkb / "analysis-reports" / "report.xls").write_bytes(b"") + + assert osv_dkb_files(tmp_path) == [dkb / "nm" / "2025-Q3" / "a.xlsx"] + assert osv_dkb_files(tmp_path / "нет") == [] + + def test_hidden_and_excel_lock_files_are_ignored(tmp_path: Path) -> None: """T1.1: скрытые файлы и каталоги и lock-файлы Excel (`~$…`, пока книга открыта) — не фикстуры.""" osv = tmp_path / "osv" -- 2.47.2 From a592c68509db92582420d78216ea0c3c59ada164 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 13:13:44 +0400 Subject: [PATCH 5/5] =?UTF-8?q?feat(osv):=20=D0=98=D0=9D=D0=9D=20=D0=B8?= =?UTF-8?q?=D0=B7=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B0=20=D0=BD=D0=B0?= =?UTF-8?q?=D0=B7=D0=B2=D0=B0=D0=BD=D0=B8=D1=8F=20=D0=BA=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D1=80=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=B0,=20=D0=98=D0=9D?= =?UTF-8?q?=D0=9D=20=D1=81=D1=82=D1=80=D0=BE=D0=BA=D0=BE=D0=B9=20=D0=B2?= =?UTF-8?q?=D1=8B=D1=88=D0=B5,=20=C2=AB=D0=98=D0=9D=D0=9D/=D0=9A=D0=9F?= =?UTF-8?q?=D0=9F=C2=BB=20=D0=B2=20=D1=8F=D1=87=D0=B5=D0=B9=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OQ-100: ИНН в конце названия («Полиграф, 3528315710», «(ИНН …)», пробел) уходит в counterparty_inn, имя — без ИНН и разделителя; колонка ИНН приоритетнее; только для COUNTERPARTY. - OQ-130: строка без сумм с одним ИНН над строкой с суммами того же уровня склеивается со следующей и не уходит в OTHER. - «ИНН/КПП» в ячейке колонки ИНН даёт ИНН. - Правила — osv_headers.yaml (counterparty_inn_suffix, inn_only_row, inn_kpp_cell), не код. - Синтетика s17–s19 (генератор, тесты); golden — в репозитории fixtures (feat/9-golden). Stacked on #13 (feat/11-dkb-formats). Closes #9 Co-Authored-By: Claude Sonnet 4.5 Claude-Session: cedd7915-2efe-41e5-988c-a9117033598b --- CLAUDE.md | 2 +- README.md | 10 +- api/rules/README.md | 2 +- api/rules/osv_headers.yaml | 14 +++ api/src/parser/classify.py | 47 ++++++- api/src/parser/header.py | 23 +--- api/src/parser/hierarchy.py | 1 + api/src/parser/numbers.py | 42 ++++++- api/src/parser/rules.py | 26 ++++ api/tests/synthetic/generate.py | 76 +++++++++-- api/tests/synthetic/test_generate.py | 2 +- api/tests/synthetic/test_synthetic_files.py | 63 +++++++++- api/tests/unit/parser/test_classify.py | 132 ++++++++++++++++++++ api/tests/unit/parser/test_rules.py | 11 ++ 14 files changed, 415 insertions(+), 36 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 04d856b..35aafd6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,7 +68,7 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote обращения к ячейке константами. Значения enum контракта вычисляются из `src.contract` и под линтер не попадают. - Каждая запись словаря должна срабатывать хотя бы на одном реальном или синтетическом файле (`tests/fixtures/test_rules_coverage.py`); мёртвый синоним удаляется, пробел в фикстурах закрывается синтетикой. -- Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 88 файлов: 72 реальных + 16 синтетических) — регрессия, а не +- Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 91 файл: 72 реальных + 19 синтетических) — регрессия, а не оракул; правятся только `make golden-update` с просмотром diff и отдельным коммитом в `fixtures`. Корректность проверяют property-тест и ручная сверка «Итого». - Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не diff --git a/README.md b/README.md index 9acd09f..fb8b90b 100644 --- a/README.md +++ b/README.md @@ -164,6 +164,14 @@ ParseOutcome`. Структура ОСВ ищется **по содержимо строка сразу за «Итого» («Итого развернутое», `total_row_secondary`) — тоже `TOTAL`, в сверку не входит. Строка сумм без подписи в конце таблицы (1С без «Итого») принимается за итоговую с предупреждением. +**ИНН контрагента** (OQ-100, OQ-130): `counterparty_inn` берётся из колонки ИНН (в том числе склеенного «ИНН/КПП»), затем из +строки без сумм прямо над названием на том же уровне оформления (такая строка в `rows` не попадает, в +`metrics.warnings` — «ИНН … над строкой … отнесён к ней»), затем из конца текста названия («Полиграф, 3528315710», +«ООО Альфа (ИНН 7701234567)»; 10 или 12 цифр после запятой, скобки, «/», пробела или метки «ИНН»). Из текста ИНН +отрезается всегда: `counterparty_name` без ИНН и разделителя, `row_value` — исходный текст. Только для строк +`COUNTERPARTY`; договоры и прочее не трогаются. Правила — `osv_headers.yaml`: `counterparty_inn_suffix`, `inn_only_row`, +`inn_kpp_cell`. + **Период-диапазон.** «Октябрь 2024 г. – Сентябрь 2025 г.» (`periods.yaml`: `months`, `range`): январь–конец квартала — нарастающий, ровно квартал — квартальный, остальное (скользящие 12 месяцев) — `period_year` = год конца, `period_quarter` и `is_cumulative` = `null` и предупреждение «не с начала года» (OQ-131: ядро такой документ к версии не привязывает). @@ -261,7 +269,7 @@ git -C ../fixtures add golden && git -C ../fixtures commit # снимки — для изменившегося — только diff; сводка «файлов / без изменений / новых / изменено / лишних» идёт в stderr. Снимки, которым не соответствует ни один файл, считаются лишними: `make golden-update` их удаляет, `make golden-check` завершается кодом 1. -Каталог `fixtures/osv/synthetic/` — 16 синтетических файлов `s01`–`s16` (генератор — `tests/synthetic/generate.py`): +Каталог `fixtures/osv/synthetic/` — 19 синтетических файлов `s01`–`s19` (генератор — `tests/synthetic/generate.py`): форматы, которых нет в реальных ОСВ (шапка варианта 2, «Вал.»/«Кол.», xlsb, без outline level, текстовые числа и т. д.). Дополнительно: `tests/fixtures/test_rules_coverage.py` требует, чтобы каждая запись словарей `api/rules/*.yaml` сработала хотя бы на одном файле (мёртвый синоним — удалить или добавить синтетику, см. `api/rules/README.md`), а diff --git a/api/rules/README.md b/api/rules/README.md index 1e69637..467871a 100644 --- a/api/rules/README.md +++ b/api/rules/README.md @@ -10,7 +10,7 @@ AST-линтер `tests/unit/parser/test_no_coordinates.py`. | Файл | Ключи | Что описывает | |---|---|---| -| `osv_headers.yaml` | `sheet_markers`, `statement_markers`, `groups` (`opening`/`turnover`/`closing`), `debit`, `credit`, `service_columns` (только `inn`), `total_row`, `total_row_secondary` («Итого развернутое»), `column_captions` («Код»/«Наименование» под названием группировки), `header_markers` (`account`, `period`, `data_type`, `currency`, `org_params`, `subkonto`) | признак листа ОСВ и листов форм ББ/ОФР; группы сумм (начало / обороты / конец); «Дебет»/«Кредит»; колонка ИНН (прочие колонки таблицы попадают в `rows[].extra` под своим заголовком и в словаре не нужны); строка «Итого»; метки шапки | +| `osv_headers.yaml` | `sheet_markers`, `statement_markers`, `groups` (`opening`/`turnover`/`closing`), `debit`, `credit`, `service_columns` (только `inn`), `total_row`, `total_row_secondary` («Итого развернутое»), `column_captions` («Код»/«Наименование» под названием группировки), `counterparty_inn_suffix` / `inn_only_row` / `inn_kpp_cell` (ИНН в тексте названия, строкой выше, «ИНН/КПП» в ячейке; обязательные именованные группы `tail`, `inn`), `header_markers` (`account`, `period`, `data_type`, `currency`, `org_params`, `subkonto`) | признак листа ОСВ и листов форм ББ/ОФР; группы сумм (начало / обороты / конец); «Дебет»/«Кредит»; колонка ИНН (прочие колонки таблицы попадают в `rows[].extra` под своим заголовком и в словаре не нужны); строка «Итого»; метки шапки | | `osv_groups.yaml` | `mapping`, `default` | название группировки из шапки -> `ACCOUNT` / `DEPARTMENT` / `COUNTERPARTY` / `CONTRACT` / `OTHER` (`default` — для всего прочего) | | `periods.yaml` | `rules` (`pattern`, `quarter`, `is_cumulative`), `year`, `end`, `months` (названия месяцев, группы `m01…m12`), `range` (диапазон месяцев с годами, группы `m1`, `y1`, `m2`, `y2`), `standalone` (период отдельной ячейкой без метки) | текст периода -> `period_quarter` и `is_cumulative` (§1.5, `ASSUMPTION(OQ-02)`); `year` — паттерн года, `end` — конец текста периода в строке шапки («г.», «год») | | `data_types.yaml` | `header`, `standalone_header` (тип данных отдельной ячейкой без метки), `row`, `currency_codes`, `currency_aliases` | «Выводимые данные» -> `BU` / `NU` / `SUM`; строки «Вал.» / «Кол.» -> `CURRENCY` / `QUANTITY`; коды валют ISO 4217 (строка, равная коду, — валютная); написания валюты в шапке (`руб.` -> `RUB`; ключ алиаса обязан быть в `currency_codes`) | diff --git a/api/rules/osv_headers.yaml b/api/rules/osv_headers.yaml index 04d4d25..05216b3 100644 --- a/api/rules/osv_headers.yaml +++ b/api/rules/osv_headers.yaml @@ -57,6 +57,20 @@ total_row_secondary: column_captions: - '^(код|наименование)$' +# ИНН в тексте строки контрагента (OQ-100): в конце текста, 10 или 12 цифр после запятой, скобки, «/», пробела или +# метки «ИНН» (дефисы нормализация превращает в пробелы), необязательный «/КПП» из 9 цифр. Группы: tail — отрезаемый хвост +# (разделитель, метка, ИНН), inn — цифры ИНН; название контрагента — текст до tail +counterparty_inn_suffix: + - '(?P[\s,;(/]+(инн\s*[:№]?\s*)?\(?(?P\d{12}|\d{10})(\s*/\s*\d{9})?\)?)\s*$' + +# Строка, где есть только ИНН («7701234567», «ИНН 7701234567 / 770101001»): ИНН строкой выше названия контрагента (OQ-130) +inn_only_row: + - '^(инн\s*[:№]?\s*)?\(?(?P\d{12}|\d{10})(\s*/\s*\d{9})?\)?$' + +# Ячейка колонки ИНН со склеенным «ИНН/КПП»: «7701234567 / 770101001» +inn_kpp_cell: + - '^(?P\d{12}|\d{10})\s*/\s*\d{9}$' + # Метки шапки документа (область выше заголовка таблицы); ключи фиксированы header_markers: # «по счету 60 за …»: код счёта — сразу после совпадения diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index e35cafd..49bd328 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -17,7 +17,7 @@ import re from collections.abc import Sequence -from dataclasses import dataclass +from dataclasses import dataclass, replace from decimal import Decimal from typing import Literal @@ -27,7 +27,7 @@ from src.parser.deadline import Deadline from src.parser.detect import GROUP_ORDER, ColumnMap, TableHeader, first_data_row, is_header_repeat from src.parser.grid import Sheet, column_letter from src.parser.hierarchy import BodyRow, Levels -from src.parser.numbers import inn_digits, to_money +from src.parser.numbers import column_inn, inn_only, split_inn_suffix, to_money from src.parser.rules import GroupType, Rules, first_match, normalize_text __all__ = [ @@ -183,6 +183,40 @@ def _sums(sheet: Sheet, row: int, columns: ColumnMap, warnings: list[str]) -> tu return tuple(sums.values()) +def _has_sums(item: BodyRow) -> bool: + return any(value is not None for value in item.sums) + + +def _glue_inn_rows(body: list[BodyRow], rules: Rules, warnings: list[str]) -> list[BodyRow]: + """Строка с одним ИНН и без сумм над строкой с суммами того же уровня оформления (outline и отступ) — ИНН + контрагента под ней (OQ-130): из строк она убирается, ИНН переходит к следующей строке (`inn_above`). + + Ничего иного такая строка не значит, а оставшись в таблице, стала бы OTHER и потеряла ИНН. Другой уровень + оформления или отсутствие сумм у следующей строки — строка остаётся как есть. + """ + glued: list[BodyRow] = [] + pending: BodyRow | None = None + for item in body: + if pending is not None: + same_level = (pending.outline, pending.indent) == (item.outline, item.indent) + if same_level and _has_sums(item) and not item.is_account: + inn = inn_only(pending.text, rules) + warnings.append( + f"строка {pending.row}: ИНН {inn} над строкой {item.row} отнесён к ней как к контрагенту" + ) + item = replace(item, inn_above=inn) # noqa: PLW2901 - строка меняется, а не подменяется другой + else: + glued.append(pending) + pending = None + if not _has_sums(item) and not item.is_account and inn_only(item.text, rules) is not None: + pending = item + continue + glued.append(item) + if pending is not None: + glued.append(pending) + return glued + + def _first_sum_col(header: TableHeader) -> int: return min(header.columns.indexes().values()) @@ -270,7 +304,7 @@ def collect_body_rows( if index % DEADLINE_CHECK_EVERY_ROWS == 0: deadline.check("суммы строк") body.append(_body_row(sheet, row, header, label_cols, warnings)) - return body, total_row, warnings + return _glue_inn_rows(body, rules, warnings), total_row, warnings # --- Классификация --- @@ -364,8 +398,11 @@ def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple if group_type != "COUNTERPARTY": return _Kind(group_type, name, code, doc_data_type), branch inn_col = context.header.inn_col - inn = None if inn_col is None else inn_digits(context.sheet.cell(item.row, inn_col)) - return _Kind(group_type, name, code, doc_data_type, counterparty_name=item.text, counterparty_inn=inn), branch + column = None if inn_col is None else column_inn(context.sheet.cell(item.row, inn_col), context.rules) + # ИНН: колонка ИНН, затем строка без сумм над названием (OQ-130), затем хвост самого названия (OQ-100) + counterparty, in_name = split_inn_suffix(item.text, context.rules) + inn = column or item.inn_above or in_name + return _Kind(group_type, name, code, doc_data_type, counterparty_name=counterparty, counterparty_inn=inn), branch def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Task 8): всё, что нужно классификатору, приходит явно diff --git a/api/src/parser/header.py b/api/src/parser/header.py index 7471d31..2c9c03a 100644 --- a/api/src/parser/header.py +++ b/api/src/parser/header.py @@ -6,7 +6,6 @@ пустая строка (для полей контракта-строк) или `None` и предупреждение на русском. """ -import bisect import re from collections.abc import Iterable from dataclasses import dataclass @@ -16,7 +15,7 @@ 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, MonthRange, Pattern, Rules, first_match, normalize_text +from src.parser.rules import GroupType, MonthRange, Pattern, Rules, first_match, source_offset __all__ = ["ACCOUNT_NOT_FOUND_WARNING", "DocHeader", "build_group_schema", "parse_doc_header"] @@ -70,21 +69,9 @@ class _HeadCell: # --- Текст --- -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() + return text[source_offset(text, match.end()) :].strip() def _find(patterns: Iterable[Pattern], text: str) -> re.Match[str] | None: @@ -159,11 +146,11 @@ def _cut_month_range(tail: str, rules: Rules) -> str | None: found = rules.periods.range.search(tail) if found is None: return None - stop = _source_offset(tail, found.end()) + stop = source_offset(tail, found.end()) rest = tail[stop:] mark = rules.periods.end.search(rest) if mark is not None and mark.start() == 0: - stop += _source_offset(rest, mark.end()) + stop += source_offset(rest, mark.end()) return tail[:stop].strip() @@ -181,7 +168,7 @@ def _cut_period(tail: str, rules: Rules) -> str: end = rules.periods.end.search(tail[start:]) if end is None: break - start += _source_offset(tail[start:], end.end()) + start += source_offset(tail[start:], end.end()) candidate = tail[:start].strip() if rules.periods.match(candidate) is not None: return candidate diff --git a/api/src/parser/hierarchy.py b/api/src/parser/hierarchy.py index e5866c1..bf30d7f 100644 --- a/api/src/parser/hierarchy.py +++ b/api/src/parser/hierarchy.py @@ -59,6 +59,7 @@ class BodyRow: outline: int sums: tuple[Decimal | None, ...] label_col: int | None = None # колонка, в которой стоит текст строки (она не всегда колонка текста таблицы) + inn_above: str | None = None # ИНН из строки без сумм прямо над этой строкой (склеен с ней, OQ-130) @dataclass(frozen=True) diff --git a/api/src/parser/numbers.py b/api/src/parser/numbers.py index abea1fe..0427cf0 100644 --- a/api/src/parser/numbers.py +++ b/api/src/parser/numbers.py @@ -15,14 +15,17 @@ from typing import NamedTuple from src.parser.detect import INN_LENGTHS, inn_from_cell from src.parser.grid import Cell +from src.parser.rules import Rules, source_offset -__all__ = ["MONEY_Q", "MoneyParse", "inn_digits", "to_money"] +__all__ = ["MONEY_Q", "MoneyParse", "column_inn", "inn_digits", "inn_only", "split_inn_suffix", "to_money"] MONEY_Q = Decimal("0.01") # ИНН регионов 01–09, записанный числом, теряет ведущий ноль: 9 или 11 цифр дополняются нулём слева (только при # извлечении из выбранной колонки ИНН, ruling R-24) _INN_LENGTHS_WITHOUT_LEADING_ZERO = frozenset(length - 1 for length in INN_LENGTHS) _ZERO = Decimal("0.00") +# Что остаётся на конце названия после отрезанного хвоста с ИНН: знаки-разделители («ООО Альфа (», «ООО Альфа,») +_NAME_TRAILING = " ,;(/:-–—" # numeric(18,2): не больше 16 цифр до запятой. Проверка по порядку числа (`adjusted()`) до квантования — квантование # числа с гигантской экспонентой само падает с InvalidOperation @@ -137,3 +140,40 @@ def _restored_leading_zero(cell: Cell) -> str | None: return None digits = str(int(value)) return digits.zfill(len(digits) + 1) if len(digits) in _INN_LENGTHS_WITHOUT_LEADING_ZERO else None + + +def column_inn(cell: Cell, rules: Rules) -> str | None: + """ИНН из ячейки колонки ИНН: `inn_digits`, а склеенное «ИНН/КПП» («7701234567 / 770101001») — по `inn_kpp_cell`.""" + found = inn_digits(cell) + if found is not None or cell.kind != "text": + return found + for pattern in rules.headers.inn_kpp_cell: + match = pattern.search(cell.text) + if match is not None: + return match.group("inn") + return None + + +def split_inn_suffix(text: str, rules: Rules) -> tuple[str, str | None]: + """Название контрагента и ИНН из текста вида «Полиграф, 3528315710» / «ООО Альфа (ИНН 7701234567)». + + ИНН — только в конце текста (`counterparty_inn_suffix`); название — текст до хвоста без разделителей по краям. + Нет ИНН (или от названия ничего не осталось) — текст как есть и `None`. + """ + for pattern in rules.headers.counterparty_inn_suffix: + match = pattern.search(text) + if match is None: + continue + name = text[: source_offset(text, match.start("tail"))].strip().rstrip(_NAME_TRAILING).rstrip() + if name: + return name, match.group("inn") + return text, None + + +def inn_only(text: str, rules: Rules) -> str | None: + """ИНН строки, где написан только он (`inn_only_row`); иначе `None`.""" + for pattern in rules.headers.inn_only_row: + match = pattern.search(text) + if match is not None: + return match.group("inn") + return None diff --git a/api/src/parser/rules.py b/api/src/parser/rules.py index 2990849..2017e7c 100644 --- a/api/src/parser/rules.py +++ b/api/src/parser/rules.py @@ -9,6 +9,7 @@ неразрывный пробел, никогда не сработал бы; загрузчик отвергает такие паттерны с указанием файла и ключа. """ +import bisect import re from collections.abc import Iterable from dataclasses import dataclass @@ -34,6 +35,7 @@ __all__ = [ "first_match", "load_rules", "normalize_text", + "source_offset", ] DEFAULT_RULES_DIR = Path(__file__).resolve().parents[2] / "rules" @@ -67,6 +69,18 @@ def normalize_text(s: str) -> str: return _SEPARATORS.sub(" ", lowered).strip() +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]))) + + class Pattern: """Регулярное выражение словаря: `search` применяется к `normalize_text(text)`, регистр не учитывается.""" @@ -138,6 +152,9 @@ class OsvHeaderRules(_Model): total_row: list[Pattern] total_row_secondary: list[Pattern] column_captions: list[Pattern] + counterparty_inn_suffix: list[Pattern] + inn_only_row: list[Pattern] + inn_kpp_cell: list[Pattern] header_markers: dict[HeaderMarker, list[Pattern]] @model_validator(mode="after") @@ -151,6 +168,15 @@ class OsvHeaderRules(_Model): raise ValueError(f"{field_name}: нет обязательных ключей {sorted(missing)}") if "inn" not in self.service_columns: raise ValueError("service_columns: нет обязательного ключа 'inn'") + for field_name, groups in ( + ("counterparty_inn_suffix", {"tail", "inn"}), + ("inn_only_row", {"inn"}), + ("inn_kpp_cell", {"inn"}), + ): + for pattern in getattr(self, field_name): + missing_groups = groups - pattern.group_names + if missing_groups: + raise ValueError(f"{field_name}: в паттерне {pattern.source!r} нет групп {sorted(missing_groups)}") return self diff --git a/api/tests/synthetic/generate.py b/api/tests/synthetic/generate.py index 21f7326..11893d3 100644 --- a/api/tests/synthetic/generate.py +++ b/api/tests/synthetic/generate.py @@ -1,7 +1,8 @@ """Генератор синтетических ОСВ (Срез 2a, Task 11): `python -m tests.synthetic.generate <каталог>` из `api/`. Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл, s14…s16 — записи словарей, которых нет в реальных -ОСВ (R-22); описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен (openpyxl + +ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9); описание — +`osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен (openpyxl + `xlsb_writer`) и детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок записи фиксирован. @@ -43,6 +44,9 @@ Pair = tuple[Money, Money] Six = tuple[D, D, D, D, D, D] Hierarchy = Literal["outline", "indent", "spaces", "none"] NumStyle = Literal["num", "text", "text_nbsp", "text_narrow"] +# Как ИНН контрагента записан в файле: отдельной колонкой, в тексте названия (разные разделители), отдельной строкой +# над названием, склеенным «ИНН/КПП» в ячейке колонки ИНН +InnStyle = Literal["column", "comma", "paren_label", "label", "space", "above", "kpp_cell"] # --- Дерево ведомости --- @@ -63,6 +67,7 @@ class Node: turnover: Pair = (None, None) indicator: bool = False num_style: NumStyle = "num" + inn_style: InnStyle = "column" closing_debit_delta: D = ZERO # порча суммы (s10): к записанному `closing_debit` прибавляется, родитель не меняется def children(self) -> list["Node"]: @@ -200,6 +205,7 @@ class OsvSpec: detail_label: str = "Детализация по субсчетам, субконто" # метка перед перечнем субконто (вариант 2) org_name: str | None = ORG # None — в шапке нет строки с названием организации group_titles: dict[str, str] | None = None # названия групп сумм вместо `GROUP_TITLES` + inn_header: str = "ИНН" # подпись колонки ИНН («ИНН/КПП» — склеенная) labels_below: bool = False # вариант 1: «Счет» и «Контрагенты» — отдельными строками под подзаголовками сумм @@ -239,7 +245,7 @@ def _header_rows(sheet: SheetSpec, spec: OsvSpec, top: int, label: str, sub_labe sheet.put(label_row + 1, 1, sub_label) service_row = label_row + 1 if below else top + 1 if spec.inn_col is not None: - sheet.put(service_row, spec.inn_col, "ИНН") + sheet.put(service_row, spec.inn_col, spec.inn_header) if spec.kpp_col is not None: sheet.put(service_row, spec.kpp_col, "КПП") for key, col, width in _groups(spec): @@ -275,19 +281,38 @@ def _header_v2(sheet: SheetSpec, spec: OsvSpec) -> int: return nxt -def _row_cells(sheet: SheetSpec, spec: OsvSpec, row: int, entry: tuple[Node, int], six: Six) -> None: +_INN_IN_NAME = { + "comma": "{name}, {inn}", + "paren_label": "{name} (ИНН {inn})", + "label": "{name} ИНН: {inn}", + "space": "{name} {inn}", +} + + +def _name_with_inn(node: Node) -> str: + """Название строки; ИНН дописан в текст, если так задан `inn_style` (иначе — только название).""" + template = _INN_IN_NAME.get(node.inn_style) + return node.text if template is None or not node.inn else template.format(name=node.text, inn=node.inn) + + +def _row_cells( # noqa: PLR0913 - строка листа описывается полностью одним вызовом + sheet: SheetSpec, spec: OsvSpec, row: int, entry: tuple[Node, int], six: Six, *, blank: bool = False +) -> None: """Одна строка данных (`entry` — узел и его глубина): текст с оформлением иерархии, ИНН/КПП и суммы.""" node, depth = entry mode = spec.hierarchy - text = INDENT_STEP * depth + node.text if mode == "spaces" else node.text + shown = _name_with_inn(node) + text = INDENT_STEP * depth + shown if mode == "spaces" else shown bold = spec.bold_accounts and bool(node.children()) sheet.put(row, 1, text, bold=bold, indent=depth if mode == "indent" else 0) if mode == "outline" and depth: sheet.outline[row] = depth - if spec.inn_col is not None and node.inn: - sheet.put(row, spec.inn_col, node.inn) + if spec.inn_col is not None and node.inn and node.inn_style in ("column", "kpp_cell"): + sheet.put(row, spec.inn_col, f"{node.inn}/{node.kpp}" if node.inn_style == "kpp_cell" else node.inn) if spec.kpp_col is not None and node.kpp: sheet.put(row, spec.kpp_col, node.kpp) + if blank: + return # строка без сумм (ИНН над названием) values = list(six) values[4] += node.closing_debit_delta for key, col, width in _groups(spec): @@ -335,6 +360,11 @@ def build_osv(spec: OsvSpec, root: Node, name: str = SHEET) -> SheetSpec: if spec.repeat_header_after == index: # Печать по страницам: пустая строка и копия блока заголовка next_row = _header_rows(sheet, spec, next_row + 1, "Счет", "Контрагенты") + if node.inn_style == "above" and node.inn: + # ИНН отдельной строкой над названием, без сумм, на том же уровне оформления + inn_line = Node(node.inn) + _row_cells(sheet, spec, next_row, (inn_line, depth), (ZERO,) * 6, blank=True) + next_row += 1 _row_cells(sheet, spec, next_row, (node, depth), node.six()) next_row += 1 total = SheetSpec("") @@ -586,6 +616,35 @@ _CORE_PART = "docProps/core.xml" _FIXED_W3CDTF = FIXED_TIME.strftime("%Y-%m-%dT%H:%M:%SZ").encode() +def _s17() -> list[SheetSpec]: + """ИНН в тексте названия контрагента (OQ-100): запятая, «(ИНН …)», «ИНН: …», пробел; колонки ИНН нет.""" + first = alfa(inn_style="comma") + second = beta(inn_style="paren_label") + third = gamma(inn_style="label") + fourth = sidorov() + fourth.inn_style = "space" + root = Node("60", [Node("60.01", [first, second, ivanov()]), Node("60.02", [third, fourth])]) + return [build_osv(OsvSpec(period="1 квартал 2025 г.", inn_col=None, first_sum_col=5), root)] + + +def _s18() -> list[SheetSpec]: + """ИНН отдельной строкой над названием контрагента (OQ-130): строка без сумм, того же уровня; колонки ИНН нет.""" + root = Node( + "60", [Node("60.01", [alfa(inn_style="above"), beta(), ivanov()]), Node("60.02", [gamma(inn_style="above")])] + ) + return [build_osv(OsvSpec(period="2 квартал 2025 г.", inn_col=None, first_sum_col=5), root)] + + +def _s19() -> list[SheetSpec]: + """Колонка «ИНН/КПП» со склеенным значением «7701234567/770101001» в одной ячейке (OQ-100).""" + items = [alfa(inn_style="kpp_cell"), beta(inn_style="kpp_cell"), ivanov()] + for node in items: + node.kpp = KPP.get(node.text) + root = Node("60", [Node("60.01", items), Node("60.02", [gamma(), delta()])]) + spec = OsvSpec(period="3 квартал 2025 г.", inn_col=5, inn_header="ИНН/КПП", first_sum_col=6) + return [build_osv(spec, root)] + + def _normalized_zip(data: bytes) -> bytes: """Тот же архив с постоянными метками времени: записей zip и `dcterms:modified` (openpyxl ставит текущее время при каждом `save`, заданное в свойствах значение перезаписывает).""" @@ -651,6 +710,9 @@ FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSp "s14_statement_forms.xlsx": (_s14, to_xlsx), "s15_group_title_synonyms.xlsx": (_s15, to_xlsx), "s16_header_synonyms.xlsx": (_s16, to_xlsx), + "s17_inn_in_name.xlsx": (_s17, to_xlsx), + "s18_inn_row_above.xlsx": (_s18, to_xlsx), + "s19_inn_kpp_cell.xlsx": (_s19, to_xlsx), } FILE_NAMES = tuple(FILES) @@ -669,7 +731,7 @@ def generate_all(directory: Path) -> list[Path]: def main(argv: Sequence[str] | None = None) -> int: parser = argparse.ArgumentParser( prog="python -m tests.synthetic.generate", - description="Синтетические ОСВ s01…s16 (xlsx и один xlsb) в указанный каталог.", + description="Синтетические ОСВ s01…s19 (xlsx и один xlsb) в указанный каталог.", ) parser.add_argument("directory", type=Path, help="каталог назначения, например ../../fixtures/osv/synthetic") args = parser.parse_args(argv) diff --git a/api/tests/synthetic/test_generate.py b/api/tests/synthetic/test_generate.py index fcaac74..3368500 100644 --- a/api/tests/synthetic/test_generate.py +++ b/api/tests/synthetic/test_generate.py @@ -26,7 +26,7 @@ def test_generation_is_reproducible_byte_for_byte(tmp_path: Path) -> None: assert left.read_bytes() == right.read_bytes(), left.name -def test_generated_set_is_s01_to_s16_with_one_xlsb(tmp_path: Path) -> None: +def test_generated_set_is_s01_to_s19_with_one_xlsb(tmp_path: Path) -> None: names = [path.name for path in generate_all(tmp_path)] assert names == list(FILE_NAMES) diff --git a/api/tests/synthetic/test_synthetic_files.py b/api/tests/synthetic/test_synthetic_files.py index 04717d6..3f0089a 100644 --- a/api/tests/synthetic/test_synthetic_files.py +++ b/api/tests/synthetic/test_synthetic_files.py @@ -43,6 +43,9 @@ EXPECTED_NAMES = [ "s14_statement_forms.xlsx", "s15_group_title_synonyms.xlsx", "s16_header_synonyms.xlsx", + "s17_inn_in_name.xlsx", + "s18_inn_row_above.xlsx", + "s19_inn_kpp_cell.xlsx", ] XLSB_NAME = "s13_no_outline_indent.xlsb" XLSB_MISSING_REASON = f"нет файла {XLSB_NAME} (xlsb не получен; см. README fixtures)" @@ -100,6 +103,12 @@ EXPECTED: dict[str, Expect] = { "s15_group_title_synonyms.xlsx": Expect("outline", 2025, "Q1", True, documents=2), # шапка без организации, период «2024»; лист 1 — вариант 1, лист 2 — вариант 2 "s16_header_synonyms.xlsx": Expect("outline", 2024, "Q4", True, documents=2, organization="", variants=(1, 2)), + # ИНН в тексте названия, колонки ИНН нет + "s17_inn_in_name.xlsx": Expect("outline", 2025, "Q1", True), + # ИНН строкой над названием + "s18_inn_row_above.xlsx": Expect("outline", 2025, "Q2", False), + # колонка «ИНН/КПП» + "s19_inn_kpp_cell.xlsx": Expect("outline", 2025, "Q3", False), } @@ -172,7 +181,7 @@ def tree(doc: OsvDocument) -> list[tuple[str, int, int | None, tuple[Decimal | N def test_synthetic_set_is_complete(fixtures_dir: Path) -> None: - """В `fixtures/osv/synthetic/` лежат файлы набора s01…s16 (s13 — xlsb; допустима деградация: нет только его).""" + """В `fixtures/osv/synthetic/` лежат файлы набора s01…s19 (s13 — xlsb; допустима деградация: нет только его).""" present = sorted(path.name for path in (fixtures_dir / SYNTHETIC_DIR).glob("s*") if path.is_file()) assert present == [name for name in EXPECTED_NAMES if name in present] missing = [name for name in EXPECTED_NAMES if name not in present] @@ -483,3 +492,55 @@ def test_s16_header_without_organization_year_only_period_and_currency_note(fixt ("OTHER", "Валюта"), ] assert outcome.status is ResultStatus.DONE + + +def test_s17_inn_in_name_is_extracted_and_name_is_clean(fixtures_dir: Path) -> None: + """OQ-100: ИНН из текста названия («, », «(ИНН …)», «ИНН: …», пробел) — в `counterparty_inn`, имя без ИНН.""" + doc = one_document(parse_named(fixtures_dir, "s17_inn_in_name.xlsx")) + + counterparties = { + row.counterparty_name: row.counterparty_inn for row in doc.rows if row.group_type == "COUNTERPARTY" + } + assert counterparties == { + ALFA: "7701234567", + BETA: "7707654321", + GAMMA: "5001234567", + "ИП Сидоров": "500100732259", + "Иванов Иван": None, + } + alfa = next(row for row in doc.rows if row.counterparty_name == ALFA) + assert alfa.row_value == "ООО Альфа, 7701234567" # исходный текст не меняется + assert doc.columns is not None + assert "inn" not in doc.columns + + +def test_s18_inn_row_above_is_glued_and_not_other(fixtures_dir: Path) -> None: + """OQ-130: строка с одним ИНН над названием не попадает в строки и не уходит в OTHER, ИНН — у контрагента.""" + outcome = parse_named(fixtures_dir, "s18_inn_row_above.xlsx") + doc = one_document(outcome) + + assert not [row for row in doc.rows if row.group_type == "OTHER"] + assert not [row for row in doc.rows if row.row_value in {"7701234567", "5001234567"}] + counterparties = { + row.counterparty_name: row.counterparty_inn for row in doc.rows if row.group_type == "COUNTERPARTY" + } + assert counterparties == {ALFA: "7701234567", BETA: None, "Иванов Иван": None, GAMMA: "5001234567"} + assert any("ИНН 7701234567" in warning for warning in warnings_of(outcome)) + + +def test_s19_inn_kpp_cell_gives_inn_only(fixtures_dir: Path) -> None: + """«ИНН/КПП» в одной ячейке: в `counterparty_inn` — только ИНН; колонка распознана как колонка ИНН.""" + doc = one_document(parse_named(fixtures_dir, "s19_inn_kpp_cell.xlsx")) + + counterparties = { + row.counterparty_name: row.counterparty_inn for row in doc.rows if row.group_type == "COUNTERPARTY" + } + assert counterparties == { + ALFA: "7701234567", + BETA: "7707654321", + "Иванов Иван": None, + GAMMA: "5001234567", + "ООО Дельта": "7812345678", + } + assert doc.columns is not None + assert doc.columns["inn"] == "E" diff --git a/api/tests/unit/parser/test_classify.py b/api/tests/unit/parser/test_classify.py index 1b1bf6d..dc177ac 100644 --- a/api/tests/unit/parser/test_classify.py +++ b/api/tests/unit/parser/test_classify.py @@ -532,6 +532,138 @@ def test_header_repeat_is_skipped(rules: Rules) -> None: assert parsed.one("ООО Поставщик").parent == ACCOUNT_ROW +# --- ИНН в тексте строки контрагента (issue #9, OQ-100, OQ-130) --- + + +def _inn_sheet(name: str, sums: Sums = S100) -> Sheet: + return osv(r(0, "60", sums), r(1, name, sums)) + + +@pytest.mark.parametrize( + ("text", "name", "inn"), + [ + ("ООО Поставщик, 7701234567", "ООО Поставщик", "7701234567"), + ("ООО Поставщик,7701234567", "ООО Поставщик", "7701234567"), + ("ООО Поставщик (7701234567)", "ООО Поставщик", "7701234567"), + ("ООО Поставщик (ИНН 7701234567)", "ООО Поставщик", "7701234567"), + ("ООО Поставщик ИНН 7701234567", "ООО Поставщик", "7701234567"), + ("ООО Поставщик ИНН: 7701234567", "ООО Поставщик", "7701234567"), + ("ООО Поставщик - ИНН 7701234567", "ООО Поставщик", "7701234567"), + ("ООО Поставщик 7701234567", "ООО Поставщик", "7701234567"), + ("ООО Поставщик / 7701234567", "ООО Поставщик", "7701234567"), + ("ИП Сидоров, 500100732259", "ИП Сидоров", "500100732259"), + ("ООО Поставщик, 7701234567/770101001", "ООО Поставщик", "7701234567"), + ("Альянс ООО (Тамбов) 6829133099", "Альянс ООО (Тамбов)", "6829133099"), + ], +) +def test_inn_is_extracted_from_counterparty_name(rules: Rules, text: str, name: str, inn: str) -> None: + parsed = parse(_inn_sheet(text), rules) + + row = parsed.rows[1] + assert row.group_type == "COUNTERPARTY" + assert (row.counterparty_name, row.counterparty_inn) == (name, inn) + assert row.body.text == text # исходный текст строки сохраняется (row_value) + + +@pytest.mark.parametrize( + "text", + [ + "ООО Поставщик 12345", # не 10/12 цифр + "ООО Поставщик 12345678901", # 11 цифр + "ООО Поставщик 1234567890123", # 13 цифр + "ООО Поставщик 7701234567 филиал", # ИНН не в конце + "ООО Поставщик№7701234567", # без разделителя + "7701234567", # весь текст — число: имени нет + ], +) +def test_inn_is_not_extracted_from_foreign_text(rules: Rules, text: str) -> None: + row = parse(_inn_sheet(text), rules).rows[1] + + assert (row.counterparty_name, row.counterparty_inn) == (text, None) + + +def test_inn_column_wins_over_inn_in_name_and_name_is_still_cleaned(rules: Rules) -> None: + sheet = osv(r(0, "60", S100), r(1, "ООО Поставщик, 7701234567", S100, inn="7707654321")) + + row = parse(sheet, rules).rows[1] + + assert (row.counterparty_name, row.counterparty_inn) == ("ООО Поставщик", "7707654321") + + +def test_inn_in_contract_name_is_not_extracted(rules: Rules) -> None: + sheet = osv( + r(0, "60", S100), + r(1, "ООО Поставщик", S100, inn="7701234567"), + r(2, "Договор № 1, 7701234567", S100), + ) + + contract = parse(sheet, rules).rows[2] + + assert contract.group_type == "CONTRACT" + assert (contract.counterparty_name, contract.counterparty_inn) == (None, None) + + +def test_inn_with_kpp_in_one_column_cell_gives_inn(rules: Rules) -> None: + sheet = osv(r(0, "60", S100), r(1, "ООО Поставщик", S100, inn="7701234567 / 770101001")) + + row = parse(sheet, rules).rows[1] + + assert (row.counterparty_name, row.counterparty_inn) == ("ООО Поставщик", "7701234567") + + +def test_inn_only_row_above_is_glued_to_next_counterparty(rules: Rules) -> None: + sheet = osv( + r(0, "60", S100), + r(1, "7701234567"), # ИНН строкой выше названия, без сумм + r(1, "ООО Поставщик", S100), + ) + + parsed = parse(sheet, rules) + + assert [row.body.text for row in parsed.rows] == ["60", "ООО Поставщик"] + supplier = parsed.rows[1] + assert (supplier.counterparty_name, supplier.counterparty_inn, supplier.parent) == ( + "ООО Поставщик", + "7701234567", + ACCOUNT_ROW, + ) + assert any("ИНН" in warning and "7701234567" in warning for warning in parsed.warnings), parsed.warnings + + +def test_inn_row_above_with_label_and_kpp_forms(rules: Rules) -> None: + sheet = osv( + r(0, "60", S100), + r(1, "ИНН 7701234567 / 770101001"), + r(1, "ООО Поставщик", S100), + ) + + assert parse(sheet, rules).rows[1].counterparty_inn == "7701234567" + + +def test_inn_row_above_on_other_level_is_not_glued(rules: Rules) -> None: + sheet = osv( + r(0, "60", S100), + r(1, "7701234567"), + r(2, "ООО Поставщик", S100), + ) + + parsed = parse(sheet, rules) + + assert [row.body.text for row in parsed.rows] == ["60", "7701234567", "ООО Поставщик"] + assert parsed.one("ООО Поставщик").counterparty_inn is None + + +def test_inn_row_above_before_row_without_sums_is_not_glued(rules: Rules) -> None: + sheet = osv( + r(0, "60", S100), + r(1, "7701234567"), + r(1, "ООО Поставщик"), + r(1, "ООО Другой", S100), + ) + + assert "7701234567" in [row.body.text for row in parse(sheet, rules).rows] + + # --- TOTAL --- diff --git a/api/tests/unit/parser/test_rules.py b/api/tests/unit/parser/test_rules.py index 21c074c..0b16162 100644 --- a/api/tests/unit/parser/test_rules.py +++ b/api/tests/unit/parser/test_rules.py @@ -236,6 +236,17 @@ def test_periods_range_without_named_groups_is_rejected(tmp_path: Path) -> None: load_rules(target) +def test_inn_pattern_without_named_group_is_rejected(tmp_path: Path) -> None: + target = _copy_rules(tmp_path) + path = target / "osv_headers.yaml" + data = yaml.safe_load(path.read_text(encoding="utf-8")) + data["counterparty_inn_suffix"] = [r"\d{10}"] + path.write_text(yaml.safe_dump(data, allow_unicode=True), encoding="utf-8") + + with pytest.raises(RuntimeError, match=r"osv_headers\.yaml.*counterparty_inn_suffix"): + load_rules(target) + + def test_unknown_key_is_rejected_as_probable_typo(tmp_path: Path) -> None: target = _copy_rules(tmp_path) path = target / "periods.yaml" -- 2.47.2