diff --git a/CLAUDE.md b/CLAUDE.md index 73cc016..0fbfd67 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,7 +18,7 @@ Stateless-сервис разбора XLS/XLSX/XLSB: ОСВ по счетам ( `../fixtures/osv-dkb/**` — `tests/fixtures/test_osv_dkb.py`: ожидаемые `needs_check` — манифест `tests/osv_dkb_expectations.yaml` (данные), период — по имени каталога (`2025-Q3`, `2024-10_2025-09`); - снимки `../fixtures/golden/expected_osv_parse/*.json` — после ручной сверки «Итого»; -- синтетика только для вариантов, которых нет в фикстурах (шапка вариант 2, Вал./Кол., xlsb, без outline level). +- синтетика только для вариантов, которых нет в фикстурах (шапка вариант 2, Вал./Кол., xlsb, xls без CODEPAGE, без outline level). ## Соглашения шаблона Тот же шаблон команды, что у `arm-core` («Шаблон FastAPI», `RSHB/DKB_ARMKA@ba172f8`); общие соглашения — @@ -70,7 +70,7 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote обращения к ячейке константами. Значения enum контракта вычисляются из `src.contract` и под линтер не попадают. - Каждая запись словаря должна срабатывать хотя бы на одном реальном или синтетическом файле (`tests/fixtures/test_rules_coverage.py`); мёртвый синоним удаляется, пробел в фикстурах закрывается синтетикой. -- Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 123 файла: 72 реальных + 23 пакета ДКБ (`osv-dkb/**` до 1 МиБ, снимки в `osv-dkb/`) + 28 синтетических) — регрессия, а не +- Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 127 файлов: 72 реальных + 23 пакета ДКБ (`osv-dkb/**` до 1 МиБ, снимки в `osv-dkb/`) + 32 синтетических) — регрессия, а не оракул; правятся только `make golden-update` с просмотром diff и отдельным коммитом в `fixtures`. Корректность проверяют property-тест и ручная сверка «Итого». - Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не diff --git a/api/rules/data_types.yaml b/api/rules/data_types.yaml index 8d1d1da..8eba216 100644 --- a/api/rules/data_types.yaml +++ b/api/rules/data_types.yaml @@ -12,10 +12,12 @@ header: standalone_header: - ['^бухгалтерск\w*(\s+учет\w*)?$', BU] -# Строки под контрагентом: «Вал.» и «Кол.» помечают строку как валютную / количественную +# Строки под контрагентом: «Вал.» и «Кол.» помечают строку как валютную / количественную. «Вал» без точки — не метка: +# так называется материал (счёт 10); валютная строка — «Вал.» с точкой или код валюты из `currency_codes` (AURORA-1663). +# «Кол-во»: нормализация текста превращает дефис в пробел row: - - ['^вал\.?$', CURRENCY] - - ['^кол(ичество)?\.?$', QUANTITY] + - ['^вал\.$', CURRENCY] + - ['^кол(ичество|\s?во)?\.?$', QUANTITY] # Коды валют ISO 4217 (буквенные): строка, целиком равная коду, — валютная; код же — результат `currency_aliases` currency_codes: [RUB, USD, EUR, CNY, KZT, BYN, GBP, CHF, JPY, TRY, AED] diff --git a/api/rules/osv_headers.yaml b/api/rules/osv_headers.yaml index a5ac32c..85d65dd 100644 --- a/api/rules/osv_headers.yaml +++ b/api/rules/osv_headers.yaml @@ -42,6 +42,11 @@ service_columns: total_row: - '^итого\b' +# Итоговая строка, чей текст — ровно «Итого»: ищется первой, чтобы контрагент «Итого-Сервис» на плоском листе (без outline +# и отступов) не обрезал таблицу; нет такой — действует `total_row` (начало текста) +total_row_exact: + - '^итого$' + # Вторая итоговая строка после «Итого» («Итого развернутое» в 1С): тоже TOTAL, но «Итого» листа — основная, первая из них total_row_secondary: - '^итого\s+развернут' diff --git a/api/src/parser/classify.py b/api/src/parser/classify.py index f5dee97..e105256 100644 --- a/api/src/parser/classify.py +++ b/api/src/parser/classify.py @@ -115,34 +115,60 @@ def _text_cell_style(sheet: Sheet, row: int, col: int) -> tuple[bool, int]: def _find_total(sheet: Sheet, header: TableHeader, rows: Sequence[int], rules: Rules) -> int | None: - """Строка «Итого»: последняя строка таблицы, чей текст матчит `total_row`, на корневом уровне оформления. + """Строка «Итого»: первая строка таблицы, чей текст матчит `total_row`, на корневом уровне оформления. - Суммы не требуются (R-13: пустая ОСВ — «Итого» без сумм). Выбор — до уровней (их каскад считает с уже известной - строкой «Итого»), поэтому «корневой уровень» проверяется по оформлению: outline level и отступ строки не больше, - чем у любой другой строки таблицы. Так контрагент «Итого-Сервис» в середине данных не TOTAL (за ним есть настоящее - «Итого»), а последний «Итого-Сервис» под счётом с outline/отступом — тоже не TOTAL (T3.3). - Вторая итоговая строка («Итого развернутое», `total_row_secondary`) основной не бывает: основная — «Итого» - перед ней; без обычной «Итого» вторая строка служит единственной итоговой. - Известный риск: на листе без outline и отступов последняя строка-контрагент «Итого-…» при отсутствии настоящего - «Итого» станет TOTAL; это ловит проверка «TOTAL = корень» (§1.4). + Таблица кончается на первой «Итого» (AURORA-1664): всё ниже — вне документа, даже если там снова строки «Итого …» + (аналитик дописывает под ОСВ подпись и свои блоки ГК/ГСК с итогами). Сначала ищется первая строка корневого уровня, + чей текст — ровно «Итого» (`total_row_exact`), и только если такой нет — первая по `total_row` («Итого …»; I-1: + иначе контрагент «Итого-Сервис» на плоском листе обрезал бы таблицу). Суммы не требуются (R-13: пустая ОСВ — + «Итого» без сумм). Выбор — до уровней (их каскад считает с уже известной строкой «Итого»), поэтому «корневой + уровень» проверяется по оформлению: outline level и отступ строки не больше, чем у любой другой строки таблицы. + Так контрагент «Итого-Сервис» под счётом с outline/отступом — не TOTAL (T3.3), а настоящее «Итого» ниже него — + TOTAL. + Вторая итоговая строка («Итого развернутое», `total_row_secondary`) основной не бывает: основная — обычная «Итого» + (в плоской форме она может стоять после «Итого развернутое»); без обычной «Итого» первая вторая строка служит + единственной итоговой. + Остаточный риск: если точного «Итого» нет вовсе, а на листе нет outline и отступов, контрагент «Итого-…» на корневом + уровне становится TOTAL; это ловит проверка «TOTAL = корень» (§1.4). """ if not rows: 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) - if sheet.outline(row) != outline_floor or indent != indent_floor: - return only_secondary - if _is_secondary_total(text, rules): + roots = [ + row + for row in rows + if sheet.outline(row) == outline_floor and _text_cell_style(sheet, row, header.text_col)[1] == indent_floor + ] + for patterns in (rules.headers.total_row_exact, rules.headers.total_row): + only_secondary: int | None = None + for row in roots: + text = sheet.text_at(row, header.text_col) + if not any(pattern.search(text) for pattern in patterns): + continue + if not _is_secondary_total(text, rules): + return row only_secondary = only_secondary or row - continue - return row - return only_secondary + if only_secondary is not None: + return only_secondary + return None + + +def _numbers_below(sheet: Sheet, header: TableHeader, last_row: int) -> list[int]: + """Строки ниже `last_row` с числовыми ячейками в колонках таблицы (от колонки текста до правого края сумм): + хвост вне документа. Числа правее таблицы (контрольные суммы) хвостом не считаются.""" + return sorted( + { + row + for (row, col), cell in sheet.cells.items() + if row > last_row and cell.kind == "number" and header.text_col <= col <= header.right_edge + } + ) + + +def _tail_warning(rows: Sequence[int]) -> str: + where = f"строка {rows[0]}" if len(rows) == 1 else f"строки {rows[0]}–{rows[-1]}" + return f"{where}: ниже таблицы (после «Итого») есть числа — строки ниже «Итого» вне документа и не разобраны" def _is_unlabelled_total(sheet: Sheet, header: TableHeader, rows: Sequence[int]) -> bool: @@ -268,7 +294,11 @@ def collect_body_rows( f"строка {total_row}: строка сумм без подписи в конце таблицы принята за «Итого» (подписи в файле нет)" ) if total_row is not None: - rows = rows[: rows.index(_trailing_totals(sheet, header, rows, total_row, rules)) + 1] + last = rows.index(_trailing_totals(sheet, header, rows, total_row, rules)) + tail = _numbers_below(sheet, header, rows[last]) + if tail: + warnings.append(_tail_warning(tail)) + rows = rows[: last + 1] label_cols = _label_cols(sheet, header, rows) body: list[BodyRow] = [] for index, row in enumerate(rows, start=1): diff --git a/api/src/parser/readers/__init__.py b/api/src/parser/readers/__init__.py index c6883ae..372db75 100644 --- a/api/src/parser/readers/__init__.py +++ b/api/src/parser/readers/__init__.py @@ -26,7 +26,7 @@ from src.parser.readers._limits import ( check_xlsx_merged_area, check_zip_uncompressed_size, ) -from src.parser.readers.xls import read_xls +from src.parser.readers.xls import NO_CODEPAGE_WARNING, has_no_codepage, read_xls from src.parser.readers.xlsb import OUTLINE_UNAVAILABLE_WARNING, read_xlsb from src.parser.readers.xlsx import read_xlsx @@ -87,6 +87,8 @@ def read_book_detected(data: bytes, file_type: FileType, *, max_uncompressed_byt check_merged_area(sheets) if detected == "XLSB": warnings.append(OUTLINE_UNAVAILABLE_WARNING) + if detected == "XLS" and has_no_codepage(data): + warnings.append(NO_CODEPAGE_WARNING) return BookRead(sheets, warnings, detected) diff --git a/api/src/parser/readers/xls.py b/api/src/parser/readers/xls.py index 349f4cf..39d1b2a 100644 --- a/api/src/parser/readers/xls.py +++ b/api/src/parser/readers/xls.py @@ -8,12 +8,35 @@ import xlrd from src.parser.grid import Cell, MergedRange, Sheet from src.parser.readers._common import make_cell -__all__ = ["read_xls"] +__all__ = ["NO_CODEPAGE_WARNING", "has_no_codepage", "read_xls"] + +# Кодовая страница 1С и Excel в русской локали: в BIFF5/7 она задаёт кодировку всех байтовых строк книги +FALLBACK_ENCODING = "cp1251" +NO_CODEPAGE_WARNING = f"xls без кодовой страницы — строки прочитаны как {FALLBACK_ENCODING}" +# BIFF8 хранит строки в Unicode и от CODEPAGE не зависит; `biff_version` xlrd для него — 80 +_BIFF8 = 80 + + +def has_no_codepage(data: bytes) -> bool: + """В книге BIFF5/7 нет записи CODEPAGE: xlrd в этом случае декодирует байтовые строки как latin-1 (кириллица + превращается в мусор). Проверяется только глобальный раздел книги (`on_demand`: листы не разбираются).""" + try: + book = xlrd.open_workbook(file_contents=data, on_demand=True, logfile=io.StringIO()) + except Exception: # noqa: BLE001 - нечитаемая книга: ошибку чтения даёт сам читатель, проба лишь не мешает ему + return False + try: + return bool(book.biff_version < _BIFF8 and book.codepage is None) + finally: + book.release_resources() def read_xls(data: bytes) -> tuple[Sheet, ...]: # logfile: xlrd пишет замечания о структуре BIFF в stdout — в выводе сервиса им не место - book = xlrd.open_workbook(file_contents=data, formatting_info=True, logfile=io.StringIO()) + # Нет CODEPAGE — читаем как cp1251, не угадывая кодировку по содержимому (решение владельца, AURORA-1662) + override = FALLBACK_ENCODING if has_no_codepage(data) else None + book = xlrd.open_workbook( + file_contents=data, formatting_info=True, logfile=io.StringIO(), encoding_override=override + ) try: return tuple(_sheet(book, sheet) for sheet in book.sheets()) finally: diff --git a/api/src/parser/rules.py b/api/src/parser/rules.py index e54b43f..1261bae 100644 --- a/api/src/parser/rules.py +++ b/api/src/parser/rules.py @@ -179,6 +179,7 @@ class OsvHeaderRules(_Model): credit: list[Pattern] service_columns: dict[str, list[Pattern]] total_row: list[Pattern] + total_row_exact: list[Pattern] total_row_secondary: list[Pattern] column_captions: list[Pattern] counterparty_inn_suffix: list[Pattern] diff --git a/api/tests/synthetic/generate.py b/api/tests/synthetic/generate.py index 74d1d02..cb415b2 100644 --- a/api/tests/synthetic/generate.py +++ b/api/tests/synthetic/generate.py @@ -2,9 +2,10 @@ Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл, s14…s16 — записи словарей, которых нет в реальных ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9), s20…s27 — восемь -раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35); описание — `osv/synthetic/README.md` в репозитории -fixtures. Генератор самодостаточен (openpyxl + `xlsb_writer`) и детерминирован: повторный запуск даёт те же байты — -метки времени книги и zip постоянны, порядок записи фиксирован. +раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35), s29…s32 — xls без CODEPAGE, «Вал»/«Кол-во», +строки ниже «Итого» и «Итого по счету» вместо точного «Итого» (#47); описание — `osv/synthetic/README.md` +в репозитории fixtures. Генератор самодостаточен (openpyxl + `xlsb_writer` + `xls_writer`) и +детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок записи фиксирован. Суммы арифметически согласованы по построению: у листа дерева сальдо на конец вычисляется из начала и оборотов, у родителя — сумма детей по колонкам; поэтому все файлы, кроме `s10_broken_sum`, сходятся по §1.4. Позиции колонок и @@ -26,6 +27,7 @@ from typing import Literal import openpyxl from openpyxl.styles import Alignment, Font +from tests.synthetic.xls_writer import write_xls from tests.synthetic.xlsb_writer import write_xlsb __all__ = ["FILE_NAMES", "generate_all", "main", "ORG", "SheetSpec", "OsvSpec", "build_osv", "base_tree", "to_xlsx"] @@ -686,6 +688,56 @@ def _inn_layout_tree() -> Node: return Node("60", [Node("60.01", [first, beta(), ivanov()]), Node("60.02", [third, sidorov()])]) +def _s29() -> list[SheetSpec]: + """xls BIFF5 без записи CODEPAGE, строки в cp1251 (AURORA-1662): отступ — пробелами, outline в xls-писателе нет.""" + return [build_osv(OsvSpec(hierarchy="spaces", inn_col=5, first_sum_col=7), base_tree())] + + +def _s30() -> list[SheetSpec]: + """Счёт 10: материал «Вал» (без точки) — обычная номенклатура, не валютная строка; «Кол-во» — строка количества + (AURORA-1663). «Вал.» с точкой и код валюты остаются метками (s04).""" + shaft = leaf("Вал", pair("1500.00", None), pair("300.00", "100.00")) + shaft.kids = [leaf("Кол-во", pair("10", None), pair("2", "1"), indicator=True)] + bolt = leaf("Болт М8", pair("200.00", None), pair("50.00", None)) + bolt.kids = [leaf("Кол-во", pair("400", None), pair("100", None), indicator=True)] + root = Node("10", [Node("10.1", [shaft, bolt])]) + spec = OsvSpec( + account="10", + variant=2, + period="2 квартал 2025 г.", + detail="Номенклатура", + detail_label="Детализация по субсчетам, субконто", + inn_col=None, + first_sum_col=3, + ) + return [build_osv(spec, root)] + + +def _s31() -> list[SheetSpec]: + """Рабочий файл аналитика (AURORA-1664): под «Итого» дописаны подпись «Ответственный:» и блок с числами, в том + числе строка, начинающаяся с «Итого»; таблица кончается на первой «Итого», хвост вне документа.""" + sheet = build_osv(OsvSpec(inn_col=5, first_sum_col=7), base_tree()) + last = max(row for row, _ in sheet.cells) + sheet.put(last + 2, 1, "Ответственный:") + sheet.put(last + 2, 3, "Иванова И. И.") + sheet.put(last + 4, 1, "ООО Альфа (ГК)") + sheet.put(last + 4, 7, D("420000.00")) + sheet.put(last + 4, 8, D("250000.50")) + sheet.put(last + 5, 1, "Итого по ГК") + sheet.put(last + 5, 7, D("420000.00")) + sheet.put(last + 5, 8, D("250000.50")) + return [sheet] + + +def _s32() -> list[SheetSpec]: + """Итоговая строка с подписью «Итого по счету 60» вместо точного «Итого» (I-1): правило `total_row` (начало текста) + действует, когда точного «Итого» на листе нет.""" + sheet = build_osv(OsvSpec(inn_col=5, first_sum_col=7), base_tree()) + last = max(row for row, _ in sheet.cells) + sheet.cells[(last, 1)] = CellSpec("Итого по счету 60", bold=sheet.cells[(last, 1)].bold) + return [sheet] + + def _s28() -> list[SheetSpec]: """Раскладка s20 («над, не в дереве, без сумм»), но ИНН ООО Альфа с опечаткой: контрольная сумма не сходится.""" tree = _inn_layout_tree() @@ -745,6 +797,13 @@ def to_xlsx(sheets: Sequence[SheetSpec]) -> bytes: return _normalized_zip(buffer.getvalue()) +def to_xls(sheets: Sequence[SheetSpec]) -> bytes: + """xls BIFF5 без записи CODEPAGE (строки cp1251), без стилей: отступ — ведущие пробелы (`hierarchy="spaces"`).""" + return write_xls( + {spec.name: {key: cell.value for key, cell in spec.cells.items()} for spec in sheets}, codepage=None + ) + + def to_xlsb(sheets: Sequence[SheetSpec]) -> bytes: """xlsb без стилей и объединений: отступ уже выражен ведущими пробелами текста (`hierarchy="spaces"`).""" return write_xlsb({spec.name: {key: cell.value for key, cell in spec.cells.items()} for spec in sheets}) @@ -779,6 +838,10 @@ FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSp "s26_inn_below_sibling_sums.xlsx": (_inn_layout("below_sibling", sums=True), to_xlsx), "s27_inn_below_sibling_blank.xlsx": (_inn_layout("below_sibling", sums=False), to_xlsx), "s28_inn_bad_checksum.xlsx": (_s28, to_xlsx), + "s29_xls_no_codepage.xls": (_s29, to_xls), + "s30_val_kol_vo_materials.xlsx": (_s30, to_xlsx), + "s31_rows_below_total.xlsx": (_s31, to_xlsx), + "s32_total_with_suffix.xlsx": (_s32, to_xlsx), } FILE_NAMES = tuple(FILES) @@ -797,7 +860,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…s28 (xlsx и один xlsb) в указанный каталог.", + description="Синтетические ОСВ s01…s32 (xlsx, один xlsb и один xls) в указанный каталог.", ) parser.add_argument("directory", type=Path, help="каталог назначения, например ../../fixtures/osv/synthetic") args = parser.parse_args(argv) diff --git a/api/tests/synthetic/test_synthetic_files.py b/api/tests/synthetic/test_synthetic_files.py index e76c92e..728c08f 100644 --- a/api/tests/synthetic/test_synthetic_files.py +++ b/api/tests/synthetic/test_synthetic_files.py @@ -16,6 +16,7 @@ from result_contract import assert_valid_result from src.contract import FileType, OsvDocument, ResultStatus from src.contract.result import OsvRow from src.parser import ParseOutcome, get_parser +from src.parser.readers.xls import NO_CODEPAGE_WARNING pytestmark = pytest.mark.fixtures @@ -37,6 +38,10 @@ INN_LAYOUT_FILES = ( "s27_inn_below_sibling_blank.xlsx", ) INN_BAD_CHECKSUM_FILE = "s28_inn_bad_checksum.xlsx" +XLS_NO_CODEPAGE_FILE = "s29_xls_no_codepage.xls" +VAL_KOL_VO_FILE = "s30_val_kol_vo_materials.xlsx" +ROWS_BELOW_TOTAL_FILE = "s31_rows_below_total.xlsx" +TOTAL_WITH_SUFFIX_FILE = "s32_total_with_suffix.xlsx" TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28 EXPECTED_NAMES = [ "s01_variant2_header.xlsx", @@ -60,6 +65,10 @@ EXPECTED_NAMES = [ "s19_inn_kpp_cell.xlsx", *INN_LAYOUT_FILES, INN_BAD_CHECKSUM_FILE, + XLS_NO_CODEPAGE_FILE, + VAL_KOL_VO_FILE, + ROWS_BELOW_TOTAL_FILE, + TOTAL_WITH_SUFFIX_FILE, ] XLSB_NAME = "s13_no_outline_indent.xlsb" XLSB_MISSING_REASON = f"нет файла {XLSB_NAME} (xlsb не получен; см. README fixtures)" @@ -127,6 +136,14 @@ EXPECTED: dict[str, Expect] = { **{name: Expect("outline", 2025, "Q1", True) for name in INN_LAYOUT_FILES}, # ИНН с неверной контрольной суммой: строка остаётся строкой, документ на проверку INN_BAD_CHECKSUM_FILE: Expect("outline", 2025, "Q1", True, status=ResultStatus.NEEDS_CHECK), + # xls BIFF5 без CODEPAGE, строки cp1251; отступ пробелами + XLS_NO_CODEPAGE_FILE: Expect("indent", 2025, "Q1", True), + # счёт 10, вариант 2: материал «Вал» и строки «Кол-во» + VAL_KOL_VO_FILE: Expect("outline", 2025, "Q2", False, variant=2), + # подпись и блок чисел под «Итого» + ROWS_BELOW_TOTAL_FILE: Expect("outline", 2025, "Q1", True), + # итог подписан «Итого по счету 60»: точного «Итого» нет + TOTAL_WITH_SUFFIX_FILE: Expect("outline", 2025, "Q1", True), } @@ -198,7 +215,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…s28 (s13 — xlsb; допустима деградация: нет только его).""" + """В `fixtures/osv/synthetic/` лежат файлы набора s01…s32 (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] @@ -616,3 +633,46 @@ def test_s19_inn_kpp_cell_gives_inn_only(fixtures_dir: Path) -> None: } assert doc.columns is not None assert doc.columns["inn"] == "E" + + +def test_s29_xls_without_codepage_is_read_as_cp1251(fixtures_dir: Path) -> None: + """AURORA-1662: в книге BIFF5 нет CODEPAGE — кириллица читается как cp1251, предупреждение об этом есть.""" + outcome = parse_named(fixtures_dir, XLS_NO_CODEPAGE_FILE) + doc = one_document(outcome) + + assert outcome.status is ResultStatus.DONE + assert doc.header.organization_name == ORG + assert {ALFA, BETA, GAMMA} <= set(rows_text(doc)) + assert NO_CODEPAGE_WARNING in warnings_of(outcome) + + +def test_s30_val_without_dot_is_a_material_kol_vo_is_quantity(fixtures_dir: Path) -> None: + """AURORA-1663: «Вал» без точки — номенклатура (не CURRENCY), «Кол-во» — QUANTITY.""" + doc = one_document(parse_named(fixtures_dir, VAL_KOL_VO_FILE)) + + (shaft,) = [row for row in doc.rows if row.row_value == "Вал"] + assert shaft.data_type == doc.header.data_type + assert shaft.group_type == "OTHER" + quantities = [row for row in doc.rows if row.row_value == "Кол-во"] + assert [row.data_type for row in quantities] == ["QUANTITY", "QUANTITY"] + assert not [row for row in doc.rows if row.data_type == "CURRENCY"] + + +def test_s31_rows_below_total_are_outside_the_document(fixtures_dir: Path) -> None: + """AURORA-1664: таблица кончается на первой «Итого»; подпись и блок с числами ниже — не строки, а предупреждение.""" + outcome = parse_named(fixtures_dir, ROWS_BELOW_TOTAL_FILE) + doc = one_document(outcome) + + assert outcome.status is ResultStatus.DONE + assert rows_text(doc)[-1] == "Итого" + assert [row.group_type for row in doc.rows].count("TOTAL") == 1 + assert not {"Ответственный:", "ООО Альфа (ГК)", "Итого по ГК"} & set(rows_text(doc)) + assert any("ниже таблицы" in warning for warning in warnings_of(outcome)), warnings_of(outcome) + + +def test_s32_total_with_suffix_is_total(fixtures_dir: Path) -> None: + """I-1: нет точного «Итого» — итогом служит строка, начинающаяся с «Итого» («Итого по счету 60»).""" + doc = one_document(parse_named(fixtures_dir, TOTAL_WITH_SUFFIX_FILE)) + + assert doc.rows[-1].row_value == "Итого по счету 60" + assert doc.rows[-1].group_type == "TOTAL" diff --git a/api/tests/synthetic/xls_writer.py b/api/tests/synthetic/xls_writer.py new file mode 100644 index 0000000..259bbac --- /dev/null +++ b/api/tests/synthetic/xls_writer.py @@ -0,0 +1,170 @@ +"""Минимальный писатель XLS BIFF5/7 для тестов: значения ячеек, имена листов, кодовая страница по выбору. + +Это НЕ файл Excel: нет стилей (одна гарнитура и один XF), объединений, outline и свойств документа. Структура — +ровно то, что нужно `xlrd` с `formatting_info=True`: поток `Book` в контейнере OLE2 (v3, сектор 512 байт), globals +(`BOF`, [`CODEPAGE`], `FONT`, `XF`, `BOUNDSHEET`, `EOF`) и по подпотоку на лист (`BOF`, `DIMENSIONS`, `LABEL`/`NUMBER`, +`EOF`). Байтовые строки записываются в кодировке книги (cp1251 для русской локали 1С/Excel). + +Назначение — xls без записи CODEPAGE (AURORA-1662): такие книги пишет 1С и старые выгрузки, а Excel и LibreOffice +запись CODEPAGE всегда добавляют, поэтому получить их из обычных инструментов нельзя. Запись детерминирована. +""" + +import struct +from collections.abc import Mapping +from decimal import Decimal + +__all__ = ["write_xls"] + +CellValue = str | int | Decimal | float +SheetCells = Mapping[tuple[int, int], CellValue] # (строка, колонка) 1-based -> значение + +CP1251 = 1251 + +_BOF = 0x0809 +_EOF = 0x000A +_CODEPAGE = 0x0042 +_FONT = 0x0031 +_XF = 0x00E0 +_BOUNDSHEET = 0x0085 +_DIMENSIONS = 0x0000 +_NUMBER = 0x0203 +_LABEL = 0x0204 + +_BIFF5 = 0x0500 +_TYPE_GLOBALS = 0x0005 +_TYPE_SHEET = 0x0010 +_MAX_STRING = 255 + +# --- контейнер OLE2 --- +_SECTOR = 512 +_MINI_STREAM_CUTOFF = 4096 # поток короче лежит в мини-потоке — писатель его не делает, поэтому добивает поток нулями +_FAT_ENTRIES = _SECTOR // 4 +_ENDOFCHAIN = 0xFFFFFFFE +_FATSECT = 0xFFFFFFFD +_FREESECT = 0xFFFFFFFF +_NOSTREAM = 0xFFFFFFFF +_DIR_ENTRY = 128 +_OLE_SIGNATURE = bytes.fromhex("D0CF11E0A1B11AE1") + + +def _record(opcode: int, payload: bytes = b"") -> bytes: + return struct.pack(" bytes: + # версия BIFF5, тип подпотока, номер сборки и год — как у Excel 95/97 в режиме BIFF5 + return _record(_BOF, struct.pack(" bytes: + raw = text.encode(encoding, errors="replace")[:_MAX_STRING] + return _record(_LABEL, struct.pack(" bytes: + return _record(_NUMBER, struct.pack(" bytes: + rows = [row for row, _ in cells] + cols = [col for _, col in cells] + body = [_bof(_TYPE_SHEET)] + body.append( + _record( + _DIMENSIONS, + struct.pack( + " bytes: + """Globals; смещения листов зависят от длины самих globals, поэтому длина считается по заготовке с нулями.""" + + def build(offsets: list[int]) -> bytes: + parts = [_bof(_TYPE_GLOBALS)] + if codepage is not None: + parts.append(_record(_CODEPAGE, struct.pack(" bytes: + def entry(name: str, kind: int, child: int, start: int, size: int) -> bytes: + encoded = name.encode("utf-16-le") + b"\x00\x00" if name else b"" + fields = struct.pack( + "<64sHBBIII16sIQQIII", + encoded, + len(encoded), + kind, + 1, + _NOSTREAM, + _NOSTREAM, + child, + b"", + 0, + 0, + 0, + start, + size, + 0, + ) + return fields + + empty = entry("", 0, _NOSTREAM, 0, 0) + entries = [entry("Root Entry", 5, 1, _ENDOFCHAIN, 0), entry("Book", 2, _NOSTREAM, 2, stream_size), empty, empty] + return b"".join(entries) + + +def _ole2(stream: bytes) -> bytes: + """Контейнер OLE2 с единственным потоком `Book`: сектор 0 — FAT, 1 — каталог, дальше — поток.""" + padded = stream.ljust(max(len(stream), _MINI_STREAM_CUTOFF), b"\x00") + padded = padded.ljust(-(-len(padded) // _SECTOR) * _SECTOR, b"\x00") + count = len(padded) // _SECTOR + if count + 2 > _FAT_ENTRIES: + raise ValueError(f"книга {len(padded)} байт не помещается в один сектор FAT писателя") + fat = [_FATSECT, _ENDOFCHAIN, *range(3, count + 2), _ENDOFCHAIN] + fat += [_FREESECT] * (_FAT_ENTRIES - len(fat)) + header = struct.pack( + "<8s16sHHHHH6sIIIIIIIII", + _OLE_SIGNATURE, b"", 0x003E, 3, 0xFFFE, 9, 6, b"", + 0, 1, 1, 0, _MINI_STREAM_CUTOFF, _ENDOFCHAIN, 0, _ENDOFCHAIN, 0, + ) # fmt: skip + difat = struct.pack("<109I", 0, *([_FREESECT] * 108)) + return ( + (header + difat).ljust(_SECTOR, b"\x00") + + struct.pack(f"<{_FAT_ENTRIES}I", *fat) + + _directory(len(padded)).ljust(_SECTOR, b"\x00") + + padded + ) + + +def write_xls(sheets: Mapping[str, SheetCells], *, codepage: int | None = CP1251, encoding: str = "cp1251") -> bytes: + """xls BIFF5 с листами `sheets` (имя -> ячейки). `codepage=None` — записи CODEPAGE в книге нет (AURORA-1662); + строки при этом всё равно записаны в `encoding`: так их пишет 1С в русской локали.""" + names = [name.encode(encoding, errors="replace") for name in sheets] + streams = [_sheet_stream(cells, encoding) for cells in sheets.values()] + globals_part = _globals_stream(names, [len(item) for item in streams], codepage) + return _ole2(globals_part + b"".join(streams)) diff --git a/api/tests/unit/parser/test_classify.py b/api/tests/unit/parser/test_classify.py index 7ba8974..1aaa77a 100644 --- a/api/tests/unit/parser/test_classify.py +++ b/api/tests/unit/parser/test_classify.py @@ -12,7 +12,7 @@ import pytest from gridbuilder import C, L, M, sheet_from_rows from src.contract.common import DataType -from src.parser.classify import ACCOUNT_RE, ClassifiedRow, classify_rows, collect_body_rows +from src.parser.classify import ACCOUNT_RE, ClassifiedRow, classify_rows, collect_body_rows, row_data_type from src.parser.deadline import Deadline from src.parser.detect import find_table_header from src.parser.errors import ParseTimeoutError @@ -28,6 +28,7 @@ TURNOVER = "Обороты за период" CLOSING = "Сальдо на конец периода" TIMEOUT_SECONDS = 60 EXPIRED_AFTER_SECONDS = 1000 +TAIL_NUMBERS_MARK = "ниже таблицы" ACCOUNT_ROW = 7 # первая строка данных в сетке `osv(...)`: шапка 3 строки + блок заголовка 3 строки DUPLICATED_EXTRA_COLUMNS = 2 @@ -411,6 +412,23 @@ def test_currency_and_quantity_rows(rules: Rules) -> None: assert (usd.group_type, usd.group_name, usd.account_code_raw) == ("OTHER", None, "60") +@pytest.mark.parametrize( + ("text", "expected"), + [ + ("Кол.", "QUANTITY"), + ("Количество", "QUANTITY"), + ("Кол-во", "QUANTITY"), # AURORA-1663, O-5 + ("кол-во.", "QUANTITY"), + ("Вал.", "CURRENCY"), + ("USD", "CURRENCY"), + ("Вал", None), # AURORA-1663, O-6: «Вал» без точки — наименование материала (ОСВ 10.1), не метка валюты + ("Вал стальной", None), + ], +) +def test_row_data_type_labels(rules: Rules, text: str, expected: str | None) -> None: + assert row_data_type(text, rules, frozenset(rules.data_types.currency_codes)) == expected + + def test_doc_data_type_none_passes_through(rules: Rules) -> None: parsed = parse(_basic(), rules, doc_data_type=None) @@ -478,6 +496,68 @@ def test_footer_after_total_is_not_data_nor_extra(rules: Rules) -> None: assert parsed.total_row == parsed.one("Итого").body.row +def test_table_ends_at_first_total_everything_below_is_outside(rules: Rules) -> None: + """AURORA-1664 (O-12): под таблицей дописаны подпись и блок аналитика, в нём есть и строка «Итого …»: таблица + кончается на первой «Итого», хвост вне документа — ни строкой, ни TOTAL; числа в хвосте — предупреждение. + """ + sheet = osv( + r(0, "60", S100), + r(1, "ООО Поставщик", S100, inn="7701234567"), + r(None, "Итого", S100), + [], + ["Ответственный:", None, "Иванова"], + r(None, "ООО Альфа (ГК)", (7, None, 5, None, 12, None)), + r(None, "Итого по ГК", (7, None, 5, None, 12, None)), + ) + + parsed = parse(sheet, rules) + + assert [row.body.text for row in parsed.rows] == ["60", "ООО Поставщик", "Итого"] + assert parsed.total_row == parsed.one("Итого").body.row + assert [row.group_type for row in parsed.rows].count("TOTAL") == 1 + assert any(TAIL_NUMBERS_MARK in warning for warning in parsed.warnings), parsed.warnings + + +def test_text_only_tail_below_total_gives_no_warning(rules: Rules) -> None: + sheet = osv( + r(0, "60", S100), + r(None, "Итого", S100), + ["Ответственный:", None, "Иванова"], + ) + + parsed = parse(sheet, rules) + + assert not any(TAIL_NUMBERS_MARK in warning for warning in parsed.warnings), parsed.warnings + + +def test_second_total_then_tail_numbers(rules: Rules) -> None: + """«Итого развернутое» сразу за «Итого» — ещё таблица; числа ниже неё — хвост.""" + sheet = osv( + r(0, "60", S100), + r(None, "Итого", S100), + r(None, "Итого развернутое", S100), + r(None, "Итого", (1, None, None, None, 1, None)), + ) + + parsed = parse(sheet, rules) + + assert [row.body.text for row in parsed.rows] == ["60", "Итого", "Итого развернутое"] + assert any(TAIL_NUMBERS_MARK in warning for warning in parsed.warnings) + + +def test_numbers_right_of_table_below_total_give_no_warning(rules: Rules) -> None: + """Контрольные суммы правее таблицы под «Итого» — не хвост таблицы: предупреждения нет.""" + sheet = osv( + r(0, "60", S100), + r(None, "Итого", S100), + [None, None, None, None, None, None, None, None, None, None, None, 375490642], + ) + + parsed = parse(sheet, rules) + + assert not any(TAIL_NUMBERS_MARK in warning for warning in parsed.warnings), parsed.warnings + + def test_column_filled_only_in_footer_is_not_an_extra_key(rules: Rules) -> None: """T4.5: колонка, заполненная только в подвале, не становится ключом `extra` у строк таблицы.""" sheet = sheet_from_rows( @@ -798,6 +878,24 @@ def test_counterparty_named_like_total_in_the_middle(rules: Rules) -> None: assert [row.group_type for row in parsed.rows].count("TOTAL") == 1 +def test_flat_counterparty_named_like_total_before_exact_total(rules: Rules) -> None: + """I-1: плоский лист без outline и отступов; «Итого-Сервис» с суммами в середине — строка данных, а не конец + таблицы: TOTAL — точное «Итого», данных ниже контрагента не теряется, хвоста нет.""" + sheet = osv( + r(None, "60", (110, None, 50, 30, 130, None)), + r(None, "Итого-Сервис", S100, inn="7701234567"), + r(None, "ООО Поставщик", (10, None, None, None, 10, None), inn="7707654321"), + r(None, "Итого", (110, None, 50, 30, 130, None)), + ) + + parsed = parse(sheet, rules) + + assert [row.body.text for row in parsed.rows] == ["60", "Итого-Сервис", "ООО Поставщик", "Итого"] + assert parsed.total_row == parsed.one("Итого").body.row + assert parsed.one("Итого-Сервис").group_type != "TOTAL" + assert not any(TAIL_NUMBERS_MARK in warning for warning in parsed.warnings), parsed.warnings + + def test_last_row_named_like_total_is_rejected_by_outline_level(rules: Rules) -> None: """Последняя строка «Итого-Сервис» без настоящего «Итого»: outline глубже минимального — не TOTAL.""" sheet = osv( @@ -812,9 +910,9 @@ def test_last_row_named_like_total_is_rejected_by_outline_level(rules: Rules) -> def test_known_risk_last_counterparty_named_like_total_without_outline(rules: Rules) -> None: - """Известный риск (документирован): без outline и отступов последняя строка «Итого-Сервис» при отсутствии - настоящего «Итого» неотличима от итоговой строки и становится TOTAL; контрольные суммы §1.4 (TOTAL = корень) - это поймают, если суммы не совпадают. + """Остаточный риск (документирован): только когда точного «Итого» нет вовсе, а на листе нет outline и отступов, + первая строка «Итого-Сервис» на корневом уровне неотличима от итоговой строки и становится TOTAL; контрольные суммы + §1.4 (TOTAL = корень) это поймают, если суммы не совпадают. """ sheet = osv( r(None, "60", S100), diff --git a/api/tests/unit/parser/test_readers.py b/api/tests/unit/parser/test_readers.py index 2a65490..c1e6615 100644 --- a/api/tests/unit/parser/test_readers.py +++ b/api/tests/unit/parser/test_readers.py @@ -18,6 +18,8 @@ from src.parser.grid import EMPTY_CELL, Cell, MergedRange from src.parser.readers import read_book from src.parser.readers import xlsb as xlsb_reader from src.parser.readers._common import make_cell +from src.parser.readers.xls import NO_CODEPAGE_WARNING +from tests.synthetic.xls_writer import write_xls def _synthetic_xlsx() -> bytes: @@ -208,3 +210,27 @@ def test_integral_float_is_read_without_fraction(value: float, expected_text: st assert cell is not None assert (cell.kind, cell.text) == ("number", expected_text) assert cell.value == Decimal(str(value)) + + +# --- xls без записи CODEPAGE (AURORA-1662) --- + +_CYRILLIC_CELLS = {(1, 1): "Оборотно-сальдовая ведомость", (2, 2): Decimal("12.5")} + + +def test_xls_without_codepage_is_read_as_cp1251_with_warning() -> None: + data = write_xls({"ОСВ 60": _CYRILLIC_CELLS}, codepage=None) + + sheets, warnings = read_book(data, "XLS") + + assert [sheet.name for sheet in sheets] == ["ОСВ 60"] + assert sheets[0].text_at(1, 1) == "Оборотно-сальдовая ведомость" + assert warnings == [NO_CODEPAGE_WARNING] + + +def test_xls_with_codepage_is_read_as_before_without_warning() -> None: + data = write_xls({"ОСВ 60": _CYRILLIC_CELLS}, codepage=1251) + + sheets, warnings = read_book(data, "XLS") + + assert sheets[0].text_at(1, 1) == "Оборотно-сальдовая ведомость" + assert warnings == [] diff --git a/api/tests/unit/parser/test_rules.py b/api/tests/unit/parser/test_rules.py index d7dfd16..765c291 100644 --- a/api/tests/unit/parser/test_rules.py +++ b/api/tests/unit/parser/test_rules.py @@ -571,13 +571,18 @@ def test_header_data_type_unknown(rules: Rules) -> None: @pytest.mark.parametrize( ("text", "expected"), - [("Вал.", "CURRENCY"), ("вал", "CURRENCY"), ("Кол.", "QUANTITY"), ("Количество", "QUANTITY")], + [ + ("Вал.", "CURRENCY"), + ("Кол.", "QUANTITY"), + ("Количество", "QUANTITY"), + ("Кол во", "QUANTITY"), # «Кол-во» после нормализации текста (дефис -> пробел) + ], ) def test_row_data_type(rules: Rules, text: str, expected: str) -> None: assert first_match(rules.data_types.row, text) == expected -@pytest.mark.parametrize("text", ["Валюта расчетов ООО", "Колесо", "60.01", "АО Русский бисквит"]) +@pytest.mark.parametrize("text", ["Вал", "Валюта расчетов ООО", "Колесо", "60.01", "АО Русский бисквит"]) def test_row_data_type_does_not_fire_on_ordinary_rows(rules: Rules, text: str) -> None: assert first_match(rules.data_types.row, text) is None