ОСВ: предупреждение о противоречии раскладки ИНН и подсказки шапки, синтетика s28 с неверной контрольной суммой (#35)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1f96012884
commit
6d97fa2c94
@ -368,6 +368,22 @@ def _without(rows: Sequence[BodyRow], levels: Levels, removed: set[int]) -> tupl
|
||||
return kept, Levels(by_row=by_row, parent=parents(kept, by_row), strategy=levels.strategy)
|
||||
|
||||
|
||||
def _contradicts(row: int, partner: int, hint: InnHint) -> bool:
|
||||
"""Фактическая раскладка противоречит подсказке шапки: ИНН стоит под наименованием (строка пары выше) при
|
||||
подсказке «над» или над ним (строка пары ниже) при подсказке «под». Годится для пар в дереве и для соседей."""
|
||||
actual: Direction = "below" if partner < row else "above"
|
||||
return actual != hint.direction
|
||||
|
||||
|
||||
def _contradiction(row: int, partner: int, inn: str, hint: InnHint) -> str:
|
||||
"""Склейка состоялась, но раскладка не та, что указана в шапке (design, решение 10): подсветить, проверка."""
|
||||
where, expected = ("под", "над") if hint.direction == "above" else ("над", "под")
|
||||
return (
|
||||
f"строка {row}: ИНН {inn} стоит {where} наименованием в строке {partner}, а шапка («{hint.name}») "
|
||||
f"указывает {expected} — проверьте"
|
||||
)
|
||||
|
||||
|
||||
def _result( # noqa: PLR0913, PLR0917 - сборка результата из всех частей прохода
|
||||
rows: Sequence[BodyRow],
|
||||
levels: Levels,
|
||||
@ -402,6 +418,9 @@ def _result( # noqa: PLR0913, PLR0917 - сборка результата из
|
||||
continue
|
||||
inns[partner] = inn
|
||||
warnings.append(f"строка {row}: ИНН {inn}{label} отнесён к контрагенту в строке {partner}")
|
||||
if hint is not None and _contradicts(row, partner, hint):
|
||||
ok = False
|
||||
warnings.append(_contradiction(row, partner, inn, hint))
|
||||
elif row in pairs.reason:
|
||||
shown = "" if inn is None else f" {inn}"
|
||||
warnings.append(f"строка {row}: ИНН{shown}{label} без пары — {pairs.reason[row]}")
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл, s14…s16 — записи словарей, которых нет в реальных
|
||||
ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9), s20…s27 — восемь
|
||||
раскладок строки ИНН (#35); описание —
|
||||
раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35); описание —
|
||||
`osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен (openpyxl +
|
||||
`xlsb_writer`) и детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок
|
||||
записи фиксирован.
|
||||
@ -687,6 +687,14 @@ def _inn_layout_tree() -> Node:
|
||||
return Node("60", [Node("60.01", [first, beta(), ivanov()]), Node("60.02", [third, sidorov()])])
|
||||
|
||||
|
||||
def _s28() -> list[SheetSpec]:
|
||||
"""Раскладка s20 («над, не в дереве, без сумм»), но ИНН ООО Альфа с опечаткой: контрольная сумма не сходится."""
|
||||
tree = _inn_layout_tree()
|
||||
tree.kids[0].kids[0].inn = "7701234567"
|
||||
root = inn_rows_tree(tree, "above_sibling", sums=False)
|
||||
return [build_osv(OsvSpec(period="1 квартал 2025 г.", inn_col=None, first_sum_col=5), root)]
|
||||
|
||||
|
||||
def _inn_layout(layout: InnLayout, *, sums: bool) -> Callable[[], list[SheetSpec]]:
|
||||
def build() -> list[SheetSpec]:
|
||||
root = inn_rows_tree(_inn_layout_tree(), layout, sums=sums)
|
||||
@ -771,6 +779,7 @@ FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSp
|
||||
"s25_inn_below_child_sums.xlsx": (_inn_layout("below_child", sums=True), to_xlsx),
|
||||
"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),
|
||||
}
|
||||
FILE_NAMES = tuple(FILES)
|
||||
|
||||
@ -789,7 +798,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…s27 (xlsx и один xlsb) в указанный каталог.",
|
||||
description="Синтетические ОСВ s01…s28 (xlsx и один xlsb) в указанный каталог.",
|
||||
)
|
||||
parser.add_argument("directory", type=Path, help="каталог назначения, например ../../fixtures/osv/synthetic")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
@ -36,6 +36,7 @@ INN_LAYOUT_FILES = (
|
||||
"s26_inn_below_sibling_sums.xlsx",
|
||||
"s27_inn_below_sibling_blank.xlsx",
|
||||
)
|
||||
INN_BAD_CHECKSUM_FILE = "s28_inn_bad_checksum.xlsx"
|
||||
EXPECTED_NAMES = [
|
||||
"s01_variant2_header.xlsx",
|
||||
"s02_no_outline_indent.xlsx",
|
||||
@ -57,6 +58,7 @@ EXPECTED_NAMES = [
|
||||
"s18_inn_row_above.xlsx",
|
||||
"s19_inn_kpp_cell.xlsx",
|
||||
*INN_LAYOUT_FILES,
|
||||
INN_BAD_CHECKSUM_FILE,
|
||||
]
|
||||
XLSB_NAME = "s13_no_outline_indent.xlsb"
|
||||
XLSB_MISSING_REASON = f"нет файла {XLSB_NAME} (xlsb не получен; см. README fixtures)"
|
||||
@ -122,6 +124,8 @@ EXPECTED: dict[str, Expect] = {
|
||||
"s19_inn_kpp_cell.xlsx": Expect("outline", 2025, "Q3", False),
|
||||
# восемь раскладок строки ИНН (#35)
|
||||
**{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),
|
||||
}
|
||||
|
||||
|
||||
@ -193,7 +197,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…s27 (s13 — xlsb; допустима деградация: нет только его)."""
|
||||
"""В `fixtures/osv/synthetic/` лежат файлы набора s01…s28 (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]
|
||||
@ -570,6 +574,24 @@ def test_inn_row_layouts_give_inn_to_own_counterparty(fixtures_dir: Path, name:
|
||||
assert not [row for row in doc.rows if row.row_value in inns]
|
||||
|
||||
|
||||
def test_s28_bad_checksum_inn_row_stays_a_row_and_needs_check(fixtures_dir: Path) -> None:
|
||||
"""Опечатка в ИНН ООО Альфа: «7701234567» остаётся обычной строкой, у Альфы ИНН нет, предупреждение, проверка;
|
||||
остальные контрагенты получают свои ИНН (направление «над» — по блоку 60.02)."""
|
||||
outcome = parse_named(fixtures_dir, INN_BAD_CHECKSUM_FILE)
|
||||
doc = one_document(outcome)
|
||||
|
||||
assert outcome.status is ResultStatus.NEEDS_CHECK
|
||||
counterparties = {
|
||||
row.counterparty_name: row.counterparty_inn for row in doc.rows if row.group_type == "COUNTERPARTY"
|
||||
}
|
||||
assert counterparties.pop("7701234567") is None # обычная строка: классификатор принял её за контрагента
|
||||
assert counterparties == {**INN_LAYOUT_COUNTERPARTIES, ALFA: None}
|
||||
assert any(
|
||||
"похожа на ИНН 7701234567" in warning and "контрольная сумма не сходится" in warning
|
||||
for warning in warnings_of(outcome)
|
||||
)
|
||||
|
||||
|
||||
def test_s19_inn_kpp_cell_gives_inn_only(fixtures_dir: Path) -> None:
|
||||
"""«ИНН/КПП» в одной ячейке: в `counterparty_inn` — только ИНН; колонка распознана как колонка ИНН."""
|
||||
doc = one_document(parse_named(fixtures_dir, "s19_inn_kpp_cell.xlsx"))
|
||||
|
||||
@ -475,3 +475,56 @@ def test_inn_without_name_neighbour_in_direction(rules: Rules) -> None:
|
||||
|
||||
assert glue.unpaired == frozenset({3})
|
||||
assert has_warning(glue, "строка 3", "нет наименования")
|
||||
|
||||
|
||||
# --- Раскладка противоречит подсказке шапки: склеить, но подсветить (H-2, design #10) ---
|
||||
|
||||
|
||||
def conflict_warning(glue: InnGlue, row: int, partner: int, where: str, expected: str) -> bool:
|
||||
return has_warning(
|
||||
glue,
|
||||
f"строка {row}: ИНН",
|
||||
f"стоит {where} наименованием в строке {partner}",
|
||||
f"указывает {expected} — проверьте",
|
||||
)
|
||||
|
||||
|
||||
def test_hint_above_inn_child_of_name_is_glued_with_conflict_warning(rules: Rules) -> None:
|
||||
# T2.12: подсказка «над», строка ИНН без детей под родителем-контрагентом — склеена с родителем, но подсвечена
|
||||
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, INN_A, A), hint=ABOVE)
|
||||
|
||||
assert (glue.inns, glue.unpaired, glue.ok) == ({2: INN_A}, frozenset(), False)
|
||||
assert has_warning(glue, f"ИНН {INN_A} («Контрагенты.ИНН») отнесён к контрагенту в строке 2")
|
||||
assert conflict_warning(glue, 3, 2, "под", "над")
|
||||
assert has_warning(glue, "шапка («Контрагенты.ИНН»)")
|
||||
|
||||
|
||||
def test_hint_below_inn_parent_of_name_is_glued_with_conflict_warning(rules: Rules) -> None:
|
||||
glue = run(rules, r(0, "60", A), r(1, INN_A, A), r(2, "ООО Альфа", A), hint=BELOW)
|
||||
|
||||
assert (glue.inns, glue.unpaired, glue.ok) == ({3: INN_A}, frozenset(), False)
|
||||
assert conflict_warning(glue, 2, 3, "над", "под")
|
||||
|
||||
|
||||
def test_hint_above_sibling_below_name_by_sums_is_glued_with_conflict_warning(rules: Rules) -> None:
|
||||
glue = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A, A), r(1, "ООО Бета", B), hint=ABOVE)
|
||||
|
||||
assert (glue.inns, glue.unpaired, glue.ok) == ({2: INN_A}, frozenset(), False)
|
||||
assert conflict_warning(glue, 3, 2, "под", "над")
|
||||
|
||||
|
||||
def test_hint_below_sibling_above_name_by_sums_is_glued_with_conflict_warning(rules: Rules) -> None:
|
||||
glue = run(rules, r(0, "60", A), r(1, "ООО Бета", B), r(1, INN_A, A), r(1, "ООО Альфа", A), hint=BELOW)
|
||||
|
||||
assert (glue.inns, glue.unpaired, glue.ok) == ({4: INN_A}, frozenset(), False)
|
||||
assert conflict_warning(glue, 3, 4, "над", "под")
|
||||
|
||||
|
||||
def test_hint_agreeing_with_layout_has_no_conflict_warning(rules: Rules) -> None:
|
||||
below = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(2, INN_A, A), hint=BELOW)
|
||||
above = run(rules, r(0, "60", A), r(1, INN_A, A), r(2, "ООО Альфа", A), hint=ABOVE)
|
||||
blank = run(rules, r(0, "60", A), r(1, "ООО Альфа", A), r(1, INN_A), hint=BELOW)
|
||||
|
||||
for glue in (below, above, blank):
|
||||
assert glue.ok
|
||||
assert not has_warning(glue, "проверьте")
|
||||
|
||||
@ -47,6 +47,14 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs
|
||||
7. **«Под, в дереве»** — несколько строк ИНН под одним наименованием — `needs_check` (как #32).
|
||||
8. **Строка ИНН — только ИНН**: 10/12 цифр числом или текстом, «ИНН …», «ИНН/КПП» (`inn_only_row`); счёт строкой ИНН не
|
||||
бывает.
|
||||
9. **Синтетика для отказа «неверная контрольная сумма»** (решение владельца 13.10.2026, H-1): «синтетика для отказа
|
||||
«неверная контрольная сумма» — да». Файл `s28_inn_bad_checksum.xlsx`: раскладка `s20` («над, не в дереве, без сумм»),
|
||||
у ООО Альфа строка ИНН `7701234567`; ожидание — строка остаётся обычной, ИНН у Альфы нет, предупреждение «похожа на ИНН»,
|
||||
`needs_check`, остальные контрагенты получают свои ИНН (направление — по блоку 60.02).
|
||||
10. **Противоречие раскладки и подсказки шапки** (решение владельца 13.10.2026, H-2): «Противоречие раскладки и подсказки
|
||||
шапки — СКЛЕИВАТЬ, НО ПОДСВЕЧИВАТЬ». Пара найдена, но ИНН стоит не там, где указывает подсказка `<группировка>.ИНН`
|
||||
(в дереве или соседом по суммам) — склейка как обычно, отдельное предупреждение «строка N: ИНН … стоит <под/над>
|
||||
наименованием в строке M, а шапка («…») указывает <над/под> — проверьте», `ok = False`, `needs_check`. Закрывает T2.12.
|
||||
|
||||
### Алгоритм `inn_rows.py`
|
||||
|
||||
@ -83,7 +91,8 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs
|
||||
от ИНН строки — колонка побеждает, предупреждение, `needs_check`. ИНН в хвосте названия — строка ИНН побеждает
|
||||
(прежний порядок §1.3).
|
||||
9. **Склейка**: строка ИНН уходит из строк и `Levels`, ИНН — наименованию, предупреждение «строка N: ИНН … отнесён к
|
||||
контрагенту в строке M» (пустой при подсказке — «ИНН пуст … без ИНН»).
|
||||
контрагенту в строке M» (пустой при подсказке — «ИНН пуст … без ИНН»). Если раскладка противоречит подсказке шапки
|
||||
(пара выше/ниже, чем указывает подсказка) — следом отдельное предупреждение о противоречии, `ok = False` (решение 10).
|
||||
10. **Без пары**: строка остаётся в `rows`, `group_type = OTHER` (в свод не попадает, суммы не задваиваются, как #32),
|
||||
предупреждение «строка N: ИНН … без пары — <причина>», признак «всё склеено» = `False` → документ `needs_check`.
|
||||
|
||||
@ -143,3 +152,5 @@ Issue RSHB/arm-tabular-parser#35 (входит #34). Правило — `../docs
|
||||
| 16 | 13.10.2026 | R-3 (design #16, T2.3): защита «над, в дереве» (нет лишнего уровня) применяется только без подсказки в шапке | Подсказка `<группировка>.ИНН` — прямое свидетельство дополнительного уровня 1С | Подсказка «над» в шапке при реально отсутствующем уровне: контрагент, названный ИНН, склеится с ребёнком; документ — на проверку не попадает |
|
||||
| 17 | 13.10.2026 | Направление листа без подсказки: сведения пар по суммам и блоков объединяются; противоречие — направление неизвестно (строки ИНН без пары). Спека («первое определённое») читается так же, как design §6 | Design §6 прямо задаёт «противоречат — неизвестно»; «первое» по порядку источников дало бы склейку, которой блоки противоречат | Лист с парами по суммам против блоков — строки ИНН без пары и `needs_check` вместо склейки по сумме; правка объединения — одна функция `_pair_flat` |
|
||||
| 18 | 13.10.2026 | Task 4: параметризованный `test_header_status_and_hierarchy[s18_inn_row_above.xlsx]` (ожидает `done`) и golden s18 не правятся в Task 4 — оба падают только из-за ИНН s18 с неверной контрольной суммой и чинятся вместе с файлом в Task 5–6; в коммите упавшие тесты выведены из прогона (`--deselect`), `test_s18_…` помечен strict xfail | Бриф Task 4 прямо запрещает обновлять golden; ожидание `done` в параметризации относится к тому же файлу | Если Task 5 забудет снять xfail/починить параметризацию, strict xfail упадёт (XPASS) и напомнит |
|
||||
| 19 | 13.10.2026 | H-2: направление фактической раскладки определяется по номерам строк пары (строка пары выше строки ИНН — ИНН «под», ниже — «над»), одинаково для пар в дереве и соседей; проверка выполняется только при подсказке и только для склеенных пар с непустым ИНН и без расхождения с колонкой ИНН | Номер строки однозначно отражает порядок на листе при любом пути поиска пары; пары без ИНН, без пары и с колонкой-победителем уже подсвечены своими предупреждениями | Нет: предупреждение лишь добавляется к уже состоявшейся склейке; результат склейки не меняется |
|
||||
| 20 | 13.10.2026 | H-1: в s28 строка «7701234567» после классификации попадает в `rows` как контрагент (обычная строка по правилам классификатора) — тест фиксирует это «как есть» и проверяет её наличие среди контрагентов без ИНН | Спека: строка «классифицируется как обычно»; подгонки парсера нет | Нет: поведение соответствует ожиданию владельца |
|
||||
|
||||
@ -129,6 +129,13 @@ SHALL получать `group_type = OTHER`, `data_type` CURRENCY/QUANTITY, MUST
|
||||
«строка N: ИНН … отнесён к контрагенту в строке M». ИНН колонки ИНН у наименования, отличный от ИНН строки, остаётся,
|
||||
в `warnings` — расхождение, документ — `needs_check`.
|
||||
|
||||
**Противоречие подсказке шапки:** если пара найдена, но фактическая раскладка противоречит направлению подсказки
|
||||
`<группировка>.ИНН` (подсказка «над», а строка ИНН — ребёнок наименования или сосед ниже него по суммам; подсказка «под»,
|
||||
а строка ИНН — родитель наименования или сосед выше него по суммам), строка ИНН MUST склеиваться как обычно, а в
|
||||
`warnings` MUST быть отдельное предупреждение «строка N: ИНН … стоит <под/над> наименованием в строке M, а шапка
|
||||
(«<группировка>.ИНН») указывает <над/под> — проверьте»; документ — `needs_check`. Для соседей без сумм направление берётся
|
||||
из подсказки, конфликта нет. Раскладка, совпадающая с подсказкой (в том числе «под, в дереве» в 1С), предупреждения не даёт.
|
||||
|
||||
**Без пары:** строка ИНН остаётся в `rows` с `group_type = OTHER` (в свод ядра не попадает), в `warnings` — «строка N:
|
||||
ИНН … без пары — <причина>», документ — `needs_check`. Структура под склейку MUST NOT подгоняться.
|
||||
|
||||
@ -156,6 +163,19 @@ SHALL получать `group_type = OTHER`, `data_type` CURRENCY/QUANTITY, MUST
|
||||
- **WHEN** в синтетической ОСВ без подсказки над наименованием строка «7701234567» (контрольная сумма не сходится)
|
||||
- **THEN** строка не склеивается, в `warnings` — «похожа на ИНН, контрольная сумма не сходится», статус `needs_check`
|
||||
|
||||
#### Scenario: Опечатка в ИНН среди верных (s28)
|
||||
- **WHEN** разбирается синтетическая ОСВ `s28` (раскладка `s20`, но у ООО Альфа строка ИНН «7701234567» с неверной
|
||||
контрольной суммой; шапка без подсказки)
|
||||
- **THEN** строка «7701234567» остаётся обычной строкой, у ООО Альфа `counterparty_inn` нет, в `warnings` — «строка N
|
||||
похожа на ИНН 7701234567, но контрольная сумма не сходится», остальные контрагенты получают свои ИНН (направление «над» —
|
||||
по блоку 60.02), статус `needs_check`
|
||||
|
||||
#### Scenario: Раскладка противоречит подсказке шапки
|
||||
- **WHEN** в шапке `Контрагенты.ИНН` («над»), а строка ИНН без детей стоит под родителем-контрагентом (или наоборот:
|
||||
подсказка «под», строка ИНН — родитель наименования)
|
||||
- **THEN** строка ИНН склеена с этим наименованием, в `warnings` — предупреждение «стоит под (над) наименованием … а шапка
|
||||
… указывает над (под) — проверьте», статус `needs_check`
|
||||
|
||||
#### Scenario: Ведущий ноль у числовой строки ИНН
|
||||
- **WHEN** строка ИНН без подсказки — числовая ячейка `123456788` (9 цифр; `0123456788` проходит контрольную сумму)
|
||||
- **THEN** `counterparty_inn = "0123456788"`
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user