Initial income_calculator project
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
venv
|
||||
.git
|
||||
__pycache__
|
||||
*.pyc
|
||||
.cursor
|
||||
*.db
|
||||
.DS_Store
|
||||
*.md
|
||||
!backend/requirements.txt
|
||||
16
.gitignore
vendored
Normal file
16
.gitignore
vendored
Normal file
@@ -0,0 +1,16 @@
|
||||
venv/
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.sqlite3
|
||||
*.db
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# Docker artifacts (if any)
|
||||
*.log
|
||||
|
||||
19
Dockerfile
Normal file
19
Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Зависимости
|
||||
COPY backend/requirements.txt backend/requirements.txt
|
||||
RUN pip install --no-cache-dir -r backend/requirements.txt
|
||||
|
||||
# Код приложения (backend + frontend/dist)
|
||||
COPY backend backend
|
||||
COPY frontend frontend
|
||||
|
||||
ENV PYTHONPATH=/app
|
||||
# БД в volume, чтобы данные сохранялись между запусками
|
||||
ENV DATABASE_PATH=/data/finance.db
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
45
Makefile
Normal file
45
Makefile
Normal file
@@ -0,0 +1,45 @@
|
||||
# Income Calculator — сборка и запуск в Docker
|
||||
|
||||
IMAGE := income-calculator
|
||||
CONTAINER := income-calculator
|
||||
PORT := 8000
|
||||
DATA_VOLUME := income_calculator_data
|
||||
STATEMENTS_DIR := Выписки банков
|
||||
|
||||
.PHONY: build run stop logs shell clean help
|
||||
|
||||
help:
|
||||
@echo "Доступные цели:"
|
||||
@echo " make build — собрать образ Docker"
|
||||
@echo " make run — запустить контейнер (порт $(PORT), volume для БД и папки выписок)"
|
||||
@echo " make stop — остановить и удалить контейнер"
|
||||
@echo " make logs — показать логи контейнера"
|
||||
@echo " make shell — войти в shell контейнера"
|
||||
@echo " make clean — остановить контейнер и удалить образ"
|
||||
|
||||
build:
|
||||
docker build -t $(IMAGE) .
|
||||
|
||||
run: build
|
||||
@mkdir -p '$(STATEMENTS_DIR)'
|
||||
docker run -d \
|
||||
-p $(PORT):8000 \
|
||||
-v $(DATA_VOLUME):/data \
|
||||
-v "$$(pwd)/$(STATEMENTS_DIR):/app/statements" \
|
||||
-e STATEMENTS_DIR=/app/statements \
|
||||
--name $(CONTAINER) \
|
||||
$(IMAGE)
|
||||
@echo "Приложение: http://127.0.0.1:$(PORT)"
|
||||
|
||||
stop:
|
||||
-docker stop $(CONTAINER)
|
||||
-docker rm $(CONTAINER)
|
||||
|
||||
logs:
|
||||
docker logs -f $(CONTAINER)
|
||||
|
||||
shell:
|
||||
docker exec -it $(CONTAINER) /bin/bash
|
||||
|
||||
clean: stop
|
||||
-docker rmi $(IMAGE)
|
||||
78
README.md
Normal file
78
README.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Личные финансы — учёт баланса и выписок
|
||||
|
||||
Учёт операций по нескольким банкам: импорт PDF-выписок, дедупликация, расчёт баланса, дохода, дебета/кредита, копилки. Дашборд с фильтрами и графиками на localhost.
|
||||
|
||||
## Поддерживаемые банки
|
||||
|
||||
- **Т** — Т-банк (файлы `Т-MM-YY.pdf`)
|
||||
- **С** — Сбербанк (`С-MM-YY.pdf`)
|
||||
- **Я** — Яндекс Банк (`Я-MM-YY.pdf`)
|
||||
|
||||
Выписки кладутся в папку `Выписки банков/`.
|
||||
|
||||
## Запуск
|
||||
|
||||
### Вариант 1: Docker (рекомендуется)
|
||||
|
||||
Нужны установленные Docker и Make.
|
||||
|
||||
```bash
|
||||
make run
|
||||
```
|
||||
|
||||
Откройте в браузере **http://127.0.0.1:8000**. База данных хранится в Docker-volume `income_calculator_data`, папка `Выписки банков/` с хоста монтируется в контейнер — импорт из папки работает с вашими PDF.
|
||||
|
||||
Полезные команды:
|
||||
- `make stop` — остановить контейнер
|
||||
- `make logs` — логи
|
||||
- `make shell` — shell внутри контейнера
|
||||
- `make help` — список целей
|
||||
|
||||
### Вариант 2: Локально (venv)
|
||||
|
||||
1. Создать venv и установить зависимости:
|
||||
```bash
|
||||
python3 -m venv venv
|
||||
./venv/bin/pip install -r backend/requirements.txt
|
||||
```
|
||||
|
||||
2. Запустить сервер:
|
||||
```bash
|
||||
cd /path/to/income_calculator
|
||||
PYTHONPATH=. ./venv/bin/uvicorn backend.main:app --host 127.0.0.1 --port 8000
|
||||
```
|
||||
|
||||
3. Открыть в браузере: **http://127.0.0.1:8000**
|
||||
|
||||
### Настройки приложения
|
||||
|
||||
В разделе **Настройки**:
|
||||
- Выбрать **зарплатный банк** (кнопка «Сделать зарплатным»), при необходимости — **зарплатную карту** (доход считается по приходам на этот банк или только на выбранную карту).
|
||||
- Включить при необходимости «Доход только по зарплатной карте».
|
||||
- Нажать **Импорт из папки**, чтобы загрузить все выписки из `Выписки банков/`, или загрузить один PDF вручную.
|
||||
|
||||
## Дашборд
|
||||
|
||||
- **Фильтры**: период (дата с/по), банки (галочки), **«Не учитывать переводы»** — при включении все операции с текстом «Перевод» в описании исключаются из расчётов (баланс, доход, дебет/кредит, копилка и графики).
|
||||
- **Сводка**: суммарный баланс, доход (по зарплатному банку/карте), дебет, кредит, копилка (накопления).
|
||||
- **Графики**: доход и расход по месяцам, динамика остатка, динамика копилки.
|
||||
- **Операции**: список с пагинацией; у каждой операции можно снять галочку «В расчёте», тогда она не участвует в балансе и сводке.
|
||||
|
||||
**Копилка**: операции с описанием «Перевод между счетами одного клиента» (Яндекс Банк): отрицательная сумма — взнос в копилку, положительная — вывод; в сводке и на графике отображается итог по копилке.
|
||||
|
||||
## Структура проекта
|
||||
|
||||
- `backend/` — FastAPI, SQLite, парсеры выписок Т/С/Я, API и раздача статики.
|
||||
- `frontend/dist/` — фронтенд (один HTML с Chart.js), сборка не требуется.
|
||||
- `Выписки банков/` — PDF-выписки (`Т-MM-YY.pdf`, `С-MM-YY.pdf`, `Я-MM-YY.pdf`).
|
||||
|
||||
База данных: **SQLite** — `backend/finance.db`.
|
||||
|
||||
## API (кратко)
|
||||
|
||||
- **Банки**: `GET /api/banks`, `PUT /api/banks/salary/{id}`.
|
||||
- **Счета**: `GET /api/accounts`, `POST /api/accounts/{account_id}/opening-balance`.
|
||||
- **Импорт**: `POST /api/import/upload`, `POST /api/import/from-folder`.
|
||||
- **Баланс и операции**: `GET /api/balance/summary`, `GET /api/balance/by-account`, `GET /api/transactions` (параметры: `period_start`, `period_end`, `bank_ids`).
|
||||
- **Графики**: `GET /api/charts/income-expense`, `GET /api/charts/balance-dynamics`, `GET /api/charts/savings-dynamics` (те же параметры фильтрации).
|
||||
- **Настройки**: `GET/PUT /api/settings/income` (зарплатная карта, «только по карте»), `GET/PUT /api/settings/exclude-transfers` (не учитывать переводы).
|
||||
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()
|
||||
0
frontend/dist/assets/.gitkeep
vendored
Normal file
0
frontend/dist/assets/.gitkeep
vendored
Normal file
579
frontend/dist/index.html
vendored
Normal file
579
frontend/dist/index.html
vendored
Normal file
@@ -0,0 +1,579 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Личные финансы</title>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0f0f12;
|
||||
--card: #1a1a1f;
|
||||
--text: #e4e4e7;
|
||||
--muted: #71717a;
|
||||
--accent: #22c55e;
|
||||
--negative: #ef4444;
|
||||
--border: #27272a;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
padding: 1.5rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
h1 { font-size: 1.5rem; margin: 0 0 1.5rem; font-weight: 600; }
|
||||
h2 { font-size: 1.1rem; margin: 0 0 0.75rem; color: var(--muted); font-weight: 500; }
|
||||
.nav { display: flex; gap: 1rem; margin-bottom: 1.5rem; }
|
||||
.nav a { color: var(--muted); text-decoration: none; }
|
||||
.nav a.active { color: var(--accent); }
|
||||
.nav a:hover { color: var(--text); }
|
||||
.card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 2rem; }
|
||||
.metric { text-align: center; min-width: 0; padding: 0 0.5rem; }
|
||||
.metric .value { font-size: 1.5rem; font-weight: 700; }
|
||||
.metric .value.positive { color: var(--accent); }
|
||||
.metric .value.negative { color: var(--negative); }
|
||||
.metric .label { font-size: 0.8rem; color: var(--muted); margin-top: 0.25rem; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
||||
th, td { padding: 0.5rem 0.75rem; text-align: left; border-bottom: 1px solid var(--border); }
|
||||
th { color: var(--muted); font-weight: 500; }
|
||||
.amount.in { color: var(--accent); }
|
||||
.amount.out { color: var(--negative); }
|
||||
.page { display: none; }
|
||||
.page.active { display: block; }
|
||||
button, .btn {
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
button.secondary { background: var(--border); color: var(--text); }
|
||||
button:hover { opacity: 0.9; }
|
||||
input[type="file"] { margin: 0.5rem 0; }
|
||||
.banks-list { list-style: none; padding: 0; margin: 0; }
|
||||
.banks-list li {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0.5rem 0; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.charts-row { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
|
||||
@media (max-width: 700px) { .charts-row { grid-template-columns: 1fr; } }
|
||||
.chart-wrap { height: 220px; }
|
||||
.error { color: var(--negative); font-size: 0.9rem; margin-top: 0.5rem; }
|
||||
.tx-excluded { opacity: 0.6; }
|
||||
input[type="checkbox"] { cursor: pointer; accent-color: var(--accent); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Личные финансы</h1>
|
||||
<nav class="nav">
|
||||
<a href="#" data-page="dashboard" class="active">Дашборд</a>
|
||||
<a href="#" data-page="settings">Настройки</a>
|
||||
</nav>
|
||||
|
||||
<div id="page-dashboard" class="page active">
|
||||
<div class="card filters-card">
|
||||
<h2>Фильтры</h2>
|
||||
<div class="filters-row" style="display:flex; flex-wrap:wrap; gap:1.5rem; align-items:flex-end;">
|
||||
<div>
|
||||
<label style="display:block; font-size:0.85rem; color:var(--muted); margin-bottom:0.25rem;">Период с</label>
|
||||
<input type="date" id="filter-period-start" style="background:var(--bg); color:var(--text); border:1px solid var(--border); padding:0.4rem; border-radius:6px;" />
|
||||
</div>
|
||||
<div>
|
||||
<label style="display:block; font-size:0.85rem; color:var(--muted); margin-bottom:0.25rem;">по</label>
|
||||
<input type="date" id="filter-period-end" style="background:var(--bg); color:var(--text); border:1px solid var(--border); padding:0.4rem; border-radius:6px;" />
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-size:0.85rem; color:var(--muted); margin-right:0.5rem;">Банки:</span>
|
||||
<span id="filter-banks"></span>
|
||||
</div>
|
||||
<label style="display:flex; align-items:center; gap:0.5rem; white-space:nowrap;">
|
||||
<input type="checkbox" id="exclude-transfers-cb" />
|
||||
<span>Не учитывать переводы</span>
|
||||
</label>
|
||||
<button type="button" id="filter-apply" class="secondary" style="padding:0.4rem 0.75rem;">Применить</button>
|
||||
<button type="button" id="filter-reset" class="secondary" style="padding:0.4rem 0.75rem;">Сбросить</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Сводка</h2>
|
||||
<div class="grid" id="summary-metrics"></div>
|
||||
</div>
|
||||
<div class="charts-row">
|
||||
<div class="card">
|
||||
<h2>Доход и расход по месяцам</h2>
|
||||
<div class="chart-wrap"><canvas id="chart-income-expense"></canvas></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Динамика остатка</h2>
|
||||
<div class="chart-wrap"><canvas id="chart-balance"></canvas></div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Динамика копилки</h2>
|
||||
<div class="chart-wrap"><canvas id="chart-savings"></canvas></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Все операции</h2>
|
||||
<div class="pagination-controls" style="display:flex; align-items:center; gap:1rem; margin-bottom:0.75rem; flex-wrap:wrap;">
|
||||
<span>На странице:</span>
|
||||
<select id="page-size" style="background:var(--card); color:var(--text); border:1px solid var(--border); padding:0.35rem 0.5rem; border-radius:6px;">
|
||||
<option value="50">50</option>
|
||||
<option value="100">100</option>
|
||||
</select>
|
||||
<span id="pagination-info" style="color:var(--muted); font-size:0.9rem;"></span>
|
||||
<button type="button" id="btn-prev" class="secondary" style="padding:0.35rem 0.75rem;">Назад</button>
|
||||
<button type="button" id="btn-next" class="secondary" style="padding:0.35rem 0.75rem;">Вперёд</button>
|
||||
</div>
|
||||
<div id="transactions-table"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="page-settings" class="page">
|
||||
<div class="card">
|
||||
<h2>Зарплатный банк</h2>
|
||||
<p class="muted" style="font-size:0.9rem; color: var(--muted);">Входящие на выбранный банк считаются доходом.</p>
|
||||
<ul class="banks-list" id="banks-list"></ul>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Доходы</h2>
|
||||
<p class="muted" style="font-size:0.9rem; color: var(--muted);">Учёт дохода в сводке и на графиках.</p>
|
||||
<label style="display:flex; align-items:center; gap:0.5rem; margin-bottom:0.75rem;">
|
||||
<input type="checkbox" id="income-only-salary-card" />
|
||||
<span>Считать доходы только по зарплатной карте</span>
|
||||
</label>
|
||||
<div id="income-salary-card-wrap" style="display:none; margin-top:0.5rem;">
|
||||
<label style="font-size:0.9rem; color:var(--muted);">Зарплатная карта:</label>
|
||||
<select id="income-salary-account" style="background:var(--bg); color:var(--text); border:1px solid var(--border); padding:0.4rem; border-radius:6px; margin-left:0.5rem; min-width:200px;">
|
||||
<option value="">— выбрать карту —</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="button" id="btn-save-income-settings" class="secondary" style="margin-top:0.75rem;">Сохранить</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2>Импорт выписок</h2>
|
||||
<p>Загрузите PDF файл (Т-MM-YY.pdf) или импортируйте из папки «Выписки банков».</p>
|
||||
<input type="file" id="file-input" accept=".pdf" />
|
||||
<button type="button" id="btn-upload">Загрузить файл</button>
|
||||
<span class="error" id="upload-error"></span>
|
||||
<div style="margin-top:1rem;">
|
||||
<button type="button" id="btn-import-folder" class="secondary">Импорт из папки</button>
|
||||
<span id="import-result"></span>
|
||||
<p style="font-size:0.85rem; color:var(--muted); margin-top:0.5rem;">Если добавлено 0 при повторном нажатии — перезапустите сервер (uvicorn) и нажмите снова.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const API = '';
|
||||
async function get(url) {
|
||||
const r = await fetch(API + url);
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
return r.json();
|
||||
}
|
||||
async function put(url) {
|
||||
const r = await fetch(API + url, { method: 'PUT' });
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
return r.json();
|
||||
}
|
||||
async function patch(url, body) {
|
||||
const r = await fetch(API + url, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) });
|
||||
if (!r.ok) throw new Error(await r.text());
|
||||
return r.json();
|
||||
}
|
||||
function fmtMoney(n) {
|
||||
return new Intl.NumberFormat('ru-RU', { style: 'decimal', minimumFractionDigits: 2 }).format(n);
|
||||
}
|
||||
function fmtDate(s) {
|
||||
if (!s) return '';
|
||||
return s.slice(0, 10).split('-').reverse().join('.');
|
||||
}
|
||||
|
||||
let chartIncomeExpense, chartBalance, chartSavings;
|
||||
let filterPeriodStart = '';
|
||||
let filterPeriodEnd = '';
|
||||
let filterBankIds = [];
|
||||
|
||||
function buildFilterQuery() {
|
||||
const p = [];
|
||||
if (filterPeriodStart) p.push('period_start=' + encodeURIComponent(filterPeriodStart));
|
||||
if (filterPeriodEnd) p.push('period_end=' + encodeURIComponent(filterPeriodEnd));
|
||||
if (filterBankIds.length) p.push('bank_ids=' + filterBankIds.join(','));
|
||||
return p.length ? '?' + p.join('&') : '';
|
||||
}
|
||||
|
||||
async function loadSummary() {
|
||||
const data = await get('/api/balance/summary' + buildFilterQuery());
|
||||
const el = document.getElementById('summary-metrics');
|
||||
el.innerHTML = `
|
||||
<div class="metric">
|
||||
<div class="value ${data.total_balance >= 0 ? 'positive' : 'negative'}">${fmtMoney(data.total_balance)} ₽</div>
|
||||
<div class="label">Суммарный баланс</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="value positive">${fmtMoney(data.income)} ₽</div>
|
||||
<div class="label">Доход (зарплатный счёт)</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="value positive">${fmtMoney(data.debit)} ₽</div>
|
||||
<div class="label">Дебет (приход)</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="value negative">${fmtMoney(data.credit)} ₽</div>
|
||||
<div class="label">Кредит (расход)</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="value positive">${fmtMoney(data.savings_balance ?? 0)} ₽</div>
|
||||
<div class="label">Копилка (накопления)</div>
|
||||
</div>
|
||||
<div class="metric">
|
||||
<div class="value ${(data.net_flow ?? 0) >= 0 ? 'positive' : 'negative'}">${fmtMoney(data.net_flow ?? 0)} ₽</div>
|
||||
<div class="label">За период: ${(data.net_flow ?? 0) >= 0 ? 'накопление' : 'траты'}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
let transactionsPage = 0;
|
||||
let transactionsPageSize = 50;
|
||||
|
||||
async function loadTransactions() {
|
||||
const offset = transactionsPage * transactionsPageSize;
|
||||
const base = buildFilterQuery();
|
||||
const data = await get('/api/transactions' + (base ? base + '&' : '?') + 'limit=' + transactionsPageSize + '&offset=' + offset);
|
||||
const items = data.items || [];
|
||||
const total = data.total ?? 0;
|
||||
const el = document.getElementById('transactions-table');
|
||||
const infoEl = document.getElementById('pagination-info');
|
||||
if (!items.length && total === 0) {
|
||||
el.innerHTML = '<p style="color:var(--muted)">Нет операций. Импортируйте выписки в Настройках.</p>';
|
||||
infoEl.textContent = '';
|
||||
document.getElementById('btn-prev').disabled = true;
|
||||
document.getElementById('btn-next').disabled = true;
|
||||
return;
|
||||
}
|
||||
const from = offset + 1;
|
||||
const to = Math.min(offset + transactionsPageSize, total);
|
||||
infoEl.textContent = 'Показано ' + from + '–' + to + ' из ' + total;
|
||||
document.getElementById('btn-prev').disabled = transactionsPage === 0;
|
||||
document.getElementById('btn-next').disabled = offset + items.length >= total;
|
||||
el.innerHTML = `
|
||||
<table>
|
||||
<thead><tr><th title="Учитывать в расчёте баланса"><input type="checkbox" id="th-include-all" title="В расчёте" /></th><th>Банк</th><th>Дата</th><th>Счёт</th><th>Описание</th><th>Сумма</th></tr></thead>
|
||||
<tbody>
|
||||
${items.map(t => {
|
||||
const included = !t.excluded_from_balance;
|
||||
return `
|
||||
<tr class="${included ? '' : 'tx-excluded'}" data-id="${t.id}">
|
||||
<td><input type="checkbox" class="tx-include-cb" data-id="${t.id}" ${included ? 'checked' : ''} title="Учитывать в расчёте баланса" /></td>
|
||||
<td>${t.bank_name || t.bank_code || ''}</td>
|
||||
<td>${fmtDate(t.operation_date)}</td>
|
||||
<td>***${t.external_id}</td>
|
||||
<td>${(t.description || '').slice(0, 40)}${(t.description||'').length > 40 ? '…' : ''}</td>
|
||||
<td class="amount ${t.amount >= 0 ? 'in' : 'out'}">${t.amount >= 0 ? '+' : ''}${fmtMoney(t.amount)} ₽</td>
|
||||
</tr>
|
||||
`;
|
||||
}).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
`;
|
||||
el.querySelectorAll('.tx-include-cb').forEach(cb => {
|
||||
cb.onclick = async () => {
|
||||
const id = parseInt(cb.dataset.id, 10);
|
||||
const excluded = !cb.checked;
|
||||
try {
|
||||
await patch('/api/transactions/' + id, { excluded_from_balance: excluded });
|
||||
cb.closest('tr').classList.toggle('tx-excluded', excluded);
|
||||
loadSummary();
|
||||
loadCharts();
|
||||
} catch (e) {
|
||||
cb.checked = !cb.checked;
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
});
|
||||
const thAll = document.getElementById('th-include-all');
|
||||
if (thAll) {
|
||||
const includedCount = items.filter(t => !t.excluded_from_balance).length;
|
||||
thAll.checked = includedCount === items.length;
|
||||
thAll.indeterminate = includedCount > 0 && includedCount < items.length;
|
||||
thAll.onclick = async () => {
|
||||
const wantInclude = thAll.checked;
|
||||
for (const row of el.querySelectorAll('tbody tr')) {
|
||||
const id = parseInt(row.dataset.id, 10);
|
||||
const cb = row.querySelector('.tx-include-cb');
|
||||
if (cb.checked !== wantInclude) {
|
||||
try {
|
||||
await patch('/api/transactions/' + id, { excluded_from_balance: !wantInclude });
|
||||
cb.checked = wantInclude;
|
||||
row.classList.toggle('tx-excluded', !wantInclude);
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
}
|
||||
loadSummary();
|
||||
loadCharts();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function initPagination() {
|
||||
document.getElementById('page-size').onchange = () => {
|
||||
transactionsPageSize = parseInt(document.getElementById('page-size').value, 10);
|
||||
transactionsPage = 0;
|
||||
loadTransactions();
|
||||
};
|
||||
document.getElementById('btn-prev').onclick = () => {
|
||||
if (transactionsPage > 0) { transactionsPage--; loadTransactions(); }
|
||||
};
|
||||
document.getElementById('btn-next').onclick = () => {
|
||||
transactionsPage++; loadTransactions();
|
||||
};
|
||||
}
|
||||
|
||||
async function loadCharts() {
|
||||
const chartQ = buildFilterQuery();
|
||||
const [byMonth, dynamics, savingsDynamics] = await Promise.all([
|
||||
get('/api/charts/income-expense' + chartQ),
|
||||
get('/api/charts/balance-dynamics' + chartQ),
|
||||
get('/api/charts/savings-dynamics' + chartQ)
|
||||
]);
|
||||
const ctx1 = document.getElementById('chart-income-expense').getContext('2d');
|
||||
if (chartIncomeExpense) chartIncomeExpense.destroy();
|
||||
chartIncomeExpense = new Chart(ctx1, {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: (byMonth || []).map(x => x.month),
|
||||
datasets: [
|
||||
{ label: 'Доход', data: (byMonth || []).map(x => x.income), backgroundColor: 'rgba(34, 197, 94, 0.7)' },
|
||||
{ label: 'Расход', data: (byMonth || []).map(x => x.credit), backgroundColor: 'rgba(239, 68, 68, 0.7)' }
|
||||
]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: { x: { grid: { color: 'rgba(255,255,255,0.06)' } }, y: { grid: { color: 'rgba(255,255,255,0.06)' } } },
|
||||
plugins: { legend: { labels: { color: '#e4e4e7' } } }
|
||||
}
|
||||
});
|
||||
const ctx2 = document.getElementById('chart-balance').getContext('2d');
|
||||
if (chartBalance) chartBalance.destroy();
|
||||
chartBalance = new Chart(ctx2, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: (dynamics || []).map(x => x.date),
|
||||
datasets: [{ label: 'Остаток (накопл.)', data: (dynamics || []).map(x => x.balance), borderColor: '#22c55e', fill: true, tension: 0.2 }]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: { x: { grid: { color: 'rgba(255,255,255,0.06)' } }, y: { grid: { color: 'rgba(255,255,255,0.06)' } } },
|
||||
plugins: { legend: { labels: { color: '#e4e4e7' } } }
|
||||
}
|
||||
});
|
||||
const ctx3 = document.getElementById('chart-savings').getContext('2d');
|
||||
if (chartSavings) chartSavings.destroy();
|
||||
chartSavings = new Chart(ctx3, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: (savingsDynamics || []).map(x => x.date),
|
||||
datasets: [{ label: 'Копилка (накопл.)', data: (savingsDynamics || []).map(x => x.savings), borderColor: '#eab308', fill: true, tension: 0.2 }]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: { x: { grid: { color: 'rgba(255,255,255,0.06)' } }, y: { grid: { color: 'rgba(255,255,255,0.06)' } } },
|
||||
plugins: { legend: { labels: { color: '#e4e4e7' } } }
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureFilterBanks() {
|
||||
if (document.querySelector('.filter-bank-cb')) return;
|
||||
const banks = await get('/api/banks');
|
||||
const el = document.getElementById('filter-banks');
|
||||
if (!el || !banks.length) return;
|
||||
el.innerHTML = banks.map(b => `<label style="margin-right:0.75rem; white-space:nowrap;"><input type="checkbox" class="filter-bank-cb" data-id="${b.id}" checked /> ${b.name}</label>`).join('');
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
filterPeriodStart = document.getElementById('filter-period-start').value || '';
|
||||
filterPeriodEnd = document.getElementById('filter-period-end').value || '';
|
||||
const all = document.querySelectorAll('.filter-bank-cb');
|
||||
const checked = document.querySelectorAll('.filter-bank-cb:checked');
|
||||
filterBankIds = (all.length && checked.length < all.length) ? Array.from(checked).map(cb => parseInt(cb.dataset.id, 10)) : [];
|
||||
transactionsPage = 0;
|
||||
loadDashboard();
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
document.getElementById('filter-period-start').value = '';
|
||||
document.getElementById('filter-period-end').value = '';
|
||||
document.querySelectorAll('.filter-bank-cb').forEach(cb => { cb.checked = true; });
|
||||
filterPeriodStart = '';
|
||||
filterPeriodEnd = '';
|
||||
filterBankIds = [];
|
||||
transactionsPage = 0;
|
||||
loadDashboard();
|
||||
}
|
||||
|
||||
async function loadExcludeTransfersCheckbox() {
|
||||
try {
|
||||
const data = await get('/api/settings/exclude-transfers');
|
||||
const cb = document.getElementById('exclude-transfers-cb');
|
||||
if (cb) cb.checked = data.exclude_transfers || false;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDashboard() {
|
||||
try {
|
||||
await ensureFilterBanks();
|
||||
await loadExcludeTransfersCheckbox();
|
||||
await loadSummary();
|
||||
await loadTransactions();
|
||||
await loadCharts();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
document.getElementById('summary-metrics').innerHTML = '<p class="error">Ошибка загрузки: ' + e.message + '</p>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadIncomeSettings() {
|
||||
const data = await get('/api/settings/income');
|
||||
const cb = document.getElementById('income-only-salary-card');
|
||||
const wrapDiv = document.getElementById('income-salary-card-wrap');
|
||||
const sel = document.getElementById('income-salary-account');
|
||||
cb.checked = data.count_income_only_salary_card || false;
|
||||
if (wrapDiv) wrapDiv.style.display = cb.checked ? 'block' : 'none';
|
||||
sel.innerHTML = '<option value="">— выбрать карту —</option>' +
|
||||
(data.salary_accounts || []).map(a => `<option value="${a.id}" ${a.id === data.salary_account_id ? 'selected' : ''}>***${a.external_id} ${a.name || ''}</option>`).join('');
|
||||
if (cb.checked && data.salary_account_id && !sel.querySelector('option[value="' + data.salary_account_id + '"]')) {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = data.salary_account_id;
|
||||
opt.textContent = '*** (id ' + data.salary_account_id + ')';
|
||||
opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBanks() {
|
||||
const banks = await get('/api/banks');
|
||||
const el = document.getElementById('banks-list');
|
||||
if (!banks.length) {
|
||||
el.innerHTML = '<li style="color:var(--muted)">Нет банков. Импортируйте выписки Т-банка.</li>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = banks.map(b => `
|
||||
<li>
|
||||
<span>${b.name} (${b.code}) ${b.is_salary ? ' ★ зарплатный' : ''}</span>
|
||||
${!b.is_salary ? `<button type="button" data-bank-id="${b.id}" class="btn-set-salary secondary">Сделать зарплатным</button>` : ''}
|
||||
</li>
|
||||
`).join('');
|
||||
el.querySelectorAll('.btn-set-salary').forEach(btn => {
|
||||
btn.onclick = async () => {
|
||||
await put('/api/banks/salary/' + btn.dataset.bankId);
|
||||
loadBanks();
|
||||
loadDashboard();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.nav a').forEach(a => {
|
||||
a.onclick = (e) => {
|
||||
e.preventDefault();
|
||||
document.querySelectorAll('.nav a').forEach(x => x.classList.remove('active'));
|
||||
document.querySelectorAll('.page').forEach(x => x.classList.remove('active'));
|
||||
a.classList.add('active');
|
||||
document.getElementById('page-' + a.dataset.page).classList.add('active');
|
||||
if (a.dataset.page === 'settings') { loadBanks(); loadIncomeSettings(); }
|
||||
else loadDashboard();
|
||||
};
|
||||
});
|
||||
|
||||
document.getElementById('btn-upload').onclick = async () => {
|
||||
const input = document.getElementById('file-input');
|
||||
const err = document.getElementById('upload-error');
|
||||
if (!input.files?.length) { err.textContent = 'Выберите файл'; return; }
|
||||
err.textContent = '';
|
||||
const form = new FormData();
|
||||
form.append('file', input.files[0]);
|
||||
try {
|
||||
const r = await fetch(API + '/api/import/upload', { method: 'POST', body: form });
|
||||
const data = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(data.detail || r.statusText);
|
||||
err.style.color = 'var(--accent)';
|
||||
err.textContent = data.parsed != null ? `Распознано: ${data.parsed}, добавлено: ${data.added}, дубликатов: ${data.skipped_duplicates}` : `Добавлено: ${data.added}, дубликатов: ${data.skipped_duplicates}`;
|
||||
input.value = '';
|
||||
loadDashboard();
|
||||
} catch (e) {
|
||||
err.style.color = 'var(--negative)';
|
||||
err.textContent = e.message;
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('btn-import-folder').onclick = async () => {
|
||||
const res = document.getElementById('import-result');
|
||||
res.textContent = ' Загрузка…';
|
||||
try {
|
||||
const r = await fetch(API + '/api/import/from-folder', { method: 'POST' });
|
||||
const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.detail || r.statusText);
|
||||
res.style.color = 'var(--accent)';
|
||||
res.textContent = d.parsed != null ? ` Распознано: ${d.parsed}, добавлено: ${d.added}, дубликатов: ${d.skipped_duplicates}` : ` Добавлено: ${d.added}, дубликатов: ${d.skipped_duplicates}`;
|
||||
loadDashboard();
|
||||
loadBanks();
|
||||
} catch (e) {
|
||||
res.style.color = 'var(--negative)';
|
||||
res.textContent = ' ' + e.message;
|
||||
}
|
||||
};
|
||||
|
||||
document.getElementById('filter-apply').onclick = applyFilters;
|
||||
document.getElementById('filter-reset').onclick = resetFilters;
|
||||
document.getElementById('exclude-transfers-cb').onchange = async function() {
|
||||
try {
|
||||
await fetch(API + '/api/settings/exclude-transfers', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ exclude_transfers: this.checked })
|
||||
});
|
||||
await loadSummary();
|
||||
await loadCharts();
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
document.getElementById('income-only-salary-card').onchange = function() {
|
||||
document.getElementById('income-salary-card-wrap').style.display = this.checked ? 'block' : 'none';
|
||||
};
|
||||
document.getElementById('btn-save-income-settings').onclick = async function() {
|
||||
const cb = document.getElementById('income-only-salary-card');
|
||||
const sel = document.getElementById('income-salary-account');
|
||||
const accountId = sel.value ? parseInt(sel.value, 10) : null;
|
||||
try {
|
||||
const r = await fetch(API + '/api/settings/income', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ count_income_only_salary_card: cb.checked, salary_account_id: accountId })
|
||||
});
|
||||
if (!r.ok) throw new Error('Ошибка сохранения');
|
||||
await loadIncomeSettings();
|
||||
loadDashboard();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
initPagination();
|
||||
loadDashboard();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
BIN
Выписки банков/С-01-25.pdf
Normal file
BIN
Выписки банков/С-01-25.pdf
Normal file
Binary file not shown.
BIN
Выписки банков/С-01-26.pdf
Normal file
BIN
Выписки банков/С-01-26.pdf
Normal file
Binary file not shown.
BIN
Выписки банков/С-10-25.pdf
Normal file
BIN
Выписки банков/С-10-25.pdf
Normal file
Binary file not shown.
BIN
Выписки банков/С-11-25.pdf
Normal file
BIN
Выписки банков/С-11-25.pdf
Normal file
Binary file not shown.
BIN
Выписки банков/С-12-25.pdf
Normal file
BIN
Выписки банков/С-12-25.pdf
Normal file
Binary file not shown.
BIN
Выписки банков/Т-01-25.pdf
Normal file
BIN
Выписки банков/Т-01-25.pdf
Normal file
Binary file not shown.
BIN
Выписки банков/Т-01-26.pdf
Normal file
BIN
Выписки банков/Т-01-26.pdf
Normal file
Binary file not shown.
BIN
Выписки банков/Т-10-25.pdf
Normal file
BIN
Выписки банков/Т-10-25.pdf
Normal file
Binary file not shown.
BIN
Выписки банков/Т-11-25.pdf
Normal file
BIN
Выписки банков/Т-11-25.pdf
Normal file
Binary file not shown.
BIN
Выписки банков/Т-12-25.pdf
Normal file
BIN
Выписки банков/Т-12-25.pdf
Normal file
Binary file not shown.
BIN
Выписки банков/Я-01-25.pdf
Normal file
BIN
Выписки банков/Я-01-25.pdf
Normal file
Binary file not shown.
BIN
Выписки банков/Я-01-26.pdf
Normal file
BIN
Выписки банков/Я-01-26.pdf
Normal file
Binary file not shown.
BIN
Выписки банков/Я-10-25.pdf
Normal file
BIN
Выписки банков/Я-10-25.pdf
Normal file
Binary file not shown.
BIN
Выписки банков/Я-11-25.pdf
Normal file
BIN
Выписки банков/Я-11-25.pdf
Normal file
Binary file not shown.
BIN
Выписки банков/Я-12-25.pdf
Normal file
BIN
Выписки банков/Я-12-25.pdf
Normal file
Binary file not shown.
Reference in New Issue
Block a user