diff --git a/api/src/domain/statement_mapping.py b/api/src/domain/statement_mapping.py index 6a4bfdc..4c34b14 100644 --- a/api/src/domain/statement_mapping.py +++ b/api/src/domain/statement_mapping.py @@ -7,7 +7,7 @@ from dataclasses import dataclass, field from decimal import Decimal from typing import Any -from src.contract.result import StatementDocument, StatementValue +from src.contract.result import StatementColumn, StatementDocument, StatementValue from src.models.enums import DocumentCheckResult, DocumentStatus, DocumentType, PeriodQuarter, StatementType __all__ = [ @@ -92,12 +92,21 @@ def _decimal(value: float | None) -> Decimal | None: def build_statement(doc: StatementDocument) -> StatementDraft: - """Черновик формы `BALANCE`/`INCOME` (D-2): шапка, колонки, строки (повтор кода — первая), значение - на каждую ячейку.""" + """Черновик формы `BALANCE`/`INCOME` (D-2): шапка, колонки, строки, значение на каждую ячейку. + + Повтор кода строки, `column_index` колонки и пары (строка, колонка) — берётся первое, о повторе — предупреждение.""" document_type = DocumentType(doc.document_type) header = doc.header warnings: list[str] = [] - column_dates = [column.period_date for column in doc.columns] + source_columns: list[StatementColumn] = [] + seen_indexes: set[int] = set() + for column in doc.columns: + if column.column_index in seen_indexes: + warnings.append(f"{doc.locator}: колонка {column.column_index} повторяется — оставлена первая") + continue + seen_indexes.add(column.column_index) + source_columns.append(column) + column_dates = [column.period_date for column in source_columns] period_date = statement_period(header.report_date, column_dates) period_year = period_date.year if period_date is not None else None period_quarter = quarter_of(period_date) if period_date is not None else None @@ -114,11 +123,11 @@ def build_statement(doc: StatementDocument) -> StatementDraft: None if latest is None else column.period_date == latest - and column.column_index == min(c.column_index for c in doc.columns if c.period_date == latest), + and column.column_index == min(c.column_index for c in source_columns if c.period_date == latest), ) - for column in doc.columns + for column in source_columns ] - known_columns = {column.column_index for column in doc.columns} + known_columns = seen_indexes lines: list[LineDraft] = [] values: list[ValueDraft] = [] seen: set[str] = set() @@ -143,8 +152,14 @@ def build_statement(doc: StatementDocument) -> StatementDraft: " вне колонок формы пропущено" ) continue - by_column.setdefault(cell_value.column_index, cell_value) - for column in doc.columns: + if cell_value.column_index in by_column: + warnings.append( + f"{doc.locator}: строка {line.line_code}: значение колонки {cell_value.column_index}" + " повторяется — оставлено первое" + ) + continue + by_column[cell_value.column_index] = cell_value + for column in source_columns: value: StatementValue | None = by_column.get(column.column_index) values.append( ValueDraft( diff --git a/api/tests/test_domain_statement_mapping.py b/api/tests/test_domain_statement_mapping.py index 29df1be..c3d7dcb 100644 --- a/api/tests/test_domain_statement_mapping.py +++ b/api/tests/test_domain_statement_mapping.py @@ -209,3 +209,35 @@ def test_fractional_confidence_kept_exactly() -> None: assert draft.lines[0].line_code_confidence == Decimal("99.5") assert draft.lines[0].line_name_confidence == Decimal("98.25") assert draft.values[0].confidence == Decimal("99.5") + + +def test_duplicate_column_index_keeps_first_with_warning() -> None: + """I-1: повтор `column_index` в колонках — берётся первая, остальные отброшены с предупреждением; текущая колонка и + значения считаются по оставшимся.""" + columns = [ + {"column_index": 0, "label": "На 30.09.2025", "period_date": "2025-09-30"}, + {"column_index": 0, "label": "дубль", "period_date": "2026-01-31"}, + {"column_index": 1, "label": "На 31.12.2024", "period_date": "2024-12-31"}, + ] + draft = build_statement(_doc(columns=columns)) + assert draft.columns == [(0, "На 30.09.2025", d(2025, 9, 30), True), (1, "На 31.12.2024", d(2024, 12, 31), False)] + assert "sheet:ББ: колонка 0 повторяется — оставлена первая" in draft.warnings + assert (draft.period_date, draft.period_quarter) == (d(2025, 9, 30), PeriodQuarter.Q3) + assert [(v.line_code, v.column_index) for v in draft.values] == [("1150", 0), ("1150", 1), ("1600", 0), ("1600", 1)] + + +def test_duplicate_cell_keeps_first_value_with_warning() -> None: + """T2.3: повтор пары (строка, колонка) среди значений — первое значение и предупреждение.""" + lines = [ + { + "line_code": "1150", + "line_name": "ОС", + "values": [ + {"column_index": 0, "amount": "10", "confidence": 100, "is_recognized": True}, + {"column_index": 0, "amount": "77", "confidence": 100, "is_recognized": True}, + ], + } + ] + draft = build_statement(_doc(lines=lines)) + assert {(v.column_index): v.amount for v in draft.values} == {0: Decimal("10"), 1: None} + assert "sheet:ББ: строка 1150: значение колонки 0 повторяется — оставлено первое" in draft.warnings diff --git a/api/tests/test_mapping_statements.py b/api/tests/test_mapping_statements.py index 31da7a4..f43d832 100644 --- a/api/tests/test_mapping_statements.py +++ b/api/tests/test_mapping_statements.py @@ -236,6 +236,36 @@ async def test_duplicate_form_in_file_skipped(seeded: AsyncSession, fixtures_dir ] +async def test_duplicate_column_index_does_not_fail_mapping( + seeded: AsyncSession, fixtures_dir: Path, tmp_path: Path +) -> None: + """I-1: повтор `column_index` в результате не роняет маппинг (раньше — IntegrityError и файл `ERROR`): файл + `PROCESSED`, колонка одна, форма `NEEDS_CHECK` с предупреждением.""" + source = fixtures_dir / MEZH / "1_Форма2_бух_09.json" + owner = await _owner(seeded, source) + data = json.loads(source.read_text("utf-8")) + columns = data["documents"][0]["columns"] + columns.insert(1, columns[0] | {"label": "дубль"}) + path = tmp_path / "duplicate_column.json" + path.write_text(json.dumps(data, ensure_ascii=False), "utf-8") + (income,) = await map_golden_snapshot(seeded, entity=owner, path=path, uploaded_by=None) + assert income.status is DocumentStatus.NEEDS_CHECK # без повтора тот же снимок — RECOGNIZED + meta = await _meta(seeded, income) + stored = ( + await seeded.execute( + select(FinancialStatementColumn.column_index, FinancialStatementColumn.label).where( + FinancialStatementColumn.meta_id == meta.id + ) + ) + ).all() + first = columns[0] + assert sorted(i for i, _ in stored) == sorted({c["column_index"] for c in columns}) + assert (first["column_index"], first["label"]) in stored and (first["column_index"], "дубль") not in stored + file = await seeded.get(StoredFile, income.stored_file_id) + assert file is not None and file.status is FileStatus.PROCESSED + assert any("повторяется — оставлена первая" in w for w in await _mapping_warnings(seeded, income)) + + async def test_form_with_needs_check_job_is_needs_check(seeded: AsyncSession, fixtures_dir: Path) -> None: """D-6: условие «задание done» — форма чистого снимка при задании `needs_check` не `RECOGNIZED`.""" path = fixtures_dir / MEZH / "1_Форма2_бух_09.json"