Pomoc z rysunkami ekranow, kafelki na cala szerokosc, kalkulator na danych GUS i Eurostatu
Deploy Polska Lokalnie / deploy (push) Successful in 22s

Pomoc i kontakt:
- kanaly kontaktu wypelniaja szerokosc panelu (auto-fit zamiast czterech kolumn)
- FAQ podzielone na cztery moduly, 21 pytan, ze schematycznymi rysunkami ekranow
  (inline SVG w helpIllustrations.tsx - nie zrzuty, wiec nie dezaktualizuja sie)
- menu konta nie przykleja sie juz nad trescia na telefonie i tablecie

Kafelki wyboru:
- kolumna nie schodzi ponizej czytelnej szerokosci, podpisy lamia sie miedzy slowami
  zamiast po literach (bylo widoczne na monitorach ok. 1440 px)

Kalkulator zdolnosci kredytowej:
- rodzaj oprocentowania decyduje o buforze: 2,5 p.p. przy stalym, 5 p.p. przy zmiennym
  (wczesniej zawsze 2,5 p.p., co zawyzalo zdolnosc przy zmiennej stopie o ok. 100 tys. zl)
- nowy modul market: przecietne wynagrodzenie z GUS BDL i stopa rynku pienieznego 3M
  z Eurostatu, odswiezane co tydzien, publiczny GET /api/market-indicators
