Files
finance/backend/app/imports/adapters/alfa_xlsx.py
T

531 lines
20 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.
from __future__ import annotations
import re
from datetime import date, datetime
from typing import TypedDict
from openpyxl.workbook import Workbook
from app.errors import AppError
from app.imports.adapters.base import (
ALFA_DEBIT_XLSX_VERSION,
BANK_STATUS_HOLD,
BANK_STATUS_POSTED,
DIRECTION_IN,
DIRECTION_OUT,
PARSER_VERSION,
SourceRow,
StatementDraft,
UnsupportedStatementLayout,
account_fingerprint,
amount_to_kopecks,
money_to_kopecks,
)
from app.imports.readers import open_xlsx
DATE_HEADERS = ("дата операции", "дата")
AMOUNT_HEADERS = ("сумма в валюте счёта", "сумма операции", "сумма")
DESCRIPTION_HEADERS = ("описание", "назначение", "комментарий")
CATEGORY_HEADERS = ("категория",)
ACCOUNT_HEADERS = ("номер карты", "номер счёта", "карта")
# Alfa «Копилка для сдачи» round-up service. The rule fails closed: only an
# exact normalized phrase with a source account equal to the statement account
# and an outgoing posted row produces the savings_transfer decision hint. The
# account numbers in the pattern are part of the bank's phrasing contract and
# are matched against the statement header, never hardcoded per user.
SAVINGS_SERVICE_RE = re.compile(
r"перечисление средств в рамках услуги \"копилка для сдачи\" "
r"со счета (\d{20}) на счет (\d{20})"
)
def _savings_rule_text(value: str) -> str:
text = value.replace("\u00a0", " ").replace("ё", "е").replace("Ё", "Е").lower()
text = re.sub(r"[«»„“”\"']", '"', text)
return " ".join(text.split())
def detect_savings_transfer(
description: str | None,
*,
direction: str,
bank_status: str,
statement_account: str,
) -> bool:
if direction != DIRECTION_OUT or bank_status != BANK_STATUS_POSTED or not description:
return False
match = SAVINGS_SERVICE_RE.search(_savings_rule_text(description))
if match is None:
return False
statement_digits = re.sub(r"\D", "", statement_account or "")
if not statement_digits:
return False
return match.group(1) == statement_digits
class AlfaXlsxAdapterBase:
"""Alfa statement contract: XLSX with a header row and bank marker 'Альфа'."""
adapter_id: str = ""
product: str = ""
product_markers: tuple[str, ...] = ()
def detect(self, raw: bytes) -> bool:
try:
text = _scan_text(raw)
except AppError:
return False
lowered = text.lower()
return "альфа" in lowered and any(m in lowered for m in self.product_markers)
def parse(self, raw: bytes) -> StatementDraft:
with open_xlsx(raw) as workbook:
sheet = workbook.worksheets[0]
rows = []
for index, row in enumerate(sheet.iter_rows(values_only=True)):
if index > get_settings_row_cap():
raise UnsupportedStatementLayout("row quota exceeded during parse")
rows.append(row)
header_idx, columns = _find_header(rows)
full_text = _rows_text(rows).lower()
if "альфа" not in full_text:
raise UnsupportedStatementLayout("Alfa bank marker not found")
if not any(m in full_text for m in self.product_markers):
raise UnsupportedStatementLayout(
f"expected {self.product} statement markers not found"
)
fingerprint = None
masked = _find_account_value(rows)
if masked:
fingerprint = account_fingerprint("alfa", masked)
parsed: list[SourceRow] = []
for offset, row in enumerate(rows[header_idx + 1 :], start=header_idx + 1):
if all(cell is None for cell in row):
continue
occurred = _parse_date(row[columns["date"]])
amount_raw = row[columns["amount"]]
warnings: list[str] = []
if occurred is None:
warnings.append("occurred_date_missing")
try:
amount = amount_to_kopecks(amount_raw)
except UnsupportedStatementLayout:
amount = 0
warnings.append("amount_unparseable")
description = (
_cell_str(row[columns["description"]]) if columns.get("description") else None
)
bank_category = (
_cell_str(row[columns["category"]]) if columns.get("category") else None
)
external_id = (
_cell_str(row[columns["external"]]) if columns.get("external") else None
)
parsed.append(
SourceRow(
external_id=external_id,
occurred_on=occurred,
posted_on=None,
amount_kopecks=amount,
direction=DIRECTION_IN if amount >= 0 else DIRECTION_OUT,
description=description,
mcc=None,
bank_category=bank_category,
location=None,
row_ref=f"sheet0:row{offset + 1}",
warnings=tuple(warnings),
)
)
return StatementDraft(
adapter_id=self.adapter_id,
parser_version=PARSER_VERSION,
bank_code="alfa",
product=self.product,
account_fingerprint=fingerprint,
currency="RUB",
period_start=min((r.occurred_on for r in parsed if r.occurred_on), default=None),
period_end=max((r.occurred_on for r in parsed if r.occurred_on), default=None),
opening_balance_kopecks=None,
closing_balance_kopecks=None,
rows=tuple(parsed),
)
def get_settings_row_cap() -> int:
from app.config import get_settings
return get_settings().max_xlsx_rows
def _scan_text(raw: bytes) -> str:
with open_xlsx(raw) as workbook:
return _rows_text(
[
row
for index, row in enumerate(workbook.worksheets[0].iter_rows(values_only=True))
if index < 40
]
)
def _rows_text(rows: list[tuple]) -> str:
parts = []
for row in rows:
for cell in row:
if cell is not None:
parts.append(str(cell))
return " | ".join(parts)
def _cell_str(value: object) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
def _parse_date(value: object) -> date | None:
if value is None:
return None
if isinstance(value, datetime):
return value.date()
if isinstance(value, date):
return value
text = str(value).strip()
for fmt in ("%d.%m.%Y", "%Y-%m-%d", "%d.%m.%Y %H:%M:%S"):
try:
return datetime.strptime(text, fmt).date()
except ValueError:
continue
return None
def _find_header(rows: list[tuple]) -> tuple[int, dict[str, int]]:
for index, row in enumerate(rows[:20]):
cells = [str(c).strip().lower() if c is not None else "" for c in row]
date_col = next((i for i, c in enumerate(cells) if c in DATE_HEADERS), None)
amount_col = next((i for i, c in enumerate(cells) if c in AMOUNT_HEADERS), None)
if date_col is None or amount_col is None:
continue
columns = {"date": date_col, "amount": amount_col}
for key, synonyms in (
("description", DESCRIPTION_HEADERS),
("category", CATEGORY_HEADERS),
("external", ("id операции", "номер операции")),
):
columns[key] = next( # type: ignore[assignment]
(i for i, c in enumerate(cells) if c in synonyms), None
)
return index, columns
raise UnsupportedStatementLayout("statement header row with date/amount columns not found")
def _find_account_value(rows: list[tuple]) -> str | None:
for row in rows[:20]:
for index, cell in enumerate(row):
if cell is None:
continue
header = str(row[index - 1]).strip().lower() if index > 0 and row[index - 1] else ""
if header in ACCOUNT_HEADERS:
return str(cell).strip()
masked = None
for row in rows[:20]:
for cell in row:
if cell is None:
continue
text = str(cell)
if len(text) >= 4 and any(ch.isdigit() for ch in text) and ("*" in text or "•" in text):
masked = text.strip()
return masked
class AlfaDebitXlsxAdapter:
"""Alfa debit statement contract, evidenced against the real export layout.
Workbook shape: a 'Выписка по счету' title block, account metadata with
the exact statement period ('За период с ... по ...') and header control
totals (Поступления / Расходы / Неподтвержденные операции / Текущий
баланс), an operation header row (Дата операции, Дата проводки, Код,
Категория, Описание, Сумма в валюте счета, Статус), then operation rows
terminated by the signature footer. A HOLD posting-date cell marks an
immutable hold row; the Код column becomes the external id; a trailing
MCC token in the description becomes the row MCC. The raw Расходы control
includes hold amounts, so declared_outflow_kopecks is canonicalized to
posted-only (raw outflow minus declared hold). Anything else fails closed.
"""
adapter_id = "alfa_debit_xlsx"
product = "debit"
parser_version = ALFA_DEBIT_XLSX_VERSION
bank_code = "alfa"
currency = "RUB"
detect_scan_rows = 60
header_scan_rows = 30
title_label = "выписка по счету"
period_re = re.compile(
r"за период с\s*(\d{2}\.\d{2}\.\d{4})\s*по\s*(\d{2}\.\d{2}\.\d{4})", re.IGNORECASE
)
mcc_re = re.compile(r"\bMCC\s*:?\s*(\d{4})\b", re.IGNORECASE)
header_labels = {
"date": "дата операции",
"posted": "дата проводки",
"code": "код",
"category": "категория",
"description": "описание",
"status": "статус",
}
amount_label_prefix = "сумма в"
meta_labels = {
"account": "номер счета",
"currency": "валюта счета",
"inflow": "поступления",
"outflow": "расходы",
"hold": "неподтвержденные операции",
"closing": "текущий баланс",
}
footer_markers = ("подпись сотрудника", "уполномоченное лицо")
footer_page_prefix = "страница"
hold_token = "hold"
def detect(self, raw: bytes) -> bool:
try:
with open_xlsx(raw) as workbook:
rows = _debit_read_rows(workbook, self.detect_scan_rows)
except Exception:
return False
cells = [_debit_norm(cell) for row in rows for cell in row]
if self.title_label not in cells:
return False
required = (self.header_labels["date"], self.header_labels["posted"], "статус")
return all(label in cells for label in required)
def parse(self, raw: bytes) -> StatementDraft:
with open_xlsx(raw) as workbook:
cap = get_settings_row_cap()
rows = _debit_read_rows(workbook, cap + 1)
if len(rows) > cap:
raise UnsupportedStatementLayout("row quota exceeded during parse")
header_idx, columns = _debit_find_header(self, rows)
meta = _debit_header_metadata(self, rows, header_idx)
period_start, period_end = _debit_statement_period(self, rows, header_idx)
parsed = _debit_operation_rows(
self, rows, header_idx, columns, statement_account=meta["account"]
)
if not parsed:
raise UnsupportedStatementLayout("no operation rows found in Alfa statement")
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, meta["account"]),
currency=meta["currency"],
period_start=period_start,
period_end=period_end,
opening_balance_kopecks=None,
closing_balance_kopecks=meta["closing"],
rows=tuple(parsed),
declared_inflow_kopecks=meta["inflow"],
declared_outflow_kopecks=meta["outflow"],
declared_hold_kopecks=meta["hold"],
)
def _debit_norm(cell: object) -> str:
if cell is None:
return ""
return str(cell).replace("\u00a0", " ").strip().lower()
def _debit_read_rows(workbook: Workbook, limit: int) -> list[tuple]:
sheet = workbook.worksheets[0]
rows: list[tuple] = []
for index, row in enumerate(sheet.iter_rows(values_only=True)):
if index >= limit:
break
rows.append(row)
return rows
def _debit_find_header(
adapter: AlfaDebitXlsxAdapter, rows: list[tuple]
) -> tuple[int, dict[str, int]]:
for index, row in enumerate(rows[: adapter.header_scan_rows]):
cells = [_debit_norm(cell) for cell in row]
columns: dict[str, int] = {}
for key, label in adapter.header_labels.items():
column = next((i for i, cell in enumerate(cells) if cell == label), None)
if column is None:
break
columns[key] = column
if len(columns) != len(adapter.header_labels):
continue
amount_column = next(
(i for i, cell in enumerate(cells) if cell.startswith(adapter.amount_label_prefix)),
None,
)
if amount_column is None:
continue
columns["amount"] = amount_column
return index, columns
raise UnsupportedStatementLayout("Alfa operation header row not found")
def _debit_header_value(rows: list[tuple], label: str) -> str:
for row in rows:
for index, cell in enumerate(row):
if _debit_norm(cell) != label:
continue
for candidate in row[index + 1 :]:
text = _cell_str(candidate)
if text:
return text
raise UnsupportedStatementLayout(f"Alfa header metadata not found: {label}")
class _AlfaHeaderMeta(TypedDict):
account: str
currency: str
inflow: int
outflow: int
hold: int
closing: int
def _debit_statement_period(
adapter: AlfaDebitXlsxAdapter, rows: list[tuple], header_idx: int
) -> tuple[date, date]:
text = "\n".join(_debit_norm(cell) for row in rows[:header_idx] for cell in row)
period_match = adapter.period_re.search(text)
if period_match is None:
raise UnsupportedStatementLayout("Alfa statement period not found")
period_start = _parse_date(period_match.group(1))
period_end = _parse_date(period_match.group(2))
if period_start is None or period_end is None:
raise UnsupportedStatementLayout("Alfa statement period not found")
return period_start, period_end
def _debit_header_metadata(
adapter: AlfaDebitXlsxAdapter, rows: list[tuple], header_idx: int
) -> _AlfaHeaderMeta:
meta: _AlfaHeaderMeta = {
"account": _debit_header_value(rows[:header_idx], adapter.meta_labels["account"]),
"currency": "RUB",
"inflow": 0,
"outflow": 0,
"hold": 0,
"closing": 0,
}
currency = _debit_header_value(rows[:header_idx], adapter.meta_labels["currency"])
if currency.replace(" ", "").upper() not in {"RUB", "RUR"}:
raise UnsupportedStatementLayout("unsupported Alfa statement currency")
raw_outflow = money_to_kopecks(
_debit_header_value(rows[:header_idx], adapter.meta_labels["outflow"])
)
meta["hold"] = money_to_kopecks(
_debit_header_value(rows[:header_idx], adapter.meta_labels["hold"])
)
# The raw expense control includes pending (hold) amounts; canonicalize to
# posted-only so service reconciliation can compare like with like.
canonical_outflow = raw_outflow - meta["hold"]
if canonical_outflow < 0:
raise UnsupportedStatementLayout("inconsistent Alfa expense header controls")
meta["outflow"] = canonical_outflow
meta["inflow"] = money_to_kopecks(
_debit_header_value(rows[:header_idx], adapter.meta_labels["inflow"])
)
meta["closing"] = money_to_kopecks(
_debit_header_value(rows[:header_idx], adapter.meta_labels["closing"])
)
return meta
def _debit_operation_rows(
adapter: AlfaDebitXlsxAdapter,
rows: list[tuple],
header_idx: int,
columns: dict[str, int],
statement_account: str = "",
) -> list[SourceRow]:
parsed: list[SourceRow] = []
separated_from_operations = False
for offset, row in enumerate(rows[header_idx + 1 :], start=header_idx + 2):
norm_cells = [_debit_norm(cell) for cell in row]
if any(marker in cell for cell in norm_cells for marker in adapter.footer_markers):
break
if any(cell.startswith(adapter.footer_page_prefix) for cell in norm_cells):
break
if all(cell == "" for cell in norm_cells):
separated_from_operations = bool(parsed)
continue
occurred = _parse_date(row[columns["date"]])
posted_cell = _cell_str(row[columns["posted"]]) or ""
status_cell = _cell_str(row[columns["status"]]) or ""
bank_status = BANK_STATUS_POSTED
if adapter.hold_token in posted_cell.lower() or adapter.hold_token in status_cell.lower():
bank_status = BANK_STATUS_HOLD
posted_on = None
else:
posted_on = _parse_date(posted_cell)
amount_text = _cell_str(row[columns["amount"]])
amount: int | None = None
if amount_text is not None:
try:
amount = abs(money_to_kopecks(amount_text))
except UnsupportedStatementLayout:
amount = None
if occurred is None and amount is None:
later_has_operation = any(
_parse_date(later[columns["date"]]) is not None
for later in rows[offset:]
if len(later) > columns["date"]
)
if separated_from_operations and not later_has_operation:
break
raise UnsupportedStatementLayout("unexpected row in Alfa operation table")
warnings: list[str] = []
if occurred is None:
warnings.append("occurred_date_missing")
if amount is None:
amount = 0
warnings.append("amount_unparseable")
if bank_status == BANK_STATUS_POSTED and posted_on is None and posted_cell:
warnings.append("posted_date_unparseable")
direction = DIRECTION_OUT if (amount_text or "").startswith("-") else DIRECTION_IN
description = _cell_str(row[columns["description"]])
mcc_match = adapter.mcc_re.search(description) if description else None
parsed.append(
SourceRow(
external_id=_cell_str(row[columns["code"]]),
occurred_on=occurred,
posted_on=posted_on,
amount_kopecks=amount,
direction=direction,
description=description,
mcc=int(mcc_match.group(1)) if mcc_match is not None else None,
bank_category=_cell_str(row[columns["category"]]),
location=None,
row_ref=f"sheet0:row{offset}",
warnings=tuple(warnings),
bank_status=bank_status,
savings_transfer=detect_savings_transfer(
description,
direction=direction,
bank_status=bank_status,
statement_account=statement_account,
),
)
)
separated_from_operations = False
return parsed
class AlfaCreditXlsxAdapter(AlfaXlsxAdapterBase):
adapter_id = "alfa_credit_xlsx"
product = "credit"
product_markers = ("кредит", "кредитная")