Initial income_calculator project
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
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}
|
||||
Reference in New Issue
Block a user