Итог ищется по точному «Итого» раньше «Итого …»; хвост — только числа в колонках таблицы (#47, AURORA-1664)
I-1: _find_total сначала берёт первую строку корневого уровня с текстом ровно «Итого» (новый ключ total_row_exact в osv_headers.yaml) и лишь без неё — первую по total_row. Контрагент «Итого-Сервис» на плоском листе без outline и отступов больше не обрезает таблицу. O-3: предупреждение о хвосте считает числа только в колонках таблицы (text_col…right_edge из TableHeader); контрольные суммы правее таблицы (rb/2024-Q1) его не дают, ослабление в test_classify_real откачено. Синтетика s32: итог «Итого по счету 60» без точного «Итого» (нужна тесту покрытия для запасного паттерна total_row). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
6efe79fec9
commit
195720c97b
@ -70,7 +70,7 @@ CORS, регистрация SMB-сессии, импорт `raisa_fastapi_prote
|
|||||||
обращения к ячейке константами. Значения enum контракта вычисляются из `src.contract` и под линтер не попадают.
|
обращения к ячейке константами. Значения enum контракта вычисляются из `src.contract` и под линтер не попадают.
|
||||||
- Каждая запись словаря должна срабатывать хотя бы на одном реальном или синтетическом файле
|
- Каждая запись словаря должна срабатывать хотя бы на одном реальном или синтетическом файле
|
||||||
(`tests/fixtures/test_rules_coverage.py`); мёртвый синоним удаляется, пробел в фикстурах закрывается синтетикой.
|
(`tests/fixtures/test_rules_coverage.py`); мёртвый синоним удаляется, пробел в фикстурах закрывается синтетикой.
|
||||||
- Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 126 файлов: 72 реальных + 23 пакета ДКБ (`osv-dkb/**` до 1 МиБ, снимки в `osv-dkb/`) + 31 синтетический) — регрессия, а не
|
- Golden-снимки (`../fixtures/golden/expected_osv_parse/`, 127 файлов: 72 реальных + 23 пакета ДКБ (`osv-dkb/**` до 1 МиБ, снимки в `osv-dkb/`) + 32 синтетических) — регрессия, а не
|
||||||
оракул; правятся только `make golden-update` с просмотром diff и отдельным коммитом в `fixtures`. Корректность
|
оракул; правятся только `make golden-update` с просмотром diff и отдельным коммитом в `fixtures`. Корректность
|
||||||
проверяют property-тест и ручная сверка «Итого».
|
проверяют property-тест и ручная сверка «Итого».
|
||||||
- Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не
|
- Деньги — `Decimal`, `float` в домене нет; `needs_check` — только по контрольным суммам, структуру под сходимость не
|
||||||
|
|||||||
@ -42,6 +42,11 @@ service_columns:
|
|||||||
total_row:
|
total_row:
|
||||||
- '^итого\b'
|
- '^итого\b'
|
||||||
|
|
||||||
|
# Итоговая строка, чей текст — ровно «Итого»: ищется первой, чтобы контрагент «Итого-Сервис» на плоском листе (без outline
|
||||||
|
# и отступов) не обрезал таблицу; нет такой — действует `total_row` (начало текста)
|
||||||
|
total_row_exact:
|
||||||
|
- '^итого$'
|
||||||
|
|
||||||
# Вторая итоговая строка после «Итого» («Итого развернутое» в 1С): тоже TOTAL, но «Итого» листа — основная, первая из них
|
# Вторая итоговая строка после «Итого» («Итого развернутое» в 1С): тоже TOTAL, но «Итого» листа — основная, первая из них
|
||||||
total_row_secondary:
|
total_row_secondary:
|
||||||
- '^итого\s+развернут'
|
- '^итого\s+развернут'
|
||||||
|
|||||||
@ -118,7 +118,9 @@ def _find_total(sheet: Sheet, header: TableHeader, rows: Sequence[int], rules: R
|
|||||||
"""Строка «Итого»: первая строка таблицы, чей текст матчит `total_row`, на корневом уровне оформления.
|
"""Строка «Итого»: первая строка таблицы, чей текст матчит `total_row`, на корневом уровне оформления.
|
||||||
|
|
||||||
Таблица кончается на первой «Итого» (AURORA-1664): всё ниже — вне документа, даже если там снова строки «Итого …»
|
Таблица кончается на первой «Итого» (AURORA-1664): всё ниже — вне документа, даже если там снова строки «Итого …»
|
||||||
(аналитик дописывает под ОСВ подпись и свои блоки ГК/ГСК с итогами). Суммы не требуются (R-13: пустая ОСВ —
|
(аналитик дописывает под ОСВ подпись и свои блоки ГК/ГСК с итогами). Сначала ищется первая строка корневого уровня,
|
||||||
|
чей текст — ровно «Итого» (`total_row_exact`), и только если такой нет — первая по `total_row` («Итого …»; I-1:
|
||||||
|
иначе контрагент «Итого-Сервис» на плоском листе обрезал бы таблицу). Суммы не требуются (R-13: пустая ОСВ —
|
||||||
«Итого» без сумм). Выбор — до уровней (их каскад считает с уже известной строкой «Итого»), поэтому «корневой
|
«Итого» без сумм). Выбор — до уровней (их каскад считает с уже известной строкой «Итого»), поэтому «корневой
|
||||||
уровень» проверяется по оформлению: outline level и отступ строки не больше, чем у любой другой строки таблицы.
|
уровень» проверяется по оформлению: outline level и отступ строки не больше, чем у любой другой строки таблицы.
|
||||||
Так контрагент «Итого-Сервис» под счётом с outline/отступом — не TOTAL (T3.3), а настоящее «Итого» ниже него —
|
Так контрагент «Итого-Сервис» под счётом с outline/отступом — не TOTAL (T3.3), а настоящее «Итого» ниже него —
|
||||||
@ -126,30 +128,42 @@ def _find_total(sheet: Sheet, header: TableHeader, rows: Sequence[int], rules: R
|
|||||||
Вторая итоговая строка («Итого развернутое», `total_row_secondary`) основной не бывает: основная — обычная «Итого»
|
Вторая итоговая строка («Итого развернутое», `total_row_secondary`) основной не бывает: основная — обычная «Итого»
|
||||||
(в плоской форме она может стоять после «Итого развернутое»); без обычной «Итого» первая вторая строка служит
|
(в плоской форме она может стоять после «Итого развернутое»); без обычной «Итого» первая вторая строка служит
|
||||||
единственной итоговой.
|
единственной итоговой.
|
||||||
Известный риск: на листе без outline и отступов контрагент «Итого-…» на корневом уровне становится TOTAL; это ловит
|
Остаточный риск: если точного «Итого» нет вовсе, а на листе нет outline и отступов, контрагент «Итого-…» на корневом
|
||||||
проверка «TOTAL = корень» (§1.4).
|
уровне становится TOTAL; это ловит проверка «TOTAL = корень» (§1.4).
|
||||||
"""
|
"""
|
||||||
if not rows:
|
if not rows:
|
||||||
return None
|
return None
|
||||||
outline_floor = min(sheet.outline(row) for row in rows)
|
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)
|
indent_floor = min(_text_cell_style(sheet, row, header.text_col)[1] for row in rows)
|
||||||
|
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
|
only_secondary: int | None = None
|
||||||
for row in rows:
|
for row in roots:
|
||||||
text = sheet.text_at(row, header.text_col)
|
text = sheet.text_at(row, header.text_col)
|
||||||
if not any(pattern.search(text) for pattern in rules.headers.total_row):
|
if not any(pattern.search(text) for pattern in patterns):
|
||||||
continue
|
|
||||||
_, indent = _text_cell_style(sheet, row, header.text_col)
|
|
||||||
if sheet.outline(row) != outline_floor or indent != indent_floor:
|
|
||||||
continue
|
continue
|
||||||
if not _is_secondary_total(text, rules):
|
if not _is_secondary_total(text, rules):
|
||||||
return row
|
return row
|
||||||
only_secondary = only_secondary or row
|
only_secondary = only_secondary or row
|
||||||
|
if only_secondary is not None:
|
||||||
return only_secondary
|
return only_secondary
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _numbers_below(sheet: Sheet, last_row: int) -> list[int]:
|
def _numbers_below(sheet: Sheet, header: TableHeader, last_row: int) -> list[int]:
|
||||||
"""Строки ниже `last_row` (по всему листу, в любых колонках) с числовыми ячейками: хвост вне документа."""
|
"""Строки ниже `last_row` с числовыми ячейками в колонках таблицы (от колонки текста до правого края сумм):
|
||||||
return sorted({row for (row, _), cell in sheet.cells.items() if row > last_row and cell.kind == "number"})
|
хвост вне документа. Числа правее таблицы (контрольные суммы) хвостом не считаются."""
|
||||||
|
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:
|
def _tail_warning(rows: Sequence[int]) -> str:
|
||||||
@ -281,7 +295,7 @@ def collect_body_rows(
|
|||||||
)
|
)
|
||||||
if total_row is not None:
|
if total_row is not None:
|
||||||
last = rows.index(_trailing_totals(sheet, header, rows, total_row, rules))
|
last = rows.index(_trailing_totals(sheet, header, rows, total_row, rules))
|
||||||
tail = _numbers_below(sheet, rows[last])
|
tail = _numbers_below(sheet, header, rows[last])
|
||||||
if tail:
|
if tail:
|
||||||
warnings.append(_tail_warning(tail))
|
warnings.append(_tail_warning(tail))
|
||||||
rows = rows[: last + 1]
|
rows = rows[: last + 1]
|
||||||
|
|||||||
@ -179,6 +179,7 @@ class OsvHeaderRules(_Model):
|
|||||||
credit: list[Pattern]
|
credit: list[Pattern]
|
||||||
service_columns: dict[str, list[Pattern]]
|
service_columns: dict[str, list[Pattern]]
|
||||||
total_row: list[Pattern]
|
total_row: list[Pattern]
|
||||||
|
total_row_exact: list[Pattern]
|
||||||
total_row_secondary: list[Pattern]
|
total_row_secondary: list[Pattern]
|
||||||
column_captions: list[Pattern]
|
column_captions: list[Pattern]
|
||||||
counterparty_inn_suffix: list[Pattern]
|
counterparty_inn_suffix: list[Pattern]
|
||||||
|
|||||||
3
api/tests/fixtures/test_classify_real.py
vendored
3
api/tests/fixtures/test_classify_real.py
vendored
@ -25,8 +25,7 @@ def test_real_osv_has_exactly_one_total_as_last_root_row(osv_file: Path) -> None
|
|||||||
total = totals[0]
|
total = totals[0]
|
||||||
assert total is parsed.rows[-1]
|
assert total is parsed.rows[-1]
|
||||||
assert (total.level, total.parent, total.body.row) == (0, None, parsed.total_row)
|
assert (total.level, total.parent, total.body.row) == (0, None, parsed.total_row)
|
||||||
# Допустимо одно предупреждение: числа в хвосте под «Итого» (контрольные суммы правее таблицы, AURORA-1664)
|
assert parsed.warnings == []
|
||||||
assert [warning for warning in parsed.warnings if "ниже таблицы" not in warning] == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.fixtures
|
@pytest.mark.fixtures
|
||||||
|
|||||||
@ -2,10 +2,10 @@
|
|||||||
|
|
||||||
Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл, s14…s16 — записи словарей, которых нет в реальных
|
Файлы s01…s13 — по одному сценарию спеки `osv-parsing` на файл, s14…s16 — записи словарей, которых нет в реальных
|
||||||
ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9), s20…s27 — восемь
|
ОСВ (R-22), s17…s19 — ИНН в тексте названия, ИНН строкой выше и «ИНН/КПП» в одной ячейке (issue #9), s20…s27 — восемь
|
||||||
раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35), s29…s31 — xls без CODEPAGE, «Вал»/«Кол-во» и
|
раскладок строки ИНН, s28 — ИНН с неверной контрольной суммой (#35), s29…s32 — xls без CODEPAGE, «Вал»/«Кол-во»,
|
||||||
строки ниже «Итого» (#47); описание — `osv/synthetic/README.md` в репозитории fixtures. Генератор самодостаточен
|
строки ниже «Итого» и «Итого по счету» вместо точного «Итого» (#47); описание — `osv/synthetic/README.md`
|
||||||
(openpyxl + `xlsb_writer` + `xls_writer`) и детерминирован: повторный запуск даёт те же байты —
|
в репозитории fixtures. Генератор самодостаточен (openpyxl + `xlsb_writer` + `xls_writer`) и
|
||||||
метки времени книги и zip постоянны, порядок записи фиксирован.
|
детерминирован: повторный запуск даёт те же байты — метки времени книги и zip постоянны, порядок записи фиксирован.
|
||||||
|
|
||||||
Суммы арифметически согласованы по построению: у листа дерева сальдо на конец вычисляется из начала и оборотов, у
|
Суммы арифметически согласованы по построению: у листа дерева сальдо на конец вычисляется из начала и оборотов, у
|
||||||
родителя — сумма детей по колонкам; поэтому все файлы, кроме `s10_broken_sum`, сходятся по §1.4. Позиции колонок и
|
родителя — сумма детей по колонкам; поэтому все файлы, кроме `s10_broken_sum`, сходятся по §1.4. Позиции колонок и
|
||||||
@ -729,6 +729,15 @@ def _s31() -> list[SheetSpec]:
|
|||||||
return [sheet]
|
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]:
|
def _s28() -> list[SheetSpec]:
|
||||||
"""Раскладка s20 («над, не в дереве, без сумм»), но ИНН ООО Альфа с опечаткой: контрольная сумма не сходится."""
|
"""Раскладка s20 («над, не в дереве, без сумм»), но ИНН ООО Альфа с опечаткой: контрольная сумма не сходится."""
|
||||||
tree = _inn_layout_tree()
|
tree = _inn_layout_tree()
|
||||||
@ -832,6 +841,7 @@ FILES: dict[str, tuple[Callable[[], list[SheetSpec]], Callable[[Sequence[SheetSp
|
|||||||
"s29_xls_no_codepage.xls": (_s29, to_xls),
|
"s29_xls_no_codepage.xls": (_s29, to_xls),
|
||||||
"s30_val_kol_vo_materials.xlsx": (_s30, to_xlsx),
|
"s30_val_kol_vo_materials.xlsx": (_s30, to_xlsx),
|
||||||
"s31_rows_below_total.xlsx": (_s31, to_xlsx),
|
"s31_rows_below_total.xlsx": (_s31, to_xlsx),
|
||||||
|
"s32_total_with_suffix.xlsx": (_s32, to_xlsx),
|
||||||
}
|
}
|
||||||
FILE_NAMES = tuple(FILES)
|
FILE_NAMES = tuple(FILES)
|
||||||
|
|
||||||
@ -850,7 +860,7 @@ def generate_all(directory: Path) -> list[Path]:
|
|||||||
def main(argv: Sequence[str] | None = None) -> int:
|
def main(argv: Sequence[str] | None = None) -> int:
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
prog="python -m tests.synthetic.generate",
|
prog="python -m tests.synthetic.generate",
|
||||||
description="Синтетические ОСВ s01…s31 (xlsx, один xlsb и один xls) в указанный каталог.",
|
description="Синтетические ОСВ s01…s32 (xlsx, один xlsb и один xls) в указанный каталог.",
|
||||||
)
|
)
|
||||||
parser.add_argument("directory", type=Path, help="каталог назначения, например ../../fixtures/osv/synthetic")
|
parser.add_argument("directory", type=Path, help="каталог назначения, например ../../fixtures/osv/synthetic")
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|||||||
@ -41,6 +41,7 @@ INN_BAD_CHECKSUM_FILE = "s28_inn_bad_checksum.xlsx"
|
|||||||
XLS_NO_CODEPAGE_FILE = "s29_xls_no_codepage.xls"
|
XLS_NO_CODEPAGE_FILE = "s29_xls_no_codepage.xls"
|
||||||
VAL_KOL_VO_FILE = "s30_val_kol_vo_materials.xlsx"
|
VAL_KOL_VO_FILE = "s30_val_kol_vo_materials.xlsx"
|
||||||
ROWS_BELOW_TOTAL_FILE = "s31_rows_below_total.xlsx"
|
ROWS_BELOW_TOTAL_FILE = "s31_rows_below_total.xlsx"
|
||||||
|
TOTAL_WITH_SUFFIX_FILE = "s32_total_with_suffix.xlsx"
|
||||||
TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28
|
TYPO_ROW = 13 # строка «7701234567» с неверной контрольной суммой в s28
|
||||||
EXPECTED_NAMES = [
|
EXPECTED_NAMES = [
|
||||||
"s01_variant2_header.xlsx",
|
"s01_variant2_header.xlsx",
|
||||||
@ -67,6 +68,7 @@ EXPECTED_NAMES = [
|
|||||||
XLS_NO_CODEPAGE_FILE,
|
XLS_NO_CODEPAGE_FILE,
|
||||||
VAL_KOL_VO_FILE,
|
VAL_KOL_VO_FILE,
|
||||||
ROWS_BELOW_TOTAL_FILE,
|
ROWS_BELOW_TOTAL_FILE,
|
||||||
|
TOTAL_WITH_SUFFIX_FILE,
|
||||||
]
|
]
|
||||||
XLSB_NAME = "s13_no_outline_indent.xlsb"
|
XLSB_NAME = "s13_no_outline_indent.xlsb"
|
||||||
XLSB_MISSING_REASON = f"нет файла {XLSB_NAME} (xlsb не получен; см. README fixtures)"
|
XLSB_MISSING_REASON = f"нет файла {XLSB_NAME} (xlsb не получен; см. README fixtures)"
|
||||||
@ -140,6 +142,8 @@ EXPECTED: dict[str, Expect] = {
|
|||||||
VAL_KOL_VO_FILE: Expect("outline", 2025, "Q2", False, variant=2),
|
VAL_KOL_VO_FILE: Expect("outline", 2025, "Q2", False, variant=2),
|
||||||
# подпись и блок чисел под «Итого»
|
# подпись и блок чисел под «Итого»
|
||||||
ROWS_BELOW_TOTAL_FILE: Expect("outline", 2025, "Q1", True),
|
ROWS_BELOW_TOTAL_FILE: Expect("outline", 2025, "Q1", True),
|
||||||
|
# итог подписан «Итого по счету 60»: точного «Итого» нет
|
||||||
|
TOTAL_WITH_SUFFIX_FILE: Expect("outline", 2025, "Q1", True),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@ -211,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:
|
def test_synthetic_set_is_complete(fixtures_dir: Path) -> None:
|
||||||
"""В `fixtures/osv/synthetic/` лежат файлы набора s01…s31 (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())
|
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]
|
assert present == [name for name in EXPECTED_NAMES if name in present]
|
||||||
missing = [name for name in EXPECTED_NAMES if name not in present]
|
missing = [name for name in EXPECTED_NAMES if name not in present]
|
||||||
@ -664,3 +668,11 @@ def test_s31_rows_below_total_are_outside_the_document(fixtures_dir: Path) -> No
|
|||||||
assert [row.group_type for row in doc.rows].count("TOTAL") == 1
|
assert [row.group_type for row in doc.rows].count("TOTAL") == 1
|
||||||
assert not {"Ответственный:", "ООО Альфа (ГК)", "Итого по ГК"} & set(rows_text(doc))
|
assert not {"Ответственный:", "ООО Альфа (ГК)", "Итого по ГК"} & set(rows_text(doc))
|
||||||
assert any("ниже таблицы" in warning for warning in warnings_of(outcome)), warnings_of(outcome)
|
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"
|
||||||
|
|||||||
@ -545,6 +545,19 @@ def test_second_total_then_tail_numbers(rules: Rules) -> None:
|
|||||||
assert any(TAIL_NUMBERS_MARK in warning for warning in parsed.warnings)
|
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:
|
def test_column_filled_only_in_footer_is_not_an_extra_key(rules: Rules) -> None:
|
||||||
"""T4.5: колонка, заполненная только в подвале, не становится ключом `extra` у строк таблицы."""
|
"""T4.5: колонка, заполненная только в подвале, не становится ключом `extra` у строк таблицы."""
|
||||||
sheet = sheet_from_rows(
|
sheet = sheet_from_rows(
|
||||||
@ -865,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
|
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:
|
def test_last_row_named_like_total_is_rejected_by_outline_level(rules: Rules) -> None:
|
||||||
"""Последняя строка «Итого-Сервис» без настоящего «Итого»: outline глубже минимального — не TOTAL."""
|
"""Последняя строка «Итого-Сервис» без настоящего «Итого»: outline глубже минимального — не TOTAL."""
|
||||||
sheet = osv(
|
sheet = osv(
|
||||||
@ -879,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:
|
def test_known_risk_last_counterparty_named_like_total_without_outline(rules: Rules) -> None:
|
||||||
"""Известный риск (документирован): без outline и отступов последняя строка «Итого-Сервис» при отсутствии
|
"""Остаточный риск (документирован): только когда точного «Итого» нет вовсе, а на листе нет outline и отступов,
|
||||||
настоящего «Итого» неотличима от итоговой строки и становится TOTAL; контрольные суммы §1.4 (TOTAL = корень)
|
первая строка «Итого-Сервис» на корневом уровне неотличима от итоговой строки и становится TOTAL; контрольные суммы
|
||||||
это поймают, если суммы не совпадают.
|
§1.4 (TOTAL = корень) это поймают, если суммы не совпадают.
|
||||||
"""
|
"""
|
||||||
sheet = osv(
|
sheet = osv(
|
||||||
r(None, "60", S100),
|
r(None, "60", S100),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user