AURORA-1735: редкий столбец с неразобранной подписью между периодами — числа вне колонок (#5)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d0467b81d5
commit
32c1b05d38
@ -15,6 +15,10 @@ from arm_statements.model import Cell, PeriodKind, Source, StatementColumn, Stat
|
||||
|
||||
_FULL_CONFIDENCE = 100.0
|
||||
_MAX_REFS = 10
|
||||
_SPARSE_MAX_NUMBERS = 1 # редкий столбец: число не более чем в одной строке с кодом (AURORA-1735)
|
||||
_PERIOD_MIN_NUMBERS = 3 # … при числах не менее чем в трёх строках у каждой колонки периода
|
||||
_RIGHT_TEXT = "числа правее таблицы формы не учитываются"
|
||||
_BESIDE_TEXT = "числа вне колонок формы не учитываются"
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@ -59,6 +63,15 @@ def has_numbers( # noqa: PLR0913, PLR0917 - сигнатура задана п
|
||||
return any(_is_number(cell_at(rows, r, col), source, rules) for r in _code_rows(rows, data_rows, code_col, rules))
|
||||
|
||||
|
||||
def _number_count( # noqa: PLR0913, PLR0917 - как has_numbers
|
||||
rows: Grid, data_rows: Sequence[int], col: int, code_col: int, source: Source, rules: Rules
|
||||
) -> int:
|
||||
"""Сколько строк с кодом содержат в столбце число."""
|
||||
return sum(
|
||||
1 for r in _code_rows(rows, data_rows, code_col, rules) if _is_number(cell_at(rows, r, col), source, rules)
|
||||
)
|
||||
|
||||
|
||||
def _width(rows: Grid, row_indexes: Sequence[int]) -> int:
|
||||
return max((len(rows[r]) for r in row_indexes if 0 <= r < len(rows)), default=0)
|
||||
|
||||
@ -97,7 +110,9 @@ def segment_columns( # noqa: PLR0913, PLR0917 - сигнатура задана
|
||||
warnings: list[StatementWarning] = []
|
||||
columns = range(code_col + 1, _width(rows, [*header_rows, *data_rows]))
|
||||
last_dated = _last_dated_column(rows, header_rows, data_rows, columns, code_col, source, rules)
|
||||
dated = _dated_counts(rows, header_rows, data_rows, columns, code_col, source, rules)
|
||||
outside: list[int] = []
|
||||
beside: list[int] = []
|
||||
for col in columns:
|
||||
numbers = has_numbers(rows, data_rows, col, code_col, source, rules)
|
||||
if not header_rows:
|
||||
@ -113,16 +128,63 @@ def segment_columns( # noqa: PLR0913, PLR0917 - сигнатура задана
|
||||
drafts.append(ColumnDraft(col, cell.text, cell.confidence, parsed.kind, parsed.date))
|
||||
elif numbers and cell is None and last_dated is not None and col > last_dated:
|
||||
outside.append(col) # числа правее последней колонки с подписью периода — не колонка сумм
|
||||
elif numbers and cell is not None and _is_sparse(col, dated, rows, data_rows, code_col, source, rules):
|
||||
beside.append(col) # обрывок подписи периода с посторонним числом — не колонка сумм (AURORA-1735)
|
||||
elif numbers:
|
||||
label, conf = (cell.text, cell.confidence) if cell is not None else ("", None)
|
||||
message = f"у столбца {col} с числами нет разобранной подписи периода"
|
||||
warnings.append(make_warning(WarningCode.COLUMN_WITHOUT_DATE, message, rules, column_index=col))
|
||||
drafts.append(ColumnDraft(col, label, conf, PeriodKind.NONE, None))
|
||||
if outside:
|
||||
warnings.append(_outside_table_warning(rows, data_rows, outside, code_col, source, rules))
|
||||
if outside or beside:
|
||||
warnings.append(
|
||||
_outside_table_warning(rows, data_rows, [*beside, *outside], code_col, source, rules, beside=bool(beside))
|
||||
)
|
||||
return drafts, warnings
|
||||
|
||||
|
||||
def _dated_counts( # noqa: PLR0913, PLR0917 - внутренняя сборка
|
||||
rows: Grid,
|
||||
header_rows: tuple[int, ...],
|
||||
data_rows: Sequence[int],
|
||||
columns: range,
|
||||
code_col: int,
|
||||
source: Source,
|
||||
rules: Rules,
|
||||
) -> dict[int, int]:
|
||||
"""Столбцы сегмента с подписью периода и числами → число строк с кодом, где есть число."""
|
||||
counts: dict[int, int] = {}
|
||||
if not header_rows:
|
||||
return counts
|
||||
for col in columns:
|
||||
cell = _label(rows, header_rows, col)
|
||||
if cell is None:
|
||||
continue
|
||||
parsed = parse_period_label(cell, rules)
|
||||
if parsed.excluded or parsed.kind is PeriodKind.NONE:
|
||||
continue
|
||||
count = _number_count(rows, data_rows, col, code_col, source, rules)
|
||||
if count:
|
||||
counts[col] = count
|
||||
return counts
|
||||
|
||||
|
||||
def _is_sparse( # noqa: PLR0913, PLR0917 - внутренняя проверка
|
||||
col: int,
|
||||
dated: dict[int, int],
|
||||
rows: Grid,
|
||||
data_rows: Sequence[int],
|
||||
code_col: int,
|
||||
source: Source,
|
||||
rules: Rules,
|
||||
) -> bool:
|
||||
"""Редкий столбец: колонки периода левее и правее, у него ≤ 1 числа, у каждой колонки периода ≥ 3."""
|
||||
if not any(c < col for c in dated) or not any(c > col for c in dated):
|
||||
return False
|
||||
if any(count < _PERIOD_MIN_NUMBERS for count in dated.values()):
|
||||
return False
|
||||
return _number_count(rows, data_rows, col, code_col, source, rules) <= _SPARSE_MAX_NUMBERS
|
||||
|
||||
|
||||
def _last_dated_column( # noqa: PLR0913, PLR0917 - внутренняя сборка границы таблицы
|
||||
rows: Grid,
|
||||
header_rows: tuple[int, ...],
|
||||
@ -149,7 +211,14 @@ def _last_dated_column( # noqa: PLR0913, PLR0917 - внутренняя сбо
|
||||
|
||||
|
||||
def _outside_table_warning( # noqa: PLR0913, PLR0917 - внутренняя сборка предупреждения
|
||||
rows: Grid, data_rows: Sequence[int], outside: Sequence[int], code_col: int, source: Source, rules: Rules
|
||||
rows: Grid,
|
||||
data_rows: Sequence[int],
|
||||
outside: Sequence[int],
|
||||
code_col: int,
|
||||
source: Source,
|
||||
rules: Rules,
|
||||
*,
|
||||
beside: bool = False,
|
||||
) -> StatementWarning:
|
||||
refs = [
|
||||
cell.ref
|
||||
@ -157,13 +226,15 @@ def _outside_table_warning( # noqa: PLR0913, PLR0917 - внутренняя с
|
||||
for col in outside
|
||||
if (cell := cell_at(rows, r, col)) is not None and _is_number(cell, source, rules)
|
||||
]
|
||||
return outside_table_warning(refs, rules)
|
||||
return outside_table_warning(refs, rules, beside=beside)
|
||||
|
||||
|
||||
def outside_table_warning(refs: Sequence[str], rules: Rules) -> StatementWarning:
|
||||
"""`NUMBERS_OUTSIDE_TABLE` (info): адреса ячеек правее таблицы (`locator` — все, через перевод строки)."""
|
||||
def outside_table_warning(refs: Sequence[str], rules: Rules, *, beside: bool = False) -> StatementWarning:
|
||||
"""`NUMBERS_OUTSIDE_TABLE` (info): адреса ячеек вне колонок формы (`locator` — все, через перевод строки).
|
||||
|
||||
`beside` — среди адресов есть редкий столбец между колонками периодов (AURORA-1735): текст «вне колонок»."""
|
||||
shown = ", ".join(refs[:_MAX_REFS]) + (f" и ещё {len(refs) - _MAX_REFS}" if len(refs) > _MAX_REFS else "")
|
||||
message = f"числа правее таблицы формы не учитываются: {shown}"
|
||||
message = f"{_BESIDE_TEXT if beside else _RIGHT_TEXT}: {shown}"
|
||||
return make_warning(WarningCode.NUMBERS_OUTSIDE_TABLE, message, rules, locator="\n".join(refs))
|
||||
|
||||
|
||||
@ -171,12 +242,14 @@ def merge_outside_table(warnings: Sequence[StatementWarning], rules: Rules) -> l
|
||||
"""Предупреждения `NUMBERS_OUTSIDE_TABLE` сегментов — в одно (адреса без повторов)."""
|
||||
refs: dict[str, None] = {}
|
||||
rest: list[StatementWarning] = []
|
||||
beside = False
|
||||
for warning in warnings:
|
||||
if warning.code == WarningCode.NUMBERS_OUTSIDE_TABLE:
|
||||
refs.update(dict.fromkeys((warning.locator or "").split("\n")))
|
||||
beside = beside or warning.message.startswith(_BESIDE_TEXT)
|
||||
else:
|
||||
rest.append(warning)
|
||||
return [*rest, outside_table_warning(list(refs), rules)] if refs else rest
|
||||
return [*rest, outside_table_warning(list(refs), rules, beside=beside)] if refs else rest
|
||||
|
||||
|
||||
def trailing_outside_refs( # noqa: PLR0913, PLR0917 - внутренняя сборка
|
||||
|
||||
@ -210,3 +210,50 @@ def test_numbers_right_of_table_do_not_break_status_in_repeated_segments(rules:
|
||||
assert [c.column_index for c in doc.columns] == [2]
|
||||
assert [(w.code, w.severity) for w in doc.warnings] == [("NUMBERS_OUTSIDE_TABLE", Severity.INFO)]
|
||||
assert all(len(line.values) == 1 for line in doc.lines)
|
||||
|
||||
|
||||
# --- редкий столбец с неразобранной подписью между периодами (AURORA-1735, решение владельца 06.11.2026) ---
|
||||
|
||||
SPARSE_COL = 3 # столбец «31» раскладки Калтана
|
||||
KALTAN_HEAD = ["Наименование показателя", "Коды", "За 1 полугодие 2026 года", "31", "За I полугодие 2025 года"]
|
||||
|
||||
|
||||
def kaltan_rows(caption: str = "31", second: str | None = None) -> list[list[str | None]]:
|
||||
head = [*KALTAN_HEAD]
|
||||
head[3] = caption
|
||||
return [
|
||||
head,
|
||||
["Выручка", "2110", "9671", None, "15405"],
|
||||
["Себестоимость продаж", "2120", "-7084", second, "-10446"],
|
||||
["Валовая прибыль (убыток)", "2100", "2587", None, "4959"],
|
||||
["Прочие расходы", "2350", "-486", "——-", "-348"],
|
||||
["Чистая прибыль (убыток)", "2400", "1935", "$301 .", "4182"],
|
||||
]
|
||||
|
||||
|
||||
def test_sparse_column_between_periods_is_outside(rules: Rules) -> None:
|
||||
doc = normalize_candidate(ocr(kaltan_rows()), rules).document
|
||||
assert doc is not None
|
||||
assert [c.column_index for c in doc.columns] == [2, 4]
|
||||
assert all(v.column_index != SPARSE_COL for line in doc.lines for v in line.values)
|
||||
(warning,) = [w for w in doc.warnings if w.code == "NUMBERS_OUTSIDE_TABLE"]
|
||||
assert warning.severity is Severity.INFO
|
||||
assert warning.message == "числа вне колонок формы не учитываются: row:5"
|
||||
assert "COLUMN_WITHOUT_DATE" not in [w.code for w in doc.warnings]
|
||||
|
||||
|
||||
def test_unparsed_caption_with_two_numbers_stays_column(rules: Rules) -> None:
|
||||
doc = normalize_candidate(ocr(kaltan_rows("Прим.", second="12")), rules).document
|
||||
assert doc is not None
|
||||
assert [c.column_index for c in doc.columns] == [2, 3, 4]
|
||||
assert [w.column_index for w in doc.warnings if w.code == "COLUMN_WITHOUT_DATE"] == [3]
|
||||
assert "NUMBERS_OUTSIDE_TABLE" not in [w.code for w in doc.warnings]
|
||||
|
||||
|
||||
def test_sparse_column_without_period_to_the_right_stays(rules: Rules) -> None:
|
||||
rows = kaltan_rows("За I п\n20:")
|
||||
rows[0][4] = "юлугодие\n25 года"
|
||||
doc = normalize_candidate(ocr(rows), rules).document
|
||||
assert doc is not None
|
||||
assert [c.column_index for c in doc.columns] == [2, 3, 4]
|
||||
assert SPARSE_COL in [w.column_index for w in doc.warnings if w.code == "COLUMN_WITHOUT_DATE"]
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user