From 46c427e3c1947e151384f5eb05ead0a77a82e01b Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 5 Oct 2026 13:50:09 +0400 Subject: [PATCH] =?UTF-8?q?fix(parser):=20inn=5Fdigits=20=D0=B8=D0=B7?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BA=D0=B0=D0=B5=D1=82=20=D0=98=D0=9D=D0=9D?= =?UTF-8?q?=20=D0=B8=D0=B7=20=D1=82=D0=B5=D0=BA=D1=81=D1=82=D0=B0=20=D1=81?= =?UTF-8?q?=20=D0=BE=D0=B1=D1=80=D0=B0=D0=BC=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5?= =?UTF-8?q?=D0=BC=20(T7.1,=20R-15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Строгая detect.inn_from_cell не меняется (ей детектор отличает колонку ИНН). inn_digits — режим извлечения для ячеек выбранной колонки: сначала строгое правило, затем для текстовых ячеек цифры всех групп склеиваются и принимаются при длине 10/12 (без дополнения нулём). Числовые ячейки во второй шаг не попадают. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/parser/detect.py | 7 ++++++- api/src/parser/numbers.py | 26 +++++++++++++++++++++++--- api/tests/unit/parser/test_numbers.py | 19 ++++++++++++++++--- 3 files changed, 45 insertions(+), 7 deletions(-) diff --git a/api/src/parser/detect.py b/api/src/parser/detect.py index e5c9e6c..3dd1441 100644 --- a/api/src/parser/detect.py +++ b/api/src/parser/detect.py @@ -332,7 +332,12 @@ def _text_col(sheet: Sheet, first_row: int, last_row: int, first_sum: int) -> in def inn_from_cell(cell: Cell) -> str | None: - """ИНН из ячейки колонки ИНН: 10 или 12 цифр, иначе `None` (единое правило для детектора и классификатора). + """ИНН из ячейки колонки ИНН: 10 или 12 цифр, иначе `None` + (строгое правило: им детектор решает, что колонка — колонка ИНН). + + Извлечение значения из ячеек уже выбранной колонки (в том числе из текста с обрамлением, «ИНН 3528052718») — + `numbers.inn_digits`; её вызывает классификатор строк. Смягчать это правило нельзя: обычные текстовые колонки + (номера договоров, телефоны) начнут выглядеть как колонка ИНН. Текст — как есть (после trim), только цифры. Целое число — без дробной части и экспоненты; если при записи числом потерян ведущий ноль (ИНН регионов 01–09: 9 или 11 цифр), он восстанавливается. Текст из 9/11 цифр не дополняется: diff --git a/api/src/parser/numbers.py b/api/src/parser/numbers.py index 88ff467..a1eaf89 100644 --- a/api/src/parser/numbers.py +++ b/api/src/parser/numbers.py @@ -1,5 +1,8 @@ """Числа ячеек (design D-5; `05-domain-rules.md` §1.0): суммы -> `Decimal` с двумя знаками, ИНН. +`inn_digits` — режим извлечения ИНН для ячеек выбранной колонки (строгое правило детектора + цифры из обрамляющего +текста); для `counterparty_inn` её вызывает классификатор строк. + `to_money` — единственное место парсера, где суммы квантуются до 0.01 (ROUND_HALF_UP) и где разбирается текст. Читатели кладут число как `Decimal(str(value))` без квантования, поэтому float-хвосты (`0.30000000000000004`) приходят сюда уже как точная десятичная запись и округляются предсказуемо. `float` в домене не используется: ни `float()`, ни @@ -10,7 +13,7 @@ import re from decimal import ROUND_HALF_UP, Decimal, InvalidOperation from typing import NamedTuple -from src.parser.detect import inn_from_cell +from src.parser.detect import INN_LENGTHS, inn_from_cell from src.parser.grid import Cell __all__ = ["MONEY_Q", "MoneyParse", "inn_digits", "to_money"] @@ -32,6 +35,7 @@ _WHITESPACE = re.compile(r"[\s   ]+") _PARENTHESES = re.compile(r"^\((.*)\)$") _NUMBER = re.compile(r"^-?[0-9]+(\.[0-9]+)?([eE][-+]?[0-9]+)?$") _MINUS_SIGN = "−" +_DIGIT_RUN = re.compile(r"[0-9]+") class MoneyParse(NamedTuple): @@ -92,5 +96,21 @@ def to_money(cell: Cell) -> MoneyParse: def inn_digits(cell: Cell) -> str | None: - """ИНН из ячейки: 10 или 12 цифр, иначе `None`. Правило единое с детектором колонки ИНН — `detect.inn_from_cell`.""" - return inn_from_cell(cell) + """ИНН из ячейки уже выбранной колонки ИНН: единая точка извлечения `counterparty_inn` (Task 8 вызывает её). + + 1. Строгое правило детектора `detect.inn_from_cell` (10/12 цифр; число с потерянным ведущим нулём дополняется). + 2. Текстовая ячейка с обрамлением («ИНН 352819549855», «ИНН: 3528052718», «3528 052718»): цифры всех групп + склеиваются и принимаются, если их ровно 10 или 12. Ведущий ноль к склеенным цифрам не добавляется + (11 цифр -> `None`). Ограничение: склейка не отличает ИНН от иного текста с 10/12 цифрами в сумме, поэтому + применяется только к ячейкам колонки, уже признанной колонкой ИНН. + Числовые ячейки во второй шаг не попадают (`3528052718.5` -> `None`). Мягкий второй шаг нельзя переносить в + `inn_from_cell`: она решает, является ли колонка колонкой ИНН, и обычные текстовые колонки (номера договоров, + телефоны) не должны под неё подпадать. + """ + strict = inn_from_cell(cell) + if strict is not None: + return strict + if cell.kind != "text": + return None + digits = "".join(_DIGIT_RUN.findall(cell.text)) + return digits if len(digits) in INN_LENGTHS else None diff --git a/api/tests/unit/parser/test_numbers.py b/api/tests/unit/parser/test_numbers.py index d49a1be..fa5aa84 100644 --- a/api/tests/unit/parser/test_numbers.py +++ b/api/tests/unit/parser/test_numbers.py @@ -196,7 +196,14 @@ def test_too_large_text_is_none_with_warning() -> None: (num("123456789"), "0123456789"), # потерян ведущий ноль: общее правило detect.inn_from_cell (text("123"), None), (num("123"), None), - (text("ИНН 352819549855"), None), # текст не «чистится»: ИНН — колонка из одних цифр + (text("ИНН 352819549855"), "352819549855"), # R-15: цифры из обрамляющего текста + (text("ИНН: 3528052718"), "3528052718"), + (text("3528 052718"), "3528052718"), + (text("123 456"), None), + (text("ИНН 35280527181"), None), # 11 цифр: ведущий ноль к извлечённым цифрам не добавляется + (text("ИНН 352805271"), None), # 9 цифр + (text("№ 12/2025"), None), + (text("ИНН"), None), (num("3528052718.5"), None), (num("-3528052718"), None), (EMPTY_CELL, None), @@ -207,6 +214,12 @@ def test_inn_digits(cell: Cell, expected: str | None) -> None: assert inn_digits(cell) == expected -def test_inn_digits_delegates_to_detector_rule() -> None: - for cell in (text("3528052718"), num("123456789"), text("abc"), num("1.5")): +def test_inn_digits_is_superset_of_strict_detector_rule() -> None: + cells = (text("3528052718"), text(" 352819549855 "), num("123456789"), num("3528052718.0"), num("352819549855")) + for cell in cells: + assert detect.inn_from_cell(cell) is not None assert inn_digits(cell) == detect.inn_from_cell(cell) + + +def test_detector_rule_stays_strict_for_prefixed_text() -> None: + assert detect.inn_from_cell(text("ИНН 352819549855")) is None