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:
parent
dd7f7cc42d
commit
f9500d3f09
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user