first commit
This commit is contained in:
+19
@@ -0,0 +1,19 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY app ./app
|
||||
RUN mkdir -p /app/exports
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,31 @@
|
||||
# Symulator kredytu hipotecznego
|
||||
|
||||
Nowoczesna aplikacja one-page do symulacji kredytu hipotecznego: zmienne stopy w czasie, nadpłaty, raty równe/malejące, skrócenie okresu albo obniżenie raty, wykresy, CSV/PDF, WebSocket live.
|
||||
|
||||
## Start
|
||||
|
||||
```bash
|
||||
docker compose up --build
|
||||
```
|
||||
|
||||
Otwórz:
|
||||
|
||||
```text
|
||||
http://localhost:8080
|
||||
```
|
||||
|
||||
## Funkcje
|
||||
|
||||
- symulacja rat równych i malejących,
|
||||
- harmonogram zmiennego oprocentowania w konkretnych miesiącach,
|
||||
- nadpłaty jednorazowe i cykliczne,
|
||||
- tryb nadpłaty: skrócenie okresu albo zmniejszenie raty,
|
||||
- live przeliczenia przez WebSocket,
|
||||
- wykresy liniowe, słupkowe i kołowe,
|
||||
- opis oszczędności: odsetki, czas, suma nadpłat,
|
||||
- eksport CSV i PDF,
|
||||
- pobieranie aktualnej stopy referencyjnej NBP z oficjalnej strony jako pomocnicza wartość bazowa.
|
||||
|
||||
## Uwaga
|
||||
|
||||
To symulator edukacyjny. Wynik może różnić się od harmonogramu banku, bo banki stosują własne zasady zaokrągleń, dat płatności, prowizji i aktualizacji WIBOR/WIRON.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Symlink
+1
@@ -0,0 +1 @@
|
||||
../exports
|
||||
+396
@@ -0,0 +1,396 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import io
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from bs4 import BeautifulSoup
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Response
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from reportlab.lib import colors
|
||||
from reportlab.lib.pagesizes import A4, landscape
|
||||
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
||||
from reportlab.lib.units import cm
|
||||
from reportlab.pdfbase import pdfmetrics
|
||||
from reportlab.pdfbase.ttfonts import TTFont
|
||||
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak
|
||||
from reportlab.graphics.shapes import Drawing, Line, PolyLine, Rect, String, Wedge
|
||||
|
||||
from .models import SimulationRequest
|
||||
from .simulator import simulate
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent
|
||||
EXPORT_DIR = Path(os.environ.get("MORTGAGE_EXPORT_DIR", "./app/exports"))
|
||||
EXPORT_DIR.mkdir(exist_ok=True)
|
||||
|
||||
FONT_DIR = BASE_DIR / "static" / "fonts"
|
||||
FONT_PATH = FONT_DIR / "DejaVuSans.ttf"
|
||||
FONT_BOLD_PATH = FONT_DIR / "DejaVuSans-Bold.ttf"
|
||||
FONT_OBLIQUE_PATH = FONT_DIR / "DejaVuSans-Oblique.ttf"
|
||||
|
||||
if FONT_PATH.exists():
|
||||
pdfmetrics.registerFont(TTFont("AppFont", str(FONT_PATH)))
|
||||
APP_FONT = "AppFont"
|
||||
|
||||
if FONT_BOLD_PATH.exists():
|
||||
pdfmetrics.registerFont(TTFont("AppFont-Bold", str(FONT_BOLD_PATH)))
|
||||
APP_FONT_BOLD = "AppFont-Bold"
|
||||
else:
|
||||
APP_FONT_BOLD = APP_FONT
|
||||
|
||||
if FONT_OBLIQUE_PATH.exists():
|
||||
pdfmetrics.registerFont(TTFont("AppFont-Oblique", str(FONT_OBLIQUE_PATH)))
|
||||
APP_FONT_OBLIQUE = "AppFont-Oblique"
|
||||
else:
|
||||
APP_FONT_OBLIQUE = APP_FONT
|
||||
else:
|
||||
APP_FONT = "Helvetica"
|
||||
APP_FONT_BOLD = "Helvetica-Bold"
|
||||
APP_FONT_OBLIQUE = "Helvetica-Oblique"
|
||||
|
||||
app = FastAPI(title="Symulator kredytu hipotecznego", version="1.0.0")
|
||||
app.mount("/static", StaticFiles(directory=BASE_DIR / "static"), name="static")
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def no_cache_headers(request, call_next):
|
||||
response = await call_next(request)
|
||||
response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate"
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/favicon.ico")
|
||||
def favicon() -> Response:
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
def index() -> str:
|
||||
return (BASE_DIR / "static" / "index.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@app.post("/api/simulate")
|
||||
def api_simulate(req: SimulationRequest):
|
||||
return simulate(req)
|
||||
|
||||
|
||||
@app.websocket("/ws/simulate")
|
||||
async def ws_simulate(websocket: WebSocket):
|
||||
await websocket.accept()
|
||||
try:
|
||||
while True:
|
||||
data = await websocket.receive_json()
|
||||
req = SimulationRequest.model_validate(data)
|
||||
result = simulate(req)
|
||||
await websocket.send_json(result.model_dump())
|
||||
except WebSocketDisconnect:
|
||||
return
|
||||
except Exception as exc:
|
||||
await websocket.send_json({"error": str(exc)})
|
||||
|
||||
|
||||
@app.get("/api/rate/nbp")
|
||||
async def nbp_rate():
|
||||
"""Pomocniczo pobiera stopę referencyjną NBP z oficjalnej strony HTML."""
|
||||
url = "https://nbp.pl/en/monetary-policy/mpc-decisions/interest-rates/"
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10, follow_redirects=True) as client:
|
||||
resp = await client.get(url, headers={"User-Agent": "mortgage-simulator/1.0"})
|
||||
resp.raise_for_status()
|
||||
soup = BeautifulSoup(resp.text, "html.parser")
|
||||
text = " ".join(soup.get_text(" ").split())
|
||||
patterns = [
|
||||
r"Reference rate[^0-9]{0,80}(\d+[\.,]\d+|\d+)\s*%",
|
||||
r"minimum money market intervention rate[^0-9]{0,80}(\d+[\.,]\d+|\d+)\s*%",
|
||||
]
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, text, flags=re.IGNORECASE)
|
||||
if match:
|
||||
value = float(match.group(1).replace(",", "."))
|
||||
return {"source": "NBP", "url": url, "rate": value, "fetched_at": datetime.utcnow().isoformat() + "Z"}
|
||||
return JSONResponse({"error": "Nie znaleziono stopy w treści strony NBP", "url": url}, status_code=502)
|
||||
except Exception as exc:
|
||||
return JSONResponse({"error": str(exc), "url": url}, status_code=502)
|
||||
|
||||
|
||||
@app.post("/api/export/csv")
|
||||
def export_csv(req: SimulationRequest):
|
||||
result = simulate(req)
|
||||
buf = io.StringIO()
|
||||
writer = csv.writer(buf, delimiter=";")
|
||||
writer.writerow(["miesiac", "oprocentowanie", "rata", "kapital", "odsetki", "nadplata", "saldo"])
|
||||
for row in result.schedule:
|
||||
writer.writerow([row.month, row.rate, row.payment, row.principal_part, row.interest_part, row.overpayment, row.remaining])
|
||||
writer.writerow([])
|
||||
writer.writerow(["Podsumowanie"])
|
||||
for key, value in result.summary.model_dump().items():
|
||||
writer.writerow([key, value])
|
||||
data = buf.getvalue().encode("utf-8-sig")
|
||||
return StreamingResponse(
|
||||
io.BytesIO(data),
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=symulacja-kredytu.csv"},
|
||||
)
|
||||
|
||||
|
||||
def _money(value: float) -> str:
|
||||
return f"{value:,.2f} PLN".replace(",", " ")
|
||||
|
||||
|
||||
def _pct(value: float) -> str:
|
||||
return f"{value:.2f}%"
|
||||
|
||||
|
||||
def _repeat_label(value: str) -> str:
|
||||
return {"once": "jednorazowo", "monthly": "co miesiac", "yearly": "co rok"}.get(value, value)
|
||||
|
||||
|
||||
def _effect_label(value: str) -> str:
|
||||
return {"shorten": "skrocenie okresu", "lower_payment": "zmniejszenie raty"}.get(value, value)
|
||||
|
||||
|
||||
def _installment_label(value: str) -> str:
|
||||
return {"equal": "rowne", "decreasing": "malejace"}.get(value, value)
|
||||
|
||||
|
||||
def _add_page_number(canvas_obj, doc):
|
||||
canvas_obj.saveState()
|
||||
canvas_obj.setFont(APP_FONT, 7)
|
||||
canvas_obj.setFillColor(colors.HexColor("#6b7280"))
|
||||
canvas_obj.drawString(1.4 * cm, 0.9 * cm, "Symulator edukacyjny - wyniki orientacyjne")
|
||||
canvas_obj.drawRightString(A4[0] - 1.4 * cm, 0.9 * cm, f"Strona {doc.page}")
|
||||
canvas_obj.restoreState()
|
||||
|
||||
|
||||
def _line_chart(rows, value_attr: str, title: str, width=480, height=165) -> Drawing:
|
||||
drawing = Drawing(width, height)
|
||||
margin_l, margin_r, margin_b, margin_t = 42, 12, 24, 24
|
||||
plot_w = width - margin_l - margin_r
|
||||
plot_h = height - margin_t - margin_b
|
||||
x0, y0 = margin_l, margin_b
|
||||
values = [float(getattr(r, value_attr)) for r in rows]
|
||||
if not values:
|
||||
values = [0]
|
||||
max_v = max(values) or 1
|
||||
count = max(len(values) - 1, 1)
|
||||
|
||||
drawing.add(String(0, height - 12, title, fontName=APP_FONT_BOLD, fontSize=9, fillColor=colors.HexColor("#111827")))
|
||||
drawing.add(Line(x0, y0, x0, y0 + plot_h, strokeColor=colors.HexColor("#9ca3af"), strokeWidth=0.7))
|
||||
drawing.add(Line(x0, y0, x0 + plot_w, y0, strokeColor=colors.HexColor("#9ca3af"), strokeWidth=0.7))
|
||||
for i in range(1, 5):
|
||||
gy = y0 + plot_h * i / 4
|
||||
drawing.add(Line(x0, gy, x0 + plot_w, gy, strokeColor=colors.HexColor("#e5e7eb"), strokeWidth=0.4))
|
||||
label = _money(max_v * i / 4).replace(" PLN", "")
|
||||
drawing.add(String(2, gy - 3, label, fontName=APP_FONT, fontSize=6, fillColor=colors.HexColor("#6b7280")))
|
||||
points = []
|
||||
step = max(1, math.ceil(len(values) / 420))
|
||||
sampled = values[::step]
|
||||
sampled_count = max(len(sampled) - 1, 1)
|
||||
for idx, value in enumerate(sampled):
|
||||
x = x0 + plot_w * idx / sampled_count
|
||||
y = y0 + plot_h * (value / max_v)
|
||||
points.extend([x, y])
|
||||
drawing.add(PolyLine(points, strokeColor=colors.HexColor("#2563eb"), strokeWidth=1.4))
|
||||
drawing.add(String(x0, 6, "1", fontName=APP_FONT, fontSize=6, fillColor=colors.HexColor("#6b7280")))
|
||||
drawing.add(String(x0 + plot_w - 18, 6, str(len(values)), fontName=APP_FONT, fontSize=6, fillColor=colors.HexColor("#6b7280")))
|
||||
return drawing
|
||||
|
||||
|
||||
def _bar_chart(yearly: dict[int, float], title: str, width=480, height=165) -> Drawing:
|
||||
drawing = Drawing(width, height)
|
||||
margin_l, margin_r, margin_b, margin_t = 42, 12, 24, 24
|
||||
plot_w = width - margin_l - margin_r
|
||||
plot_h = height - margin_t - margin_b
|
||||
x0, y0 = margin_l, margin_b
|
||||
items = list(yearly.items())[:35]
|
||||
max_v = max([v for _, v in items], default=1) or 1
|
||||
drawing.add(String(0, height - 12, title, fontName=APP_FONT_BOLD, fontSize=9, fillColor=colors.HexColor("#111827")))
|
||||
drawing.add(Line(x0, y0, x0, y0 + plot_h, strokeColor=colors.HexColor("#9ca3af"), strokeWidth=0.7))
|
||||
drawing.add(Line(x0, y0, x0 + plot_w, y0, strokeColor=colors.HexColor("#9ca3af"), strokeWidth=0.7))
|
||||
for i in range(1, 5):
|
||||
gy = y0 + plot_h * i / 4
|
||||
drawing.add(Line(x0, gy, x0 + plot_w, gy, strokeColor=colors.HexColor("#e5e7eb"), strokeWidth=0.4))
|
||||
if items:
|
||||
gap = 2
|
||||
bar_w = max(3, (plot_w / len(items)) - gap)
|
||||
for idx, (year, value) in enumerate(items):
|
||||
bh = plot_h * value / max_v
|
||||
x = x0 + idx * (plot_w / len(items)) + gap / 2
|
||||
drawing.add(Rect(x, y0, bar_w, bh, fillColor=colors.HexColor("#64748b"), strokeColor=None))
|
||||
if len(items) <= 25 or idx % 2 == 0:
|
||||
drawing.add(String(x, 6, str(year), fontName=APP_FONT, fontSize=5.5, fillColor=colors.HexColor("#6b7280")))
|
||||
return drawing
|
||||
|
||||
|
||||
def _pie_chart(parts: list[tuple[str, float, str]], title: str, width=240, height=165) -> Drawing:
|
||||
drawing = Drawing(width, height)
|
||||
drawing.add(String(0, height - 12, title, fontName=APP_FONT_BOLD, fontSize=9, fillColor=colors.HexColor("#111827")))
|
||||
total = sum(v for _, v, _ in parts) or 1
|
||||
cx, cy, r = 65, 78, 45
|
||||
start = 90
|
||||
for name, value, color in parts:
|
||||
extent = 360 * value / total
|
||||
drawing.add(Wedge(cx, cy, r, start, start + extent, fillColor=colors.HexColor(color), strokeColor=colors.white, strokeWidth=0.5))
|
||||
start += extent
|
||||
ly = height - 38
|
||||
for name, value, color in parts:
|
||||
drawing.add(Rect(130, ly - 2, 7, 7, fillColor=colors.HexColor(color), strokeColor=None))
|
||||
pct = value / total * 100
|
||||
drawing.add(String(142, ly, f"{name}: {pct:.1f}%", fontName=APP_FONT, fontSize=7, fillColor=colors.HexColor("#111827")))
|
||||
ly -= 14
|
||||
return drawing
|
||||
|
||||
|
||||
@app.post("/api/export/pdf")
|
||||
def export_pdf(req: SimulationRequest):
|
||||
result = simulate(req)
|
||||
buffer = io.BytesIO()
|
||||
doc = SimpleDocTemplate(
|
||||
buffer,
|
||||
pagesize=A4,
|
||||
rightMargin=1.2 * cm,
|
||||
leftMargin=1.2 * cm,
|
||||
topMargin=1.3 * cm,
|
||||
bottomMargin=1.4 * cm,
|
||||
title="Symulacja kredytu hipotecznego",
|
||||
)
|
||||
styles = getSampleStyleSheet()
|
||||
for style in styles.byName.values():
|
||||
style.fontName = APP_FONT
|
||||
styles["Title"].fontName = APP_FONT_BOLD
|
||||
styles["Heading2"].fontName = APP_FONT_BOLD
|
||||
styles.add(ParagraphStyle(name="Small", parent=styles["Normal"], fontSize=8, leading=10, fontName=APP_FONT))
|
||||
styles.add(ParagraphStyle(name="Tiny", parent=styles["Normal"], fontSize=6.7, leading=8, fontName=APP_FONT))
|
||||
story = []
|
||||
|
||||
story.append(Paragraph("Symulacja kredytu hipotecznego - pelny raport", styles["Title"]))
|
||||
story.append(Paragraph(f"Wygenerowano: {datetime.now().strftime('%Y-%m-%d %H:%M')}", styles["Small"]))
|
||||
story.append(Spacer(1, 0.25 * cm))
|
||||
|
||||
params = [
|
||||
["Kwota kredytu", _money(req.principal), "Okres", f"{req.years} lat"],
|
||||
["Stopa bazowa", _pct(req.base_rate), "Marza", _pct(req.margin)],
|
||||
["Oprocentowanie startowe", _pct(req.base_rate + req.margin), "Typ rat", _installment_label(req.installment_type.value)],
|
||||
["Efekt nadplat", _effect_label(req.overpayment_effect.value), "Liczba rat po symulacji", str(result.summary.months)],
|
||||
]
|
||||
story.append(Paragraph("Parametry wejściowe", styles["Heading2"]))
|
||||
story.append(Table(params, colWidths=[4.9 * cm, 3.4 * cm, 4.4 * cm, 3.9 * cm], style=TableStyle([
|
||||
("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#cbd5e1")),
|
||||
("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#f8fafc")),
|
||||
("FONTNAME", (0, 0), (-1, -1), APP_FONT),
|
||||
("FONTNAME", (0, 0), (0, -1), APP_FONT_BOLD),
|
||||
("FONTNAME", (2, 0), (2, -1), APP_FONT_BOLD),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 7.4),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
])))
|
||||
story.append(Spacer(1, 0.35 * cm))
|
||||
|
||||
s = result.summary
|
||||
summary = [
|
||||
["Liczba rat", s.months, "Bazowo", s.baseline_months],
|
||||
["Suma zaplacona", _money(s.total_paid), "Suma odsetek", _money(s.total_interest)],
|
||||
["Oszczednosc na odsetkach", _money(s.interest_saved), "Skrocenie okresu", f"{s.months_saved} mies."],
|
||||
["Suma nadplat", _money(s.total_overpayment), "Srednia / maks. rata", f"{_money(s.average_payment)} / {_money(s.max_payment)}"],
|
||||
]
|
||||
story.append(Paragraph("Podsumowanie", styles["Heading2"]))
|
||||
story.append(Table(summary, colWidths=[4.9 * cm, 3.4 * cm, 4.4 * cm, 3.9 * cm], style=TableStyle([
|
||||
("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#cbd5e1")),
|
||||
("BACKGROUND", (0, 0), (-1, -1), colors.HexColor("#ffffff")),
|
||||
("FONTNAME", (0, 0), (-1, -1), APP_FONT),
|
||||
("FONTNAME", (0, 0), (0, -1), APP_FONT_BOLD),
|
||||
("FONTNAME", (2, 0), (2, -1), APP_FONT_BOLD),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 7.4),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
])))
|
||||
story.append(Spacer(1, 0.35 * cm))
|
||||
|
||||
story.append(Paragraph("Wykresy", styles["Heading2"]))
|
||||
yearly = {}
|
||||
for row in result.schedule:
|
||||
year = math.ceil(row.month / 12)
|
||||
yearly[year] = yearly.get(year, 0.0) + row.interest_part
|
||||
story.append(_line_chart(result.schedule, "remaining", "Saldo kredytu w czasie"))
|
||||
story.append(Spacer(1, 0.15 * cm))
|
||||
story.append(_line_chart(result.schedule, "payment", "Rata w czasie"))
|
||||
story.append(Spacer(1, 0.15 * cm))
|
||||
story.append(Table([[
|
||||
_bar_chart(yearly, "Odsetki rocznie", width=285, height=150),
|
||||
_pie_chart([
|
||||
("Kapital", req.principal, "#2563eb"),
|
||||
("Odsetki", s.total_interest, "#64748b"),
|
||||
("Nadplaty", s.total_overpayment, "#16a34a"),
|
||||
], "Struktura kosztow", width=220, height=150),
|
||||
]], colWidths=[9.6 * cm, 7.3 * cm]))
|
||||
story.append(PageBreak())
|
||||
|
||||
story.append(Paragraph("Zmienne oprocentowanie", styles["Heading2"]))
|
||||
rate_rows = [["Od miesiaca", "Oprocentowanie roczne"]]
|
||||
rate_rows.append(["1", _pct(req.base_rate + req.margin)])
|
||||
for change in sorted(req.rate_changes, key=lambda x: x.month):
|
||||
rate_rows.append([str(change.month), _pct(change.annual_rate)])
|
||||
story.append(Table(rate_rows, repeatRows=1, colWidths=[4 * cm, 6 * cm], style=TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e5e7eb")),
|
||||
("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#cbd5e1")),
|
||||
("FONTNAME", (0, 0), (-1, -1), APP_FONT),
|
||||
("FONTNAME", (0, 0), (-1, 0), APP_FONT_BOLD),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 8),
|
||||
])))
|
||||
story.append(Spacer(1, 0.35 * cm))
|
||||
|
||||
story.append(Paragraph("Nadplaty", styles["Heading2"]))
|
||||
over_rows = [["Miesiac", "Kwota", "Powtarzanie", "Do miesiaca"]]
|
||||
if req.overpayments:
|
||||
for op in sorted(req.overpayments, key=lambda x: x.month):
|
||||
over_rows.append([str(op.month), _money(op.amount), _repeat_label(op.repeat), str(op.until_month or "-")])
|
||||
else:
|
||||
over_rows.append(["-", "-", "-", "-"])
|
||||
story.append(Table(over_rows, repeatRows=1, colWidths=[3 * cm, 4 * cm, 4 * cm, 4 * cm], style=TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e5e7eb")),
|
||||
("GRID", (0, 0), (-1, -1), 0.25, colors.HexColor("#cbd5e1")),
|
||||
("FONTNAME", (0, 0), (-1, -1), APP_FONT),
|
||||
("FONTNAME", (0, 0), (-1, 0), APP_FONT_BOLD),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 8),
|
||||
])))
|
||||
story.append(PageBreak())
|
||||
|
||||
story.append(Paragraph("Kompletny harmonogram wszystkich rat", styles["Heading2"]))
|
||||
schedule_rows = [["Mies.", "Oproc.", "Rata", "Kapital", "Odsetki", "Nadplata", "Saldo"]]
|
||||
for row in result.schedule:
|
||||
schedule_rows.append([
|
||||
row.month,
|
||||
_pct(row.rate),
|
||||
_money(row.payment),
|
||||
_money(row.principal_part),
|
||||
_money(row.interest_part),
|
||||
_money(row.overpayment),
|
||||
_money(row.remaining),
|
||||
])
|
||||
table = Table(schedule_rows, repeatRows=1, colWidths=[1.35 * cm, 1.7 * cm, 2.55 * cm, 2.55 * cm, 2.55 * cm, 2.55 * cm, 3.0 * cm])
|
||||
table.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e5e7eb")),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), colors.HexColor("#111827")),
|
||||
("GRID", (0, 0), (-1, -1), 0.2, colors.HexColor("#cbd5e1")),
|
||||
("FONTNAME", (0, 0), (-1, -1), APP_FONT),
|
||||
("FONTNAME", (0, 0), (-1, 0), APP_FONT_BOLD),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 6.5),
|
||||
("LEADING", (0, 0), (-1, -1), 7.5),
|
||||
("ALIGN", (0, 1), (-1, -1), "RIGHT"),
|
||||
("ALIGN", (0, 0), (-1, 0), "CENTER"),
|
||||
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
||||
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f8fafc")]),
|
||||
]))
|
||||
story.append(table)
|
||||
|
||||
doc.build(story, onFirstPage=_add_page_number, onLaterPages=_add_page_number)
|
||||
buffer.seek(0)
|
||||
return Response(
|
||||
buffer.read(),
|
||||
media_type="application/pdf",
|
||||
headers={"Content-Disposition": "attachment; filename=symulacja-kredytu.pdf"},
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import Enum
|
||||
from typing import Literal
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class InstallmentType(str, Enum):
|
||||
equal = "equal"
|
||||
decreasing = "decreasing"
|
||||
|
||||
|
||||
class OverpaymentEffect(str, Enum):
|
||||
shorten = "shorten"
|
||||
lower_payment = "lower_payment"
|
||||
|
||||
|
||||
class RateChange(BaseModel):
|
||||
month: int = Field(ge=1, description="Miesiac od startu kredytu")
|
||||
annual_rate: float = Field(ge=0, le=30, description="Roczne oprocentowanie procentowo")
|
||||
|
||||
|
||||
class Overpayment(BaseModel):
|
||||
month: int = Field(ge=1)
|
||||
amount: float = Field(gt=0)
|
||||
repeat: Literal["once", "monthly", "yearly"] = "once"
|
||||
until_month: int | None = Field(default=None, ge=1)
|
||||
|
||||
|
||||
class SimulationRequest(BaseModel):
|
||||
principal: float = Field(gt=0)
|
||||
years: int = Field(ge=1, le=50)
|
||||
margin: float = Field(ge=0, le=20, default=2.0)
|
||||
base_rate: float = Field(ge=0, le=30, default=5.75)
|
||||
installment_type: InstallmentType = InstallmentType.equal
|
||||
overpayment_effect: OverpaymentEffect = OverpaymentEffect.shorten
|
||||
rate_changes: list[RateChange] = Field(default_factory=list)
|
||||
overpayments: list[Overpayment] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ScheduleRow(BaseModel):
|
||||
month: int
|
||||
rate: float
|
||||
payment: float
|
||||
principal_part: float
|
||||
interest_part: float
|
||||
overpayment: float
|
||||
remaining: float
|
||||
|
||||
|
||||
class Summary(BaseModel):
|
||||
months: int
|
||||
total_paid: float
|
||||
total_interest: float
|
||||
total_overpayment: float
|
||||
interest_saved: float
|
||||
months_saved: int
|
||||
baseline_interest: float
|
||||
baseline_months: int
|
||||
average_payment: float
|
||||
max_payment: float
|
||||
|
||||
|
||||
class SimulationResponse(BaseModel):
|
||||
schedule: list[ScheduleRow]
|
||||
summary: Summary
|
||||
@@ -0,0 +1,129 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from math import pow
|
||||
from .models import SimulationRequest, ScheduleRow, Summary, SimulationResponse, InstallmentType, OverpaymentEffect
|
||||
|
||||
|
||||
def _round_money(value: float) -> float:
|
||||
return round(max(value, 0.0) + 1e-9, 2)
|
||||
|
||||
|
||||
def _rate_for_month(req: SimulationRequest, month: int) -> float:
|
||||
changes = sorted(req.rate_changes, key=lambda x: x.month)
|
||||
rate = req.base_rate + req.margin
|
||||
for change in changes:
|
||||
if month >= change.month:
|
||||
rate = change.annual_rate
|
||||
else:
|
||||
break
|
||||
return rate
|
||||
|
||||
|
||||
def _monthly_payment(balance: float, monthly_rate: float, months_left: int) -> float:
|
||||
if months_left <= 0:
|
||||
return balance
|
||||
if monthly_rate == 0:
|
||||
return balance / months_left
|
||||
factor = pow(1 + monthly_rate, months_left)
|
||||
return balance * monthly_rate * factor / (factor - 1)
|
||||
|
||||
|
||||
def _overpayment_for_month(req: SimulationRequest, month: int) -> float:
|
||||
total = 0.0
|
||||
for op in req.overpayments:
|
||||
until = op.until_month or month
|
||||
if op.repeat == "once" and month == op.month:
|
||||
total += op.amount
|
||||
elif op.repeat == "monthly" and month >= op.month and month <= until:
|
||||
total += op.amount
|
||||
elif op.repeat == "yearly" and month >= op.month and month <= until and (month - op.month) % 12 == 0:
|
||||
total += op.amount
|
||||
return total
|
||||
|
||||
|
||||
def _simulate_raw(req: SimulationRequest, include_overpayments: bool = True) -> list[ScheduleRow]:
|
||||
balance = float(req.principal)
|
||||
total_months = int(req.years * 12)
|
||||
rows: list[ScheduleRow] = []
|
||||
fixed_payment = None
|
||||
recalculation_month = 1
|
||||
month = 1
|
||||
|
||||
while balance > 0.005 and month <= total_months + 600:
|
||||
months_left = max(total_months - month + 1, 1)
|
||||
annual_rate = _rate_for_month(req, month)
|
||||
monthly_rate = annual_rate / 100 / 12
|
||||
interest = balance * monthly_rate
|
||||
|
||||
if req.installment_type == InstallmentType.equal:
|
||||
if fixed_payment is None or req.overpayment_effect == OverpaymentEffect.lower_payment:
|
||||
fixed_payment = _monthly_payment(balance, monthly_rate, months_left)
|
||||
payment = fixed_payment
|
||||
principal_part = max(payment - interest, 0.0)
|
||||
else:
|
||||
principal_base = req.principal / total_months
|
||||
if req.overpayment_effect == OverpaymentEffect.lower_payment:
|
||||
active_months_left = max(total_months - month + 1, 1)
|
||||
principal_base = balance / active_months_left
|
||||
payment = principal_base + interest
|
||||
principal_part = principal_base
|
||||
|
||||
if principal_part > balance:
|
||||
principal_part = balance
|
||||
payment = interest + principal_part
|
||||
|
||||
balance -= principal_part
|
||||
|
||||
overpayment = _overpayment_for_month(req, month) if include_overpayments else 0.0
|
||||
overpayment = min(overpayment, balance)
|
||||
balance -= overpayment
|
||||
|
||||
rows.append(ScheduleRow(
|
||||
month=month,
|
||||
rate=round(annual_rate, 4),
|
||||
payment=_round_money(payment),
|
||||
principal_part=_round_money(principal_part),
|
||||
interest_part=_round_money(interest),
|
||||
overpayment=_round_money(overpayment),
|
||||
remaining=_round_money(balance),
|
||||
))
|
||||
|
||||
if include_overpayments and overpayment > 0 and req.overpayment_effect == OverpaymentEffect.lower_payment:
|
||||
fixed_payment = None
|
||||
recalculation_month = month + 1
|
||||
|
||||
if include_overpayments and overpayment > 0 and req.overpayment_effect == OverpaymentEffect.shorten:
|
||||
# przy skroceniu okresu rata zostaje z grubsza taka sama; petla zakonczy sie szybciej
|
||||
pass
|
||||
|
||||
month += 1
|
||||
if month - recalculation_month > 1200:
|
||||
break
|
||||
|
||||
return rows
|
||||
|
||||
|
||||
def simulate(req: SimulationRequest) -> SimulationResponse:
|
||||
actual = _simulate_raw(req, include_overpayments=True)
|
||||
baseline_req = req.model_copy(update={"overpayments": []})
|
||||
baseline = _simulate_raw(baseline_req, include_overpayments=False)
|
||||
|
||||
total_interest = sum(r.interest_part for r in actual)
|
||||
total_overpayment = sum(r.overpayment for r in actual)
|
||||
total_paid = sum(r.payment + r.overpayment for r in actual)
|
||||
baseline_interest = sum(r.interest_part for r in baseline)
|
||||
payments = [r.payment for r in actual]
|
||||
|
||||
summary = Summary(
|
||||
months=len(actual),
|
||||
total_paid=_round_money(total_paid),
|
||||
total_interest=_round_money(total_interest),
|
||||
total_overpayment=_round_money(total_overpayment),
|
||||
interest_saved=_round_money(baseline_interest - total_interest),
|
||||
months_saved=max(len(baseline) - len(actual), 0),
|
||||
baseline_interest=_round_money(baseline_interest),
|
||||
baseline_months=len(baseline),
|
||||
average_payment=_round_money(sum(payments) / len(payments)) if payments else 0,
|
||||
max_payment=_round_money(max(payments)) if payments else 0,
|
||||
)
|
||||
return SimulationResponse(schedule=actual, summary=summary)
|
||||
@@ -0,0 +1,189 @@
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const money = (v) => new Intl.NumberFormat('pl-PL', { style: 'currency', currency: 'PLN', maximumFractionDigits: 0 }).format(v || 0);
|
||||
const num = (id) => Number($(id).value || 0);
|
||||
|
||||
let lineChart, pieChart, barChart;
|
||||
let socket;
|
||||
let debounceTimer;
|
||||
let lastRequest = null;
|
||||
window.lastSimulationData = null;
|
||||
|
||||
function setTheme(theme) {
|
||||
document.body.dataset.theme = theme;
|
||||
localStorage.setItem('mortgage-theme', theme);
|
||||
const btn = $('themeToggle');
|
||||
if (btn) btn.textContent = theme === 'dark' ? '☀️ Jasny' : '🌙 Ciemny';
|
||||
}
|
||||
|
||||
function initTheme() {
|
||||
const saved = localStorage.getItem('mortgage-theme') || 'dark';
|
||||
setTheme(saved);
|
||||
$('themeToggle').onclick = () => setTheme(document.body.dataset.theme === 'dark' ? 'light' : 'dark');
|
||||
}
|
||||
|
||||
function connectWs() {
|
||||
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
socket = new WebSocket(`${proto}://${location.host}/ws/simulate`);
|
||||
socket.onopen = () => recalc();
|
||||
socket.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data.error) return console.error(data.error);
|
||||
render(data);
|
||||
};
|
||||
socket.onclose = () => setTimeout(connectWs, 1200);
|
||||
}
|
||||
|
||||
function buildRequest() {
|
||||
const rateChanges = [...document.querySelectorAll('#rateChanges .dynamic-row')].map(row => ({
|
||||
month: Number(row.querySelector('[data-field="month"]').value || 1),
|
||||
annual_rate: Number(row.querySelector('[data-field="rate"]').value || 0)
|
||||
})).filter(x => x.month > 0 && x.annual_rate >= 0);
|
||||
|
||||
const overpayments = [...document.querySelectorAll('#overpayments .dynamic-row')].map(row => ({
|
||||
month: Number(row.querySelector('[data-field="month"]').value || 1),
|
||||
amount: Number(row.querySelector('[data-field="amount"]').value || 0),
|
||||
repeat: row.querySelector('[data-field="repeat"]').value,
|
||||
until_month: Number(row.querySelector('[data-field="until"]').value || 0) || null
|
||||
})).filter(x => x.month > 0 && x.amount > 0);
|
||||
|
||||
return {
|
||||
principal: num('principal'),
|
||||
years: num('years'),
|
||||
margin: num('margin'),
|
||||
base_rate: num('baseRate'),
|
||||
installment_type: $('installmentType').value,
|
||||
overpayment_effect: $('overpaymentEffect').value,
|
||||
rate_changes: rateChanges,
|
||||
overpayments: overpayments
|
||||
};
|
||||
}
|
||||
|
||||
function recalc() {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(() => {
|
||||
lastRequest = buildRequest();
|
||||
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify(lastRequest));
|
||||
}, 120);
|
||||
}
|
||||
|
||||
function addRateRow(month = 13, rate = 7.0) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'dynamic-row';
|
||||
div.innerHTML = `
|
||||
<div><label class="form-label">Od miesiąca</label><input data-field="month" type="number" min="1" class="form-control form-control-sm" value="${month}"></div>
|
||||
<div><label class="form-label">Oproc. roczne %</label><input data-field="rate" type="number" step="0.01" class="form-control form-control-sm" value="${rate}"></div>
|
||||
<button class="btn btn-sm btn-outline-danger" type="button">Usuń</button>`;
|
||||
div.querySelector('button').onclick = () => { div.remove(); recalc(); };
|
||||
div.querySelectorAll('input').forEach(x => x.addEventListener('input', recalc));
|
||||
$('rateChanges').appendChild(div);
|
||||
recalc();
|
||||
}
|
||||
|
||||
function addOverpaymentRow(month = 12, amount = 10000, repeat = 'once', until = '') {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'dynamic-row overpay';
|
||||
div.innerHTML = `
|
||||
<div><label class="form-label">Miesiąc</label><input data-field="month" type="number" min="1" class="form-control form-control-sm" value="${month}"></div>
|
||||
<div><label class="form-label">Kwota</label><input data-field="amount" type="number" min="1" class="form-control form-control-sm" value="${amount}"></div>
|
||||
<div><label class="form-label">Powtarzaj</label><select data-field="repeat" class="form-select form-select-sm"><option value="once">raz</option><option value="monthly">co miesiąc</option><option value="yearly">co rok</option></select></div>
|
||||
<div><label class="form-label">Do mies.</label><input data-field="until" type="number" min="1" class="form-control form-control-sm" value="${until}"></div>
|
||||
<button class="btn btn-sm btn-outline-danger" type="button">Usuń</button>`;
|
||||
div.querySelector('[data-field="repeat"]').value = repeat;
|
||||
div.querySelector('button').onclick = () => { div.remove(); recalc(); };
|
||||
div.querySelectorAll('input,select').forEach(x => x.addEventListener('input', recalc));
|
||||
$('overpayments').appendChild(div);
|
||||
recalc();
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
window.lastSimulationData = data;
|
||||
const s = data.summary;
|
||||
$('summaryCards').innerHTML = [
|
||||
['Odsetki', money(s.total_interest)],
|
||||
['Oszczędność', money(s.interest_saved)],
|
||||
['Nadpłaty', money(s.total_overpayment)],
|
||||
['Okres', `${s.months} mies. / ${Math.ceil(s.months / 12)} lat`],
|
||||
['Skrócenie', `${s.months_saved} mies.`],
|
||||
['Średnia rata', money(s.average_payment)]
|
||||
].map(([label, value]) => `<div class="col-6 col-md-4 col-xxl-2"><div class="card border-0 shadow-sm stat-card"><div class="card-body py-3"><div class="stat-label">${label}</div><div class="stat-value">${value}</div></div></div></div>`).join('');
|
||||
|
||||
$('resultText').textContent = `Dzięki nadpłatom w wysokości ${money(s.total_overpayment)} oszczędzasz około ${money(s.interest_saved)} na odsetkach. Kredyt kończy się po ${s.months} miesiącach zamiast ${s.baseline_months}. Łącznie płacisz ${money(s.total_paid)}, z czego odsetki to ${money(s.total_interest)}.`;
|
||||
|
||||
$('scheduleTable').innerHTML = data.schedule.slice(0, 180).map(r => `<tr><td>${r.month}</td><td>${r.rate.toFixed(2)}%</td><td>${money(r.payment)}</td><td>${money(r.principal_part)}</td><td>${money(r.interest_part)}</td><td>${money(r.overpayment)}</td><td>${money(r.remaining)}</td></tr>`).join('');
|
||||
|
||||
renderCharts(data);
|
||||
}
|
||||
|
||||
function renderCharts(data) {
|
||||
if (!data) return;
|
||||
const labels = data.schedule.map(r => r.month);
|
||||
const balance = data.schedule.map(r => r.remaining);
|
||||
const payment = data.schedule.map(r => r.payment);
|
||||
const yearly = new Map();
|
||||
data.schedule.forEach(r => {
|
||||
const year = Math.ceil(r.month / 12);
|
||||
yearly.set(year, (yearly.get(year) || 0) + r.interest_part);
|
||||
});
|
||||
|
||||
lineChart?.destroy();
|
||||
lineChart = new Chart($('lineChart'), {
|
||||
type: 'line',
|
||||
data: { labels, datasets: [{ label: 'Saldo', data: balance, yAxisID: 'y' }, { label: 'Rata', data: payment, yAxisID: 'y1' }] },
|
||||
options: { responsive: true, maintainAspectRatio: false, interaction: { mode: 'index', intersect: false }, scales: { y: { beginAtZero: true }, y1: { position: 'right', beginAtZero: true, grid: { drawOnChartArea: false } } } }
|
||||
});
|
||||
|
||||
pieChart?.destroy();
|
||||
pieChart = new Chart($('pieChart'), {
|
||||
type: 'pie',
|
||||
data: { labels: ['Kapitał', 'Odsetki', 'Nadpłaty'], datasets: [{ data: [lastRequest.principal, data.summary.total_interest, data.summary.total_overpayment] }] },
|
||||
options: { responsive: true, maintainAspectRatio: false }
|
||||
});
|
||||
|
||||
barChart?.destroy();
|
||||
barChart = new Chart($('barChart'), {
|
||||
type: 'bar',
|
||||
data: { labels: [...yearly.keys()].map(x => `Rok ${x}`), datasets: [{ label: 'Odsetki', data: [...yearly.values()] }] },
|
||||
options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true } } }
|
||||
});
|
||||
}
|
||||
|
||||
async function download(endpoint, filename) {
|
||||
const res = await fetch(endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(buildRequest()) });
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function loadNbp() {
|
||||
const btn = $('loadNbp');
|
||||
const old = btn.textContent;
|
||||
btn.textContent = 'Pobieram...';
|
||||
try {
|
||||
const res = await fetch('/api/rate/nbp');
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.error) throw new Error(data.error || 'Błąd pobierania');
|
||||
$('baseRate').value = data.rate;
|
||||
recalc();
|
||||
btn.textContent = `NBP: ${data.rate}%`;
|
||||
setTimeout(() => btn.textContent = old, 2500);
|
||||
} catch (e) {
|
||||
alert(`Nie udało się pobrać stopy NBP: ${e.message}`);
|
||||
btn.textContent = old;
|
||||
}
|
||||
}
|
||||
|
||||
['principal','years','baseRate','margin','installmentType','overpaymentEffect'].forEach(id => $(id).addEventListener('input', recalc));
|
||||
$('addRate').onclick = () => addRateRow();
|
||||
$('addOverpayment').onclick = () => addOverpaymentRow();
|
||||
$('exportCsv').onclick = () => download('/api/export/csv', 'symulacja-kredytu.csv');
|
||||
$('exportPdf').onclick = () => download('/api/export/pdf', 'symulacja-kredytu.pdf');
|
||||
$('loadNbp').onclick = loadNbp;
|
||||
initTheme();
|
||||
|
||||
addOverpaymentRow(24, 20000, 'once', '');
|
||||
addOverpaymentRow(36, 500, 'monthly', 120);
|
||||
connectWs();
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,136 @@
|
||||
<!doctype html>
|
||||
<html lang="pl">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Symulator kredytu hipotecznego</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link href="/static/styles.css" rel="stylesheet">
|
||||
</head>
|
||||
<body data-theme="dark">
|
||||
<main class="container-fluid app-shell py-3">
|
||||
<header class="hero card border-0 shadow-sm mb-3">
|
||||
<div class="card-body d-flex flex-wrap align-items-center justify-content-between gap-3">
|
||||
<div>
|
||||
<h1 class="h3 mb-1">Symulator kredytu hipotecznego</h1>
|
||||
<p class="text-muted mb-0">@linuiarz.pl</p>
|
||||
</div>
|
||||
<div class="d-flex gap-2 flex-wrap justify-content-end">
|
||||
<button id="themeToggle" class="btn btn-outline-secondary btn-sm" type="button" aria-label="Przełącz motyw">☀️ Jasny</button>
|
||||
<button id="loadNbp" class="btn btn-outline-primary btn-sm">Pobierz stopę NBP</button>
|
||||
<button id="exportCsv" class="btn btn-outline-secondary btn-sm">CSV</button>
|
||||
<button id="exportPdf" class="btn btn-primary btn-sm">PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="row g-3">
|
||||
<section class="col-12 col-xl-4">
|
||||
<div class="card shadow-sm border-0 sticky-xl-top slim-card">
|
||||
<div class="card-body">
|
||||
<h2 class="h5 mb-3">Parametry</h2>
|
||||
<div class="row g-2">
|
||||
<div class="col-6">
|
||||
<label class="form-label">Kwota kredytu</label>
|
||||
<input id="principal" type="number" class="form-control form-control-sm" value="600000" min="1">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Okres lat</label>
|
||||
<input id="years" type="number" class="form-control form-control-sm" value="25" min="1" max="50">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Stopa bazowa %</label>
|
||||
<input id="baseRate" type="number" step="0.01" class="form-control form-control-sm" value="5.75">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Marża %</label>
|
||||
<input id="margin" type="number" step="0.01" class="form-control form-control-sm" value="2.00">
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Typ rat</label>
|
||||
<select id="installmentType" class="form-select form-select-sm">
|
||||
<option value="equal">Równe</option>
|
||||
<option value="decreasing">Malejące</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label class="form-label">Efekt nadpłat</label>
|
||||
<select id="overpaymentEffect" class="form-select form-select-sm">
|
||||
<option value="shorten">Skrócenie okresu</option>
|
||||
<option value="lower_payment">Zmniejszenie raty</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h3 class="h6 mb-0">Zmiany oprocentowania</h3>
|
||||
<button id="addRate" class="btn btn-sm btn-outline-primary">+</button>
|
||||
</div>
|
||||
<div id="rateChanges" class="stack"></div>
|
||||
|
||||
<hr>
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h3 class="h6 mb-0">Nadpłaty</h3>
|
||||
<button id="addOverpayment" class="btn btn-sm btn-outline-primary">+</button>
|
||||
</div>
|
||||
<div id="overpayments" class="stack"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="col-12 col-xl-8">
|
||||
<div class="row g-3 mb-3" id="summaryCards"></div>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-lg-8">
|
||||
<div class="card shadow-sm border-0 chart-card">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">Saldo i rata w czasie</h2>
|
||||
<canvas id="lineChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-lg-4">
|
||||
<div class="card shadow-sm border-0 chart-card">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">Struktura kosztów</h2>
|
||||
<canvas id="pieChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm border-0 chart-card-sm">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">Odsetki rocznie</h2>
|
||||
<canvas id="barChart"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body">
|
||||
<h2 class="h6">Opis wyniku</h2>
|
||||
<p id="resultText" class="mb-0 text-muted"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm border-0">
|
||||
<div class="card-body table-responsive">
|
||||
<h2 class="h6">Harmonogram</h2>
|
||||
<table class="table table-sm align-middle mb-0">
|
||||
<thead><tr><th>Mies.</th><th>Oproc.</th><th>Rata</th><th>Kapitał</th><th>Odsetki</th><th>Nadpłata</th><th>Saldo</th></tr></thead>
|
||||
<tbody id="scheduleTable"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,117 @@
|
||||
@font-face {
|
||||
font-family: "AppSans";
|
||||
src: url("/static/fonts/DejaVuSans.ttf") format("truetype");
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "AppSans";
|
||||
src: url("/static/fonts/DejaVuSans-Bold.ttf") format("truetype");
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg: #f4f7fb;
|
||||
--text: #1e2a36;
|
||||
--card: #ffffff;
|
||||
--muted: #6c7684;
|
||||
--border: #edf1f7;
|
||||
--row: #f8fafc;
|
||||
--input-bg: #ffffff;
|
||||
--input-text: #1e2a36;
|
||||
--table-border: #e7edf5;
|
||||
}
|
||||
|
||||
body[data-theme="dark"] {
|
||||
--bg: #05070d;
|
||||
--text: #e5e7eb;
|
||||
--card: #0d1422;
|
||||
--muted: #9aa7bb;
|
||||
--border: #10192a;
|
||||
--row: #111a2b;
|
||||
--input-bg: #070b13;
|
||||
--input-text: #e5e7eb;
|
||||
--table-border: #3b4658;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "AppSans", system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.card,
|
||||
.hero,
|
||||
.stat-card {
|
||||
background: var(--card);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border) !important;
|
||||
}
|
||||
|
||||
.text-muted,
|
||||
.form-label,
|
||||
.stat-label,
|
||||
#resultText {
|
||||
color: var(--muted) !important;
|
||||
}
|
||||
|
||||
.form-control,
|
||||
.form-select {
|
||||
background-color: var(--input-bg);
|
||||
color: var(--input-text);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.form-control:focus,
|
||||
.form-select:focus {
|
||||
background-color: var(--input-bg);
|
||||
color: var(--input-text);
|
||||
}
|
||||
|
||||
.form-control::placeholder { color: var(--muted); }
|
||||
|
||||
.table {
|
||||
color: var(--text);
|
||||
--bs-table-color: var(--text);
|
||||
--bs-table-bg: transparent;
|
||||
--bs-table-border-color: var(--table-border);
|
||||
}
|
||||
|
||||
.app-shell { max-width: 1680px; }
|
||||
.hero { border-radius: 20px; }
|
||||
.slim-card { top: 1rem; border-radius: 18px; }
|
||||
.chart-card canvas { max-height: 330px; }
|
||||
.chart-card-sm canvas { max-height: 230px; }
|
||||
.form-label { font-size: .78rem; margin-bottom: .2rem; }
|
||||
.form-control, .form-select { border-radius: 10px; }
|
||||
.stack { display: grid; gap: .45rem; }
|
||||
.dynamic-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: .4rem;
|
||||
align-items: end;
|
||||
background: var(--row);
|
||||
border: 1px solid var(--border);
|
||||
padding: .55rem;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.dynamic-row.overpay { grid-template-columns: .75fr 1fr .9fr .75fr auto; }
|
||||
.stat-card { border-radius: 16px; }
|
||||
.stat-value { font-size: 1.15rem; font-weight: 700; }
|
||||
.table { font-size: .84rem; }
|
||||
.btn { border-radius: 999px; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dynamic-row, .dynamic-row.overpay { grid-template-columns: 1fr 1fr; }
|
||||
.dynamic-row button { grid-column: span 2; }
|
||||
}
|
||||
|
||||
.card { box-shadow: 0 12px 34px rgba(0, 0, 0, .16) !important; }
|
||||
body[data-theme="dark"] .card { box-shadow: 0 14px 38px rgba(0, 0, 0, .34) !important; }
|
||||
body[data-theme="dark"] .btn-outline-secondary { border-color: var(--border); color: var(--text); }
|
||||
body[data-theme="dark"] .btn-outline-primary { border-color: #52637a; }
|
||||
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT_DIR = Path(__file__).resolve().parent
|
||||
REQUIREMENTS = ROOT_DIR / "requirements.txt"
|
||||
EXPORT_DIR = ROOT_DIR / "exports"
|
||||
|
||||
|
||||
def install_requirements() -> None:
|
||||
if not REQUIREMENTS.exists():
|
||||
raise SystemExit("Brak pliku requirements.txt")
|
||||
subprocess.check_call([sys.executable, "-m", "pip", "install", "-r", str(REQUIREMENTS)])
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Lokalne uruchamianie symulatora kredytu bez Dockera.")
|
||||
parser.add_argument("--host", default="127.0.0.1", help="Adres hosta, domyślnie 127.0.0.1")
|
||||
parser.add_argument("--port", type=int, default=8047, help="Port, domyślnie 8047")
|
||||
parser.add_argument("--no-reload", action="store_true", help="Wyłącz auto-reload podczas developmentu")
|
||||
parser.add_argument("--install", action="store_true", help="Zainstaluj zależności z requirements.txt przed startem")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
os.chdir(ROOT_DIR)
|
||||
EXPORT_DIR.mkdir(parents=True, exist_ok=True)
|
||||
os.environ.setdefault("MORTGAGE_EXPORT_DIR", str(EXPORT_DIR))
|
||||
|
||||
if args.install:
|
||||
install_requirements()
|
||||
|
||||
try:
|
||||
import uvicorn
|
||||
except ModuleNotFoundError:
|
||||
print("Brak zależności. Uruchom:")
|
||||
print(" python dev.py --install")
|
||||
print("albo:")
|
||||
print(" python -m pip install -r requirements.txt")
|
||||
raise SystemExit(1)
|
||||
|
||||
print(f"Start: http://{args.host}:{args.port}")
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
reload=not args.no_reload,
|
||||
app_dir=str(ROOT_DIR),
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,9 @@
|
||||
services:
|
||||
mortgage-simulator:
|
||||
build: .
|
||||
container_name: mortgage-simulator
|
||||
ports:
|
||||
- "8147:8000"
|
||||
volumes:
|
||||
- ./exports:/app/exports
|
||||
restart: unless-stopped
|
||||
@@ -0,0 +1,32 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
|
||||
# Virtualenv
|
||||
venv/
|
||||
.env
|
||||
.venv
|
||||
|
||||
# App data
|
||||
*.log
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
|
||||
# Tests / cache
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
|
||||
# Build
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
exports/*
|
||||
*.zip
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import sys
|
||||
import zipfile
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def run_git_command(args, repo_path: Path) -> bytes:
|
||||
result = subprocess.run(
|
||||
["git", *args],
|
||||
cwd=repo_path,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout
|
||||
|
||||
|
||||
def get_files_to_archive(repo_path: Path) -> list[str]:
|
||||
output = run_git_command(
|
||||
["ls-files", "--cached", "--others", "--exclude-standard", "-z"],
|
||||
repo_path,
|
||||
)
|
||||
files = output.decode("utf-8", errors="surrogateescape").split("\0")
|
||||
return [f for f in files if f]
|
||||
|
||||
|
||||
def make_zip(repo_path: Path, output_zip: Path) -> None:
|
||||
files = get_files_to_archive(repo_path)
|
||||
|
||||
output_zip = output_zip.resolve()
|
||||
if output_zip.exists():
|
||||
output_zip.unlink()
|
||||
|
||||
with zipfile.ZipFile(output_zip, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for rel_path in files:
|
||||
abs_path = repo_path / rel_path
|
||||
|
||||
if not abs_path.exists():
|
||||
continue
|
||||
|
||||
if abs_path.resolve() == output_zip:
|
||||
continue
|
||||
|
||||
zf.write(abs_path, arcname=rel_path)
|
||||
|
||||
print(f"Utworzono archiwum: {output_zip}")
|
||||
print(f"Added files: {len(files)}")
|
||||
|
||||
|
||||
def main():
|
||||
repo_path = Path.cwd()
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
output_zip = Path(sys.argv[1])
|
||||
else:
|
||||
output_zip = repo_path / f"{repo_path.name}.zip"
|
||||
|
||||
try:
|
||||
run_git_command(["rev-parse", "--show-toplevel"], repo_path)
|
||||
except subprocess.CalledProcessError:
|
||||
print("Error: this directory is not a Git repository.", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
make_zip(repo_path, output_zip)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Binary file not shown.
@@ -0,0 +1,7 @@
|
||||
fastapi==0.115.6
|
||||
uvicorn[standard]==0.34.0
|
||||
pydantic>=2.10.4
|
||||
httpx==0.28.1
|
||||
beautifulsoup4==4.12.3
|
||||
reportlab==4.2.5
|
||||
python-multipart==0.0.20
|
||||
Reference in New Issue
Block a user