Files
finance/backend/app/imports/adapters/ozon_pdf.py
T
k1nq 023612ce0a Update manifest.json with new file metadata and add pytest configuration
- Updated timestamps and AST hashes for multiple files in the manifest.json.
- Added new test file backend/tests/test_expected_income.py.
- Added documentation files: перево́д_между_своими.md and 0002-fixes.md.
- Introduced pytest.ini to configure pytest settings, including test paths and warning filters.
2026-09-24 08:34:13 +05:00

306 lines
13 KiB
Python

from __future__ import annotations
import re
from datetime import date, datetime
from app.imports.adapters.base import (
DIRECTION_IN,
DIRECTION_OUT,
OZON_DEBIT_PDF_VERSION,
PARSER_VERSION,
SourceRow,
StatementDraft,
UnsupportedStatementLayout,
account_fingerprint,
amount_to_kopecks,
money_to_kopecks,
)
from app.imports.readers import open_pdf
PERIOD_RE = re.compile(
r"период:?\s*(\d{2}\.\d{2}\.\d{4})\s*[-–—]\s*(\d{2}\.\d{2}\.\d{4})", re.IGNORECASE
)
ROW_RE = re.compile(
r"^(?P<date>\d{2}\.\d{2}\.\d{4})\s+(?P<desc>.+?)\s+(?P<amount>-?\d[\d\s]*,\d{2})"
r"(?:\s*(?:₽|руб\.?|RUB))?$"
)
MASK_RE = re.compile(r"(?:\*{2,}|•{2,})\s*(\d{4})")
class OzonPdfAdapterBase:
"""Ozon statement contract: text-layer PDF, per-page line scanning."""
adapter_id: str = ""
product: str = ""
bank_markers: tuple[str, ...] = ("ozon банк", "озон банк", "ozon bank")
product_markers: tuple[str, ...] = ()
def detect(self, raw: bytes) -> bool:
try:
text = _first_page_text(raw)
except Exception:
return False
lowered = text.lower()
return any(m in lowered for m in self.bank_markers) and any(
m in lowered for m in self.product_markers
)
def parse(self, raw: bytes) -> StatementDraft:
with open_pdf(raw) as pdf:
lines: list[str] = []
period_start: date | None = None
period_end: date | None = None
masks: list[str] = []
for page_index, page in enumerate(pdf.pages):
text = page.extract_text() or ""
if page_index == 0 and not text.strip():
raise UnsupportedStatementLayout(
"PDF has no extractable text layer; OCR is not supported"
)
lowered = text.lower()
if not any(m in lowered for m in self.bank_markers):
raise UnsupportedStatementLayout("bank marker not found in PDF text")
if not any(m in lowered for m in self.product_markers):
raise UnsupportedStatementLayout(
f"expected {self.product} statement markers not found"
)
match = PERIOD_RE.search(text)
if match and period_start is None:
period_start = datetime.strptime(match.group(1), "%d.%m.%Y").date()
period_end = datetime.strptime(match.group(2), "%d.%m.%Y").date()
masks.extend(MASK_RE.findall(text))
for line in text.splitlines():
line = line.strip()
if ROW_RE.match(line):
lines.append(line)
if not lines:
raise UnsupportedStatementLayout("no statement rows matched the layout contract")
fingerprint = (
account_fingerprint(self.bank_markers[0].split()[0], f"**{masks[0]}")
if masks
else None
)
parsed = [_parse_row(line, f"page:line{n + 1}") for n, line in enumerate(lines)]
return StatementDraft(
adapter_id=self.adapter_id,
parser_version=PARSER_VERSION,
bank_code=self.bank_markers[0].split()[0],
product=self.product,
account_fingerprint=fingerprint,
currency="RUB",
period_start=period_start,
period_end=period_end,
opening_balance_kopecks=None,
closing_balance_kopecks=None,
rows=tuple(parsed),
)
def _parse_row(line: str, row_ref: str) -> SourceRow:
match = ROW_RE.match(line)
assert match is not None
occurred = datetime.strptime(match.group("date"), "%d.%m.%Y").date()
raw_amount = match.group("amount").replace(" ", "")
amount = amount_to_kopecks(raw_amount)
return SourceRow(
external_id=None,
occurred_on=occurred,
posted_on=None,
amount_kopecks=amount,
direction=DIRECTION_IN if amount >= 0 else DIRECTION_OUT,
description=match.group("desc").strip(),
mcc=None,
bank_category=None,
location=None,
row_ref=row_ref,
warnings=() if occurred else ("occurred_date_missing",),
)
def _first_page_text(raw: bytes) -> str:
with open_pdf(raw) as pdf:
if not pdf.pages:
raise UnsupportedStatementLayout("PDF has no pages")
return pdf.pages[0].extract_text() or ""
class OzonDebitPdfAdapter:
"""Ozon debit statement contract, evidenced against the real document.
A 'Справка о движении средств' text-layer PDF: a legal-bank header block
with account/period/opening balance, per-page tables whose rows keep
multiline document IDs and descriptions attached to one transaction, and a
closing block with declared inflow/outflow totals and the closing balance.
Amounts are signed strings ('- 500.00 ₽'); the sign maps to direction and
the stored amount is always positive.
"""
adapter_id = "ozon_debit_pdf"
product = "debit"
parser_version = OZON_DEBIT_PDF_VERSION
bank_code = "ozon"
currency = "RUB"
bank_marker = "озон банк"
title_marker = "справка о движении средств"
account_marker_re = re.compile(r"лицевого\s+сч[её]та:?\s*№\s*([\d*•]+)", re.IGNORECASE)
period_re = re.compile(
r"период\s+выписки:?\s*(\d{2}\.\d{2}\.\d{4})\s*[–—-]\s*(\d{2}\.\d{2}\.\d{4})",
re.IGNORECASE,
)
opening_re = re.compile(r"входящий\s+остаток:?\s*(.+)", re.IGNORECASE)
inflow_re = re.compile(r"итого\s+зачислений\s+за\s+период:?\s*(.+)", re.IGNORECASE)
outflow_re = re.compile(r"итого\s+списаний\s+за\s+период:?\s*(.+)", re.IGNORECASE)
closing_re = re.compile(r"исходящий\s+остаток:?\s*(.+)", re.IGNORECASE)
amount_re = re.compile(r"^([+-])\s*(\d[\d\s]*(?:[.,]\d{2})?)\s*(?:₽|rur|rub)?$", re.IGNORECASE)
table_header_labels = ("дата операции", "документ", "назначение платежа")
table_subheader_tokens = ("рубли", "валюта")
def detect(self, raw: bytes) -> bool:
try:
text = _first_page_text(raw)
except Exception:
return False
lowered = _ozon_normalize(text)
return (
self.bank_marker in lowered
and self.title_marker in lowered
and "лицевого сч" in lowered
)
def parse(self, raw: bytes) -> StatementDraft:
pages_text: list[str] = []
pages_tables: list[list[list[list[str | None]]]] = []
with open_pdf(raw) as pdf:
if not pdf.pages:
raise UnsupportedStatementLayout("PDF has no pages")
for page_index, page in enumerate(pdf.pages):
text = page.extract_text() or ""
if page_index == 0 and not text.strip():
raise UnsupportedStatementLayout(
"PDF has no extractable text layer; OCR is not supported"
)
pages_text.append(text)
extracted = page.extract_tables()
pages_tables.append(extracted if extracted else [])
return self._draft_from_extracted(pages_text, pages_tables)
def _draft_from_extracted(
self, pages_text: list[str], pages_tables: list[list[list[list[str | None]]]]
) -> StatementDraft:
page_zero = _ozon_normalize(pages_text[0])
if self.bank_marker not in page_zero or self.title_marker not in page_zero:
raise UnsupportedStatementLayout("Ozon statement markers not found")
account_match = self.account_marker_re.search(page_zero)
if account_match is None:
raise UnsupportedStatementLayout("Ozon account line not found")
period_match = self.period_re.search(page_zero)
if period_match is None:
raise UnsupportedStatementLayout("Ozon period line not found")
period_start = datetime.strptime(period_match.group(1), "%d.%m.%Y").date()
period_end = datetime.strptime(period_match.group(2), "%d.%m.%Y").date()
opening = money_to_kopecks(self._required_line(self.opening_re, page_zero, "opening"))
full_text = _ozon_normalize("\n".join(pages_text))
declared_inflow = money_to_kopecks(self._required_line(self.inflow_re, full_text, "inflow"))
declared_outflow = money_to_kopecks(
self._required_line(self.outflow_re, full_text, "outflow")
)
closing = money_to_kopecks(self._required_line(self.closing_re, full_text, "closing"))
rows: list[SourceRow] = []
for page_index, tables in enumerate(pages_tables, start=1):
for table_index, table in enumerate(tables):
for row_index, cells in enumerate(table):
row = self._table_row(
cells, f"page{page_index}:table{table_index}:row{row_index}"
)
if row is not None:
rows.append(row)
if not rows:
raise UnsupportedStatementLayout("no statement rows matched the Ozon contract")
return StatementDraft(
adapter_id=self.adapter_id,
parser_version=self.parser_version,
bank_code=self.bank_code,
product=self.product,
account_fingerprint=account_fingerprint(self.bank_code, account_match.group(1)),
currency=self.currency,
period_start=period_start,
period_end=period_end,
opening_balance_kopecks=opening,
closing_balance_kopecks=closing,
rows=tuple(rows),
declared_inflow_kopecks=declared_inflow,
declared_outflow_kopecks=declared_outflow,
declared_hold_kopecks=None,
)
def _required_line(self, pattern: re.Pattern[str], text: str, what: str) -> str:
match = pattern.search(text)
if match is None:
raise UnsupportedStatementLayout(f"Ozon {what} total line not found")
return match.group(1)
def _table_row(self, cells: list[str | None], row_ref: str) -> SourceRow | None:
if cells is None:
raise UnsupportedStatementLayout("unexpected Ozon table row shape")
texts = [_ozon_cell(cell) for cell in cells]
if not any(texts):
return None
lowered = [text.lower() for text in texts]
joined = " ".join(text for text in lowered if text)
if all(label in joined for label in self.table_header_labels):
return None
if lowered[0] == "" and any(token in joined for token in self.table_subheader_tokens):
return None
if lowered[0] == "" or len(texts) < 4:
raise UnsupportedStatementLayout("unexpected Ozon table row shape")
occurred = None
date_match = re.match(r"^(\d{2}\.\d{2}\.\d{4})", texts[0])
if date_match is not None:
occurred = datetime.strptime(date_match.group(1), "%d.%m.%Y").date()
external_id = re.sub(r"\s+", "", texts[1]) if len(texts) > 1 else ""
description = " ".join(texts[2].split()) if len(texts) > 2 else ""
warnings: list[str] = []
if occurred is None:
warnings.append("occurred_date_missing")
if not external_id:
warnings.append("external_id_missing")
amount_match = self.amount_re.match(texts[3]) if len(texts) > 3 else None
if amount_match is None:
amount = 0
warnings.append("amount_unparseable")
direction = DIRECTION_OUT
else:
sign, body = amount_match.group(1), amount_match.group(2)
amount = amount_to_kopecks(body.replace(" ", "").replace(",", "."))
direction = DIRECTION_IN if sign == "+" else DIRECTION_OUT
return SourceRow(
external_id=external_id or None,
occurred_on=occurred,
posted_on=None,
amount_kopecks=amount,
direction=direction,
description=description or None,
mcc=None,
bank_category=None,
location=None,
row_ref=row_ref,
warnings=tuple(warnings),
)
def _ozon_normalize(text: str) -> str:
return text.replace("\u00a0", " ").lower()
def _ozon_cell(cell: object) -> str:
if cell is None:
return ""
return str(cell).replace("\u00a0", " ").strip()
class OzonCreditPdfAdapter(OzonPdfAdapterBase):
adapter_id = "ozon_credit_pdf"
product = "credit"
product_markers = ("кредитн", "кредитная")