Документирование warnings адаптера, тест листа-продолжения, обезличенная фамилия (M-2, M-3, M-4)

M-2: README и docstring normalize_candidate — при отсутствии документа
candidate.warnings хост обрабатывает сам. M-3: тесты сопоставления
по дате (переставленные даты) и по порядку (дубль даты).
M-4: «Косарев» заменён на «Иванов» в тестах и docstring.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
This commit is contained in:
Andrey Epifancev 2026-10-18 14:29:10 +04:00
parent 5cc4522e4a
commit 2973cc7966
6 changed files with 40 additions and 6 deletions

View File

@ -96,9 +96,11 @@ NormalizationResult(documents: tuple[StatementDocument, ...], file_warnings: tup
```python
normalize_candidate(candidate: FormCandidate, rules: Rules | None = None) -> CandidateResult
# шаги 2–8 одного кандидата; документа нет — file_warnings (TABLE_LOST | TABLE_SKIPPED | SHEET_OTHER)
# шаги 2–8 одного кандидата; документа нет — file_warnings (TABLE_LOST | TABLE_SKIPPED | SHEET_OTHER);
# при этом candidate.warnings адаптера пакет не переносит — хост обрабатывает их сам
normalize(candidates: Sequence[FormCandidate], rules: Rules | None = None) -> NormalizationResult
# по порядку; исключение кандидата не перехватывается — хост, которому нужна изоляция кандидатов
# по порядку; у кандидата без документа candidate.warnings не попадают в результат (см. выше);
# исключение кандидата не перехватывается — хост, которому нужна изоляция кандидатов
# (TABLE_PARSE_ERROR), зовёт normalize_candidate в цикле
recompute(form: StoredForm, rules: Rules | None = None) -> RecomputeResult
parse_header(cells: HeaderCells, rules: Rules) -> StatementHeader # form_name = None (ставит конвейер)

View File

@ -160,7 +160,10 @@ def _lines_document( # noqa: PLR0913 -- сборка документа со с
def normalize_candidate(candidate: FormCandidate, rules: Rules | None = None) -> CandidateResult:
"""Шаги 2–8 кандидата; документа нет — предупреждение файла (`TABLE_LOST`, `TABLE_SKIPPED`, `SHEET_OTHER`)."""
"""Шаги 2–8 кандидата; документа нет — предупреждение файла (`TABLE_LOST`, `TABLE_SKIPPED`, `SHEET_OTHER`).
Когда документа нет, `candidate.warnings` (предупреждения адаптера) никуда не попадают: хост обрабатывает их сам.
"""
rules = rules if rules is not None else default_rules()
header = replace(parse_header(candidate.header, rules), form_name=_form_name(candidate))
layout = find_layout(candidate, header, rules)

View File

@ -108,7 +108,7 @@ def test_excel_signature_text_is_not_content(rules: Rules) -> None:
rows = [
["Наименование", "", "Код", "На 30.09.2025"],
["Запасы", "", "1150", "5"],
["Руководитель", "", "", "Косарев"],
["Руководитель", "", "", "Иванов"],
["", "", "", "(расшифровка подписи)"],
]
lines, _ = lines_of(rows, rules, source="excel")

View File

@ -158,3 +158,32 @@ def test_repeat_header_in_two_rows(rules: Rules) -> None:
assert [s.first_row for s in layout.segments] == [0, 3]
assert layout.roles[4] is RowRole.HEADER and layout.roles[5] is RowRole.DATA
_no_date_noise(layout)
def _npz_sheets(second_dates: list[str]) -> list[tuple[str, list[list[str | D | None]]]]:
first = [
place({1: "Наименование показателя", 12: "Код", 16: DATES3[0], 21: DATES3[1], 24: DATES3[2]}),
place({1: "Нематериальные активы", 12: "1110", 16: D("10"), 21: D("9"), 24: D("8")}),
]
second = [
place({0: "Наименование показателя", 6: "Код", 8: second_dates[0], 9: second_dates[1], 10: second_dates[2]}),
place({0: "Заемные средства", 6: "1510", 8: D("3"), 9: D("2"), 10: D("1")}),
]
return [("Бухгалтерский баланс", first), ("Бухгалтерский баланс. Лист 4", second)]
def test_continuation_sheet_with_permuted_dates_maps_by_date_not_order(rules: Rules) -> None:
"""M-3: даты листа-продолжения переставлены — колонки ищутся по дате, а не по порядку."""
permuted = [DATES3[2], DATES3[0], DATES3[1]] # 31.12.2023, 30.09.2025, 31.12.2024
cand = excel(_npz_sheets(permuted))
layout = find_layout(cand, parse_header(hdr(report_date="30.09.2025"), rules), rules)
assert layout.segments[1].value_cols == ((8, 24), (9, 16), (10, 21))
assert not [w for w in layout.warnings if w.code.startswith("SEGMENT_")]
def test_continuation_sheet_with_duplicate_date_maps_by_order(rules: Rules) -> None:
"""M-3: две колонки на одну дату — сопоставление по порядку."""
duplicate = [DATES3[0], DATES3[0], DATES3[2]]
cand = excel(_npz_sheets(duplicate))
layout = find_layout(cand, parse_header(hdr(report_date="30.09.2025"), rules), rules)
assert layout.segments[1].value_cols == ((8, 16), (9, 21), (10, 24))

View File

@ -44,7 +44,7 @@ def test_lookalikes_only_near_digit(rules: Rules) -> None:
def test_disguised_digit(rules: Rules) -> None:
assert is_disguised_digit("I", rules.lookalikes) and is_disguised_digit("| О", rules.lookalikes)
assert not is_disguised_digit("Косарев", rules.lookalikes)
assert not is_disguised_digit("Иванов", rules.lookalikes)
assert not is_disguised_digit("(расшифровка подписи)", rules.lookalikes)

View File

@ -39,7 +39,7 @@ def has_digit(text: str | None) -> bool:
def is_disguised_digit(text: str, lookalikes: Mapping[str, str]) -> bool:
"""После замены двойников везде есть цифра и нет других букв («I», «| О» — да; «Косарев» — нет)."""
"""После замены двойников везде есть цифра и нет других букв («I», «| О» — да; «Иванов» — нет)."""
fixed = fix_lookalikes(text, lookalikes, anywhere=True)
return has_digit(fixed) and not any(char.isalpha() for char in fixed)