- prog wyzszego DStI i podpowiadane oprocentowanie licza sie z tych wskaznikow,
  z podpisem zrodla i okresu; brak danych = wartosci zapasowe

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-25 12:13:10 +02:00
parent cf02f480ed
commit d9819d0488
13 changed files with 1494 additions and 60 deletions
@@ -55,6 +55,8 @@ public class SecurityConfig {
.requestMatchers(HttpMethod.GET, "/api/og/**").permitAll()
// Historia srednich cen z ogloszen - publiczna, jak same ogloszenia.
.requestMatchers(HttpMethod.GET, "/api/price-history").permitAll()
// Wskazniki rynkowe kalkulatora zdolnosci - dane publiczne z GUS i Eurostatu.
.requestMatchers(HttpMethod.GET, "/api/market-indicators").permitAll()
.requestMatchers(HttpMethod.GET, "/api/articles", "/api/articles/**").permitAll()
.requestMatchers(HttpMethod.POST, "/api/articles").authenticated()
.requestMatchers(HttpMethod.GET, "/api/banners").permitAll()
@@ -0,0 +1,85 @@
package pl.polskalokalnie.market;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import java.math.BigDecimal;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Comparator;
import java.util.Map;
import org.springframework.stereotype.Component;
/**
* Stopa rynku pienieznego dla Polski z Eurostatu (zbior irt_st_m, seria IRT_M3 - stawka 3-miesieczna,
* czyli odpowiednik WIBOR 3M). Dane miesieczne, bez klucza dostepu.
*
* DLACZEGO NIE NBP: NBP publikuje srednie oprocentowanie nowych kredytow mieszkaniowych, ale pliki
* lezą pod adresami zmieniajacymi sie co miesiac, a strony nbp.pl sa chronione przed automatycznym
* pobieraniem (odpowiadaja strona bota zamiast trescia). Dziala tylko static.nbp.pl z plikiem cen
* mieszkan, ktory juz pobieramy w module historii cen.
*/
@Component
public class EurostatRateClient {
static final String URL = "https://ec.europa.eu/eurostat/api/dissemination/statistics/1.0/data/irt_st_m"
+ "?format=JSON&geo=PL&int_rt=IRT_M3&lastTimePeriod=6";
/** Wartosc stopy w procentach wraz z miesiacem, ktorego dotyczy ("2026-08"). */
public record Rate(BigDecimal percent, String period) {
}
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(15))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
private final ObjectMapper mapper = new ObjectMapper();
public Rate latestThreeMonthRate() throws IOException, InterruptedException {
HttpRequest request = HttpRequest.newBuilder(URI.create(URL))
.timeout(Duration.ofSeconds(30))
.header("Accept", "application/json")
.GET()
.build();
HttpResponse<String> response = http.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() != 200) {
throw new IOException("Eurostat odpowiedzial " + response.statusCode());
}
return parseLatest(mapper.readTree(response.body()));
}
/**
* Odpowiedz w formacie JSON-stat: wartosci leza w mapie "value" pod indeksem plaskim, a wymiar
* czasu ma wlasne indeksy. Przy jednej serii (geo=PL, int_rt=IRT_M3) indeks czasu jest zarazem
* indeksem wartosci, ale zamiast na tym polegac dobieramy wartosc po indeksie z wymiaru "time".
*/
static Rate parseLatest(JsonNode root) throws IOException {
JsonNode values = root.path("value");
JsonNode periods = root.path("dimension").path("time").path("category").path("index");
if (values.isMissingNode() || periods.isMissingNode() || periods.isEmpty()) {
throw new IOException("Eurostat: odpowiedz bez danych");
}
// Najpozniejszy miesiac z dostepna wartoscia - ostatnie okresy bywaja jeszcze puste.
Map.Entry<String, JsonNode> latest = null;
for (var it = periods.fields(); it.hasNext(); ) {
Map.Entry<String, JsonNode> entry = it.next();
if (values.has(String.valueOf(entry.getValue().asInt())) && (latest == null
|| Comparator.<String>naturalOrder().compare(entry.getKey(), latest.getKey()) > 0)) {
latest = entry;
}
}
if (latest == null) {
throw new IOException("Eurostat: brak wartosci dla ktoregokolwiek miesiaca");
}
BigDecimal percent = values.path(String.valueOf(latest.getValue().asInt())).decimalValue();
if (percent.signum() <= 0) {
throw new IOException("Eurostat: nieprawidlowa wartosc stopy " + percent);
}
return new Rate(percent, latest.getKey());
}
}
@@ -0,0 +1,92 @@
package pl.polskalokalnie.market;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;
import java.math.BigDecimal;
import java.time.Instant;
/**
* Pojedynczy wskaznik rynkowy pobierany z zewnatrz (przecietne wynagrodzenie, stopa rynku
* pieniężnego). Trzymamy ostatnia znana wartosc razem z okresem, ktorego dotyczy, i momentem
* pobrania - dzieki temu kalkulator moze pokazac zrodlo i date, a nieudany import niczego nie psuje.
*/
@Entity
@Table(name = "market_indicators")
public class MarketIndicator {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "indicator_key", nullable = false, unique = true, length = 40)
private String key;
@Column(nullable = false, precision = 12, scale = 4)
private BigDecimal value;
/** Okres, ktorego dotyczy wartosc, w formie podanej przez zrodlo: "2025" albo "2026-08". */
@Column(name = "as_of", nullable = false, length = 20)
private String asOf;
/** Nazwa zrodla pokazywana uzytkownikowi, np. "GUS, Bank Danych Lokalnych". */
@Column(nullable = false, length = 120)
private String source;
@Column(name = "fetched_at", nullable = false)
private Instant fetchedAt;
protected MarketIndicator() {
}
public MarketIndicator(String key, BigDecimal value, String asOf, String source, Instant fetchedAt) {
this.key = key;
this.value = value;
this.asOf = asOf;
this.source = source;
this.fetchedAt = fetchedAt;
}
public Long getId() {
return id;
}
public String getKey() {
return key;
}
public BigDecimal getValue() {
return value;
}
public void setValue(BigDecimal value) {
this.value = value;
}
public String getAsOf() {
return asOf;
}
public void setAsOf(String asOf) {
this.asOf = asOf;
}
public String getSource() {
return source;
}
public void setSource(String source) {
this.source = source;
}
public Instant getFetchedAt() {
return fetchedAt;
}
public void setFetchedAt(Instant fetchedAt) {
this.fetchedAt = fetchedAt;
}
}
@@ -0,0 +1,34 @@
package pl.polskalokalnie.market;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* Wskazniki rynkowe dla kalkulatora zdolnosci - publiczne, jak sam kalkulator.
* Odpowiedz niesie tez zrodlo i okres, zeby interfejs mogl je podpisac przy liczbach.
*/
@RestController
@RequestMapping("/api/market-indicators")
public class MarketIndicatorController {
public record IndicatorResponse(String key, BigDecimal value, String asOf, String source, Instant fetchedAt) {
}
private final MarketIndicatorService service;
public MarketIndicatorController(MarketIndicatorService service) {
this.service = service;
}
@GetMapping
public List<IndicatorResponse> list() {
return service.all().stream()
.map(indicator -> new IndicatorResponse(indicator.getKey(), indicator.getValue(),
indicator.getAsOf(), indicator.getSource(), indicator.getFetchedAt()))
.toList();
}
}
@@ -0,0 +1,9 @@
package pl.polskalokalnie.market;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
public interface MarketIndicatorRepository extends JpaRepository<MarketIndicator, Long> {
Optional<MarketIndicator> findByKey(String key);
}
@@ -0,0 +1,119 @@
package pl.polskalokalnie.market;
import java.io.IOException;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Optional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import pl.polskalokalnie.pricehistory.GusBdlClient;
/**
* Wskazniki rynkowe dla kalkulatora zdolnosci kredytowej - pobierane raz w tygodniu, tak jak ceny
* transakcyjne w module historii cen. Nieudane pobranie zostawia poprzednia wartosc; gdy zadnej
* jeszcze nie ma, kalkulator korzysta z wartosci zapasowych wpisanych w interfejsie.
*/
@Service
public class MarketIndicatorService {
/** Przecietne miesieczne wynagrodzenie brutto w Polsce (GUS). */
public static final String AVERAGE_WAGE = "average-wage-gross";
/** Stopa rynku pienieznego 3M dla Polski - odpowiednik WIBOR 3M (Eurostat). */
public static final String MONEY_MARKET_3M = "money-market-3m";
private static final Logger log = LoggerFactory.getLogger(MarketIndicatorService.class);
private final MarketIndicatorRepository repository;
private final GusBdlClient gusClient;
private final EurostatRateClient eurostatClient;
public MarketIndicatorService(MarketIndicatorRepository repository, GusBdlClient gusClient,
EurostatRateClient eurostatClient) {
this.repository = repository;
this.gusClient = gusClient;
this.eurostatClient = eurostatClient;
}
@Transactional(readOnly = true)
public List<MarketIndicator> all() {
return repository.findAll();
}
@Async
@EventListener(ApplicationReadyEvent.class)
public void refreshOnStartupIfStale() {
Instant weekAgo = Instant.now().minus(7, ChronoUnit.DAYS);
if (isStale(AVERAGE_WAGE, weekAgo)) {
refreshAverageWage();
}
if (isStale(MONEY_MARKET_3M, weekAgo)) {
refreshMoneyMarketRate();
}
}
@Scheduled(cron = "0 25 4 * * MON", zone = "Europe/Warsaw")
public void refreshWeekly() {
refreshAverageWage();
refreshMoneyMarketRate();
}
public boolean refreshAverageWage() {
try {
GusBdlClient.AnnualValue wage = gusClient.latestAverageWage();
save(AVERAGE_WAGE, wage.value(), String.valueOf(wage.year()), "GUS, Bank Danych Lokalnych");
log.info("Wskazniki: przecietne wynagrodzenie {} zl za {}", wage.value(), wage.year());
return true;
} catch (IOException | RuntimeException ex) {
log.warn("Wskazniki: nie udalo sie pobrac wynagrodzenia z GUS ({}) - zostaje poprzednia wartosc",
ex.getMessage());
return false;
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
return false;
}
}
public boolean refreshMoneyMarketRate() {
try {
EurostatRateClient.Rate rate = eurostatClient.latestThreeMonthRate();
save(MONEY_MARKET_3M, rate.percent(), rate.period(), "Eurostat, stopy rynku pieniężnego (irt_st_m)");
log.info("Wskazniki: stopa rynku pienieznego 3M {}% za {}", rate.percent(), rate.period());
return true;
} catch (IOException | RuntimeException ex) {
log.warn("Wskazniki: nie udalo sie pobrac stopy z Eurostatu ({}) - zostaje poprzednia wartosc",
ex.getMessage());
return false;
} catch (InterruptedException ex) {
Thread.currentThread().interrupt();
return false;
}
}
private boolean isStale(String key, Instant threshold) {
return repository.findByKey(key).map(indicator -> indicator.getFetchedAt().isBefore(threshold)).orElse(true);
}
@Transactional
protected void save(String key, BigDecimal value, String asOf, String source) {
Optional<MarketIndicator> existing = repository.findByKey(key);
Instant now = Instant.now();
if (existing.isPresent()) {
MarketIndicator indicator = existing.get();
indicator.setValue(value);
indicator.setAsOf(asOf);
indicator.setSource(source);
indicator.setFetchedAt(now);
repository.save(indicator);
} else {
repository.save(new MarketIndicator(key, value, asOf, source, now));
}
}
}
@@ -49,6 +49,35 @@ public class GusBdlClient {
return matchCityUnit(results, city);
}
/** Przecietne miesieczne wynagrodzenie brutto w Polsce (temat P2497, zmienna 64428, dane roczne). */
static final long WAGE_VARIABLE_ID = 64428;
public record AnnualValue(int year, java.math.BigDecimal value) {
}
/** Najnowszy rok z wartoscia przecietnego wynagrodzenia brutto dla calej Polski. */
public AnnualValue latestAverageWage() throws IOException, InterruptedException {
JsonNode response = get("/data/by-variable/" + WAGE_VARIABLE_ID + "?format=json&unit-level=0&page-size=100");
return parseLatestAnnualValue(response);
}
static AnnualValue parseLatestAnnualValue(JsonNode response) throws IOException {
AnnualValue latest = null;
for (JsonNode result : response.path("results")) {
for (JsonNode value : result.path("values")) {
int year = value.path("year").asInt(0);
java.math.BigDecimal amount = value.path("val").decimalValue();
if (year > 0 && amount.signum() > 0 && (latest == null || year > latest.year())) {
latest = new AnnualValue(year, amount);
}
}
}
if (latest == null) {
throw new IOException("GUS BDL: brak wartosci przecietnego wynagrodzenia");
}
return latest;
}
public List<Variable> variables() throws IOException, InterruptedException {
return parseVariables(get("/variables?subject-id=" + SUBJECT + "&format=json&page-size=100").path("results"));
}
@@ -0,0 +1,53 @@
package pl.polskalokalnie.market;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import org.junit.jupiter.api.Test;
/** Parsowanie odpowiedzi JSON-stat z Eurostatu - ksztalt jak w zbiorze irt_st_m. */
class EurostatRateClientTest {
private final ObjectMapper mapper = new ObjectMapper();
private String response(String values, String periods) {
return "{\"value\":" + values + ",\"dimension\":{\"time\":{\"category\":{\"index\":" + periods + "}}}}";
}
@Test
void bierzeNajpozniejszyMiesiacZWartoscia() throws Exception {
String json = response("{\"0\":3.85,\"1\":3.82,\"2\":3.83}",
"{\"2026-06\":0,\"2026-07\":1,\"2026-08\":2}");
EurostatRateClient.Rate rate = EurostatRateClient.parseLatest(mapper.readTree(json));
assertThat(rate.period()).isEqualTo("2026-08");
assertThat(rate.percent()).isEqualByComparingTo("3.83");
}
@Test
void pomijaMiesiaceBezWartosci() throws Exception {
// Ostatni okres bywa jeszcze pusty - wtedy bierzemy poprzedni, a nie zglaszamy bledu.
String json = response("{\"0\":3.85,\"1\":3.82}",
"{\"2026-06\":0,\"2026-07\":1,\"2026-08\":2}");
EurostatRateClient.Rate rate = EurostatRateClient.parseLatest(mapper.readTree(json));
assertThat(rate.period()).isEqualTo("2026-07");
assertThat(rate.percent()).isEqualByComparingTo("3.82");
}
@Test
void odrzucaOdpowiedzBezDanych() throws Exception {
assertThatThrownBy(() -> EurostatRateClient.parseLatest(mapper.readTree("{}")))
.isInstanceOf(IOException.class);
assertThatThrownBy(() -> EurostatRateClient.parseLatest(
mapper.readTree(response("{}", "{\"2026-08\":0}"))))
.isInstanceOf(IOException.class);
}
@Test
void odrzucaNieprawidlowaStope() throws Exception {
String json = response("{\"0\":0}", "{\"2026-08\":0}");
assertThatThrownBy(() -> EurostatRateClient.parseLatest(mapper.readTree(json)))
.isInstanceOf(IOException.class);
}
}
@@ -0,0 +1,37 @@
package pl.polskalokalnie.pricehistory;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import org.junit.jupiter.api.Test;
/** Parsowanie rocznych wartosci z BDL - przecietne wynagrodzenie brutto dla calej Polski. */
class GusWageParsingTest {
private final ObjectMapper mapper = new ObjectMapper();
@Test
void bierzeNajnowszyRok() throws Exception {
String json = "{\"results\":[{\"id\":\"000000000000\",\"name\":\"POLSKA\",\"values\":["
+ "{\"year\":\"2023\",\"val\":7595.30},{\"year\":\"2025\",\"val\":9371.11},"
+ "{\"year\":\"2024\",\"val\":8630.27}]}]}";
GusBdlClient.AnnualValue wage = GusBdlClient.parseLatestAnnualValue(mapper.readTree(json));
assertThat(wage.year()).isEqualTo(2025);
assertThat(wage.value()).isEqualByComparingTo("9371.11");
}
@Test
void pomijaLataBezWartosci() throws Exception {
String json = "{\"results\":[{\"values\":[{\"year\":\"2024\",\"val\":8630.27},{\"year\":\"2025\",\"val\":0}]}]}";
GusBdlClient.AnnualValue wage = GusBdlClient.parseLatestAnnualValue(mapper.readTree(json));
assertThat(wage.year()).isEqualTo(2024);
}
@Test
void odrzucaPustaOdpowiedz() {
assertThatThrownBy(() -> GusBdlClient.parseLatestAnnualValue(mapper.readTree("{\"results\":[]}")))
.isInstanceOf(IOException.class);
}
}