"""Оркестратор `OsvParser` (design D-6, D-8; spec «Листы ОСВ находятся по содержимому», «Контрольные суммы определяют статус», «Форматы, лимиты, время», «Словари — данные, не код»). Книги собираются openpyxl в памяти: лист описывается строками сверху вниз, как в `gridbuilder` (`M` — объединённая ячейка, `L` — outline level строки первым элементом). Позиции колонок произвольные — парсер находит их по тексту. """ import io import itertools import shutil import time import traceback from collections.abc import Callable, Iterator, Sequence from decimal import Decimal from pathlib import Path from typing import Any import openpyxl import pytest from gridbuilder import L, M, sheet_from_rows from loguru import logger 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.errors import ParseTimeoutError 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.2" 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)) # --- Шапка без меток и код/наименование счёта в двух колонках (issue #11 п. 4, OQ-134) --- NO_LABEL_ORG_SUMS = (None, 600, 100, None, None, 500) NO_LABEL_SUPPLIER = (None, 400, None, None, None, 400) NO_LABEL_CONTRACTOR = (None, 200, 100, None, None, 100) def code_name_osv(*, total_label: str | None = None) -> list[Cells]: """ОСВ 63 Новоросметалл: «Бухгалтерский | период» отдельной строкой, организация — в названии ведомости, колонки «Счет: Код / Наименование», контрагенты — в колонке правее кода (уровень — сдвиг колонки), «Итого» без подписи. """ blank = [None] * 3 sums_head = ["Дебет", "Кредит", "Дебет", "Кредит", "Дебет", "Кредит"] def line(label_cells: Sequence[object], sums: Sequence[object]) -> Cells: return [*label_cells, *sums] return [ ["Оборотно-сальдовая ведомость НОВОРОСМЕТАЛЛ ООО"], # 1 [], # 2 ["Бухгалтерский", *([None] * 10), "9 месяцев 2025 г."], # 3 ["Счет", *([None] * 5), M(OPENING, 2), M(TURNOVER, 2), M(CLOSING, 2)], # 4 ["Код", *blank, "Наименование", None, *sums_head], # 5 line([63, *blank, "Резервы по сомнительным долгам", None], NO_LABEL_ORG_SUMS), # 6 line([None, "ООО Поставщик", None, None, None, None], NO_LABEL_SUPPLIER), # 7 line([None, "ООО Подрядчик", None, None, None, None], NO_LABEL_CONTRACTOR), # 8 line([total_label, None, None, None, None, None], NO_LABEL_ORG_SUMS), # 9 ] def test_header_without_labels_and_code_name_columns_are_found(rules: Rules) -> None: outcome = parser(rules).parse(xlsx({"Лист1": code_name_osv()}), "XLSX") document = only_document(outcome) header = document.header assert header.organization_name == "НОВОРОСМЕТАЛЛ ООО" assert header.account_code_raw == "63" assert (header.period_text, header.period_year, header.period_quarter, header.is_cumulative) == ( "9 месяцев 2025 г.", 2025, "Q3", True, ) assert header.data_type == "BU" warnings = warnings_of(outcome) assert not any("период" in warning and "не найден" in warning for warning in warnings), warnings assert not any("тип выводимых данных" in warning for warning in warnings), warnings def test_code_name_columns_rows_are_account_and_children_by_label_column(rules: Rules) -> None: outcome = parser(rules).parse(xlsx({"Лист1": code_name_osv(total_label="Итого")}), "XLSX") document = only_document(outcome) assert [(row.row_value, row.level, row.group_type) for row in document.rows] == [ ("63", 0, "ACCOUNT"), ("ООО Поставщик", 1, "OTHER"), ("ООО Подрядчик", 1, "OTHER"), ("Итого", 0, "TOTAL"), ] assert document.rows[0].extra == {"Наименование": "Резервы по сомнительным долгам"} assert outcome.status is ResultStatus.DONE def test_total_row_without_caption_is_the_last_row_of_sums(rules: Rules) -> None: outcome = parser(rules).parse(xlsx({"Лист1": code_name_osv()}), "XLSX") document = only_document(outcome) assert document.rows[-1].group_type == "TOTAL" assert document.checks is not None assert document.checks.total_row_matches_root is True assert outcome.status is ResultStatus.DONE assert any("без подписи" in warning for warning in warnings_of(outcome)), 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: сбой стратегии вторая строка" @pytest.fixture def log_records() -> Iterator[list[Any]]: """Записи loguru, выпущенные за время теста.""" records: list[Any] = [] handler_id = logger.add(lambda message: records.append(message.record), level="DEBUG") yield records logger.remove(handler_id) def test_unexpected_exception_is_logged_with_traceback( rules: Rules, monkeypatch: pytest.MonkeyPatch, log_records: list[Any] ) -> None: """I-2: ответ прежний (RECOGNITION_FAILED одной строкой), но стек непредвиденной ошибки уходит в лог.""" 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.error is not None assert outcome.error.code is ErrorCode.RECOGNITION_FAILED assert outcome.error.message == "ValueError: сбой стратегии вторая строка" (record,) = [record for record in log_records if record["level"].name == "ERROR"] assert "Непредвиденная ошибка разбора" in record["message"] assert "ValueError: сбой стратегии вторая строка" in record["message"] exception = record["exception"] assert exception is not None assert exception.type is ValueError assert exception.traceback is not None assert "broken" in "".join(traceback.format_tb(exception.traceback)) def test_unreadable_book_is_logged_as_warning_without_traceback(rules: Rules, log_records: list[Any]) -> 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 (record,) = [record for record in log_records if record["level"].name == "WARNING"] assert outcome.error.message in record["message"] assert record["exception"] is None assert not [record for record in log_records if record["level"].name == "ERROR"] def test_expected_errors_other_than_unreadable_are_not_logged(rules: Rules, log_records: list[Any]) -> None: outcome = parser(rules).parse(b"%PDF-1.7", "XLSX") assert outcome.error is not None assert outcome.error.code is ErrorCode.UNSUPPORTED_FORMAT assert not [record for record in log_records if record["level"].no >= logger.level("WARNING").no] def test_header_search_is_interrupted_by_deadline(rules: Rules) -> None: """R-23 (в): дедлайн из `parse_sheet` доходит до поиска заголовка таблицы — цикла по всему листу.""" sheet = sheet_from_rows([[TITLE], *([["строка"]] * 1000)]) deadline = Deadline(1, clock=itertools.chain([0.0], itertools.repeat(1e9)).__next__) with pytest.raises(ParseTimeoutError, match="поиск заголовка таблицы"): parser(rules).parse_sheet(sheet, deadline) # --- Словари — данные (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]