Initial income_calculator project
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
1
backend/__init__.py
Normal file
1
backend/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Backend package
|
||||
1
backend/api/__init__.py
Normal file
1
backend/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# API package
|
||||
39
backend/api/accounts.py
Normal file
39
backend/api/accounts.py
Normal file
@@ -0,0 +1,39 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from backend.db.database import get_connection
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(prefix="/api/accounts", tags=["accounts"])
|
||||
|
||||
|
||||
class OpeningBalanceBody(BaseModel):
|
||||
period_start: str # YYYY-MM-DD
|
||||
amount: float
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_accounts():
|
||||
conn = get_connection()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""SELECT a.id, a.bank_id, a.external_id, a.name, b.code as bank_code, b.name as bank_name, b.is_salary
|
||||
FROM accounts a JOIN banks b ON a.bank_id = b.id ORDER BY b.code, a.external_id"""
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.post("/{account_id}/opening-balance")
|
||||
def set_opening_balance(account_id: int, body: OpeningBalanceBody):
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"""INSERT INTO opening_balances (account_id, period_start, amount) VALUES (?, ?, ?)
|
||||
ON CONFLICT(account_id, period_start) DO UPDATE SET amount = excluded.amount""",
|
||||
(account_id, body.period_start, body.amount),
|
||||
)
|
||||
conn.commit()
|
||||
return {"account_id": account_id, "period_start": body.period_start, "amount": body.amount}
|
||||
finally:
|
||||
conn.close()
|
||||
83
backend/api/balance.py
Normal file
83
backend/api/balance.py
Normal file
@@ -0,0 +1,83 @@
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from backend.db.database import get_connection
|
||||
from backend.services.balance_service import get_summary, get_transactions
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(tags=["balance"])
|
||||
|
||||
|
||||
class TransactionExcludedBody(BaseModel):
|
||||
excluded_from_balance: bool
|
||||
|
||||
|
||||
def _parse_bank_ids(bank_ids: str | None) -> list[int] | None:
|
||||
if not bank_ids or not bank_ids.strip():
|
||||
return None
|
||||
try:
|
||||
return [int(x.strip()) for x in bank_ids.split(",") if x.strip()]
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/api/balance/summary")
|
||||
def balance_summary(
|
||||
period_start: str | None = Query(None, description="YYYY-MM-DD"),
|
||||
period_end: str | None = Query(None, description="YYYY-MM-DD"),
|
||||
bank_ids: str | None = Query(None, description="ID банков через запятую, например 1,2"),
|
||||
):
|
||||
return get_summary(
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
bank_ids=_parse_bank_ids(bank_ids),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/balance/by-account")
|
||||
def balance_by_account(
|
||||
period_start: str | None = Query(None),
|
||||
period_end: str | None = Query(None),
|
||||
bank_ids: str | None = Query(None),
|
||||
):
|
||||
data = get_summary(
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
bank_ids=_parse_bank_ids(bank_ids),
|
||||
)
|
||||
return {"by_account": data["by_account"]}
|
||||
|
||||
|
||||
@router.get("/api/transactions")
|
||||
def list_transactions(
|
||||
account_id: int | None = Query(None),
|
||||
bank_ids: str | None = Query(None),
|
||||
period_start: str | None = Query(None),
|
||||
period_end: str | None = Query(None),
|
||||
limit: int = Query(50, ge=1, le=500),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
items, total = get_transactions(
|
||||
account_id=account_id,
|
||||
bank_ids=_parse_bank_ids(bank_ids),
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
return {"items": items, "total": total}
|
||||
|
||||
|
||||
@router.patch("/api/transactions/{transaction_id}")
|
||||
def set_transaction_excluded(transaction_id: int, body: TransactionExcludedBody):
|
||||
conn = get_connection()
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"UPDATE transactions SET excluded_from_balance = ? WHERE id = ?",
|
||||
(1 if body.excluded_from_balance else 0, transaction_id),
|
||||
)
|
||||
conn.commit()
|
||||
if cur.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="Transaction not found")
|
||||
return {"id": transaction_id, "excluded_from_balance": body.excluded_from_balance}
|
||||
finally:
|
||||
conn.close()
|
||||
31
backend/api/banks.py
Normal file
31
backend/api/banks.py
Normal file
@@ -0,0 +1,31 @@
|
||||
from fastapi import APIRouter, HTTPException
|
||||
|
||||
from backend.db.database import get_connection
|
||||
|
||||
router = APIRouter(prefix="/api/banks", tags=["banks"])
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_banks():
|
||||
conn = get_connection()
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"SELECT id, code, name, is_salary FROM banks ORDER BY code"
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.put("/salary/{bank_id}")
|
||||
def set_salary_bank(bank_id: int):
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute("UPDATE banks SET is_salary = 0")
|
||||
cur = conn.execute("UPDATE banks SET is_salary = 1 WHERE id = ?", (bank_id,))
|
||||
conn.commit()
|
||||
if cur.rowcount == 0:
|
||||
raise HTTPException(status_code=404, detail="Bank not found")
|
||||
return {"salary_bank_id": bank_id}
|
||||
finally:
|
||||
conn.close()
|
||||
164
backend/api/charts.py
Normal file
164
backend/api/charts.py
Normal file
@@ -0,0 +1,164 @@
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from backend.db.database import get_connection
|
||||
from backend.services.settings_service import get_income_settings, get_exclude_transfers
|
||||
|
||||
router = APIRouter(prefix="/api/charts", tags=["charts"])
|
||||
|
||||
|
||||
def _parse_bank_ids(bank_ids: str | None) -> list[int] | None:
|
||||
if not bank_ids or not bank_ids.strip():
|
||||
return None
|
||||
try:
|
||||
return [int(x.strip()) for x in bank_ids.split(",") if x.strip()]
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/income-expense")
|
||||
def income_expense_by_month(
|
||||
year: int | None = Query(None),
|
||||
period_start: str | None = Query(None),
|
||||
period_end: str | None = Query(None),
|
||||
bank_ids: str | None = Query(None),
|
||||
):
|
||||
"""Доход и расход по месяцам. Фильтры: year или period_start/period_end, bank_ids."""
|
||||
conn = get_connection()
|
||||
try:
|
||||
salary_id = conn.execute("SELECT id FROM banks WHERE is_salary = 1").fetchone()
|
||||
salary_id = salary_id["id"] if salary_id else None
|
||||
only_salary_card, salary_account_id = get_income_settings()
|
||||
exclude_filter = " AND (t.excluded_from_balance = 0 OR t.excluded_from_balance IS NULL)"
|
||||
if get_exclude_transfers():
|
||||
exclude_filter += " AND t.description NOT LIKE '%Перевод%'"
|
||||
bank_id_list = _parse_bank_ids(bank_ids)
|
||||
bank_filter = ""
|
||||
params = []
|
||||
if bank_id_list:
|
||||
placeholders = ",".join("?" * len(bank_id_list))
|
||||
bank_filter = f" AND a.bank_id IN ({placeholders})"
|
||||
params = list(bank_id_list)
|
||||
date_filter = ""
|
||||
if year is not None:
|
||||
date_filter = " AND strftime('%Y', t.operation_date) = ?"
|
||||
params = [str(year)] + params
|
||||
elif period_start or period_end:
|
||||
if period_start:
|
||||
date_filter += " AND t.operation_date >= ?"
|
||||
params.append(period_start)
|
||||
if period_end:
|
||||
date_filter += " AND t.operation_date <= ?"
|
||||
params.append(period_end)
|
||||
rows = conn.execute("""
|
||||
SELECT strftime('%Y-%m', t.operation_date) as month,
|
||||
SUM(CASE WHEN t.amount > 0 THEN t.amount ELSE 0 END) as debit,
|
||||
SUM(CASE WHEN t.amount < 0 THEN -t.amount ELSE 0 END) as credit,
|
||||
a.bank_id, a.id as account_id
|
||||
FROM transactions t
|
||||
JOIN accounts a ON t.account_id = a.id
|
||||
WHERE 1=1
|
||||
""" + exclude_filter + bank_filter + date_filter + """
|
||||
GROUP BY month, a.bank_id, a.id
|
||||
ORDER BY month
|
||||
""", params).fetchall()
|
||||
|
||||
# Агрегируем по месяцам; доход — по зарплатному банку или только по выбранной карте
|
||||
by_month = {}
|
||||
for r in rows:
|
||||
m = r["month"]
|
||||
if m not in by_month:
|
||||
by_month[m] = {"month": m, "debit": 0.0, "credit": 0.0, "income": 0.0}
|
||||
by_month[m]["debit"] += r["debit"]
|
||||
by_month[m]["credit"] += r["credit"]
|
||||
if only_salary_card and salary_account_id and r["account_id"] == salary_account_id:
|
||||
by_month[m]["income"] += r["debit"]
|
||||
elif not only_salary_card and salary_id and r["bank_id"] == salary_id:
|
||||
by_month[m]["income"] += r["debit"]
|
||||
|
||||
return list(by_month.values())
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/balance-dynamics")
|
||||
def balance_dynamics(
|
||||
period_start: str | None = Query(None),
|
||||
period_end: str | None = Query(None),
|
||||
bank_ids: str | None = Query(None),
|
||||
):
|
||||
"""Суммарный остаток на конец каждого дня (накопленно). bank_ids — фильтр по банкам."""
|
||||
conn = get_connection()
|
||||
try:
|
||||
bank_id_list = _parse_bank_ids(bank_ids)
|
||||
if bank_id_list:
|
||||
placeholders = ",".join("?" * len(bank_id_list))
|
||||
acc_filter = f" AND account_id IN (SELECT id FROM accounts WHERE bank_id IN ({placeholders}))"
|
||||
params = list(bank_id_list)
|
||||
else:
|
||||
acc_filter = ""
|
||||
params = []
|
||||
transfer_filter = " AND description NOT LIKE '%Перевод%'" if get_exclude_transfers() else ""
|
||||
sql = """
|
||||
SELECT date(operation_date) as day, SUM(amount) as daily_total
|
||||
FROM transactions
|
||||
WHERE (excluded_from_balance = 0 OR excluded_from_balance IS NULL)
|
||||
""" + transfer_filter + acc_filter
|
||||
if period_start:
|
||||
sql += " AND operation_date >= ?"
|
||||
params.append(period_start)
|
||||
if period_end:
|
||||
sql += " AND operation_date <= ?"
|
||||
params.append(period_end)
|
||||
sql += " GROUP BY day ORDER BY day"
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
# Накопленный итог
|
||||
cumul = 0.0
|
||||
result = []
|
||||
for r in rows:
|
||||
cumul += r["daily_total"]
|
||||
result.append({"date": r["day"], "balance": round(cumul, 2)})
|
||||
return result
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
@router.get("/savings-dynamics")
|
||||
def savings_dynamics(
|
||||
period_start: str | None = Query(None),
|
||||
period_end: str | None = Query(None),
|
||||
bank_ids: str | None = Query(None),
|
||||
):
|
||||
"""Динамика копилки: накопленная сумма операций «Перевод между счетами одного клиента» по дням."""
|
||||
conn = get_connection()
|
||||
try:
|
||||
bank_id_list = _parse_bank_ids(bank_ids)
|
||||
if bank_id_list:
|
||||
placeholders = ",".join("?" * len(bank_id_list))
|
||||
acc_filter = f" AND account_id IN (SELECT id FROM accounts WHERE bank_id IN ({placeholders}))"
|
||||
params = ["%Перевод между счетами одного клиента%"] + list(bank_id_list)
|
||||
else:
|
||||
acc_filter = ""
|
||||
params = ["%Перевод между счетами одного клиента%"]
|
||||
transfer_filter = " AND description NOT LIKE '%Перевод%'" if get_exclude_transfers() else ""
|
||||
sql = """
|
||||
SELECT date(operation_date) as day, SUM(-amount) as daily_savings
|
||||
FROM transactions
|
||||
WHERE description LIKE ?
|
||||
AND (excluded_from_balance = 0 OR excluded_from_balance IS NULL)
|
||||
""" + transfer_filter + acc_filter
|
||||
if period_start:
|
||||
sql += " AND operation_date >= ?"
|
||||
params.append(period_start)
|
||||
if period_end:
|
||||
sql += " AND operation_date <= ?"
|
||||
params.append(period_end)
|
||||
sql += " GROUP BY day ORDER BY day"
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
cumul = 0.0
|
||||
result = []
|
||||
for r in rows:
|
||||
cumul += r["daily_savings"]
|
||||
result.append({"date": r["day"], "savings": round(cumul, 2)})
|
||||
return result
|
||||
finally:
|
||||
conn.close()
|
||||
33
backend/api/import_api.py
Normal file
33
backend/api/import_api.py
Normal file
@@ -0,0 +1,33 @@
|
||||
import os
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, File, HTTPException, UploadFile
|
||||
|
||||
from backend.services.import_service import import_file, import_from_statements_dir
|
||||
|
||||
router = APIRouter(prefix="/api/import", tags=["import"])
|
||||
|
||||
|
||||
@router.post("/upload")
|
||||
async def upload_statement(file: UploadFile = File(...)):
|
||||
if not file.filename or not file.filename.lower().endswith(".pdf"):
|
||||
raise HTTPException(status_code=400, detail="Only PDF files are allowed")
|
||||
if not (file.filename.startswith("Т-") or file.filename.startswith("С-") or file.filename.startswith("Я-")):
|
||||
raise HTTPException(status_code=400, detail="Поддерживаются выписки Т-, С- и Я-банка (Т/С/Я-MM-YY.pdf)")
|
||||
suffix = Path(file.filename).suffix
|
||||
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
||||
content = await file.read()
|
||||
tmp.write(content)
|
||||
tmp_path = tmp.name
|
||||
try:
|
||||
added, skipped, parsed = import_file(tmp_path, file.filename)
|
||||
return {"added": added, "skipped_duplicates": skipped, "parsed": parsed, "filename": file.filename}
|
||||
finally:
|
||||
os.unlink(tmp_path)
|
||||
|
||||
|
||||
@router.post("/from-folder")
|
||||
def import_from_folder():
|
||||
added, skipped, parsed = import_from_statements_dir()
|
||||
return {"added": added, "skipped_duplicates": skipped, "parsed": parsed}
|
||||
58
backend/api/settings_api.py
Normal file
58
backend/api/settings_api.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from fastapi import APIRouter
|
||||
|
||||
from backend.db.database import get_connection
|
||||
from backend.services.settings_service import get_income_settings, set_income_settings
|
||||
from pydantic import BaseModel
|
||||
|
||||
router = APIRouter(prefix="/api/settings", tags=["settings"])
|
||||
|
||||
|
||||
class IncomeSettingsBody(BaseModel):
|
||||
count_income_only_salary_card: bool
|
||||
salary_account_id: int | None = None
|
||||
|
||||
|
||||
@router.get("/income")
|
||||
def get_income_settings_api():
|
||||
"""Настройки учёта доходов + список счетов зарплатного банка для выбора карты."""
|
||||
only_card, account_id = get_income_settings()
|
||||
conn = get_connection()
|
||||
try:
|
||||
rows = conn.execute("""
|
||||
SELECT a.id, a.external_id, a.name, b.name as bank_name
|
||||
FROM accounts a
|
||||
JOIN banks b ON a.bank_id = b.id
|
||||
WHERE b.is_salary = 1
|
||||
ORDER BY a.external_id
|
||||
""").fetchall()
|
||||
salary_accounts = [dict(r) for r in rows]
|
||||
finally:
|
||||
conn.close()
|
||||
return {
|
||||
"count_income_only_salary_card": only_card,
|
||||
"salary_account_id": account_id,
|
||||
"salary_accounts": salary_accounts,
|
||||
}
|
||||
|
||||
|
||||
@router.put("/income")
|
||||
def put_income_settings(body: IncomeSettingsBody):
|
||||
set_income_settings(body.count_income_only_salary_card, body.salary_account_id)
|
||||
return {"count_income_only_salary_card": body.count_income_only_salary_card, "salary_account_id": body.salary_account_id}
|
||||
|
||||
|
||||
@router.get("/exclude-transfers")
|
||||
def get_exclude_transfers_api():
|
||||
from backend.services.settings_service import get_exclude_transfers
|
||||
return {"exclude_transfers": get_exclude_transfers()}
|
||||
|
||||
|
||||
class ExcludeTransfersBody(BaseModel):
|
||||
exclude_transfers: bool
|
||||
|
||||
|
||||
@router.put("/exclude-transfers")
|
||||
def put_exclude_transfers_api(body: ExcludeTransfersBody):
|
||||
from backend.services.settings_service import set_exclude_transfers
|
||||
set_exclude_transfers(body.exclude_transfers)
|
||||
return {"exclude_transfers": body.exclude_transfers}
|
||||
8
backend/config.py
Normal file
8
backend/config.py
Normal file
@@ -0,0 +1,8 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
PROJECT_ROOT = BASE_DIR.parent
|
||||
|
||||
DATABASE_PATH = os.environ.get("DATABASE_PATH", str(BASE_DIR / "finance.db"))
|
||||
STATEMENTS_DIR = os.environ.get("STATEMENTS_DIR", str(PROJECT_ROOT / "Выписки банков"))
|
||||
3
backend/db/__init__.py
Normal file
3
backend/db/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from .database import init_db, get_connection
|
||||
|
||||
__all__ = ["init_db", "get_connection"]
|
||||
74
backend/db/database.py
Normal file
74
backend/db/database.py
Normal file
@@ -0,0 +1,74 @@
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from backend.config import DATABASE_PATH
|
||||
|
||||
|
||||
def get_connection() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(DATABASE_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def init_db() -> None:
|
||||
Path(DATABASE_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS banks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
is_salary INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
bank_id INTEGER NOT NULL REFERENCES banks(id),
|
||||
external_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
UNIQUE(bank_id, external_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS opening_balances (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL REFERENCES accounts(id),
|
||||
period_start TEXT NOT NULL,
|
||||
amount REAL NOT NULL,
|
||||
UNIQUE(account_id, period_start)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS transactions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
account_id INTEGER NOT NULL REFERENCES accounts(id),
|
||||
operation_date TEXT NOT NULL,
|
||||
debit_date TEXT,
|
||||
amount REAL NOT NULL,
|
||||
currency TEXT DEFAULT 'RUB',
|
||||
amount_card_currency REAL,
|
||||
description TEXT,
|
||||
source_file TEXT NOT NULL,
|
||||
imported_at TEXT NOT NULL,
|
||||
dedup_key TEXT NOT NULL UNIQUE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_account ON transactions(account_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_operation_date ON transactions(operation_date);
|
||||
CREATE INDEX IF NOT EXISTS idx_transactions_dedup ON transactions(dedup_key);
|
||||
""")
|
||||
conn.commit()
|
||||
try:
|
||||
conn.execute("ALTER TABLE transactions ADD COLUMN excluded_from_balance INTEGER NOT NULL DEFAULT 0")
|
||||
conn.commit()
|
||||
except sqlite3.OperationalError:
|
||||
pass
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS app_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT
|
||||
);
|
||||
""")
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
50
backend/main.py
Normal file
50
backend/main.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from backend.api import accounts, balance, banks, charts, import_api, settings_api
|
||||
from backend.db.database import init_db
|
||||
|
||||
init_db()
|
||||
|
||||
app = FastAPI(title="Income Calculator", version="0.1.0")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
app.include_router(banks.router)
|
||||
app.include_router(accounts.router)
|
||||
app.include_router(import_api.router)
|
||||
app.include_router(balance.router)
|
||||
app.include_router(charts.router)
|
||||
app.include_router(settings_api.router)
|
||||
|
||||
# Статика Vue (собранная в frontend/dist)
|
||||
static_path = Path(__file__).resolve().parent.parent / "frontend" / "dist"
|
||||
if static_path.exists():
|
||||
app.mount("/assets", StaticFiles(directory=static_path / "assets"), name="assets")
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
from fastapi.responses import FileResponse
|
||||
return FileResponse(static_path / "index.html")
|
||||
|
||||
@app.get("/{full_path:path}")
|
||||
def serve_spa(full_path: str):
|
||||
from fastapi.responses import FileResponse
|
||||
if full_path.startswith("api/"):
|
||||
return FileResponse(static_path / "index.html") # let API handle; this won't be hit
|
||||
f = static_path / full_path
|
||||
if f.is_file():
|
||||
return FileResponse(f)
|
||||
return FileResponse(static_path / "index.html")
|
||||
else:
|
||||
@app.get("/")
|
||||
def root():
|
||||
return {"message": "Backend running. Build frontend: cd frontend && npm run build"}
|
||||
1
backend/parsers/__init__.py
Normal file
1
backend/parsers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Parsers package
|
||||
87
backend/parsers/bank_s.py
Normal file
87
backend/parsers/bank_s.py
Normal file
@@ -0,0 +1,87 @@
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import pdfplumber
|
||||
|
||||
from backend.parsers.base import BaseBankParser, ParsedTransaction
|
||||
|
||||
|
||||
def _normalize_amount(s: str) -> float:
|
||||
"""Пробелы убрать, запятая — десятичный разделитель."""
|
||||
return float(s.replace("\u00a0", " ").replace(" ", "").replace(",", "."))
|
||||
|
||||
|
||||
def _parse_datetime_s(date_str: str, time_str: str) -> str:
|
||||
"""DD.MM.YYYY + HH:MM -> ISO."""
|
||||
try:
|
||||
part = date_str.strip() + " " + (time_str or "00:00").strip()
|
||||
dt = datetime.strptime(part, "%d.%m.%Y %H:%M")
|
||||
return dt.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
except ValueError:
|
||||
return date_str.strip()[:10].replace(".", "-") + "T00:00:00"
|
||||
|
||||
|
||||
class BankSParser(BaseBankParser):
|
||||
"""Парсер выписок Сбербанка. Файлы С-MM-YY.pdf."""
|
||||
|
||||
# Первая строка: дата время код_авторизации категория сумма остаток (сумма с + = приход, без = расход)
|
||||
ROW_RE = re.compile(
|
||||
r"^(\d{2}\.\d{2}\.\d{4})\s+(\d{1,2}:\d{2})\s+(\d{6})\s+(.+?)\s+([+-]?[\d\s]+,\d{2})\s+([\d\s]+,\d{2})\s*$",
|
||||
re.UNICODE,
|
||||
)
|
||||
# Вторая строка: дата описание ... Операция по (перенос)
|
||||
DESC_LINE1_RE = re.compile(r"^(\d{2}\.\d{2}\.\d{4})\s+(.+?)\s+Операция по\s*$", re.UNICODE)
|
||||
# Третья строка: карте ****0566
|
||||
CARD_LINE_RE = re.compile(r"^карте\s+\*\*\*\*(\d{4})\s*$", re.UNICODE)
|
||||
|
||||
def can_parse(self, filename: str) -> bool:
|
||||
name = Path(filename).name
|
||||
return name.startswith("С-") and name.lower().endswith(".pdf")
|
||||
|
||||
def parse(self, file_path: str) -> List[ParsedTransaction]:
|
||||
result: List[ParsedTransaction] = []
|
||||
with pdfplumber.open(file_path) as pdf:
|
||||
lines: List[str] = []
|
||||
for page in pdf.pages:
|
||||
text = page.extract_text()
|
||||
if text:
|
||||
lines.extend(text.split("\n"))
|
||||
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i].strip()
|
||||
m = self.ROW_RE.match(line)
|
||||
if m:
|
||||
date_op, time_op, _code, category, amount_str, _balance = m.groups()
|
||||
amount = _normalize_amount(amount_str)
|
||||
if not amount_str.strip().startswith("+"):
|
||||
amount = -amount
|
||||
desc_extra = ""
|
||||
card_tail = ""
|
||||
if i + 1 < len(lines):
|
||||
d1 = self.DESC_LINE1_RE.match(lines[i + 1].strip())
|
||||
if d1:
|
||||
desc_extra = " " + d1.group(2).strip()
|
||||
i += 1
|
||||
if i + 1 < len(lines):
|
||||
c2 = self.CARD_LINE_RE.match(lines[i + 1].strip())
|
||||
if c2:
|
||||
card_tail = c2.group(1)
|
||||
i += 1
|
||||
if not card_tail:
|
||||
card_tail = "0000"
|
||||
description = (category + desc_extra).strip()
|
||||
result.append(
|
||||
ParsedTransaction(
|
||||
operation_date=_parse_datetime_s(date_op, time_op),
|
||||
debit_date=None,
|
||||
amount=amount,
|
||||
amount_card_currency=None,
|
||||
description=description,
|
||||
card_tail=card_tail,
|
||||
)
|
||||
)
|
||||
i += 1
|
||||
return result
|
||||
86
backend/parsers/bank_t.py
Normal file
86
backend/parsers/bank_t.py
Normal file
@@ -0,0 +1,86 @@
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import pdfplumber
|
||||
|
||||
from backend.parsers.base import BaseBankParser, ParsedTransaction
|
||||
|
||||
|
||||
def _normalize_amount(s: str) -> float:
|
||||
return float(s.replace("\u00a0", " ").replace(" ", "").replace(",", "."))
|
||||
|
||||
|
||||
def _parse_date(d: str) -> str:
|
||||
"""DD.MM.YYYY -> YYYY-MM-DD"""
|
||||
try:
|
||||
dt = datetime.strptime(d.strip(), "%d.%m.%Y")
|
||||
return dt.strftime("%Y-%m-%d")
|
||||
except ValueError:
|
||||
return d.strip()
|
||||
|
||||
|
||||
def _parse_datetime(d: str, time_str: str) -> str:
|
||||
"""Date DD.MM.YYYY + time HH:MM -> ISO"""
|
||||
try:
|
||||
part = d.strip() + " " + (time_str or "00:00").strip()
|
||||
dt = datetime.strptime(part, "%d.%m.%Y %H:%M")
|
||||
return dt.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
except ValueError:
|
||||
return _parse_date(d) + "T00:00:00"
|
||||
|
||||
|
||||
class BankTParser(BaseBankParser):
|
||||
"""Парсер выписок Т-банка. Файлы Т-MM-YY.pdf."""
|
||||
|
||||
# Первая строка операции: дата дата сумма ₽ сумма ₽ описание 4цифры (сумма может быть + или -)
|
||||
ROW_RE = re.compile(
|
||||
r"^(\d{2}\.\d{2}\.\d{4})\s+(\d{2}\.\d{2}\.\d{4})\s+([-+]?[\d\s,]+\.\d{2})\s*₽\s+([-+]?[\d\s,]+\.\d{2})\s*₽\s+(.+?)\s+(\d{4})\s*$",
|
||||
re.UNICODE,
|
||||
)
|
||||
# Вторая строка (время): HH:MM HH:MM остаток текста
|
||||
TIME_RE = re.compile(r"^(\d{1,2}:\d{2})\s+(\d{1,2}:\d{2})\s*(.*)$")
|
||||
|
||||
def can_parse(self, filename: str) -> bool:
|
||||
name = Path(filename).name
|
||||
return name.startswith("Т-") and name.lower().endswith(".pdf")
|
||||
|
||||
def parse(self, file_path: str) -> List[ParsedTransaction]:
|
||||
result: List[ParsedTransaction] = []
|
||||
with pdfplumber.open(file_path) as pdf:
|
||||
lines: List[str] = []
|
||||
for page in pdf.pages:
|
||||
text = page.extract_text()
|
||||
if text:
|
||||
lines.extend(text.split("\n"))
|
||||
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
m = self.ROW_RE.match(line.strip())
|
||||
if m:
|
||||
date_op, date_debit, amt_op, amt_card, desc, card_tail = m.groups()
|
||||
op_time, debit_time = "00:00", "00:00"
|
||||
if i + 1 < len(lines):
|
||||
tm = self.TIME_RE.match(lines[i + 1].strip())
|
||||
if tm:
|
||||
op_time, debit_time, rest = tm.groups()
|
||||
if rest:
|
||||
desc = (desc + " " + rest).strip()
|
||||
i += 1
|
||||
|
||||
amount = _normalize_amount(amt_op)
|
||||
amount_card = _normalize_amount(amt_card) if amt_card else None
|
||||
result.append(
|
||||
ParsedTransaction(
|
||||
operation_date=_parse_datetime(date_op, op_time),
|
||||
debit_date=_parse_datetime(date_debit, debit_time),
|
||||
amount=amount,
|
||||
amount_card_currency=amount_card,
|
||||
description=(desc or "").strip(),
|
||||
card_tail=card_tail,
|
||||
)
|
||||
)
|
||||
i += 1
|
||||
return result
|
||||
84
backend/parsers/bank_y.py
Normal file
84
backend/parsers/bank_y.py
Normal file
@@ -0,0 +1,84 @@
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import pdfplumber
|
||||
|
||||
from backend.parsers.base import BaseBankParser, ParsedTransaction
|
||||
|
||||
# Я-банк использует EN DASH (U+2013) для минуса
|
||||
MINUS_CHARS = "\u2013-"
|
||||
|
||||
|
||||
def _normalize_amount(s: str) -> float:
|
||||
s = s.replace("\u00a0", " ").replace(" ", "").replace(",", ".")
|
||||
for c in MINUS_CHARS:
|
||||
s = s.replace(c, "-")
|
||||
if s.startswith("−"):
|
||||
s = "-" + s[1:]
|
||||
return float(s)
|
||||
|
||||
|
||||
def _parse_datetime_y(date_str: str, time_str: str = "") -> str:
|
||||
try:
|
||||
part = date_str.strip() + " " + (time_str or "00:00").strip()
|
||||
dt = datetime.strptime(part, "%d.%m.%Y %H:%M")
|
||||
return dt.strftime("%Y-%m-%dT%H:%M:%S")
|
||||
except ValueError:
|
||||
return date_str.strip().replace(".", "-")[:10] + "T00:00:00"
|
||||
|
||||
|
||||
class BankYParser(BaseBankParser):
|
||||
"""Парсер выписок Яндекс Банка. Файлы Я-MM-YY.pdf."""
|
||||
|
||||
# Строка: описание ... DD.MM.YYYY DD.MM.YYYY [*XXXX] сумма ₽ сумма ₽ (минус может быть – U+2013, карта опциональна)
|
||||
ROW_RE = re.compile(
|
||||
r"^(.+?)\s+(\d{2}\.\d{2}\.\d{4})\s+(\d{2}\.\d{2}\.\d{4})\s+(?:\*(\d{4})\s+)?([+\u2013\-]?[\d\s,]+)\s*₽\s+([+\u2013\-]?[\d\s,]+)\s*₽\s*$",
|
||||
re.UNICODE,
|
||||
)
|
||||
# Вторая строка может содержать время: "в 18:13" или "клиента в 21:35"
|
||||
TIME_RE = re.compile(r"^(?:.*\s+)?в\s+(\d{1,2}:\d{2})\s*$", re.UNICODE)
|
||||
|
||||
PIGGY_MARKER = "Перевод между счетами одного клиента"
|
||||
|
||||
def can_parse(self, filename: str) -> bool:
|
||||
name = Path(filename).name
|
||||
return name.startswith("Я-") and name.lower().endswith(".pdf")
|
||||
|
||||
def parse(self, file_path: str) -> List[ParsedTransaction]:
|
||||
result: List[ParsedTransaction] = []
|
||||
with pdfplumber.open(file_path) as pdf:
|
||||
lines: List[str] = []
|
||||
for page in pdf.pages:
|
||||
text = page.extract_text()
|
||||
if text:
|
||||
lines.extend(text.split("\n"))
|
||||
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i].strip()
|
||||
m = self.ROW_RE.match(line)
|
||||
if m:
|
||||
desc, date_op, date_proc, card_tail, amt1, amt2 = m.groups()
|
||||
card_tail = card_tail or "0000"
|
||||
amount = _normalize_amount(amt1)
|
||||
time_str = ""
|
||||
if i + 1 < len(lines):
|
||||
tm = self.TIME_RE.match(lines[i + 1].strip())
|
||||
if tm:
|
||||
time_str = tm.group(1)
|
||||
desc = (desc + " " + lines[i + 1].strip()).strip()
|
||||
i += 1
|
||||
result.append(
|
||||
ParsedTransaction(
|
||||
operation_date=_parse_datetime_y(date_op, time_str),
|
||||
debit_date=_parse_datetime_y(date_proc),
|
||||
amount=amount,
|
||||
amount_card_currency=_normalize_amount(amt2) if amt2 else None,
|
||||
description=desc.strip(),
|
||||
card_tail=card_tail,
|
||||
)
|
||||
)
|
||||
i += 1
|
||||
return result
|
||||
23
backend/parsers/base.py
Normal file
23
backend/parsers/base.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import List
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedTransaction:
|
||||
operation_date: str # ISO date or datetime
|
||||
debit_date: str | None
|
||||
amount: float # signed: negative = expense
|
||||
amount_card_currency: float | None
|
||||
description: str
|
||||
card_tail: str # last 4 digits for account matching
|
||||
|
||||
|
||||
class BaseBankParser(ABC):
|
||||
@abstractmethod
|
||||
def can_parse(self, filename: str) -> bool:
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def parse(self, file_path: str) -> List[ParsedTransaction]:
|
||||
pass
|
||||
5
backend/requirements.txt
Normal file
5
backend/requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.32.1
|
||||
pdfplumber==0.11.4
|
||||
pydantic==2.10.3
|
||||
python-multipart==0.0.17
|
||||
1
backend/services/__init__.py
Normal file
1
backend/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Services package
|
||||
172
backend/services/balance_service.py
Normal file
172
backend/services/balance_service.py
Normal file
@@ -0,0 +1,172 @@
|
||||
from typing import Any
|
||||
|
||||
from backend.db.database import get_connection
|
||||
from backend.services.settings_service import get_income_settings, get_exclude_transfers
|
||||
|
||||
|
||||
def get_summary(
|
||||
period_start: str | None = None,
|
||||
period_end: str | None = None,
|
||||
bank_ids: list[int] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Итог по всем счетам (или по выбранным банкам): суммарный баланс, доход, дебет, кредит.
|
||||
period_start/period_end в формате YYYY-MM-DD; bank_ids — список id банков для фильтра (пусто = все).
|
||||
"""
|
||||
conn = get_connection()
|
||||
try:
|
||||
salary_bank_id = conn.execute(
|
||||
"SELECT id FROM banks WHERE is_salary = 1"
|
||||
).fetchone()
|
||||
salary_bank_id = salary_bank_id["id"] if salary_bank_id else None
|
||||
|
||||
only_salary_card, salary_account_id = get_income_settings()
|
||||
income_account_filter = salary_account_id if (only_salary_card and salary_account_id) else None
|
||||
|
||||
accounts_sql = "SELECT a.id, a.bank_id, a.external_id, a.name, b.code FROM accounts a JOIN banks b ON a.bank_id = b.id"
|
||||
params_acc = []
|
||||
if bank_ids:
|
||||
placeholders = ",".join("?" * len(bank_ids))
|
||||
accounts_sql += f" WHERE a.bank_id IN ({placeholders})"
|
||||
params_acc = list(bank_ids)
|
||||
accounts = conn.execute(accounts_sql, params_acc).fetchall()
|
||||
|
||||
total_balance = 0.0
|
||||
by_account = []
|
||||
income = 0.0
|
||||
debit = 0.0
|
||||
credit = 0.0
|
||||
|
||||
exclude_filter = " AND (t.excluded_from_balance = 0 OR t.excluded_from_balance IS NULL)"
|
||||
if get_exclude_transfers():
|
||||
exclude_filter += " AND t.description NOT LIKE '%Перевод%'"
|
||||
for acc in accounts:
|
||||
# Начальный остаток на начало периода (последний установленный на дату <= period_start)
|
||||
ob = conn.execute(
|
||||
"SELECT amount FROM opening_balances WHERE account_id = ? AND (? IS NULL OR period_start <= ?) ORDER BY period_start DESC LIMIT 1",
|
||||
(acc["id"], period_start, period_start or "0000-01-01"),
|
||||
).fetchone()
|
||||
opening = float(ob["amount"]) if ob else 0.0
|
||||
|
||||
sql = "SELECT COALESCE(SUM(t.amount), 0) FROM transactions t WHERE t.account_id = ?" + exclude_filter
|
||||
params = [acc["id"]]
|
||||
if period_start:
|
||||
sql += " AND t.operation_date >= ?"
|
||||
params.append(period_start)
|
||||
if period_end:
|
||||
sql += " AND t.operation_date <= ?"
|
||||
params.append(period_end)
|
||||
row = conn.execute(sql, params).fetchone()
|
||||
tx_sum = float(row[0])
|
||||
balance = opening + tx_sum
|
||||
total_balance += balance
|
||||
|
||||
# Доход: приходы по зарплатному банку или только по выбранной зарплатной карте
|
||||
count_income = False
|
||||
if income_account_filter is not None:
|
||||
count_income = acc["id"] == income_account_filter
|
||||
elif acc["bank_id"] == salary_bank_id:
|
||||
count_income = True
|
||||
if count_income:
|
||||
inc_sql = "SELECT COALESCE(SUM(t.amount), 0) FROM transactions t WHERE t.account_id = ? AND t.amount > 0" + exclude_filter
|
||||
inc_params = [acc["id"]]
|
||||
if period_start:
|
||||
inc_sql += " AND t.operation_date >= ?"
|
||||
inc_params.append(period_start)
|
||||
if period_end:
|
||||
inc_sql += " AND t.operation_date <= ?"
|
||||
inc_params.append(period_end)
|
||||
income += float(conn.execute(inc_sql, inc_params).fetchone()[0])
|
||||
|
||||
# Дебет = приход (увеличение счёта), Кредит = расход (уменьшение счёта)
|
||||
d_sql = "SELECT COALESCE(SUM(CASE WHEN t.amount > 0 THEN t.amount ELSE 0 END), 0), COALESCE(SUM(CASE WHEN t.amount < 0 THEN -t.amount ELSE 0 END), 0) FROM transactions t WHERE t.account_id = ?" + exclude_filter
|
||||
d_params = [acc["id"]]
|
||||
if period_start:
|
||||
d_sql += " AND t.operation_date >= ?"
|
||||
d_params.append(period_start)
|
||||
if period_end:
|
||||
d_sql += " AND t.operation_date <= ?"
|
||||
d_params.append(period_end)
|
||||
d_row = conn.execute(d_sql, d_params).fetchone()
|
||||
debit += float(d_row[0]) # приход
|
||||
credit += float(d_row[1]) # расход
|
||||
|
||||
by_account.append({
|
||||
"account_id": acc["id"],
|
||||
"bank_code": acc["code"],
|
||||
"external_id": acc["external_id"],
|
||||
"name": acc["name"],
|
||||
"opening_balance": opening,
|
||||
"transactions_sum": tx_sum,
|
||||
"balance": balance,
|
||||
})
|
||||
|
||||
# Копилка: только если в фильтр входит Я-банк (или фильтра нет)
|
||||
piggy_sql = "SELECT COALESCE(SUM(-t.amount), 0) FROM transactions t WHERE t.description LIKE ? " + exclude_filter
|
||||
piggy_params = ["%Перевод между счетами одного клиента%"]
|
||||
if bank_ids:
|
||||
placeholders = ",".join("?" * len(bank_ids))
|
||||
piggy_sql += f" AND t.account_id IN (SELECT id FROM accounts WHERE bank_id IN ({placeholders}))"
|
||||
piggy_params.extend(bank_ids)
|
||||
piggy_row = conn.execute(piggy_sql, piggy_params).fetchone()
|
||||
savings_balance = float(piggy_row[0]) if piggy_row else 0.0
|
||||
|
||||
net_flow = debit - credit # за период: положительный = накопление, отрицательный = траты
|
||||
return {
|
||||
"total_balance": round(total_balance, 2),
|
||||
"income": round(income, 2),
|
||||
"debit": round(debit, 2),
|
||||
"credit": round(credit, 2),
|
||||
"net_flow": round(net_flow, 2),
|
||||
"savings_balance": round(savings_balance, 2),
|
||||
"by_account": by_account,
|
||||
"salary_bank_id": salary_bank_id,
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_transactions(
|
||||
account_id: int | None = None,
|
||||
bank_ids: list[int] | None = None,
|
||||
period_start: str | None = None,
|
||||
period_end: str | None = None,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Возвращает (список транзакций, общее количество). Фильтры: bank_ids, period_start, period_end."""
|
||||
conn = get_connection()
|
||||
try:
|
||||
conditions = []
|
||||
params = []
|
||||
if account_id is not None:
|
||||
conditions.append("t.account_id = ?")
|
||||
params.append(account_id)
|
||||
if bank_ids:
|
||||
placeholders = ",".join("?" * len(bank_ids))
|
||||
conditions.append(f"a.bank_id IN ({placeholders})")
|
||||
params.extend(bank_ids)
|
||||
if period_start:
|
||||
conditions.append("t.operation_date >= ?")
|
||||
params.append(period_start)
|
||||
if period_end:
|
||||
conditions.append("t.operation_date <= ?")
|
||||
params.append(period_end)
|
||||
where = " WHERE " + " AND ".join(conditions) if conditions else ""
|
||||
count_sql = "SELECT COUNT(*) FROM transactions t JOIN accounts a ON t.account_id = a.id JOIN banks b ON a.bank_id = b.id" + where
|
||||
total = conn.execute(count_sql, params).fetchone()[0]
|
||||
|
||||
sql = """
|
||||
SELECT t.id, t.account_id, t.operation_date, t.amount, t.description, t.source_file,
|
||||
t.excluded_from_balance,
|
||||
a.external_id, b.code as bank_code, b.name as bank_name
|
||||
FROM transactions t
|
||||
JOIN accounts a ON t.account_id = a.id
|
||||
JOIN banks b ON a.bank_id = b.id
|
||||
""" + where + " ORDER BY t.operation_date DESC, t.id DESC LIMIT ? OFFSET ?"
|
||||
params.extend([limit, offset])
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
items = [dict(r) for r in rows]
|
||||
return items, total
|
||||
finally:
|
||||
conn.close()
|
||||
124
backend/services/import_service.py
Normal file
124
backend/services/import_service.py
Normal file
@@ -0,0 +1,124 @@
|
||||
import hashlib
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Tuple
|
||||
|
||||
from backend.config import STATEMENTS_DIR
|
||||
from backend.db.database import get_connection
|
||||
from backend.parsers.bank_s import BankSParser
|
||||
from backend.parsers.bank_t import BankTParser
|
||||
from backend.parsers.bank_y import BankYParser
|
||||
from backend.parsers.base import BaseBankParser, ParsedTransaction
|
||||
|
||||
BANK_REGISTRY: list[tuple[str, str, type[BaseBankParser]]] = [
|
||||
("T", "Т-банк", BankTParser),
|
||||
("S", "Сбербанк", BankSParser),
|
||||
("Y", "Яндекс Банк", BankYParser),
|
||||
]
|
||||
|
||||
|
||||
def _dedup_key(account_id: int, operation_date: str, amount: float, description: str) -> str:
|
||||
raw = f"{account_id}|{operation_date}|{amount}|{description}"
|
||||
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _ensure_bank(conn, code: str, name: str) -> int:
|
||||
"""Создать банк по коду и имени. Возвращает bank_id."""
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO banks (code, name, is_salary) VALUES (?, ?, 0)",
|
||||
(code, name),
|
||||
)
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT id FROM banks WHERE code = ?", (code,)).fetchone()
|
||||
return row["id"]
|
||||
|
||||
|
||||
def _parser_for_filename(filename: str) -> tuple[str, str, BaseBankParser] | None:
|
||||
"""По имени файла возвращает (code, name, parser) или None."""
|
||||
for code, name, parser_cls in BANK_REGISTRY:
|
||||
p = parser_cls()
|
||||
if p.can_parse(filename):
|
||||
return (code, name, p)
|
||||
return None
|
||||
|
||||
|
||||
def _get_or_create_account(conn, bank_id: int, card_tail: str) -> int:
|
||||
cur = conn.execute(
|
||||
"SELECT id FROM accounts WHERE bank_id = ? AND external_id = ?",
|
||||
(bank_id, card_tail),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
return row["id"]
|
||||
conn.execute(
|
||||
"INSERT INTO accounts (bank_id, external_id, name) VALUES (?, ?, ?)",
|
||||
(bank_id, card_tail, f"Карта ***{card_tail}"),
|
||||
)
|
||||
conn.commit()
|
||||
return conn.execute("SELECT last_insert_rowid()").fetchone()[0]
|
||||
|
||||
|
||||
def import_file(file_path: str, source_filename: str) -> Tuple[int, int, int]:
|
||||
"""
|
||||
Парсит PDF выписку (Т- или С-банк), дедуплицирует и сохраняет в БД.
|
||||
Возвращает (добавлено, пропущено_дубликатов, распознано_всего).
|
||||
"""
|
||||
match = _parser_for_filename(source_filename)
|
||||
if not match:
|
||||
raise ValueError("Поддерживаются выписки Т-банка (Т-MM-YY.pdf), С-банка (С-MM-YY.pdf) и Я-банка (Я-MM-YY.pdf)")
|
||||
|
||||
code, name, parser = match
|
||||
transactions = parser.parse(file_path)
|
||||
parsed_count = len(transactions)
|
||||
conn = get_connection()
|
||||
try:
|
||||
bank_id = _ensure_bank(conn, code, name)
|
||||
added, skipped = 0, 0
|
||||
imported_at = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%S")
|
||||
|
||||
for t in transactions:
|
||||
account_id = _get_or_create_account(conn, bank_id, t.card_tail)
|
||||
key = _dedup_key(account_id, t.operation_date, t.amount, t.description)
|
||||
try:
|
||||
conn.execute(
|
||||
"""INSERT INTO transactions
|
||||
(account_id, operation_date, debit_date, amount, currency, amount_card_currency, description, source_file, imported_at, dedup_key)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
account_id,
|
||||
t.operation_date,
|
||||
t.debit_date,
|
||||
t.amount,
|
||||
"RUB",
|
||||
t.amount_card_currency,
|
||||
t.description,
|
||||
source_filename,
|
||||
imported_at,
|
||||
key,
|
||||
),
|
||||
)
|
||||
added += 1
|
||||
except sqlite3.IntegrityError:
|
||||
skipped += 1
|
||||
except Exception:
|
||||
raise
|
||||
conn.commit()
|
||||
return added, skipped, parsed_count
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def import_from_statements_dir() -> Tuple[int, int, int]:
|
||||
"""Импортировать все файлы Т-*.pdf и С-*.pdf из папки Выписки банков. Возвращает (добавлено, дубликатов, распознано)."""
|
||||
path = Path(STATEMENTS_DIR)
|
||||
if not path.exists():
|
||||
return 0, 0, 0
|
||||
total_added, total_skipped, total_parsed = 0, 0, 0
|
||||
for pattern in ("Т-*.pdf", "С-*.pdf", "Я-*.pdf"):
|
||||
for f in sorted(path.glob(pattern)):
|
||||
added, skipped, parsed = import_file(str(f), f.name)
|
||||
total_added += added
|
||||
total_skipped += skipped
|
||||
total_parsed += parsed
|
||||
return total_added, total_skipped, total_parsed
|
||||
61
backend/services/settings_service.py
Normal file
61
backend/services/settings_service.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from backend.db.database import get_connection
|
||||
|
||||
|
||||
def get_income_settings() -> tuple[bool, int | None]:
|
||||
"""Возвращает (count_income_only_salary_card, salary_account_id)."""
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM app_settings WHERE key = ?",
|
||||
("count_income_only_salary_card",),
|
||||
).fetchone()
|
||||
only_salary_card = row and row["value"] == "1"
|
||||
row2 = conn.execute(
|
||||
"SELECT value FROM app_settings WHERE key = ?",
|
||||
("salary_account_id",),
|
||||
).fetchone()
|
||||
account_id = int(row2["value"]) if row2 and row2["value"] else None
|
||||
return (only_salary_card, account_id)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def set_income_settings(count_income_only_salary_card: bool, salary_account_id: int | None) -> None:
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)",
|
||||
("count_income_only_salary_card", "1" if count_income_only_salary_card else "0"),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)",
|
||||
("salary_account_id", str(salary_account_id) if salary_account_id else ""),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def get_exclude_transfers() -> bool:
|
||||
"""Игнорировать операции с «Перевод» в расчётах."""
|
||||
conn = get_connection()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT value FROM app_settings WHERE key = ?",
|
||||
("exclude_transfers",),
|
||||
).fetchone()
|
||||
return row and row["value"] == "1"
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def set_exclude_transfers(value: bool) -> None:
|
||||
conn = get_connection()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)",
|
||||
("exclude_transfers", "1" if value else "0"),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
Reference in New Issue
Block a user