zmiany
This commit is contained in:
+133
-32
@@ -9,6 +9,7 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from io import BytesIO
|
||||
from bs4 import BeautifulSoup
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Response
|
||||
from fastapi.responses import HTMLResponse, StreamingResponse, JSONResponse
|
||||
@@ -21,6 +22,8 @@ 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 openpyxl import load_workbook
|
||||
|
||||
|
||||
from .models import SimulationRequest
|
||||
from .simulator import simulate
|
||||
@@ -94,39 +97,107 @@ async def ws_simulate(websocket: WebSocket):
|
||||
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)
|
||||
"""
|
||||
Pobiera średnie oprocentowanie nowych kredytów mieszkaniowych PLN z XLSX NBP.
|
||||
Uwaga: to nie jest stopa referencyjna NBP, tylko średnie oprocentowanie kredytów.
|
||||
"""
|
||||
url = "https://static.nbp.pl/dane/statystyka/inne/stopy_proc_pl_srdW.xlsx"
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=20, follow_redirects=True) as client:
|
||||
resp = await client.get(
|
||||
url,
|
||||
headers={
|
||||
"User-Agent": "Mozilla/5.0 mortgage-simulator/1.0",
|
||||
"Accept": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,*/*",
|
||||
},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
wb = load_workbook(BytesIO(resp.content), data_only=True, read_only=True)
|
||||
ws = wb["4 OPN2PLN"]
|
||||
|
||||
target_row = None
|
||||
|
||||
for row in ws.iter_rows():
|
||||
values = [cell.value for cell in row]
|
||||
joined = " ".join(str(v).lower() for v in values if v is not None)
|
||||
|
||||
if "nieruchomości mieszkaniowe" in joined or "nieruchomosci mieszkaniowe" in joined:
|
||||
target_row = values
|
||||
break
|
||||
|
||||
if not target_row:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Nie znaleziono wiersza: na nieruchomości mieszkaniowe",
|
||||
"url": url,
|
||||
"sheet": "4 OPN2PLN",
|
||||
},
|
||||
status_code=502,
|
||||
)
|
||||
|
||||
# Daty są w wierszu 2, wartości w znalezionym wierszu.
|
||||
dates = [cell.value for cell in ws[2]]
|
||||
|
||||
latest_rate = None
|
||||
latest_date = None
|
||||
|
||||
for idx in range(len(target_row) - 1, -1, -1):
|
||||
value = target_row[idx]
|
||||
|
||||
if isinstance(value, (int, float)):
|
||||
latest_rate = float(value)
|
||||
latest_date_cell = dates[idx] if idx < len(dates) else None
|
||||
|
||||
if isinstance(latest_date_cell, datetime):
|
||||
latest_date = latest_date_cell.date().isoformat()
|
||||
elif latest_date_cell:
|
||||
latest_date = str(latest_date_cell)
|
||||
|
||||
break
|
||||
|
||||
if latest_rate is None:
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "Nie znaleziono wartości liczbowej oprocentowania kredytu mieszkaniowego",
|
||||
"url": url,
|
||||
"sheet": "4 OPN2PLN",
|
||||
},
|
||||
status_code=502,
|
||||
)
|
||||
|
||||
# W XLSX wartości są jako ułamek, np. 0.0591 = 5.91%.
|
||||
rate_percent = round(latest_rate * 100, 4)
|
||||
|
||||
return {
|
||||
"source": "NBP",
|
||||
"kind": "average_new_housing_loan_pln_rate",
|
||||
"label": "Średnie oprocentowanie nowych kredytów mieszkaniowych PLN",
|
||||
"url": url,
|
||||
"sheet": "4 OPN2PLN",
|
||||
"period": latest_date,
|
||||
"rate": rate_percent,
|
||||
"fetched_at": datetime.utcnow().isoformat() + "Z",
|
||||
}
|
||||
|
||||
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"])
|
||||
writer.writerow(["miesiac", "data_splaty", "dni", "oprocentowanie", "rata", "kapital", "odsetki", "nadplata", "prowizja_nadplaty", "saldo", "karencja", "odsetki_narastajaco", "koszt_narastajaco", "nadplaty_narastajaco"])
|
||||
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([row.month, row.due_date, row.days, row.rate, row.payment, row.principal_part, row.interest_part, row.overpayment, row.overpayment_fee, row.remaining, row.grace_type.value, row.cumulative_interest, row.cumulative_cost, row.cumulative_overpayment])
|
||||
writer.writerow([])
|
||||
writer.writerow(["Podsumowanie"])
|
||||
for key, value in result.summary.model_dump().items():
|
||||
@@ -278,6 +349,8 @@ def export_pdf(req: SimulationRequest):
|
||||
["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)],
|
||||
["Data startu", req.loan_start_date.isoformat(), "Dzien splaty", str(req.due_day)],
|
||||
["Przesuwaj dni wolne", "tak" if req.move_due_date_to_business_day else "nie", "Data konca", result.summary.payoff_date or "-"],
|
||||
]
|
||||
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([
|
||||
@@ -296,16 +369,17 @@ def export_pdf(req: SimulationRequest):
|
||||
["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)}"],
|
||||
["Suma nadplat", _money(s.total_overpayment), "Prowizje nadplat", _money(s.total_overpayment_fees)],
|
||||
["Srednia / maks. rata", f"{_money(s.average_payment)} / {_money(s.max_payment)}", "Data splaty", s.payoff_date or "-"],
|
||||
]
|
||||
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([
|
||||
story.append(Table(summary, colWidths=[4.3 * cm, 4.25 * cm, 3.7 * cm, 4.35 * 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),
|
||||
("FONTSIZE", (0, 0), (-1, -1), 7.0),
|
||||
("VALIGN", (0, 0), (-1, -1), "TOP"),
|
||||
])))
|
||||
story.append(Spacer(1, 0.35 * cm))
|
||||
@@ -344,34 +418,61 @@ def export_pdf(req: SimulationRequest):
|
||||
story.append(Spacer(1, 0.35 * cm))
|
||||
|
||||
story.append(Paragraph("Nadplaty", styles["Heading2"]))
|
||||
over_rows = [["Miesiac", "Kwota", "Powtarzanie", "Do miesiaca"]]
|
||||
over_rows = [["Miesiac", "Kwota", "Prowizja", "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 "-")])
|
||||
over_rows.append([str(op.month), _money(op.amount), _pct(op.commission_percent), _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([
|
||||
over_rows.append(["-", "-", "-", "-", "-"])
|
||||
story.append(Table(over_rows, repeatRows=1, colWidths=[2.4 * cm, 3.4 * cm, 2.4 * cm, 3.4 * cm, 3.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(Spacer(1, 0.35 * cm))
|
||||
|
||||
story.append(Paragraph("Kredyt historyczny", styles["Heading2"]))
|
||||
hist_rows = [["Miesiac", "Oprocentowanie", "Karencja", "Nadplata", "Prowizja", "Opis"]]
|
||||
if req.historical_months:
|
||||
for item in sorted(req.historical_months, key=lambda x: x.month):
|
||||
hist_rows.append([
|
||||
str(item.month),
|
||||
_pct(item.annual_rate) if item.annual_rate is not None else "-",
|
||||
item.grace_type.value,
|
||||
_money(item.overpayment),
|
||||
_pct(item.overpayment_commission_percent),
|
||||
item.note or "-",
|
||||
])
|
||||
else:
|
||||
hist_rows.append(["-", "-", "-", "-", "-", "-"])
|
||||
story.append(Table(hist_rows, repeatRows=1, colWidths=[2 * cm, 2.6 * cm, 2.6 * cm, 2.8 * cm, 2.2 * cm, 4.5 * 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), 7.5),
|
||||
])))
|
||||
story.append(PageBreak())
|
||||
|
||||
story.append(Paragraph("Kompletny harmonogram wszystkich rat", styles["Heading2"]))
|
||||
schedule_rows = [["Mies.", "Oproc.", "Rata", "Kapital", "Odsetki", "Nadplata", "Saldo"]]
|
||||
schedule_rows = [["Mies.", "Data", "Dni", "Oproc.", "Rata", "Kapital", "Odsetki", "Nadplata", "Prow.", "Koszt nar.", "Saldo"]]
|
||||
for row in result.schedule:
|
||||
schedule_rows.append([
|
||||
row.month,
|
||||
row.due_date,
|
||||
row.days,
|
||||
_pct(row.rate),
|
||||
_money(row.payment),
|
||||
_money(row.principal_part),
|
||||
_money(row.interest_part),
|
||||
_money(row.overpayment),
|
||||
_money(row.overpayment_fee),
|
||||
_money(row.cumulative_cost),
|
||||
_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 = Table(schedule_rows, repeatRows=1, colWidths=[0.9 * cm, 1.75 * cm, 0.75 * cm, 1.15 * cm, 1.85 * cm, 1.85 * cm, 1.85 * cm, 1.85 * cm, 1.5 * cm, 2.0 * cm, 2.05 * cm])
|
||||
table.setStyle(TableStyle([
|
||||
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e5e7eb")),
|
||||
("TEXTCOLOR", (0, 0), (-1, 0), colors.HexColor("#111827")),
|
||||
|
||||
Reference in New Issue
Block a user