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\d{2}\.\d{2}\.\d{4})\s+(?P.+?)\s+(?P-?\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 = ("кредитн", "кредитная")