Files
k1nq e43428c1a2
Gitea Actions CI / detect (push) Successful in 3s
Gitea Actions CI / Compose & nginx validation (push) Failing after 20s
Gitea Actions CI / Frontend (lint / type / test / build) (push) Failing after 24s
Gitea Actions CI / Backend (lint / type / test / build) (push) Failing after 1m34s
Implement Gitea CI and real statement imports
2026-09-18 17:59:02 +05:00

289 lines
11 KiB
Python
Raw Permalink 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.
from __future__ import annotations
import re
from datetime import date
from app.imports.adapters.base import (
DIRECTION_IN,
DIRECTION_OUT,
ROCKETBANK_DEBIT_PDF_VERSION,
SourceRow,
StatementDraft,
UnsupportedStatementLayout,
account_fingerprint,
money_to_kopecks,
)
from app.imports.readers import open_pdf
MONTH_NAMES = {
"января": 1,
"февраля": 2,
"марта": 3,
"апреля": 4,
"мая": 5,
"июня": 6,
"июля": 7,
"августа": 8,
"сентября": 9,
"октября": 10,
"ноября": 11,
"декабря": 12,
}
DAY_HEADING_RE = re.compile(
r"^(\d{1,2})\s+(января|февраля|марта|апреля|мая|июня|июля|августа|сентября|"
r"октября|ноября|декабря)\s+(\d{4})\s+баланс на начало дня:\s*(-?[\d\s]+,\d{2})\s*rur$",
re.IGNORECASE,
)
RECORD_RE = re.compile(
r"^(\d{1,2}:\d{2})\s+(\d{6,})\s+(.*?)([+-]?\s?[\d\s]+,\d{2})\s*rur$",
re.IGNORECASE,
)
HEADER_VALUES_RE = re.compile(
r"^(-?[\d\s]+,\d{2})\s*rur\s+(-?[\d\s]+,\d{2})\s*rur(?:\s+(\d{2}\.\d{2}\.\d{4}))?$",
re.IGNORECASE,
)
PERIOD_RE = re.compile(r"^с\s+(\d{2}\.\d{2}\.\d{4})\s+по\s+(\d{2}\.\d{2}\.\d{4})$", re.IGNORECASE)
AMOUNT_RE = re.compile(r"^([+-]?)\s*(\d[\d\s]*,\d{2})$")
MCC_RE = re.compile(r"\bmcc\s*:?\s*(\d{4})\b", re.IGNORECASE)
LOCATION_RE = re.compile(r"rur,\s*([^,]+?),\s*mcc\b", re.IGNORECASE)
class RocketbankDebitPdfAdapter:
"""Rocketbank debit statement contract, evidenced against the real document.
Independent text-layer state machine: every page repeats the statement
header and column captions; day sections start with a Russian day heading
('DD MONTH YYYY БАЛАНС НА НАЧАЛО ДНЯ: X RUR'); each record is a
'time + operation code + multiline description + signed RUR amount' line
whose description column starts slightly above and continues below that
line, so the surrounding non-structural lines are folded into one
description. Daily totals and repeated captions are ignored. Statement
period, account, opening/closing balances and declared inflow/outflow
totals come from the first-page header block.
"""
adapter_id = "rocketbank_debit_pdf"
product = "debit"
parser_version = ROCKETBANK_DEBIT_PDF_VERSION
bank_code = "rocketbank"
currency = "RUB"
title_marker = "выписка по счету"
card_re = re.compile(r"дебетовая карта рокет\s*(\d{10,30})\s*rur", re.IGNORECASE)
column_caption = "время код операции описание сумма"
issued_prefix = "дата выдачи выписки:"
page_footer_prefix = "страница"
daily_total_prefixes = ("всего поступлений:", "всего расходов:")
opening_label = "входящий остаток"
closing_label = "исходящий остаток"
max_fold_lines = 6
def detect(self, raw: bytes) -> bool:
try:
with open_pdf(raw) as pdf:
if not pdf.pages:
return False
text = pdf.pages[0].extract_text() or ""
except Exception:
return False
lowered = text.lower()
return self.title_marker in lowered and "дебетовая карта рокет" in lowered
def parse(self, raw: bytes) -> StatementDraft:
lines: list[tuple[int, str]] = []
with open_pdf(raw) as pdf:
if not pdf.pages:
raise UnsupportedStatementLayout("PDF has no pages")
for page_number, page in enumerate(pdf.pages, start=1):
text = page.extract_text() or ""
if page_number == 1 and not text.strip():
raise UnsupportedStatementLayout(
"PDF has no extractable text layer; OCR is not supported"
)
for line in text.splitlines():
stripped = line.strip()
if stripped:
lines.append((page_number, stripped))
return self._draft_from_lines(lines)
def _draft_from_lines(self, lines: list[tuple[int, str]]) -> StatementDraft:
blob = "\n".join(line.lower() for _, line in lines)
if self.title_marker not in blob:
raise UnsupportedStatementLayout("Rocketbank statement marker not found")
card_match = self.card_re.search(blob)
if card_match is None:
raise UnsupportedStatementLayout("Rocketbank card line not found")
period_start: date | None = None
period_end: date | None = None
opening: int | None = None
closing: int | None = None
declared_inflow: int | None = None
declared_outflow: int | None = None
rows: list[SourceRow] = []
current_day: date | None = None
pending: _PendingRecord | None = None
pre_lines: list[tuple[int, str]] = []
record_ordinals: dict[int, int] = {}
index = 0
while index < len(lines):
page, line = lines[index]
low = line.lower()
if (
low == self.title_marker
or low.startswith(self.issued_prefix)
or low.startswith(self.page_footer_prefix)
or low == self.column_caption
or low.startswith(self.daily_total_prefixes)
):
index += 1
continue
period_match = PERIOD_RE.match(low)
if period_match is not None and period_start is None:
period_start = _date_from(period_match.group(1))
period_end = _date_from(period_match.group(2))
index += 1
continue
if self.opening_label in low or self.closing_label in low:
if index + 1 >= len(lines):
raise UnsupportedStatementLayout("Rocketbank balance values missing")
values_match = HEADER_VALUES_RE.match(lines[index + 1][1].lower())
if values_match is None:
raise UnsupportedStatementLayout("Rocketbank balance values missing")
first = money_to_kopecks(values_match.group(1))
second = money_to_kopecks(values_match.group(2))
if self.opening_label in low:
opening, declared_inflow = first, second
else:
closing, declared_outflow = first, second
index += 2
continue
day_match = DAY_HEADING_RE.match(low)
if day_match is not None:
if pending is not None:
rows.append(self._record(pending, current_day))
pending = None
pre_lines = []
current_day = date(
int(day_match.group(3)),
MONTH_NAMES[day_match.group(2).lower()],
int(day_match.group(1)),
)
index += 1
continue
record_match = RECORD_RE.match(line)
if record_match is not None:
if pending is not None:
rows.append(self._record(pending, current_day))
ordinal = record_ordinals.get(page, 0) + 1
record_ordinals[page] = ordinal
pending = _PendingRecord(
row_ref=f"page{page}:record{ordinal}",
code=record_match.group(2),
description=record_match.group(3).strip(),
amount_text=record_match.group(4),
pre_lines=pre_lines,
)
pre_lines = []
index += 1
continue
if pending is not None:
pending.post_lines.append((page, line))
index += 1
continue
pre_lines.append((page, line))
if len(pre_lines) > self.max_fold_lines:
raise UnsupportedStatementLayout("unrecognized content between Rocketbank sections")
index += 1
if pending is not None:
rows.append(self._record(pending, current_day))
pre_lines = []
if pre_lines:
raise UnsupportedStatementLayout("unrecognized trailing Rocketbank content")
if (
period_start is None
or opening is None
or closing is None
or declared_inflow is None
or declared_outflow is None
):
raise UnsupportedStatementLayout("Rocketbank statement header block not found")
if not rows:
raise UnsupportedStatementLayout("no Rocketbank operation rows found")
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, card_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 _record(
self,
pending: _PendingRecord,
current_day: date | None,
) -> SourceRow:
if current_day is None:
raise UnsupportedStatementLayout("Rocketbank record outside a day section")
chunks = [text for _, text in pending.pre_lines]
chunks.append(pending.description)
chunks.extend(text for _, text in pending.post_lines)
description = " ".join(" ".join(chunk.split()) for chunk in chunks if chunk.strip())
amount_match = AMOUNT_RE.match(" ".join(pending.amount_text.split()))
if amount_match is None:
raise UnsupportedStatementLayout("Rocketbank record amount missing")
sign = amount_match.group(1)
amount = money_to_kopecks(amount_match.group(2))
direction = DIRECTION_OUT if sign == "-" else DIRECTION_IN
mcc_match = MCC_RE.search(description)
location_match = LOCATION_RE.search(description)
return SourceRow(
external_id=pending.code,
occurred_on=current_day,
posted_on=None,
amount_kopecks=amount,
direction=direction,
description=description or None,
mcc=int(mcc_match.group(1)) if mcc_match is not None else None,
bank_category=None,
location=location_match.group(1).strip() if location_match is not None else None,
row_ref=pending.row_ref,
warnings=(),
)
class _PendingRecord:
__slots__ = ("row_ref", "code", "description", "amount_text", "pre_lines", "post_lines")
def __init__(
self,
*,
row_ref: str,
code: str,
description: str,
amount_text: str,
pre_lines: list[tuple[int, str]],
) -> None:
self.row_ref = row_ref
self.code = code
self.description = description
self.amount_text = amount_text
self.pre_lines = pre_lines
self.post_lines: list[tuple[int, str]] = []
def _date_from(text: str) -> date:
day, month, year = (int(part) for part in text.split("."))
return date(year, month, day)