310 lines
14 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 → статус
документа и его предупреждения с кодами `OSV_*` (контракт 2.0). Имя листа только подставляется в `locator` и
сообщения, поиск по нему не ведётся.
"""
import datetime
from collections.abc import Iterable, Sequence
from dataclasses import dataclass, replace
from src.contract import OsvDocument, ResultWarning
from src.contract.common import DataType
from src.contract.result import DocumentStatus, 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.code_blocks import apply_code_blocks
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 BodyRow
from src.parser.indicators import levels_with_indicators
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,
group_row_warnings,
hierarchy_note,
notes_of,
)
from src.parser.rules import Rules
__all__ = ["OsvParser", "SheetResult", "data_rows"]
LOCATOR_PREFIX = "sheet:"
INN_COLUMN_KEY = "inn"
NO_DATA_ROWS_WARNING = "нет строк данных"
DEFAULT_DATA_TYPE: DataType = "BU"
DEFAULT_DATA_TYPE_WARNING = "вид данных в шапке не указан — строки без метки приняты как БУ"
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 _without_foreign_bare_codes(body: list[BodyRow], header_account: str) -> list[BodyRow]:
"""Голый числовой код («441», «199») в таблице ОСВ с известным счётом шапки — код контрагента, а не счёт (#71).
Счётом остаётся голый код, совпадающий со счётом шапки («60» при «по счету 60.01»); коды с точкой и названием
(«60.01», «60, Расчеты») под фильтр не попадают. Без счёта шапки список не меняется.
"""
if not header_account:
return body
root = header_account.split(".", maxsplit=1)[0]
return [
replace(item, is_account=False, account_rank=None)
if item.is_account and item.text.isdigit() and item.text != root
else item
for item in body
]
def _by_account_warnings(rows: Sequence[ClassifiedRow]) -> list[str]:
"""По одному предупреждению на счёт, чьи строки признаны контрагентами по плану счетов (R-6), в порядке листа."""
codes = dict.fromkeys(item.account_code_raw or "" for item in rows if item.counterparty_by_account)
return [
f"вид субконто в шапке не назван: строки аналитики счёта {code} приняты за контрагентов по плану счетов — "
"проверьте"
for code in codes
]
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]:
"""Без повторов по паре (код, текст), в порядке первого появления (T6.1)."""
seen: dict[tuple[str, str], OsvNote] = {}
for note in notes:
seen.setdefault((note.code, 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, reader_notes: Sequence[OsvNote] = ()) -> SheetResult:
"""Документ листа ОСВ, его заметки (первая — иерархия) и признак «контрольные суммы сошлись»;
нет заголовка/колонок/иерархии — `InvalidFormatError` с листом и шагом. `reader_notes` — заметки читателя книги
(xlsb без outline): дописываются в конец предупреждений документа.
"""
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)
body = _without_foreign_bare_codes(body, doc.account_code_raw)
blocks = apply_code_blocks(body, total_row, rules, doc.account_code_raw)
body = blocks.rows
step = "иерархия строк"
levels = levels_with_indicators(body, total_row, deadline, header_account=bool(doc.account_code_raw))
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 = "классификация строк"
data_type = doc.data_type or DEFAULT_DATA_TYPE
# Вид данных шапки не указан: строки без метки приняты как БУ — предупреждение, если умолчание сработало
defaulted = doc.data_type is None and any(
not item.indicator and item.label_data_type is None for item in glue.rows
)
classified = classify_rows(
glue.rows,
glue.levels,
schema.groups,
header,
sheet,
data_type,
total_row,
rules,
inns=glue.inns,
unpaired=glue.unpaired,
header_account=doc.account_code_raw or None,
names=blocks.names,
)
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, secondary_totals=rules.headers.total_row_secondary)
notes = [
hierarchy_note(levels.strategy),
*notes_of(OSV_HEADER, doc.warnings),
*([OsvNote(OSV_HEADER, DEFAULT_DATA_TYPE_WARNING)] if defaulted else []),
*notes_of(OSV_ROWS, group_row_warnings(body_warnings)),
*notes_of(OSV_ROWS, _by_account_warnings(classified)),
*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)
notes.extend(reader_notes)
unique_notes = tuple(_unique_notes(notes))
# Строка ИНН без пары, похожая на ИНН строка или расхождение с колонкой (#35) — needs_check
ok = _is_ok(checks) and glue.ok
document = self._document(
sheet=sheet,
header=header,
doc=doc,
schema=schema.groups,
rows=classified,
checks=checks,
status="done" if ok else "needs_check",
notes=unique_notes,
)
return SheetResult(document, unique_notes, ok)
# --- внутреннее ---
@staticmethod
def _document( # noqa: PLR0913 - части документа собираются в parse_sheet и передаются явно
*,
sheet: Sheet,
header: TableHeader,
doc: DocHeader,
schema: list[OsvGroup],
rows: list[ClassifiedRow],
checks: OsvChecks,
status: DocumentStatus,
notes: Sequence[OsvNote],
) -> OsvDocument:
return OsvDocument(
document_type="OSV",
locator=f"{LOCATOR_PREFIX}{sheet.name}",
status=status,
warnings=[ResultWarning(code=note.code, severity="info", message=note.message) for note in notes],
header=_header(doc),
group_schema=schema,
columns=_columns(header),
rows=[_row(item) for item in rows],
checks=checks,
)