checks.py: тождество сальдо по каждой строке, суммы родителя = Σ детей того же data_type (CURRENCY/QUANTITY исключены; обороты — по колонкам, сальдо — по колонкам или по нетто при свёрнутом сальдо родителя), «Итого» = корневой счёт (R-13: нет корня — «Итого» пусто/0; R-20: нет «Итого» — false и предупреждение). osv.py: OsvParser — лимит размера, чтение по сигнатуре, листы по содержимому (формы ББ/ОФР и прочие — пропуск с предупреждением), карта исходов D-6, кооперативный дедлайн, metrics (rows без TOTAL, warnings с «sheet=<имя> hierarchy=<стратегия>» первой записью по листу). get_parser(settings, rules_dir=…) → OsvParser (словари грузятся при создании: битый словарь — падение старта); PARSE_MAX_BYTES / PARSE_TIMEOUT_SECONDS в Settings и .env.*; app.py передаёт настройки. test_null_parser.py → test_parser_factory.py. Тесты: юнит-карта статусов, «Испорченная сумма», «Пустая ОСВ», два листа ОСВ, новый синоним без релиза кода (R-8); property-тест на 66 реальных файлах (все done, < 5 с, схема v1.1). test_app и интеграционный round-trip ждут ошибку реального парсера вместо NOT_IMPLEMENTED. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
452 lines
20 KiB
Python
452 lines
20 KiB
Python
"""Оркестратор `OsvParser` (design D-6, D-8; spec «Листы ОСВ находятся по содержимому», «Контрольные суммы определяют
|
||
статус», «Форматы, лимиты, время», «Словари — данные, не код»).
|
||
|
||
Книги собираются openpyxl в памяти: лист описывается строками сверху вниз, как в `gridbuilder` (`M` — объединённая
|
||
ячейка, `L` — outline level строки первым элементом). Позиции колонок произвольные — парсер находит их по тексту.
|
||
"""
|
||
|
||
import io
|
||
import itertools
|
||
import shutil
|
||
import time
|
||
from collections.abc import Callable, Sequence
|
||
from decimal import Decimal
|
||
from pathlib import Path
|
||
|
||
import openpyxl
|
||
import pytest
|
||
from gridbuilder import L, M
|
||
from result_contract import assert_valid_result
|
||
|
||
from src.contract import ErrorCode, OsvDocument, ResultStatus
|
||
from src.parser import ParseOutcome, hierarchy
|
||
from src.parser.classify import SUM_FIELDS
|
||
from src.parser.deadline import Deadline
|
||
from src.parser.hierarchy import BodyRow
|
||
from src.parser.osv import OsvParser
|
||
from src.parser.rules import DEFAULT_RULES_DIR, HEADERS_FILE, Rules, load_rules
|
||
|
||
OPENING = "Сальдо на начало периода"
|
||
TURNOVER = "Обороты за период"
|
||
CLOSING = "Сальдо на конец периода"
|
||
TITLE = "Оборотно-сальдовая ведомость по счету 60 за 1 квартал 2025 г."
|
||
MAX_BYTES = 50 * 1024 * 1024
|
||
TIMEOUT_SECONDS = 120
|
||
# Строки мини-ОСВ: шапка 6 строк, блок заголовка 2 строки → данные с 9-й
|
||
ACCOUNT_ROW, SUPPLIER_ROW, CONTRACTOR_ROW, TOTAL_ROW = 9, 10, 11, 12
|
||
DATA_ROWS = 3
|
||
# Часы-заглушка: каждый вызов +100 с; лимит 150 с истекает на второй проверке дедлайна
|
||
CLOCK_STEP = 100
|
||
SHORT_TIMEOUT = 150
|
||
|
||
Cells = list[object]
|
||
|
||
|
||
@pytest.fixture(scope="module")
|
||
def rules() -> Rules:
|
||
return load_rules()
|
||
|
||
|
||
def xlsx(sheets: dict[str, Sequence[Cells]]) -> bytes:
|
||
"""Книга XLSX: имя листа → строки (значение, `M` — merged-диапазон, `L` — outline level первым элементом)."""
|
||
book = openpyxl.Workbook()
|
||
default = book.active
|
||
assert default is not None
|
||
book.remove(default)
|
||
for name, rows in sheets.items():
|
||
sheet = book.create_sheet(name)
|
||
for row_index, cells in enumerate(rows, start=1):
|
||
col = 1
|
||
for value in cells:
|
||
if isinstance(value, L):
|
||
sheet.row_dimensions[row_index].outline_level = value.level
|
||
continue
|
||
if isinstance(value, M):
|
||
sheet.cell(row_index, col, value.value)
|
||
sheet.merge_cells(
|
||
start_row=row_index,
|
||
start_column=col,
|
||
end_row=row_index + value.height - 1,
|
||
end_column=col + value.width - 1,
|
||
)
|
||
col += value.width
|
||
continue
|
||
if value is not None:
|
||
sheet.cell(row_index, col, value)
|
||
col += 1
|
||
buffer = io.BytesIO()
|
||
book.save(buffer)
|
||
return buffer.getvalue()
|
||
|
||
|
||
def head(opening: str = OPENING, title: str = TITLE) -> list[Cells]:
|
||
"""Шапка варианта 1 и блок заголовка «Счет | начало | обороты | конец», группировки Счет / Контрагенты (с ИНН)."""
|
||
return [
|
||
['АО "Ромашка"'], # 1
|
||
[title], # 2
|
||
[], # 3
|
||
["Выводимые данные: БУ (данные бухгалтерского учета)"], # 4
|
||
["Параметры:", None, "Валюта БУ, НУ: RUB"], # 5
|
||
[], # 6
|
||
["Счет", None, M(opening, 2), M(TURNOVER, 2), M(CLOSING, 2)], # 7
|
||
["Контрагенты", "ИНН", "Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"], # 8
|
||
]
|
||
|
||
|
||
def data(level: int, text: str, sums: Sequence[object], inn: object = None) -> Cells:
|
||
return [L(level), text, inn, *sums]
|
||
|
||
|
||
ROOT = (100, None, 50, 30, 120, None)
|
||
SUPPLIER = (60, None, 20, 10, 70, None)
|
||
CONTRACTOR = (40, None, 30, 20, 50, None)
|
||
|
||
|
||
def mini_osv(supplier: Sequence[object] = SUPPLIER, *, total: bool = True, opening: str = OPENING) -> list[Cells]:
|
||
rows = [
|
||
*head(opening),
|
||
data(0, "60", ROOT), # 9
|
||
data(1, "ООО Поставщик", supplier, inn="7701234567"), # 10
|
||
data(1, "ООО Подрядчик", CONTRACTOR, inn="7707654321"), # 11
|
||
]
|
||
if total:
|
||
rows.append(data(0, "Итого", ROOT)) # 12
|
||
return rows
|
||
|
||
|
||
def statement() -> list[Cells]:
|
||
return [["Бухгалтерский баланс"], ["на 31 марта 2025 г."], ["Актив", "Код", "На 31 марта 2025 г."]]
|
||
|
||
|
||
def parser(
|
||
rules: Rules,
|
||
*,
|
||
max_bytes: int = MAX_BYTES,
|
||
timeout_seconds: float = TIMEOUT_SECONDS,
|
||
clock: Callable[[], float] = time.monotonic,
|
||
) -> OsvParser:
|
||
return OsvParser(rules, max_bytes=max_bytes, timeout_seconds=timeout_seconds, clock=clock)
|
||
|
||
|
||
def only_document(outcome: ParseOutcome) -> OsvDocument:
|
||
(document,) = outcome.documents
|
||
assert isinstance(document, OsvDocument)
|
||
return document
|
||
|
||
|
||
def warnings_of(outcome: ParseOutcome) -> list[str]:
|
||
assert outcome.metrics is not None
|
||
assert outcome.metrics.warnings is not None
|
||
return outcome.metrics.warnings
|
||
|
||
|
||
# --- Штатный разбор ---
|
||
|
||
|
||
def test_mini_osv_is_done_with_full_fields(rules: Rules) -> None:
|
||
outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv()}), "XLSX")
|
||
|
||
assert outcome.status is ResultStatus.DONE
|
||
assert outcome.error is None
|
||
document = only_document(outcome)
|
||
assert document.document_type == "OSV"
|
||
assert document.locator == "sheet:ОСВ 60"
|
||
header = document.header
|
||
assert (header.organization_name, header.account_code_raw, header.period_text) == (
|
||
'АО "Ромашка"',
|
||
"60",
|
||
"1 квартал 2025 г.",
|
||
)
|
||
assert (header.period_year, header.period_quarter, header.is_cumulative) == (2025, "Q1", True)
|
||
assert (header.data_type, header.currency, header.header_variant) == ("BU", "RUB", 1)
|
||
assert [(group.type, group.name, group.has_inn) for group in document.group_schema] == [
|
||
("ACCOUNT", "Счет", None),
|
||
("COUNTERPARTY", "Контрагенты", True),
|
||
]
|
||
assert document.columns == {
|
||
"opening_debit": "C",
|
||
"opening_credit": "D",
|
||
"turnover_debit": "E",
|
||
"turnover_credit": "F",
|
||
"closing_debit": "G",
|
||
"closing_credit": "H",
|
||
"inn": "B",
|
||
}
|
||
rows = document.rows
|
||
assert [(row.row_number, row.level, row.parent_row_number, row.group_type) for row in rows] == [
|
||
(ACCOUNT_ROW, 0, None, "ACCOUNT"),
|
||
(SUPPLIER_ROW, 1, ACCOUNT_ROW, "COUNTERPARTY"),
|
||
(CONTRACTOR_ROW, 1, ACCOUNT_ROW, "COUNTERPARTY"),
|
||
(TOTAL_ROW, 0, None, "TOTAL"),
|
||
]
|
||
supplier = rows[1]
|
||
assert (supplier.row_value, supplier.counterparty_name, supplier.counterparty_inn) == (
|
||
"ООО Поставщик",
|
||
"ООО Поставщик",
|
||
"7701234567",
|
||
)
|
||
assert (supplier.group_name, supplier.account_code_raw, supplier.data_type) == ("Контрагенты", "60", "BU")
|
||
sums = [getattr(supplier, name) for name in SUM_FIELDS]
|
||
assert sums == [Decimal(60), None, Decimal(20), Decimal(10), Decimal(70), None]
|
||
assert document.checks is not None
|
||
assert document.checks.total_row_matches_root is True
|
||
assert document.checks.balance_identity_violations == []
|
||
assert document.checks.parent_sum_violations == []
|
||
assert outcome.metrics is not None
|
||
assert outcome.metrics.rows == DATA_ROWS
|
||
assert outcome.metrics.duration_ms is not None
|
||
assert outcome.metrics.duration_ms >= 0
|
||
assert warnings_of(outcome) == ["sheet=ОСВ 60 hierarchy=outline"]
|
||
payload = assert_valid_result(outcome)
|
||
assert payload["schema_version"] == "1.1"
|
||
|
||
|
||
def test_spoiled_sum_needs_check_with_row_numbers(rules: Rules) -> None:
|
||
"""Spec «Испорченная сумма»: `closing_debit` контрагента +1 → тождество в его строке и сумма родителя."""
|
||
spoiled = (60, None, 20, 10, 71, None)
|
||
|
||
outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv(spoiled)}), "XLSX")
|
||
|
||
assert outcome.status is ResultStatus.NEEDS_CHECK
|
||
checks = only_document(outcome).checks
|
||
assert checks is not None
|
||
assert checks.balance_identity_violations == [SUPPLIER_ROW]
|
||
assert checks.parent_sum_violations == [ACCOUNT_ROW]
|
||
assert checks.total_row_matches_root is True
|
||
warnings = warnings_of(outcome)
|
||
assert warnings[0] == "sheet=ОСВ 60 hierarchy=outline"
|
||
assert any(f"строки {SUPPLIER_ROW}" in warning for warning in warnings), warnings
|
||
assert any(f"строки {ACCOUNT_ROW}" in warning for warning in warnings), warnings
|
||
assert_valid_result(outcome)
|
||
|
||
|
||
def test_missing_total_needs_check(rules: Rules) -> None:
|
||
"""R-20: нет «Итого» — сверку с корнем выполнить нельзя: `false`, предупреждение, `needs_check`."""
|
||
outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv(total=False)}), "XLSX")
|
||
|
||
assert outcome.status is ResultStatus.NEEDS_CHECK
|
||
checks = only_document(outcome).checks
|
||
assert checks is not None
|
||
assert checks.total_row_matches_root is False
|
||
assert "строка «Итого» не найдена" in warnings_of(outcome)
|
||
|
||
|
||
def test_empty_osv_is_done_with_total_only(rules: Rules) -> None:
|
||
"""Spec «Пустая ОСВ» (R-13): заголовок и сразу «Итого» без сумм."""
|
||
outcome = parser(rules).parse(xlsx({"ОСВ 66": [*head(), [None, "Итого"]]}), "XLSX")
|
||
|
||
assert outcome.status is ResultStatus.DONE
|
||
document = only_document(outcome)
|
||
assert [(row.group_type, row.level, row.parent_row_number) for row in document.rows] == [("TOTAL", 0, None)]
|
||
assert document.checks is not None
|
||
assert document.checks.total_row_matches_root is True
|
||
assert outcome.metrics is not None
|
||
assert outcome.metrics.rows == 0
|
||
assert warnings_of(outcome) == ["sheet=ОСВ 66 hierarchy=trivial", "нет строк данных"]
|
||
assert_valid_result(outcome)
|
||
|
||
|
||
def test_number_warnings_carry_row_numbers(rules: Rules) -> None:
|
||
rows = mini_osv()
|
||
rows[CONTRACTOR_ROW - 1] = data(1, "ООО Подрядчик", (40, None, 30, 20, 50, "н/д"), inn="7707654321")
|
||
|
||
outcome = parser(rules).parse(xlsx({"ОСВ 60": rows}), "XLSX")
|
||
|
||
assert outcome.status is ResultStatus.DONE
|
||
assert any(warning.startswith(f"строка {CONTRACTOR_ROW}:") for warning in warnings_of(outcome))
|
||
|
||
|
||
# --- Несколько листов ---
|
||
|
||
|
||
def test_statement_sheet_is_skipped_and_osv_parsed(rules: Rules) -> None:
|
||
outcome = parser(rules).parse(xlsx({"Баланс": statement(), "ОСВ 60": mini_osv()}), "XLSX")
|
||
|
||
assert outcome.status is ResultStatus.DONE
|
||
assert only_document(outcome).locator == "sheet:ОСВ 60"
|
||
assert warnings_of(outcome) == ["sheet=Баланс skipped: statement (slice 2b)", "sheet=ОСВ 60 hierarchy=outline"]
|
||
assert_valid_result(outcome)
|
||
|
||
|
||
def test_two_osv_sheets_and_other_sheet(rules: Rules) -> None:
|
||
"""Spec «Два листа ОСВ и посторонний лист»: два документа в порядке листов, пропуск «Справки» в warnings."""
|
||
other: list[Cells] = [["Справка о дебиторской задолженности"], ["Контрагент", "Сумма"], ["ООО Лютик", 5]]
|
||
book = xlsx({"ОСВ 60": mini_osv(), "Справка": other, "ОСВ 62": mini_osv()})
|
||
|
||
outcome = parser(rules).parse(book, "XLSX")
|
||
|
||
assert outcome.status is ResultStatus.DONE
|
||
assert [document.locator for document in outcome.documents] == ["sheet:ОСВ 60", "sheet:ОСВ 62"]
|
||
assert outcome.metrics is not None
|
||
assert outcome.metrics.rows == 2 * DATA_ROWS
|
||
warnings = warnings_of(outcome)
|
||
assert "sheet=ОСВ 60 hierarchy=outline" in warnings
|
||
assert "sheet=ОСВ 62 hierarchy=outline" in warnings
|
||
assert any(warning.startswith("sheet=Справка skipped") for warning in warnings), warnings
|
||
assert_valid_result(outcome)
|
||
|
||
|
||
def test_one_spoiled_sheet_makes_whole_result_needs_check(rules: Rules) -> None:
|
||
spoiled = (60, None, 20, 10, 71, None)
|
||
book = xlsx({"ОСВ 60": mini_osv(), "ОСВ 62": mini_osv(spoiled)})
|
||
|
||
outcome = parser(rules).parse(book, "XLSX")
|
||
|
||
assert outcome.status is ResultStatus.NEEDS_CHECK
|
||
assert len(outcome.documents) == 2 # noqa: PLR2004 - два листа ОСВ
|
||
|
||
|
||
# --- Ошибки (карта D-6) ---
|
||
|
||
|
||
def test_book_without_osv_is_invalid_document(rules: Rules) -> None:
|
||
"""Spec «Нет ОСВ»: сообщение называет число просмотренных листов."""
|
||
outcome = parser(rules).parse(xlsx({"Баланс": statement(), "Данные": [["a", "b"], [1, 2]]}), "XLSX")
|
||
|
||
assert outcome.status is ResultStatus.ERROR
|
||
assert outcome.error is not None
|
||
assert outcome.error.code is ErrorCode.INVALID_DOCUMENT
|
||
assert "листов просмотрено 2" in outcome.error.message
|
||
assert outcome.documents == ()
|
||
|
||
|
||
def test_osv_sheet_without_table_header_is_invalid_format(rules: Rules) -> None:
|
||
rows: list[Cells] = [['АО "Ромашка"'], [TITLE], [], ["Счет", "Сумма"], ["60", 100]]
|
||
|
||
outcome = parser(rules).parse(xlsx({"ОСВ 60": rows}), "XLSX")
|
||
|
||
assert outcome.status is ResultStatus.ERROR
|
||
assert outcome.error is not None
|
||
assert outcome.error.code is ErrorCode.INVALID_FORMAT
|
||
assert "«ОСВ 60»" in outcome.error.message
|
||
assert "заголовок таблицы" in outcome.error.message
|
||
assert outcome.error.message.count("ОСВ 60") == 1
|
||
|
||
|
||
def test_undetermined_hierarchy_is_invalid_format_with_sheet_and_step(
|
||
rules: Rules, monkeypatch: pytest.MonkeyPatch
|
||
) -> None:
|
||
def nothing(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> None:
|
||
return None
|
||
|
||
monkeypatch.setattr(hierarchy, "STRATEGIES", (("nothing", nothing),))
|
||
|
||
outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv()}), "XLSX")
|
||
|
||
assert outcome.error is not None
|
||
assert outcome.error.code is ErrorCode.INVALID_FORMAT
|
||
assert "«ОСВ 60»" in outcome.error.message
|
||
assert "иерархия" in outcome.error.message
|
||
|
||
|
||
def test_size_over_limit_is_unsupported_format_before_reading(rules: Rules) -> None:
|
||
"""Spec «Превышен лимит»: на байт больше лимита — `UNSUPPORTED_FORMAT`, ровно лимит — разбор."""
|
||
book = xlsx({"ОСВ 60": mini_osv()})
|
||
|
||
over = parser(rules, max_bytes=len(book) - 1).parse(book, "XLSX")
|
||
exact = parser(rules, max_bytes=len(book)).parse(book, "XLSX")
|
||
|
||
assert over.status is ResultStatus.ERROR
|
||
assert over.error is not None
|
||
assert over.error.code is ErrorCode.UNSUPPORTED_FORMAT
|
||
assert "лимит" in over.error.message
|
||
assert exact.status is ResultStatus.DONE
|
||
|
||
|
||
def test_tiny_limit_rejects_any_file(rules: Rules) -> None:
|
||
outcome = parser(rules, max_bytes=10).parse(b"PK\x03\x04" + b"x" * 7, "XLSX")
|
||
|
||
assert outcome.error is not None
|
||
assert outcome.error.code is ErrorCode.UNSUPPORTED_FORMAT
|
||
|
||
|
||
def test_unknown_signature_is_unsupported_format(rules: Rules) -> None:
|
||
outcome = parser(rules).parse(b"%PDF-1.7 not a workbook", "XLSX")
|
||
|
||
assert outcome.error is not None
|
||
assert outcome.error.code is ErrorCode.UNSUPPORTED_FORMAT
|
||
|
||
|
||
def test_broken_zip_is_unreadable(rules: Rules) -> None:
|
||
outcome = parser(rules).parse(b"PK\x03\x04 broken archive", "XLSX")
|
||
|
||
assert outcome.error is not None
|
||
assert outcome.error.code is ErrorCode.UNREADABLE
|
||
|
||
|
||
def test_deadline_exceeded_is_timeout(rules: Rules) -> None:
|
||
clock = itertools.count(step=CLOCK_STEP)
|
||
|
||
outcome = parser(rules, timeout_seconds=SHORT_TIMEOUT, clock=lambda: float(next(clock))).parse(
|
||
xlsx({"ОСВ 60": mini_osv()}), "XLSX"
|
||
)
|
||
|
||
assert outcome.status is ResultStatus.ERROR
|
||
assert outcome.error is not None
|
||
assert outcome.error.code is ErrorCode.TIMEOUT
|
||
assert "превышен лимит" in outcome.error.message
|
||
|
||
|
||
def test_unexpected_exception_is_recognition_failed_in_one_line(rules: Rules, monkeypatch: pytest.MonkeyPatch) -> None:
|
||
def broken(rows: Sequence[BodyRow], deadline: Deadline | None = None) -> dict[int, int] | None:
|
||
raise ValueError("сбой стратегии\nвторая строка")
|
||
|
||
monkeypatch.setattr(hierarchy, "STRATEGIES", (("broken", broken),))
|
||
|
||
outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv()}), "XLSX")
|
||
|
||
assert outcome.status is ResultStatus.ERROR
|
||
assert outcome.error is not None
|
||
assert outcome.error.code is ErrorCode.RECOGNITION_FAILED
|
||
assert outcome.error.message == "ValueError: сбой стратегии вторая строка"
|
||
|
||
|
||
# --- Словари — данные (R-8, spec «Новый синоним без релиза кода») ---
|
||
|
||
NEW_OPENING = "Нач. остаток"
|
||
NEW_SYNONYM_LINE = " - 'нач\\.\\s*остаток'\n"
|
||
|
||
|
||
def rules_with_new_opening_synonym(tmp_path: Path) -> Path:
|
||
"""Копия штатного каталога словарей, в `osv_headers.yaml` синоним «Нач. остаток» добавлен к группе opening."""
|
||
copy = tmp_path / "rules"
|
||
shutil.copytree(DEFAULT_RULES_DIR, copy)
|
||
headers = copy / HEADERS_FILE
|
||
text = headers.read_text(encoding="utf-8")
|
||
anchor = " opening:\n"
|
||
assert text.count(anchor) == 1
|
||
headers.write_text(text.replace(anchor, anchor + NEW_SYNONYM_LINE), encoding="utf-8")
|
||
return copy
|
||
|
||
|
||
def test_new_synonym_parses_without_code_changes(tmp_path: Path, rules: Rules) -> None:
|
||
book = xlsx({"ОСВ 60": mini_osv(opening=NEW_OPENING)})
|
||
|
||
outcome = parser(load_rules(rules_with_new_opening_synonym(tmp_path))).parse(book, "XLSX")
|
||
|
||
assert outcome.status is ResultStatus.DONE
|
||
document = only_document(outcome)
|
||
assert document.columns is not None
|
||
assert (document.columns["opening_debit"], document.columns["opening_credit"]) == ("C", "D")
|
||
assert document.rows[0].opening_debit == Decimal(100)
|
||
assert_valid_result(outcome)
|
||
|
||
|
||
def test_default_dictionary_does_not_know_the_new_synonym(rules: Rules) -> None:
|
||
"""До правки словаря «Нач. остаток» не распознаётся: заголовок находится по «оборотам» и «концу» (начало
|
||
необязательно, R-12), колонки начала уходят в `extra`, `opening_*` = null, и тождество сальдо не сходится в
|
||
строках с ненулевым началом → `needs_check` (фактическое поведение, зафиксировано тестом).
|
||
"""
|
||
outcome = parser(rules).parse(xlsx({"ОСВ 60": mini_osv(opening=NEW_OPENING)}), "XLSX")
|
||
|
||
assert outcome.status is ResultStatus.NEEDS_CHECK
|
||
document = only_document(outcome)
|
||
assert document.columns is not None
|
||
assert "opening_debit" not in document.columns
|
||
assert all(row.opening_debit is None and row.opening_credit is None for row in document.rows)
|
||
assert document.rows[0].extra is not None
|
||
assert "100" in document.rows[0].extra.values()
|
||
checks = document.checks
|
||
assert checks is not None
|
||
assert checks.balance_identity_violations == [ACCOUNT_ROW, SUPPLIER_ROW, CONTRACTOR_ROW, TOTAL_ROW]
|