257 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Конвейер листа ОСВ: лист → документ `OsvDocument`, предупреждения, признак «контрольные суммы сошлись».
Книгу (лимиты, чтение, дедлайн, вид листа, статус и карта ошибок) разбирает `book.BookParser`. Здесь: заголовок
таблицы → шапка и схема группировок → строки → каскад иерархии → классификация → контрольные суммы §1.4. Имя листа
только подставляется в `locator` и сообщения, поиск по нему не ведётся.
"""
import datetime
from collections.abc import Iterable, Sequence
from dataclasses import dataclass, replace
from src.contract import OsvDocument
from src.contract.result import OsvChecks, OsvGroup, OsvHeader, OsvRow
from src.parser.checks import run_checks
from src.parser.classify import SUM_FIELDS, ClassifiedRow, classify_rows, collect_body_rows
from src.parser.deadline import Deadline
from src.parser.detect import TableHeader, find_table_header
from src.parser.errors import InvalidFormatError
from src.parser.grid import Sheet, column_letter
from src.parser.header import ACCOUNT_NOT_FOUND_WARNING, DocHeader, build_group_levels, parse_doc_header
from src.parser.hierarchy import resolve_levels
from src.parser.inn_rows import InnGlue, glue_inn_rows
from src.parser.osv_warnings import (
OSV_HEADER,
OSV_INN_ROWS,
OSV_NO_DATA_ROWS,
OSV_ROWS,
OsvNote,
hierarchy_note,
notes_of,
)
from src.parser.rules import Rules
__all__ = ["OsvParser", "SheetResult", "data_rows", "sheet_line", "unique"]
LOCATOR_PREFIX = "sheet:"
INN_COLUMN_KEY = "inn"
NO_DATA_ROWS_WARNING = "нет строк данных"
def sheet_line(sheet: Sheet, text: str) -> str:
return f"sheet={sheet.name} {text}"
def unique(items: Iterable[str]) -> list[str]:
"""Без повторов, в порядке первого появления."""
return list(dict.fromkeys(items))
def _columns(header: TableHeader) -> dict[str, str]:
"""Диагностика `columns`: шесть колонок сумм и, если найдена, колонка ИНН (буквы — только здесь)."""
columns = header.columns.as_diagnostics()
if header.inn_col is not None:
columns[INN_COLUMN_KEY] = column_letter(header.inn_col)
return columns
def _from_year_start(start: datetime.date | None) -> bool | None:
return None if start is None else (start.month, start.day) == (1, 1)
def _header(doc: DocHeader) -> OsvHeader:
return OsvHeader(
organization_name=doc.organization_name,
organization_inn=None,
account_code_raw=doc.account_code_raw,
period_text=doc.period_text,
period_year=doc.period_year,
period_quarter=doc.period_quarter,
is_cumulative=doc.is_cumulative,
period_start=doc.period_start,
period_end=doc.period_end,
is_from_year_start=_from_year_start(doc.period_start),
data_type=doc.data_type,
currency=doc.currency,
header_variant=doc.header_variant,
)
def _row(item: ClassifiedRow) -> OsvRow:
sums = dict(zip(SUM_FIELDS, item.body.sums, strict=True))
return OsvRow(
row_number=item.body.row,
level=item.level,
parent_row_number=item.parent,
row_value=item.body.text,
group_type=item.group_type,
group_name=item.group_name,
account_code_raw=item.account_code_raw,
counterparty_name=item.counterparty_name,
counterparty_inn=item.counterparty_inn,
data_type=item.data_type,
opening_debit=sums["opening_debit"],
opening_credit=sums["opening_credit"],
turnover_debit=sums["turnover_debit"],
turnover_credit=sums["turnover_credit"],
closing_debit=sums["closing_debit"],
closing_credit=sums["closing_credit"],
extra=item.extra,
)
def _with_table_account(doc: DocHeader, rows: Sequence[ClassifiedRow]) -> DocHeader:
"""Код счёта шапки, которой нет («по счету …» в названии нет), — код первой строки-счёта таблицы.
«Счет: Код / Наименование»: код стоит в строке таблицы, а не в названии ведомости. Предупреждение «код счёта в
шапке не найден» заменяется пометкой, откуда код взят.
"""
if doc.account_code_raw:
return doc
accounts = (item.account_code_raw for item in rows if item.group_type == "ACCOUNT")
code = next((code for code in accounts if code), None)
if code is None:
return doc
warnings = [warning for warning in doc.warnings if warning != ACCOUNT_NOT_FOUND_WARNING]
warnings.append(f"код счёта «{code}» взят из первой строки-счёта таблицы: в шапке «по счету …» нет")
return replace(doc, account_code_raw=code, warnings=tuple(warnings))
def _is_ok(checks: OsvChecks) -> bool:
return (
bool(checks.total_row_matches_root)
and not checks.balance_identity_violations
and not (checks.parent_sum_violations)
)
def data_rows(document: OsvDocument) -> int:
return sum(1 for row in document.rows if row.group_type != "TOTAL")
def _unique_notes(notes: Iterable[OsvNote]) -> list[OsvNote]:
"""Без повторов по тексту, в порядке первого появления (как `unique` для строк вывода 1.x)."""
seen: dict[str, OsvNote] = {}
for note in notes:
seen.setdefault(note.message, note)
return list(seen.values())
def _invalid_format(sheet: Sheet, step: str, exc: InvalidFormatError) -> InvalidFormatError:
"""Сообщение `INVALID_FORMAT` с листом и шагом; префикс «лист «…»:» детектора не дублируется."""
reason = exc.message.removeprefix(f"лист «{sheet.name}»: ")
return InvalidFormatError(f"лист «{sheet.name}», шаг «{step}»: {reason}")
def _check_inn_owners(glue: InnGlue, classified: list[ClassifiedRow]) -> InnGlue:
"""Страховка (R-8, design №24): ИНН склеен со строкой, которая по схеме не контрагент (подразделение, договор,
OTHER) — ИНН пропадёт молча, поэтому предупреждение и `needs_check`."""
types = {item.body.row: item.group_type for item in classified}
extra = [
f"строка {glue.sources[owner]}: ИНН {inn} отнесён к строке {owner}, но она не контрагент — проверьте"
for owner, inn in glue.inns.items()
if owner in glue.sources and types.get(owner) != "COUNTERPARTY"
]
if not extra:
return glue
return replace(glue, warnings=[*glue.warnings, *extra], ok=False)
@dataclass(frozen=True)
class SheetResult:
"""Итог листа ОСВ: документ, заметки с кодами `OSV_*` (первая — иерархия) и признак «контрольные суммы сошлись»."""
document: OsvDocument
notes: tuple[OsvNote, ...]
ok: bool
class OsvParser:
"""Конвейер листа ОСВ по словарям `rules`; книгу разбирает `BookParser`."""
def __init__(self, rules: Rules) -> None:
self._rules = rules
def parse_sheet(self, sheet: Sheet, deadline: Deadline) -> SheetResult:
"""Документ листа ОСВ, его заметки (первая — иерархия) и признак «контрольные суммы сошлись»;
нет заголовка/колонок/иерархии — `InvalidFormatError` с листом и шагом.
"""
rules = self._rules
step = "заголовок таблицы"
try:
header = find_table_header(sheet, rules, deadline)
step = "шапка документа"
doc = parse_doc_header(sheet, header, rules)
schema = build_group_levels(sheet, header, rules)
deadline.check(f"лист «{sheet.name}»: строки таблицы")
step = "строки таблицы"
body, total_row, body_warnings = collect_body_rows(sheet, header, rules, deadline)
step = "иерархия строк"
levels = resolve_levels(body, total_row, deadline)
step = "строки ИНН"
glue = glue_inn_rows(
body,
levels,
total_row,
sheet,
inn_col=header.inn_col,
schema=schema.groups,
hint=schema.inn_hint,
rules=rules,
)
step = "классификация строк"
classified = classify_rows(
glue.rows,
glue.levels,
schema.groups,
header,
sheet,
doc.data_type,
total_row,
rules,
inns=glue.inns,
unpaired=glue.unpaired,
)
except InvalidFormatError as exc:
raise _invalid_format(sheet, step, exc) from exc
glue = _check_inn_owners(glue, classified)
doc = _with_table_account(doc, classified)
deadline.check(f"лист «{sheet.name}»: контрольные суммы")
checks, check_notes = run_checks(classified, rules.checks)
document = self._document(
sheet=sheet, header=header, doc=doc, schema=schema.groups, rows=classified, checks=checks
)
notes = [
hierarchy_note(levels.strategy),
*notes_of(OSV_HEADER, doc.warnings),
*notes_of(OSV_ROWS, body_warnings),
*notes_of(OSV_INN_ROWS, glue.warnings),
]
if not any(item.group_type != "TOTAL" for item in classified):
notes.append(OsvNote(OSV_NO_DATA_ROWS, NO_DATA_ROWS_WARNING))
notes.extend(check_notes)
# Строка ИНН без пары, похожая на ИНН строка или расхождение с колонкой (#35) — needs_check
return SheetResult(document, tuple(_unique_notes(notes)), _is_ok(checks) and glue.ok)
# --- внутреннее ---
@staticmethod
def _document( # noqa: PLR0913 - части документа собираются в parse_sheet и передаются явно
*,
sheet: Sheet,
header: TableHeader,
doc: DocHeader,
schema: list[OsvGroup],
rows: list[ClassifiedRow],
checks: OsvChecks,
) -> OsvDocument:
return OsvDocument(
document_type="OSV",
locator=f"{LOCATOR_PREFIX}{sheet.name}",
header=_header(doc),
group_schema=schema,
columns=_columns(header),
rows=[_row(item) for item in rows],
checks=checks,
)