From ef14747f4952ee7da3661e08e7351e8ab0e9539e Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 01:16:21 +0400 Subject: [PATCH 1/3] =?UTF-8?q?fix(osv):=20=D1=81=D1=87=D1=91=D1=82=20?= =?UTF-8?q?=D1=88=D0=B0=D0=BF=D0=BA=D0=B8=20=C2=AB=D0=A1=D1=87=D0=B5=D1=82?= =?UTF-8?q?=20=D0=9F=D0=BE=D1=81=D1=82=D0=B0=D0=B2=D1=89=D0=B8=D0=BA=D0=B0?= =?UTF-8?q?=20|=2060*=C2=BB=20=D0=B8=20=D0=B3=D0=BE=D0=BB=D1=8B=D0=B5=20?= =?UTF-8?q?=D0=BA=D0=BE=D0=B4=D1=8B=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0?= =?UTF-8?q?=D0=B3=D0=B5=D0=BD=D1=82=D0=BE=D0=B2=20(AURORA-1714,=20#71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Метки «Счет Поставщика»/«Счет Клиента» добавлены в header_markers.account; код счёта читается из соседней ячейки строки, «*» отбрасывается. При известном счёте шапки голый числовой код, не совпадающий с ним, не считается счётом. Синтетика s45, тест на книге openpyxl, сценарий в спеке osv-parsing. Co-Authored-By: Claude Opus 5.5 --- api/rules/README.md | 2 +- api/rules/osv_headers.yaml | 2 + api/src/parser/header.py | 13 ++- api/src/parser/osv.py | 19 +++++ api/tests/synthetic/generate.py | 23 +++++- api/tests/synthetic/test_generate.py | 4 +- .../unit/parser/test_osv_account_marker.py | 81 +++++++++++++++++++ openspec/specs/osv-parsing/spec.md | 8 +- 8 files changed, 143 insertions(+), 9 deletions(-) create mode 100644 api/tests/unit/parser/test_osv_account_marker.py diff --git a/api/rules/README.md b/api/rules/README.md index 9acdd21..ea2426b 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`, `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_headers.yaml` | `sheet_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` — для всего прочего) | | `osv_checks.yaml` | `accounts_without_analytic_balance` | коды счетов верхнего уровня без сальдо по аналитике (состав `90`, `91`, `99`; OQ-132, 90 добавлен 19.10.2026): тождество строки «начало + обороты = конец» на них не проверяется (субсчета включены, `910` — нет); сверки «сумма детей = родитель» по оборотам и «Итого» сохраняются. Не `Pattern`: записи — коды счетов, а не регулярные выражения | | `periods.yaml` | `rules` (`pattern`, `quarter`, `is_cumulative`), `year`, `end`, `months` (названия месяцев, группы `m01…m12`), `not_single_month` (тексты, где один месяц не задаёт границы периода: дата на число, квартал, «N месяцев»), `range` (диапазон месяцев с годами, группы `m1`, `y1`, `m2`, `y2`), `standalone` (период отдельной ячейкой без метки) | текст периода -> `period_quarter` и `is_cumulative` (§1.5, `ASSUMPTION(OQ-02)`); `year` — паттерн года, `end` — конец текста периода в строке шапки («г.», «год») | diff --git a/api/rules/osv_headers.yaml b/api/rules/osv_headers.yaml index bafa8ac..116038a 100644 --- a/api/rules/osv_headers.yaml +++ b/api/rules/osv_headers.yaml @@ -77,8 +77,10 @@ inn_kpp_cell: # Метки шапки документа (область выше заголовка таблицы); ключи фиксированы header_markers: # «по счету 60 за …»: код счёта — сразу после совпадения + # выгрузка 1С «ОСВ по счёту 60 по контрагентам»: «Счет Поставщика» | «60*» — метка и код в соседних ячейках строки account: - '\bпо\s+счету\b' + - '\bсчет\s+(поставщика|клиента)\b' # вариант 1: «… за 1 квартал 2025 г.», вариант 2: отдельная строка «Период: 1 квартал 2026 г.» period: - '\bза\s+' diff --git a/api/src/parser/header.py b/api/src/parser/header.py index acbadb7..698ea3e 100644 --- a/api/src/parser/header.py +++ b/api/src/parser/header.py @@ -42,7 +42,7 @@ _LIST_SEPARATOR = "," # Реквизит субконто отделяется от имени группировки точкой: «Контрагенты.ИНН» _ATTRIBUTE_SEPARATOR = "." # Знаки, которыми код счёта может заканчиваться в тексте, но которые кодом не являются -_CODE_TRAILING = ".,;:" +_CODE_TRAILING = ".,;:*" @dataclass(frozen=True) @@ -306,12 +306,19 @@ def _organization( return _title_tail_organization(cells, rules) if found is None else found.text -def _account_code(cells: list[_HeadCell], rules: Rules) -> str | None: +def _account_code(sheet: Sheet, cells: list[_HeadCell], rules: Rules) -> str | None: + """Код счёта после метки: в той же ячейке («по счету 60»), иначе в следующей ячейке («Счет Поставщика» | «60*»). + + Метка без кода («Счет Клиента» с пустой ячейкой справа) пропускается — берётся следующая метка. + """ for cell in cells: match = _find(rules.headers.header_markers["account"], cell.text) if match is None: continue code = _ACCOUNT_CODE.match(_tail(cell.text, match).lstrip(": ")) + if code is None: + neighbours = [other.text.strip() for col, other in sheet.row_cells(cell.row) if col > cell.col] + code = _ACCOUNT_CODE.match(neighbours[0]) if neighbours else None if code is not None: return code.group(0).rstrip(_CODE_TRAILING) return None @@ -473,7 +480,7 @@ def parse_doc_header(sheet: Sheet, header: TableHeader, rules: Rules) -> DocHead 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) + account = _account_code(sheet, cells, rules) if account is None: warnings.append(ACCOUNT_NOT_FOUND_WARNING) period_text = None if period_hit is None else period_hit.text diff --git a/api/src/parser/osv.py b/api/src/parser/osv.py index 5bceb0e..583571e 100644 --- a/api/src/parser/osv.py +++ b/api/src/parser/osv.py @@ -20,6 +20,7 @@ from src.parser.detect import TableHeader, find_table_header from src.parser.errors import InvalidFormatError from src.parser.grid import Sheet, column_letter from src.parser.header import ACCOUNT_NOT_FOUND_WARNING, DocHeader, build_group_levels, parse_doc_header +from src.parser.hierarchy import BodyRow from src.parser.indicators import levels_with_indicators from src.parser.inn_rows import InnGlue, glue_inn_rows from src.parser.osv_warnings import ( @@ -112,6 +113,23 @@ def _with_table_account(doc: DocHeader, rows: Sequence[ClassifiedRow]) -> DocHea return replace(doc, account_code_raw=code, warnings=tuple(warnings)) +def _without_foreign_bare_codes(body: list[BodyRow], header_account: str) -> list[BodyRow]: + """Голый числовой код («441», «199») в таблице ОСВ с известным счётом шапки — код контрагента, а не счёт (#71). + + Счётом остаётся голый код, совпадающий со счётом шапки («60» при «по счету 60.01»); коды с точкой и названием + («60.01», «60, Расчеты») под фильтр не попадают. Без счёта шапки список не меняется. + """ + if not header_account: + return body + root = header_account.split(".", maxsplit=1)[0] + return [ + replace(item, is_account=False, account_rank=None) + if item.is_account and item.text.isdigit() and item.text != root + else item + for item in body + ] + + def _by_account_warnings(rows: Sequence[ClassifiedRow]) -> list[str]: """По одному предупреждению на счёт, чьи строки признаны контрагентами по плану счетов (R-6), в порядке листа.""" codes = dict.fromkeys(item.account_code_raw or "" for item in rows if item.counterparty_by_account) @@ -193,6 +211,7 @@ class OsvParser: deadline.check(f"лист «{sheet.name}»: строки таблицы") step = "строки таблицы" body, total_row, body_warnings = collect_body_rows(sheet, header, rules, deadline) + body = _without_foreign_bare_codes(body, doc.account_code_raw) step = "иерархия строк" levels = levels_with_indicators(body, total_row, deadline, header_account=bool(doc.account_code_raw)) step = "строки ИНН" diff --git a/api/tests/synthetic/generate.py b/api/tests/synthetic/generate.py index ab638d8..95b4219 100644 --- a/api/tests/synthetic/generate.py +++ b/api/tests/synthetic/generate.py @@ -4,8 +4,8 @@ ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9), s20…s27 — восемь раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35), s29…s32 — xls без CODEPAGE, «Вал»/«Кол-во», строки ниже «Итого» и «Итого по счету» вместо точного «Итого» (#47), s40…s44 — плоская форма ОСВ 1С (#59; s33…s39 заняты -другим треком); описание — `osv/synthetic/README.md` -в репозитории fixtures. Генератор самодостаточен (openpyxl + `xlsb_writer` + `xls_writer`) и +другим треком), s45 — счёт шапки «Счет Поставщика» | «60*» в соседних ячейках (#71); описание — +`osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен (openpyxl + `xlsb_writer` + `xls_writer`) и детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок записи фиксирован. Суммы арифметически согласованы по построению: у листа дерева сальдо на конец вычисляется из начала и оборотов, у @@ -972,6 +972,24 @@ def _s44() -> list[SheetSpec]: return _s43_s44("10", "10.01", ("Мука пшеничная", "Сахар-песок")) +def _s45() -> list[SheetSpec]: + """Выгрузка 1С «ОСВ по счёту 60 по контрагентам» (#71, AURORA-1714): счёт в шапке — «Счет Поставщика» | «60*» в + соседних ячейках («Счет Клиента» без значения), без слов «по счету»; в первой колонке голые числовые коды + контрагентов.""" + rows: list[FlatRow] = [ + ("441", (None, "108000.00", "108000.00", None, None, None), 0), + ("431", (None, "200.00", "200.00", None, None, None), 0), + ("199", (None, "50.00", "50.00", None, None, None), 0), + ("Итого", (None, "108250.00", "108250.00", None, None, None), 0), + ] + sheet = _flat_sheet({1: "Оборотно-сальдовая ведомость", 2: "За период"}, 6, rows) + sheet.put(2, 3, "01.01.25..30.06.25") + sheet.put(3, 2, "Счет Поставщика") + sheet.put(3, 3, "60*") + sheet.put(4, 2, "Счет Клиента") + return [sheet] + + def _s28() -> list[SheetSpec]: """Раскладка s20 («над, не в дереве, без сумм»), но ИНН ООО Альфа с опечаткой: контрольная сумма не сходится.""" tree = _inn_layout_tree() @@ -1085,6 +1103,7 @@ FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSp "s42_subaccounts_totals_reversed.xlsx": (_s42, to_xlsx), "s43_subkonto_unnamed_60.xlsx": (_s43, to_xlsx), "s44_subkonto_unnamed_10.xlsx": (_s44, to_xlsx), + "s45_account_label_value_cells.xlsx": (_s45, to_xlsx), } FILE_NAMES = tuple(FILES) diff --git a/api/tests/synthetic/test_generate.py b/api/tests/synthetic/test_generate.py index 6c78d51..4d82eb2 100644 --- a/api/tests/synthetic/test_generate.py +++ b/api/tests/synthetic/test_generate.py @@ -26,13 +26,13 @@ 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_s36_and_s40_to_s44_with_one_xlsb(tmp_path: Path) -> None: +def test_generated_set_is_s01_to_s36_and_s40_to_s45_with_one_xlsb(tmp_path: Path) -> None: names = [path.name for path in generate_all(tmp_path)] assert names == list(FILE_NAMES) assert [re.match(r"s(\d\d)_", name) is not None for name in names] == [True] * len(names) # s33…s36 — строки-показатели (AURORA-1613, #58), плоская форма ОСВ 1С (#59) — s40…s44 - numbers = [*range(1, 37), *range(40, 45)] + numbers = [*range(1, 37), *range(40, 46)] assert [name[1:3] for name in names] == [f"{number:02d}" for number in numbers] assert [name for name in names if name.endswith(".xlsb")] == ["s13_no_outline_indent.xlsb"] assert sorted(path.name for path in tmp_path.iterdir()) == sorted(names) diff --git a/api/tests/unit/parser/test_osv_account_marker.py b/api/tests/unit/parser/test_osv_account_marker.py new file mode 100644 index 0000000..6b600cb --- /dev/null +++ b/api/tests/unit/parser/test_osv_account_marker.py @@ -0,0 +1,81 @@ +"""Счёт шапки «Счет Поставщика | 60*» (метка и значение в разных ячейках; AURORA-1714, #71). + +Книга собирается openpyxl в памяти: шапка 1С «ОСВ по счёту 60 по контрагентам» без слов «по счету», голые числовые коды +контрагентов (441, 431) в первой колонке. Счёт документа — из шапки, строки — контрагенты по плану счетов. +""" + +import io + +import openpyxl +import pytest + +from arm_statements import default_rules +from src.contract import OsvDocument +from src.parser.book import BookParser +from src.parser.osv import OsvParser +from src.parser.rules import Rules, load_rules +from src.parser.statements.pipeline import StatementPipeline + +TIMEOUT_SECONDS = 60 +MAX_BYTES = 50 * 1024 * 1024 + + +@pytest.fixture(scope="module") +def rules() -> Rules: + return load_rules() + + +def _book(label: str, empty_label: str) -> bytes: + rows: list[list[object]] = [ + ["МВС2009", "Оборотно-сальдовая ведомость"], + ["15 июля 2025(18:40)", "За период", "01.01.25..30.06.25"], + [None, label, "60*"], + [None, empty_label, None], + [], + ["Субконто", "Начальное сальдо", None, "Обороты", None, "Конечное сальдо", None], + [None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], + ["441", 0, 100, 100, 0, 0, 0], + ["431", 0, 200, 200, 0, 0, 0], + ["199", 0, 50, 50, 0, 0, 0], + ["Итого", 0, 350, 350, 0, 0, 0], + ] + workbook = openpyxl.Workbook() + sheet = workbook.active + assert sheet is not None + for index, cells in enumerate(rows, start=1): + for col, value in enumerate(cells, start=1): + if value is not None: + sheet.cell(index, col, value) + buffer = io.BytesIO() + workbook.save(buffer) + return buffer.getvalue() + + +def _document(rules: Rules, data: bytes) -> OsvDocument: + statement_rules = default_rules() + parser = BookParser( + rules, + osv=OsvParser(rules), + statements=StatementPipeline(statement_rules), + statement_rules=statement_rules, + max_bytes=MAX_BYTES, + timeout_seconds=TIMEOUT_SECONDS, + ) + outcome = parser.parse(data, "xlsx") + documents = [doc for doc in outcome.documents if isinstance(doc, OsvDocument)] + assert len(documents) == 1 + return documents[0] + + +@pytest.mark.parametrize( + ("label", "empty_label"), + [("Счет Поставщика", "Счет Клиента"), ("Счет Клиента", "Счет Поставщика"), ("Счёт поставщика:", "Счёт клиента:")], +) +def test_account_from_label_and_value_in_separate_cells(rules: Rules, label: str, empty_label: str) -> None: + document = _document(rules, _book(label, empty_label)) + + assert document.header.account_code_raw == "60" + data = [item for item in document.rows if item.group_type != "TOTAL"] + assert [item.row_value for item in data] == ["441", "431", "199"] + assert {item.group_type for item in data} == {"COUNTERPARTY"} + assert {item.account_code_raw for item in data} == {"60"} diff --git a/openspec/specs/osv-parsing/spec.md b/openspec/specs/osv-parsing/spec.md index 2f5905d..04e02e4 100644 --- a/openspec/specs/osv-parsing/spec.md +++ b/openspec/specs/osv-parsing/spec.md @@ -61,7 +61,7 @@ SHALL пропускаться с предупреждением файла `SHE ### Requirement: Шапка документа и схема группировок Парсер SHALL извлечь из области выше заголовка таблицы: `organization_name` (хвост метки «План счетов <организация>»; иначе первая непустая текстовая строка, не являющаяся меткой шапки и не лежащая между строкой названия ведомости и строкой периода; иначе хвост названия ведомости; trim), -`account_code_raw` (после «по счету»/«по счёту», в том числе через двоеточие «по счету: 76.Н.1», как в файле: «60», «58.03»; код счёта шапки важнее первой строки-счёта таблицы — она используется, только если в шапке кода нет, OQ-134), `period_text` (после «за» до «г.»/конца), +`account_code_raw` (после «по счету»/«по счёту» либо в соседней ячейке строки после метки «Счет Поставщика»/«Счет Клиента» («60*» -> «60»; метка без значения пропускается), в том числе через двоеточие «по счету: 76.Н.1», как в файле: «60», «58.03»; код счёта шапки важнее первой строки-счёта таблицы — она используется, только если в шапке кода нет, OQ-134), `period_text` (после «за» до «г.»/конца), `data_type` («Выводимые данные: БУ/НУ/сумма»), `currency` («Валюта …: RUB/руб.» → `RUB`), `header_variant` (1 — группировки строками первой колонки блока заголовка; 2 — перечисление «субконто: …»). `group_schema` SHALL начинаться с ACCOUNT и далее перечислять группировки в порядке вложенности по словарю `rules/osv_groups.yaml`; ячейка `ИНН` в строке группировки → @@ -110,6 +110,12 @@ SHALL пропускаться с предупреждением файла `SHE - **THEN** `organization_name = "ООО Хреновская Нива"`, `account_code_raw = "60"`, `group_schema = [ACCOUNT(Субконто), COUNTERPARTY(Организации)]` (множественное «Организации» — синоним COUNTERPARTY в словаре; единственное «Организация» — собственная организация учёта, R-17) +#### Scenario: Счёт шапки в соседней ячейке от метки +- **WHEN** разбирается синтетическая ОСВ `s45` (выгрузка 1С «ОСВ по счёту 60 по контрагентам»: шапка «Счет Поставщика» | «60*» + в соседних ячейках, «Счет Клиента» без значения, слов «по счету» нет; в первой колонке голые числовые коды 441, 431, 199) +- **THEN** `account_code_raw = "60"`; голый числовой код, не совпадающий со счётом шапки, счётом не считается (это код + контрагента); строки — `COUNTERPARTY` по плану счетов, `done` + #### Scenario: Счёт шапки важнее строки таблицы - **WHEN** разбирается синтетическая ОСВ `s42` (шапка «…по счету: 76», первая строка таблицы «76.2») - **THEN** `account_code_raw = "76"` -- 2.47.2 From 266d4e8a3a3ac4661e681725f9348011595ae987 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 01:28:47 +0400 Subject: [PATCH 2/3] =?UTF-8?q?test(osv):=20=D0=BE=D0=B6=D0=B8=D0=B4=D0=B0?= =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B4=D0=BB=D1=8F=20=D1=81=D0=B8=D0=BD?= =?UTF-8?q?=D1=82=D0=B5=D1=82=D0=B8=D0=BA=D0=B8=20s45=20(#71)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- api/tests/synthetic/test_synthetic_files.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/api/tests/synthetic/test_synthetic_files.py b/api/tests/synthetic/test_synthetic_files.py index 0ede05d..ddae103 100644 --- a/api/tests/synthetic/test_synthetic_files.py +++ b/api/tests/synthetic/test_synthetic_files.py @@ -50,6 +50,7 @@ FLAT_FORM_FILES = ( "s42_subaccounts_totals_reversed.xlsx", "s43_subkonto_unnamed_60.xlsx", "s44_subkonto_unnamed_10.xlsx", + "s45_account_label_value_cells.xlsx", ) TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28 EXPECTED_NAMES = [ @@ -101,9 +102,9 @@ class Expect: """Ожидания по файлу, общие для всех сценариев (шапка и итог разбора).""" hierarchy: str - year: int - quarter: str - cumulative: bool + year: int | None + quarter: str | None + cumulative: bool | None status: ResultStatus = ResultStatus.DONE documents: int = 1 variant: int = 1 @@ -183,6 +184,11 @@ EXPECTED: dict[str, Expect] = { "s44_subkonto_unnamed_10.xlsx": Expect( "indent", 2024, "Q3", True, data_type=None, organization='ООО "Октябрьское"' ), + # #71: «Счет Поставщика | 60*». Период «За период | 01.01.25..30.06.25» не распознан, а «За период» принимается за + # организацию — известное ограничение (O-2), зафиксировано как есть + "s45_account_label_value_cells.xlsx": Expect( + "header_account", None, None, None, data_type=None, organization="За период" + ), } -- 2.47.2 From 5be03db68dfb812601b808a55c23fb071b3758c0 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 27 Oct 2026 10:38:54 +0400 Subject: [PATCH 3/3] =?UTF-8?q?feat(osv):=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=B8?= =?UTF-8?q?=20=D1=81=D1=83=D0=B1=D1=81=D1=87=D0=B5=D1=82=D0=BE=D0=B2=20?= =?UTF-8?q?=D1=81=20=D0=BA=D0=BE=D0=B4=D0=B0=D0=BC=D0=B8=20=D0=BA=D0=BE?= =?UTF-8?q?=D0=BD=D1=82=D1=80=D0=B0=D0=B3=D0=B5=D0=BD=D1=82=D0=BE=D0=B2,?= =?UTF-8?q?=20=C2=AB=D0=98=D0=A2=D0=9E=D0=93=20=D0=BF=D0=BE=20=E2=80=A6?= =?UTF-8?q?=C2=BB,=20=C2=AB=D0=97=D0=B0=20=D0=BF=D0=B5=D1=80=D0=B8=D0=BE?= =?UTF-8?q?=D0=B4=C2=BB=20=D1=81=20=D0=B4=D0=B8=D0=B0=D0=BF=D0=B0=D0=B7?= =?UTF-8?q?=D0=BE=D0=BD=D0=BE=D0=BC=20=C2=AB..=C2=BB=20(AURORA-1714,=20#71?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Решения владельца 27.10.2026 (docs #172): субсчёт с 3 цифрами в сегменте под счётом шапки, код контрагента + название + договоры (стратегия code_blocks), суммы субсчёта из «ИТОГ по …», «ИТОГ» — итог листа, «Поставщики» — вид субконто в таблице, метка «За период» со значением в соседней ячейке, диапазон «01.01.25..30.06.25», год из двух цифр. Синтетика s45 (период, организация) и s46 (блоки), спека osv-parsing. Co-Authored-By: Claude Opus 5.5 --- api/rules/osv_groups.yaml | 3 + api/rules/osv_headers.yaml | 10 ++ api/rules/periods.yaml | 12 +- api/src/parser/classify.py | 48 ++++++- api/src/parser/code_blocks.py | 127 ++++++++++++++++++ api/src/parser/detect.py | 21 +++ api/src/parser/header.py | 41 +++++- api/src/parser/hierarchy.py | 30 ++++- api/src/parser/osv.py | 4 + api/src/parser/osv_warnings.py | 1 + api/src/parser/rules.py | 14 ++ api/tests/synthetic/generate.py | 58 +++++++- api/tests/synthetic/test_generate.py | 4 +- api/tests/synthetic/test_synthetic_files.py | 10 +- api/tests/unit/parser/test_hierarchy.py | 4 +- .../unit/parser/test_osv_account_marker.py | 103 ++++++++++++++ openspec/specs/osv-parsing/spec.md | 11 ++ 17 files changed, 472 insertions(+), 29 deletions(-) create mode 100644 api/src/parser/code_blocks.py diff --git a/api/rules/osv_groups.yaml b/api/rules/osv_groups.yaml index 9509235..7468996 100644 --- a/api/rules/osv_groups.yaml +++ b/api/rules/osv_groups.yaml @@ -4,6 +4,9 @@ mapping: - ['^(счета?|субконто):?$', ACCOUNT] - ['^подразделени', DEPARTMENT] - ['^контрагент', COUNTERPARTY] + # вид субконто «Поставщики» (выгрузка 1С «ОСВ по счету 60 по контрагентам», строка внутри таблицы) — контрагенты; + # только целиком: название контрагента «Поставщики ООО» этой записью не узнаётся + - ['^поставщики$', COUNTERPARTY] # вид субконто «Организации» (плоская форма 1С, счёт 60) — контрагенты-организации (R-10, R-17); единственное число # «Организация» — собственная организация учёта (измерение шапки), контрагентом не является - ['^организации\b', COUNTERPARTY] diff --git a/api/rules/osv_headers.yaml b/api/rules/osv_headers.yaml index 116038a..17c89c4 100644 --- a/api/rules/osv_headers.yaml +++ b/api/rules/osv_headers.yaml @@ -50,6 +50,13 @@ total_row: # и отступов) не обрезал таблицу; нет такой — действует `total_row` (начало текста) total_row_exact: - '^итого$' + # выгрузка 1С «по контрагентам»: итог листа — «ИТОГ» (без «О»); «ИТОГ по 60.111» — итог субсчёта (subaccount_sums) + - '^итог$' + +# Итог субсчёта в выгрузке 1С «по контрагентам»: «ИТОГ по 60.111» — суммы строки-счёта с тем же кодом (группа account); +# отдельной строкой в результат не попадает (05-domain-rules.md §1.3, AURORA-1714) +subaccount_sums: + - '^итог\s+по\s+(?P\S+)$' # Вторая итоговая строка («Итого развернутое» в 1С): тоже TOTAL, но «Итого» листа — основная. Стоит сразу после основной # «Итого» или ПЕРЕД ней (плоская форма 1С, #59); сверяется основная @@ -83,6 +90,9 @@ header_markers: - '\bсчет\s+(поставщика|клиента)\b' # вариант 1: «… за 1 квартал 2025 г.», вариант 2: отдельная строка «Период: 1 квартал 2026 г.» period: + # выгрузка 1С «по контрагентам»: «За период» | «01.01.25..30.06.25» — значение в соседней ячейке строки; запись + # стоит первой: «\bза\s+» ниже совпала бы с «За период» и оставила бы хвостом слово «период» + - '^за\s+период\b\s*:?\s*' - '\bза\s+' - '^период\s*:\s*' # «Выводимые данные: БУ (данные бухгалтерского учета)» diff --git a/api/rules/periods.yaml b/api/rules/periods.yaml index 33480ee..cfad685 100644 --- a/api/rules/periods.yaml +++ b/api/rules/periods.yaml @@ -10,10 +10,10 @@ rules: - {pattern: '\b(3|iii)\s*(й|я|ый|ой)?\s*кв', quarter: Q3, is_cumulative: false} - {pattern: '\b(4|iv)\s*(й|я|ый|ой)?\s*кв', quarter: Q4, is_cumulative: false} # Нарастающие периоды: месяцы, диапазон месяцев, диапазон дат «01.01 - 31.03», «с 01.01.2025 по 31.03.2025» - - {pattern: '\b3\s*мес|январ\w*\s+(по\s+)?март|\b01\.01(\.\d{2,4})?(\s*г\.?)?\s+(по\s+)?31\.03', quarter: Q1, is_cumulative: true} - - {pattern: 'полугоди|\b6\s*мес|январ\w*\s+(по\s+)?июн|\b01\.01(\.\d{2,4})?(\s*г\.?)?\s+(по\s+)?30\.06', quarter: Q2, is_cumulative: true} - - {pattern: '\b9\s*мес|январ\w*\s+(по\s+)?сентябр|\b01\.01(\.\d{2,4})?(\s*г\.?)?\s+(по\s+)?30\.09', quarter: Q3, is_cumulative: true} - - {pattern: '\b12\s*мес|январ\w*\s+(по\s+)?декабр|\b01\.01(\.\d{2,4})?(\s*г\.?)?\s+(по\s+)?31\.12|годов|\bгод\b', quarter: Q4, is_cumulative: true} + - {pattern: '\b3\s*мес|январ\w*\s+(по\s+)?март|\b01\.01(\.\d{2,4})?(\s*г\.?)?(\s+(по\s+)?|\.\.)31\.03', quarter: Q1, is_cumulative: true} + - {pattern: 'полугоди|\b6\s*мес|январ\w*\s+(по\s+)?июн|\b01\.01(\.\d{2,4})?(\s*г\.?)?(\s+(по\s+)?|\.\.)30\.06', quarter: Q2, is_cumulative: true} + - {pattern: '\b9\s*мес|январ\w*\s+(по\s+)?сентябр|\b01\.01(\.\d{2,4})?(\s*г\.?)?(\s+(по\s+)?|\.\.)30\.09', quarter: Q3, is_cumulative: true} + - {pattern: '\b12\s*мес|январ\w*\s+(по\s+)?декабр|\b01\.01(\.\d{2,4})?(\s*г\.?)?(\s+(по\s+)?|\.\.)31\.12|годов|\bгод\b', quarter: Q4, is_cumulative: true} # Год: «за 2024 г.», «2024 год»; год в конце даты («31.03.2025 г.») годом периода не считается - {pattern: '(?\d{2})(?![\d.]*\d)' + # Конец текста периода в строке шапки: «за 3 квартал 2024 г. (по подразделениям)» -> «3 квартал 2024 г.»; # без совпадения период идёт до конца строки end: '\bг\.|\bгода?\b' diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index aa35132..79e94c9 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -21,14 +21,21 @@ import re from collections.abc import Mapping, Sequence from collections.abc import Set as AbstractSet -from dataclasses import dataclass +from dataclasses import dataclass, field from decimal import Decimal from typing import Literal from src.contract.common import DataType from src.contract.result import OsvGroup from src.parser.deadline import Deadline -from src.parser.detect import GROUP_ORDER, ColumnMap, TableHeader, first_data_row, is_header_repeat +from src.parser.detect import ( + GROUP_ORDER, + ColumnMap, + TableHeader, + first_data_row, + is_header_repeat, + table_kind_row, +) from src.parser.grid import Sheet, column_letter from src.parser.hierarchy import BodyRow, Levels from src.parser.indicators import is_indicator_total, read_label @@ -37,6 +44,7 @@ from src.parser.rules import GroupType, Rules __all__ = [ "ACCOUNT_RE", + "match_account", "DEADLINE_CHECK_EVERY_ROWS", "SUM_FIELDS", "ClassifiedRow", @@ -53,7 +61,20 @@ __all__ = [ # `[А-ЯЁA-Z]` флаг не трогает), re.DOTALL — название после запятой может занимать несколько строк ячейки # («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) +# Субсчёт с тремя цифрами в сегменте («60.111», «60.243»): счёт, только если код начинается со счёта шапки (§1.3, +# AURORA-1714); без счёта шапки действует `ACCOUNT_RE` +_WIDE_ACCOUNT_RE = re.compile(r"^(\d{2,3}(?:\.\d{1,3}){1,2})(?:,\s*(.*))?$", re.ASCII | re.DOTALL) _SEGMENT_SEPARATOR = "." + + +def match_account(text: str, header_account: str | None = None) -> re.Match[str] | None: + """Код счёта в тексте строки: `ACCOUNT_RE`, а при счёте шапки — и его субсчета с 1–3 цифрами в сегменте.""" + found = ACCOUNT_RE.match(text) + if found is not None or not header_account or not text.startswith(f"{header_account}{_SEGMENT_SEPARATOR}"): + return found + return _WIDE_ACCOUNT_RE.match(text) + + # Сдвиг на одну колонку текста в «индентах» строки: больше любого отступа ячейки LABEL_COLUMN_INDENT_STEP = 1000 # Строка итога без подписи возможна, только если над ней есть хотя бы одна строка данных @@ -101,7 +122,10 @@ def _table_rows(sheet: Sheet, header: TableHeader, rules: Rules, deadline: Deadl Строка из одних пробелов пустая: `Sheet` не хранит такие ячейки в строках, а повтор заголовка — не данные. """ found: list[int] = [] + kind_row = table_kind_row(sheet, header, rules) # вид субконто строкой в таблице — не данные for index, row in enumerate(range(first_data_row(sheet, header), sheet.n_rows + 1), start=1): + if kind_row is not None and row == kind_row[0]: + continue if index % DEADLINE_CHECK_EVERY_ROWS == 0: deadline.check("строки таблицы") if sheet.is_row_empty(row) or not _in_table(sheet, row, header): @@ -402,6 +426,8 @@ class _Context: currency_codes: frozenset[str] inns: Mapping[int, str] unpaired: AbstractSet[int] + header_account: str | None = None + names: Mapping[int, str] = field(default_factory=dict) # названия контрагентов, склеенные с кодом (`code_blocks`) def _schema_group(schema: Sequence[OsvGroup], position: int) -> tuple[GroupType, str | None]: @@ -419,7 +445,7 @@ def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple return _Kind("OTHER", None, code, item.label_data_type, indicator=True), inherited # ASSUMPTION(OQ-45): `data_type` шапки (в том числе НУ) — как есть; метка строки («БУ») важнее шапки own = item.label_data_type or context.doc_data_type - account = ACCOUNT_RE.match(item.text) if item.is_account else None + account = match_account(item.text, context.header_account) if item.is_account else None if account is not None: code = account.group(1) _, name = _schema_group(context.schema, _ACCOUNT_POSITION) @@ -443,12 +469,14 @@ def _classify_row(item: BodyRow, inherited: _Branch, context: _Context) -> tuple ) if by_account: group_type = "COUNTERPARTY" + if group_type == "OTHER" and name is None and item.role == "contract": + group_type = "CONTRACT" # договор контрагента в блоке субсчёта с кодами контрагентов (§1.3, `code_blocks`) if group_type != "COUNTERPARTY": return _Kind(group_type, name, code, own), branch inn_col = context.header.inn_col column = None if inn_col is None else column_inn(context.sheet.cell(item.row, inn_col), context.rules) # ИНН: колонка ИНН, затем строка ИНН рядом (`inn_rows`, #35), затем хвост самого названия (OQ-100) - counterparty, in_name = split_inn_suffix(item.text, context.rules) + counterparty, in_name = split_inn_suffix(context.names.get(item.row, item.text), context.rules) inn = column or context.inns.get(item.row) or in_name kind = _Kind( group_type, @@ -475,6 +503,7 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta inns: Mapping[int, str] | None = None, unpaired: AbstractSet[int] = frozenset(), header_account: str | None = None, + names: Mapping[int, str] | None = None, ) -> list[ClassifiedRow]: """Классификация строк по дереву `levels` и схеме группировок. @@ -494,7 +523,16 @@ def classify_rows( # noqa: PLR0913, PLR0917 - интерфейс плана (Ta получают его код. """ context = _Context( - schema, header, sheet, doc_data_type, rules, frozenset(rules.data_types.currency_codes), inns or {}, unpaired + schema, + header, + sheet, + doc_data_type, + rules, + frozenset(rules.data_types.currency_codes), + inns or {}, + unpaired, + header_account, + names or {}, ) extra_keys = _extra_keys(sheet, header, rows) root_branch = _Branch(header_account, 0) diff --git a/api/src/parser/code_blocks.py b/api/src/parser/code_blocks.py new file mode 100644 index 0000000..b4bfd9c --- /dev/null +++ b/api/src/parser/code_blocks.py @@ -0,0 +1,127 @@ +"""Блоки субсчетов с кодами контрагентов (`05-domain-rules.md` §1.3, AURORA-1714, решение владельца 27.10.2026). + +Выгрузка 1С «ОСВ по счету 60 по контрагентам»: строка-субсчёт без сумм (`60.111`), внутри — код контрагента с суммами, +строка названия без сумм и договоры с суммами, в конце блока — «ИТОГ по 60.111». Шаг работает над строками тела между +сбором строк (`collect_body_rows`) и иерархией: + +1. «ИТОГ по <субсчёт>» (`subaccount_sums`, словарь) отдаёт шесть сумм строке-счёту с тем же кодом, у которой сумм нет, и + сама из тела исчезает (в `rows` контракта отдельной строкой не выдаётся); +2. в блоке такого субсчёта строка с суммами, за которой идёт строка без сумм, — контрагент (`role = counterparty`); + следующая за ней строка без сумм — его название: склеивается с ним (`names`) и из тела исчезает; прочие строки с + суммами после контрагента — его договоры (`role = contract`). + +Без «ИТОГ по …», сопоставленного субсчёту, шаг ничего не меняет: раскладка узнаётся по ним. Координат и имён нет — +только суммы, порядок строк и словарь. +""" + +from collections.abc import Sequence +from dataclasses import dataclass, field, replace + +from src.parser.classify import match_account +from src.parser.hierarchy import BodyRow, Role +from src.parser.rules import Rules, normalize_text + +__all__ = ["CodeBlocks", "apply_code_blocks"] + + +@dataclass(frozen=True) +class CodeBlocks: + """Тело таблицы после шага: строки, названия контрагентов по номеру строки с кодом, признак «раскладка найдена».""" + + rows: list[BodyRow] + names: dict[int, str] = field(default_factory=dict) + applied: bool = False + + +def _has_sums(item: BodyRow) -> bool: + return any(value is not None for value in item.sums) + + +def _subtotal_code(item: BodyRow, rules: Rules) -> str | None: + """Код субсчёта из «ИТОГ по 60.111» (в нормализованном виде) или `None`, если строка — не итог субсчёта.""" + for pattern in rules.headers.subaccount_sums: + match = pattern.search(item.text) + if match is not None: + return match.group("account") + return None + + +def _mark_wide_subaccounts(body: Sequence[BodyRow], header_account: str) -> list[BodyRow]: + """Субсчёт с тремя цифрами в сегменте («60.111» при шапке «60») — строка-счёт (§1.3, правило 1).""" + result: list[BodyRow] = [] + for item in body: + under_header = bool(header_account) and item.text.startswith(f"{header_account}.") + found = match_account(item.text, header_account) if under_header and not item.is_account else None + if found is None: + result.append(item) + else: + result.append(replace(item, is_account=True, account_rank=found.group(1).count(".") + 1)) + return result + + +def _block_roles(block: Sequence[BodyRow]) -> tuple[dict[int, Role], dict[int, str], set[int]]: + """Роли строк блока: `{строка: роль}`, названия `{строка кода: текст}` и номера склеенных (убираемых) строк.""" + roles: dict[int, Role] = {} + names: dict[int, str] = {} + glued: set[int] = set() + has_counterparty = False + index = 0 + while index < len(block): + item = block[index] + if item.indicator or not _has_sums(item): + index += 1 + continue + following = block[index + 1] if index + 1 < len(block) else None + if following is not None and not following.indicator and not _has_sums(following) and following.text.strip(): + roles[item.row] = "counterparty" + names[item.row] = following.text + glued.add(following.row) + has_counterparty = True + index += 2 + continue + if has_counterparty: + roles[item.row] = "contract" + index += 1 + return roles, names, glued + + +def apply_code_blocks(body: Sequence[BodyRow], total_row: int | None, rules: Rules, header_account: str) -> CodeBlocks: + """Итоги субсчетов -> суммы строк-счетов; роли и названия в блоках (см. модуль). Без счёта шапки и без пар + «субсчёт — ИТОГ по субсчёту» тело возвращается как есть.""" + rows = _mark_wide_subaccounts(body, header_account) + if not header_account or not rules.headers.subaccount_sums: + return CodeBlocks(rows) + data = [item for item in rows if total_row is None or item.row < total_row] + accounts = {normalize_text(item.text): item for item in data if item.is_account and not _has_sums(item)} + sums_of: dict[int, tuple] = {} + dropped: set[int] = set() + for item in data: + code = _subtotal_code(item, rules) + account = None if code is None else accounts.get(code) + if account is not None and account.row < item.row and account.row not in sums_of: + sums_of[account.row] = item.sums + dropped.add(item.row) + if not sums_of: + return CodeBlocks(rows) + roles: dict[int, Role] = {} + names: dict[int, str] = {} + block: list[BodyRow] = [] + block_open = False + for current in [*(item for item in data if item.row not in dropped), None]: + if current is None or current.is_account: + if block_open: + block_roles, block_names, glued = _block_roles(block) + roles.update(block_roles) + names.update(block_names) + dropped |= glued + block, block_open = [], current is not None and current.row in sums_of + continue + block.append(current) + result: list[BodyRow] = [] + for source in rows: + if source.row in dropped: + continue + sums = sums_of.get(source.row, source.sums) + role = roles.get(source.row) + result.append(replace(source, sums=sums, role=role) if (sums is not source.sums or role) else source) + return CodeBlocks(result, names, applied=True) diff --git a/api/src/parser/detect.py b/api/src/parser/detect.py index 5e1359b..fade3e9 100644 --- a/api/src/parser/detect.py +++ b/api/src/parser/detect.py @@ -33,6 +33,7 @@ __all__ = [ "inn_from_cell", "is_header_repeat", "is_osv_sheet", + "table_kind_row", ] CollapsedGroup = Literal["opening", "closing"] @@ -504,6 +505,26 @@ def first_data_row(sheet: Sheet, header: TableHeader) -> int: return next(_data_rows(sheet, header.last_row), sheet.n_rows + 1) +def table_kind_row(sheet: Sheet, header: TableHeader, rules: Rules) -> tuple[int, str] | None: + """Вид субконто строкой внутри таблицы (выгрузка 1С «по контрагентам», §1.1): первая строка данных — одна текстовая + ячейка вне колонки текста, а текст — вид субконто, который словарь групп знает явной записью («Поставщики»). + + Возвращает номер строки и текст. Строка данных не является: суммы ей не читаются. Текст в колонке текста + («Поставщики» как название контрагента) под правило не попадает. + """ + row = first_data_row(sheet, header) + if row > sheet.n_rows: + return None + cells = list(sheet.row_cells(row)) + if len(cells) != 1: + return None + col, cell = cells[0] + if col == header.text_col or cell.kind != "text": + return None + known = rules.groups.known(cell.text) + return (row, cell.text) if known is not None and known != "ACCOUNT" else None + + def _signature(sheet: Sheet, row: int, right_edge: int) -> tuple[tuple[int, str], ...]: return tuple((col, text) for col, text in _texts(sheet, row) if col <= right_edge) diff --git a/api/src/parser/header.py b/api/src/parser/header.py index 698ea3e..e45d042 100644 --- a/api/src/parser/header.py +++ b/api/src/parser/header.py @@ -15,7 +15,7 @@ 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.detect import TableHeader, table_kind_row from src.parser.grid import Sheet from src.parser.rules import GroupType, MonthRange, Pattern, Rules, first_match, source_offset @@ -149,6 +149,7 @@ def _currency_cells(cells: list[_HeadCell], rules: Rules) -> list[_HeadCell]: class _PeriodHit: cell: _HeadCell text: str + value_cell: _HeadCell | None = None # значение в соседней ячейке («За период» | «01.01.25..30.06.25») def _cut_month_range(tail: str, rules: Rules) -> str | None: @@ -190,9 +191,17 @@ def _cut_period(tail: str, rules: Rules) -> str: return tail +def _next_cell(cells: list[_HeadCell], cell: _HeadCell) -> _HeadCell | None: + """Ближайшая справа текстовая ячейка той же строки шапки (значение метки «За период» | «01.01.25..30.06.25»).""" + right = [other for other in cells if other.row == cell.row and other.col > cell.col] + return min(right, key=lambda other: other.col, default=None) + + def _period_candidates(cells: list[_HeadCell], rules: Rules) -> list[_PeriodHit]: """Ячейки с меткой периода («за …», «Период: …») и текст после метки; пустой текст — не кандидат. + Метка без текста («За период» | значение в соседней ячейке) берёт значение справа: ячейка значения запоминается, + организацией она не считается. Какой из кандидатов — период, решает `_find_period`: «за» в названии организации не должно его перехватывать. """ hits: list[_PeriodHit] = [] @@ -203,11 +212,15 @@ def _period_candidates(cells: list[_HeadCell], rules: Rules) -> list[_PeriodHit] tail = _tail(cell.text, match) if tail: hits.append(_PeriodHit(cell, _cut_period(tail, rules))) + continue + value = _next_cell(cells, cell) + if value is not None: + hits.append(_PeriodHit(cell, _cut_period(value.text, rules), value)) return hits 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 + return rules.periods.match(text) is not None or rules.periods.year_of(text) is not None def _standalone_period(cells: list[_HeadCell], rules: Rules) -> _PeriodHit | None: @@ -280,6 +293,19 @@ def _chart_of_accounts_tail(cells: list[_HeadCell], rules: Rules) -> str | None: return None +def _account_value_cells(cells: list[_HeadCell], rules: Rules) -> list[_HeadCell]: + """Ячейки со значением метки счёта в соседней ячейке («Счет Поставщика» | «60*»): организацией не считаются.""" + found: list[_HeadCell] = [] + for cell in cells: + match = _find(rules.headers.header_markers["account"], cell.text) + if match is None or _ACCOUNT_CODE.match(_tail(cell.text, match).lstrip(": ")) is not None: + continue + value = _next_cell(cells, cell) + if value is not None: + found.append(value) + return found + + def _organization( cells: list[_HeadCell], rules: Rules, period: _PeriodHit | None, data_type: _HeadCell | None ) -> str | None: @@ -293,9 +319,11 @@ def _organization( cell for cell in ( None if period is None else period.cell, + None if period is None else period.value_cell, data_type, *_currency_cells(cells, rules), *_kind_row_cells(cells, rules), + *_account_value_cells(cells, rules), ) if cell ] @@ -424,14 +452,13 @@ def _parse_period(text: str, rules: Rules, warnings: list[str]) -> _Period: if span is not None: return _month_range_period(span, text, warnings) rule = rules.periods.match(text) - year = rules.periods.year.search(text) + year_value = rules.periods.year_of(text) if rule is None: warnings.append(f"период «{text}»: квартал и признак нарастающего итога не определены по словарю периодов") - if year is None: + if year_value is None: warnings.append(f"период «{text}»: год не найден") # ASSUMPTION(OQ-02): is_cumulative=false для Q2–Q4 определяется по тексту «N квартал»; ядро может переопределить # по данным (opening Q3 == opening Q1 -> нарастающая) - year_value = None if year is None else int(year.group(0)) quarter = None if rule is None else rule.quarter is_cumulative = None if rule is None else rule.is_cumulative start, end = _bounds(year_value, quarter, is_cumulative) @@ -584,6 +611,10 @@ def _variant1_groups(sheet: Sheet, header: TableHeader, rules: Rules) -> GroupSc groups = [_group("ACCOUNT")] if len(groups) == 1: groups.extend(_kind_row_groups(sheet, header, rules)) + if len(groups) == 1: + table_kind = table_kind_row(sheet, header, rules) + if table_kind is not None: + groups.append(_group(rules.groups.classify(table_kind[1]), table_kind[1])) return GroupSchema(groups, 1, hint) diff --git a/api/src/parser/hierarchy.py b/api/src/parser/hierarchy.py index 295029e..202ee87 100644 --- a/api/src/parser/hierarchy.py +++ b/api/src/parser/hierarchy.py @@ -12,7 +12,7 @@ from collections.abc import Iterator, Mapping, Sequence from dataclasses import dataclass from decimal import Decimal -from typing import Protocol +from typing import Literal, Protocol from src.contract.common import DataType from src.parser.deadline import Deadline @@ -47,6 +47,9 @@ DEADLINE_CHECK_EVERY = 500 _ZERO = Decimal(0) +Role = Literal["counterparty", "contract"] + + @dataclass(frozen=True) class BodyRow: """Строка данных до классификации: текст, признак счёта, оформление и шесть сумм (по `ColumnMap`).""" @@ -62,6 +65,8 @@ class BodyRow: label_col: int | None = None # колонка, в которой стоит текст строки (она не всегда колонка текста таблицы) indicator: bool = False # строка-показатель (§1.3): вид сумм предыдущей строки данных, не уровень аналитики label_data_type: DataType | None = None # вид данных по метке строки (колонка «Показатели» или «Вал.»/«Кол.») + # Роль в блоке субсчёта с кодами контрагентов (`code_blocks`, §1.3): контрагент (код с суммами) или его договор + role: Role | None = None @dataclass(frozen=True) @@ -174,6 +179,28 @@ def strategy_account_pattern(rows: Sequence[BodyRow], deadline: Deadline | None return by_row +def strategy_code_blocks(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None: + """Блоки субсчетов с кодами контрагентов (§1.3): субсчёт -> контрагент -> договор. + + Применима, только если `code_blocks.apply_code_blocks` назначил строкам роли. Субсчёт — по рангу кода (как в + `account_pattern`), контрагент — на уровень глубже субсчёта, договор — ещё глубже, прочие строки — дети субсчёта. + """ + if not any(item.role for item in rows): + return None + ranks = [item.account_rank for item in rows if item.is_account and item.account_rank is not None] + base = min(ranks) if ranks else 0 + by_row: dict[int, int] = {} + account_level: int | None = None + for item in _paced(rows, deadline, "иерархия (code_blocks)"): + if item.is_account and item.account_rank is not None: + account_level = item.account_rank - base + by_row[item.row] = account_level + continue + child_level = ROOT_LEVEL if account_level is None else account_level + 1 + by_row[item.row] = child_level + 1 if item.role == "contract" else child_level + return by_row + + def _vector(item: BodyRow) -> tuple[Decimal, ...]: return tuple(_ZERO if value is None else value for value in item.sums) @@ -247,6 +274,7 @@ def strategy_sums(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> STRATEGIES: tuple[tuple[str, Strategy], ...] = ( ("outline", strategy_outline), ("indent", strategy_indent), + ("code_blocks", strategy_code_blocks), ("account_pattern", strategy_account_pattern), ("sums", strategy_sums), ) diff --git a/api/src/parser/osv.py b/api/src/parser/osv.py index 583571e..2173055 100644 --- a/api/src/parser/osv.py +++ b/api/src/parser/osv.py @@ -15,6 +15,7 @@ from src.contract.common import DataType from src.contract.result import DocumentStatus, OsvChecks, OsvGroup, OsvHeader, OsvRow from src.parser.checks import run_checks from src.parser.classify import SUM_FIELDS, ClassifiedRow, classify_rows, collect_body_rows +from src.parser.code_blocks import apply_code_blocks from src.parser.deadline import Deadline from src.parser.detect import TableHeader, find_table_header from src.parser.errors import InvalidFormatError @@ -212,6 +213,8 @@ class OsvParser: step = "строки таблицы" body, total_row, body_warnings = collect_body_rows(sheet, header, rules, deadline) body = _without_foreign_bare_codes(body, doc.account_code_raw) + blocks = apply_code_blocks(body, total_row, rules, doc.account_code_raw) + body = blocks.rows step = "иерархия строк" levels = levels_with_indicators(body, total_row, deadline, header_account=bool(doc.account_code_raw)) step = "строки ИНН" @@ -243,6 +246,7 @@ class OsvParser: inns=glue.inns, unpaired=glue.unpaired, header_account=doc.account_code_raw or None, + names=blocks.names, ) except InvalidFormatError as exc: raise _invalid_format(sheet, step, exc) from exc diff --git a/api/src/parser/osv_warnings.py b/api/src/parser/osv_warnings.py index b0d07ad..e89153e 100644 --- a/api/src/parser/osv_warnings.py +++ b/api/src/parser/osv_warnings.py @@ -44,6 +44,7 @@ HIERARCHY_TEXTS = { "outline": "по группировке листа (outline)", "indent": "по отступам (indent)", "account_pattern": "по кодам счетов (account_pattern)", + "code_blocks": "блоки субсчетов с кодами контрагентов (code_blocks)", "header_account": "плоский список под счётом шапки (header_account)", "sums": "по суммам (sums)", "trivial": "плоский список (trivial)", diff --git a/api/src/parser/rules.py b/api/src/parser/rules.py index 037cd30..f08bad0 100644 --- a/api/src/parser/rules.py +++ b/api/src/parser/rules.py @@ -155,6 +155,8 @@ class OsvHeaderRules(_Model): total_row: list[Pattern] total_row_exact: list[Pattern] total_row_secondary: list[Pattern] + # Итог субсчёта «ИТОГ по 60.111» (группа account — код субсчёта); пусто — правило не действует + subaccount_sums: list[Pattern] = [] column_captions: list[Pattern] counterparty_inn_suffix: list[Pattern] inn_only_row: list[Pattern] @@ -176,6 +178,7 @@ class OsvHeaderRules(_Model): ("counterparty_inn_suffix", {"tail", "inn"}), ("inn_only_row", {"inn"}), ("inn_kpp_cell", {"inn"}), + ("subaccount_sums", {"account"}), ): for pattern in getattr(self, field_name): missing_groups = groups - pattern.group_names @@ -243,6 +246,7 @@ class PeriodRule(_Model): _MONTHS_IN_YEAR = 12 +_CENTURY = 2000 # двузначный год даты «…25» — 2025 _RANGE_GROUPS = frozenset({"m1", "y1", "m2", "y2"}) _MONTH_GROUPS = frozenset(f"m{number:02d}" for number in range(1, _MONTHS_IN_YEAR + 1)) @@ -265,6 +269,8 @@ class MonthRange: class PeriodRules(_Model): rules: list[PeriodRule] year: Pattern + # Двузначный год даты (группа yy), если года из четырёх цифр в тексте нет + short_year_date: Pattern end: Pattern # Названия месяцев: одна запись с именованными группами m01..m12; диапазон месяцев с годами — группы m1, y1, m2, y2 months: Pattern @@ -329,6 +335,14 @@ class PeriodRules(_Model): return None return MonthRange(month, int(year.group(0)), month, int(year.group(0))) + def year_of(self, text: str) -> int | None: + """Год периода: четыре цифры (`year`), иначе двузначный год даты конца диапазона «01.01.25..30.06.25» (20yy).""" + found = self.year.search(text) + if found is not None: + return int(found.group(0)) + short = self.short_year_date.search(text) + return None if short is None else _CENTURY + int(short.group("yy")) + def match(self, text: str) -> PeriodRule | None: """Первое правило, паттерн которого совпал с текстом периода; порядок в YAML — от специфичных к общим.""" for rule in self.rules: diff --git a/api/tests/synthetic/generate.py b/api/tests/synthetic/generate.py index 95b4219..7be1702 100644 --- a/api/tests/synthetic/generate.py +++ b/api/tests/synthetic/generate.py @@ -4,9 +4,10 @@ ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9), s20…s27 — восемь раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35), s29…s32 — xls без CODEPAGE, «Вал»/«Кол-во», строки ниже «Итого» и «Итого по счету» вместо точного «Итого» (#47), s40…s44 — плоская форма ОСВ 1С (#59; s33…s39 заняты -другим треком), s45 — счёт шапки «Счет Поставщика» | «60*» в соседних ячейках (#71); описание — -`osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен (openpyxl + `xlsb_writer` + `xls_writer`) и -детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок записи фиксирован. +другим треком), s45 — счёт шапки «Счет Поставщика» | «60*» в соседних ячейках, s46 — выгрузка «по контрагентам» с +блоками субсчетов (#71); описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен +(openpyxl + `xlsb_writer` + `xls_writer`) и детерминирован: повторный запуск даёт те же байты — метки времени книги +и zip постоянны, порядок записи фиксирован. Суммы арифметически согласованы по построению: у листа дерева сальдо на конец вычисляется из начала и оборотов, у родителя — сумма детей по колонкам; поэтому все файлы, кроме `s10_broken_sum`, сходятся по §1.4. Позиции колонок и @@ -972,6 +973,15 @@ def _s44() -> list[SheetSpec]: return _s43_s44("10", "10.01", ("Мука пшеничная", "Сахар-песок")) +def _put_by_counterparty_head(sheet: SheetSpec) -> None: + """Первые строки выгрузки 1С «по контрагентам»: организация, название, «За период | диапазон» (s45, s46).""" + sheet.put(1, 1, 'ООО "Тестовая организация"') + sheet.put(1, 2, "Оборотно-сальдовая ведомость") + sheet.put(2, 1, "15 июля 2025(18:40)") + sheet.put(2, 2, "За период") + sheet.put(2, 3, "01.01.25..30.06.25") + + def _s45() -> list[SheetSpec]: """Выгрузка 1С «ОСВ по счёту 60 по контрагентам» (#71, AURORA-1714): счёт в шапке — «Счет Поставщика» | «60*» в соседних ячейках («Счет Клиента» без значения), без слов «по счету»; в первой колонке голые числовые коды @@ -982,14 +992,51 @@ def _s45() -> list[SheetSpec]: ("199", (None, "50.00", "50.00", None, None, None), 0), ("Итого", (None, "108250.00", "108250.00", None, None, None), 0), ] - sheet = _flat_sheet({1: "Оборотно-сальдовая ведомость", 2: "За период"}, 6, rows) - sheet.put(2, 3, "01.01.25..30.06.25") + sheet = _flat_sheet({}, 6, rows) + _put_by_counterparty_head(sheet) sheet.put(3, 2, "Счет Поставщика") sheet.put(3, 3, "60*") sheet.put(4, 2, "Счет Клиента") return [sheet] +def _s46() -> list[SheetSpec]: + """Выгрузка 1С «ОСВ по счёту 60 по контрагентам» целиком (#71, AURORA-1714): «За период | 01.01.25..30.06.25», + «Счет Поставщика | 60*», вид субконто «Поставщики» в колонке суммы первой строки данных; блоки субсчетов 60.111 и + 60.112 — код контрагента с суммами, строка названия без сумм, договоры (в том числе «441»), «ИТОГ по <субсчёт>», + в конце «ИТОГ».""" + table: list[tuple[str, tuple[str | None, ...]]] = [ + ("60.111", (None,) * 6), + ("П005482", (None, "100.00", "100.00", None, None, None)), + ("ВЕСТ БИР ООО", (None,) * 6), + ("ДП87825;З-139/21-1", (None, "100.00", "100.00", None, None, None)), + ("П002913", (None, "50.00", "70.00", "20.00", None, None)), + ("М ДИСТРИБЬЮШН", (None,) * 6), + ("ДП60469;З-46/20", (None, "20.00", "30.00", "10.00", None, None)), + ("441", (None, "30.00", "40.00", "10.00", None, None)), + ("ИТОГ по 60.111", (None, "150.00", "170.00", "20.00", None, None)), + ("60.112", (None,) * 6), + ("П003258", (None, "10.00", "10.00", None, None, None)), + ('ООО "РОМАШКА"', (None,) * 6), + ("ДП1;1", (None, "10.00", "10.00", None, None, None)), + ("ИТОГ по 60.112", (None, "10.00", "10.00", None, None, None)), + ("ИТОГ", (None, "160.00", "180.00", "20.00", None, None)), + ] + sheet = _flat_sheet({}, 6, []) + _put_by_counterparty_head(sheet) + sheet.put(3, 2, "Счет Поставщика") + sheet.put(3, 3, "60*") + sheet.put(4, 2, "Счет Клиента") + sheet.put(8, 3, "Поставщики") + for offset, (text, sums) in enumerate(table): + row = 9 + offset + sheet.put(row, 1, text) + for col, value in enumerate(sums, start=2): + if value is not None: + sheet.put(row, col, D(value)) + return [sheet] + + def _s28() -> list[SheetSpec]: """Раскладка s20 («над, не в дереве, без сумм»), но ИНН ООО Альфа с опечаткой: контрольная сумма не сходится.""" tree = _inn_layout_tree() @@ -1104,6 +1151,7 @@ FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSp "s43_subkonto_unnamed_60.xlsx": (_s43, to_xlsx), "s44_subkonto_unnamed_10.xlsx": (_s44, to_xlsx), "s45_account_label_value_cells.xlsx": (_s45, to_xlsx), + "s46_counterparty_code_blocks.xlsx": (_s46, to_xlsx), } FILE_NAMES = tuple(FILES) diff --git a/api/tests/synthetic/test_generate.py b/api/tests/synthetic/test_generate.py index 4d82eb2..83a9142 100644 --- a/api/tests/synthetic/test_generate.py +++ b/api/tests/synthetic/test_generate.py @@ -26,13 +26,13 @@ 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_s36_and_s40_to_s45_with_one_xlsb(tmp_path: Path) -> None: +def test_generated_set_is_s01_to_s36_and_s40_to_s46_with_one_xlsb(tmp_path: Path) -> None: names = [path.name for path in generate_all(tmp_path)] assert names == list(FILE_NAMES) assert [re.match(r"s(\d\d)_", name) is not None for name in names] == [True] * len(names) # s33…s36 — строки-показатели (AURORA-1613, #58), плоская форма ОСВ 1С (#59) — s40…s44 - numbers = [*range(1, 37), *range(40, 46)] + numbers = [*range(1, 37), *range(40, 47)] assert [name[1:3] for name in names] == [f"{number:02d}" for number in numbers] assert [name for name in names if name.endswith(".xlsb")] == ["s13_no_outline_indent.xlsb"] assert sorted(path.name for path in tmp_path.iterdir()) == sorted(names) diff --git a/api/tests/synthetic/test_synthetic_files.py b/api/tests/synthetic/test_synthetic_files.py index ddae103..7db7c77 100644 --- a/api/tests/synthetic/test_synthetic_files.py +++ b/api/tests/synthetic/test_synthetic_files.py @@ -51,6 +51,7 @@ FLAT_FORM_FILES = ( "s43_subkonto_unnamed_60.xlsx", "s44_subkonto_unnamed_10.xlsx", "s45_account_label_value_cells.xlsx", + "s46_counterparty_code_blocks.xlsx", ) TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28 EXPECTED_NAMES = [ @@ -184,11 +185,10 @@ EXPECTED: dict[str, Expect] = { "s44_subkonto_unnamed_10.xlsx": Expect( "indent", 2024, "Q3", True, data_type=None, organization='ООО "Октябрьское"' ), - # #71: «Счет Поставщика | 60*». Период «За период | 01.01.25..30.06.25» не распознан, а «За период» принимается за - # организацию — известное ограничение (O-2), зафиксировано как есть - "s45_account_label_value_cells.xlsx": Expect( - "header_account", None, None, None, data_type=None, organization="За период" - ), + # #71: «Счет Поставщика | 60*», «За период | 01.01.25..30.06.25» -> 2025, Q2, нарастающий + "s45_account_label_value_cells.xlsx": Expect("header_account", 2025, "Q2", True, data_type=None), + # #71: выгрузка «по контрагентам» целиком: блоки субсчетов, коды контрагентов, договоры, «ИТОГ по …», «ИТОГ» + "s46_counterparty_code_blocks.xlsx": Expect("code_blocks", 2025, "Q2", True, data_type=None), } diff --git a/api/tests/unit/parser/test_hierarchy.py b/api/tests/unit/parser/test_hierarchy.py index e4ce1e9..8b7db95 100644 --- a/api/tests/unit/parser/test_hierarchy.py +++ b/api/tests/unit/parser/test_hierarchy.py @@ -78,8 +78,8 @@ def never_expires() -> Deadline: # --- Каскад: какая стратегия выбирается --- -def test_strategies_are_ordered_outline_indent_account_pattern_sums() -> None: - assert [name for name, _ in STRATEGIES] == ["outline", "indent", "account_pattern", "sums"] +def test_strategies_are_ordered_outline_indent_code_blocks_account_pattern_sums() -> None: + assert [name for name, _ in STRATEGIES] == ["outline", "indent", "code_blocks", "account_pattern", "sums"] def test_outline_levels_win_first() -> None: diff --git a/api/tests/unit/parser/test_osv_account_marker.py b/api/tests/unit/parser/test_osv_account_marker.py index 6b600cb..72acd90 100644 --- a/api/tests/unit/parser/test_osv_account_marker.py +++ b/api/tests/unit/parser/test_osv_account_marker.py @@ -39,6 +39,10 @@ def _book(label: str, empty_label: str) -> bytes: ["199", 0, 50, 50, 0, 0, 0], ["Итого", 0, 350, 350, 0, 0, 0], ] + return _xlsx(rows) + + +def _xlsx(rows: list[list[object]]) -> bytes: workbook = openpyxl.Workbook() sheet = workbook.active assert sheet is not None @@ -51,6 +55,37 @@ def _book(label: str, empty_label: str) -> bytes: return buffer.getvalue() +def _blocks_book() -> bytes: + """Выгрузка 1С «ОСВ по счёту 60 по контрагентам»: субсчета 60.111/60.112, код контрагента с суммами, строка названия + без сумм, договоры с суммами (в том числе с числовым названием «441»), «ИТОГ по <субсчёт>» и «ИТОГ».""" + rows: list[list[object]] = [ + ["МВС2009", "Оборотно-сальдовая ведомость"], + ["15 июля 2025(18:40)", "За период", "01.01.25..30.06.25"], + [None, "Счет Поставщика", "60*"], + [None, "Счет Клиента", None], + [], + ["Субконто", "Начальное сальдо", None, "Обороты", None, "Конечное сальдо", None], + [None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], + [None, None, "Поставщики"], + ["60.111"], + ["П005482", 0, 100, 100, 0, 0, 0], + ["ВЕСТ БИР ООО"], + ["ДП87825;З-139/21-1", 0, 100, 100, 0, 0, 0], + ["П002913", 0, 50, 70, 20, 0, 0], + ["М ДИСТРИБЬЮШН"], + ["ДП60469;З-46/20", 0, 20, 30, 10, 0, 0], + ["441", 0, 30, 40, 10, 0, 0], + ["ИТОГ по 60.111", 0, 150, 170, 20, 0, 0], + ["60.112"], + ["П003258", 0, 10, 10, 0, 0, 0], + ['ООО "РОМАШКА"'], + ["ДП1;1", 0, 10, 10, 0, 0, 0], + ["ИТОГ по 60.112", 0, 10, 10, 0, 0, 0], + ["ИТОГ", 0, 160, 180, 20, 0, 0], + ] + return _xlsx(rows) + + def _document(rules: Rules, data: bytes) -> OsvDocument: statement_rules = default_rules() parser = BookParser( @@ -79,3 +114,71 @@ def test_account_from_label_and_value_in_separate_cells(rules: Rules, label: str assert [item.row_value for item in data] == ["441", "431", "199"] assert {item.group_type for item in data} == {"COUNTERPARTY"} assert {item.account_code_raw for item in data} == {"60"} + + +def test_period_from_label_cell_and_double_dot_range(rules: Rules) -> None: + """«За период | 01.01.25..30.06.25»: 2025, Q2, нарастающий; метка и значение не становятся организацией.""" + header = _document(rules, _blocks_book()).header + + assert (header.period_year, header.period_quarter, header.is_cumulative) == (2025, "Q2", True) + assert (str(header.period_start), str(header.period_end)) == ("2025-01-01", "2025-06-30") + assert header.organization_name == "МВС2009" + + +def test_blocks_of_subaccounts_with_counterparty_codes(rules: Rules) -> None: + document = _document(rules, _blocks_book()) + + assert document.status == "done", [w.message for w in document.warnings] + assert [group.type for group in document.group_schema] == ["ACCOUNT", "COUNTERPARTY"] + assert [(row.row_value, row.group_type, row.level) for row in document.rows] == [ + ("60.111", "ACCOUNT", 0), + ("П005482", "COUNTERPARTY", 1), + ("ДП87825;З-139/21-1", "CONTRACT", 2), + ("П002913", "COUNTERPARTY", 1), + ("ДП60469;З-46/20", "CONTRACT", 2), + ("441", "CONTRACT", 2), + ("60.112", "ACCOUNT", 0), + ("П003258", "COUNTERPARTY", 1), + ("ДП1;1", "CONTRACT", 2), + ("ИТОГ", "TOTAL", 0), + ] + by_value = {row.row_value: row for row in document.rows} + # суммы субсчёта — из «ИТОГ по …», строка «ИТОГ по …» отдельно не выдаётся + assert str(by_value["60.111"].opening_credit) == "150.00" + assert by_value["П005482"].counterparty_name == "ВЕСТ БИР ООО" + assert by_value["П002913"].counterparty_name == "М ДИСТРИБЬЮШН" + assert by_value["П005482"].counterparty_inn is None + assert {row.account_code_raw for row in document.rows if row.group_type != "TOTAL"} == {"60.111", "60.112"} + assert not any("нечисловое" in warning.message or "Требует" in warning.message for warning in document.warnings) + + +def test_bare_three_digit_subaccount_needs_header_account(rules: Rules) -> None: + """Без счёта шапки «60.111» — прежний шаблон (сегмент 1–2 цифры): строка-счёт только при шапке «60».""" + rows: list[list[object]] = [ + ["Оборотно-сальдовая ведомость"], + [], + ["Субконто", "Начальное сальдо", None, "Обороты", None, "Конечное сальдо", None], + [None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], + ["60.111", 0, 10, 10, 0, 0, 0], + ["Итого", 0, 10, 10, 0, 0, 0], + ] + + document = _document(rules, _xlsx(rows)) + + assert [row.group_type for row in document.rows if row.row_value == "60.111"] != ["ACCOUNT"] + + +def test_kind_word_in_text_column_is_a_data_row(rules: Rules) -> None: + """«Поставщики» в колонке текста — название контрагента, а не вид субконто строкой таблицы.""" + rows: list[list[object]] = [ + ["Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."], + [], + ["Субконто", "Начальное сальдо", None, "Обороты", None, "Конечное сальдо", None], + [None, "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], + ["Поставщики", 0, 10, 10, 0, 0, 0], + ["Итого", 0, 10, 10, 0, 0, 0], + ] + + document = _document(rules, _xlsx(rows)) + + assert [row.row_value for row in document.rows] == ["Поставщики", "Итого"] diff --git a/openspec/specs/osv-parsing/spec.md b/openspec/specs/osv-parsing/spec.md index 04e02e4..8bd5d49 100644 --- a/openspec/specs/osv-parsing/spec.md +++ b/openspec/specs/osv-parsing/spec.md @@ -116,6 +116,17 @@ SHALL пропускаться с предупреждением файла `SHE - **THEN** `account_code_raw = "60"`; голый числовой код, не совпадающий со счётом шапки, счётом не считается (это код контрагента); строки — `COUNTERPARTY` по плану счетов, `done` +#### Scenario: Выгрузка «по контрагентам»: блоки субсчетов с кодами контрагентов +- **WHEN** разбирается синтетическая ОСВ `s46` (шапка «За период | 01.01.25..30.06.25», «Счет Поставщика | 60*», вид + субконто «Поставщики» в колонке суммы первой строки данных; субсчета 60.111/60.112 без сумм; код контрагента с суммами, + строка названия без сумм, договоры с суммами; «ИТОГ по 60.111»; «ИТОГ») +- **THEN** период 2025, Q2, нарастающий (метка «За период» со значением в соседней ячейке, разделитель «..», год из двух + цифр); `group_schema = [ACCOUNT, COUNTERPARTY(Поставщики)]`; `hierarchy=code_blocks` (субсчёт → контрагент → договор); + субсчета — `ACCOUNT` с суммами из «ИТОГ по …» (строк «ИТОГ по …», названий и «Поставщики» в `rows` нет); коды — + `COUNTERPARTY` с `counterparty_name` = строка названия, `counterparty_inn = null`; договоры (в том числе «441») — + `CONTRACT`; «ИТОГ» — `TOTAL`, таблица кончается на нём; `done`. Субсчёт с тремя цифрами в сегменте — строка-счёт, только + если код начинается со счёта шапки; голый числовой код, не совпадающий со счётом шапки, счётом не считается. + #### Scenario: Счёт шапки важнее строки таблицы - **WHEN** разбирается синтетическая ОСВ `s42` (шапка «…по счету: 76», первая строка таблицы «76.2») - **THEN** `account_code_raw = "76"` -- 2.47.2