fix(statements): повтор column_index и пары (строка, колонка) при маппинге формы (I-1, T2.3)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-07 14:27:38 +04:00
parent dd7f7cc42d
commit f9500d3f09
3 changed files with 86 additions and 9 deletions

View File

@ -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(

View File

@ -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

View File

@ -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"