diff --git a/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java b/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java index cb23376..0cb382e 100644 --- a/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java +++ b/backend/src/main/java/pl/polskalokalnie/auth/SecurityConfig.java @@ -49,6 +49,8 @@ public class SecurityConfig { .requestMatchers(HttpMethod.GET, "/api/listings", "/api/listings/**").permitAll() // Podglad ogloszenia dla crawlerow social media (Open Graph) - publiczny, tylko GET. .requestMatchers(HttpMethod.GET, "/api/og/**").permitAll() + // Historia srednich cen z ogloszen - publiczna, jak same ogloszenia. + .requestMatchers(HttpMethod.GET, "/api/price-history").permitAll() .requestMatchers(HttpMethod.GET, "/api/articles", "/api/articles/**").permitAll() .requestMatchers(HttpMethod.POST, "/api/articles").authenticated() .requestMatchers(HttpMethod.GET, "/api/banners").permitAll() diff --git a/backend/src/main/java/pl/polskalokalnie/pricehistory/ExternalPriceImportService.java b/backend/src/main/java/pl/polskalokalnie/pricehistory/ExternalPriceImportService.java new file mode 100644 index 0000000..1c9e648 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/pricehistory/ExternalPriceImportService.java @@ -0,0 +1,166 @@ +package pl.polskalokalnie.pricehistory; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +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.support.TransactionTemplate; +import pl.polskalokalnie.listing.ListingRepository; +import pl.polskalokalnie.listing.ListingStatus; +import pl.polskalokalnie.listing.PropertyListing; +import pl.polskalokalnie.pricehistory.ExternalQuarterPrice.Source; + +/** + * Import cen transakcyjnych z NBP (plik XLSX) i GUS (API BDL) - zrodla porownawcze dla historii cen. + * Raz w tygodniu oraz przy starcie, gdy dane sa starsze niz tydzien. Blad zrodla nie rusza starych danych. + */ +@Service +public class ExternalPriceImportService { + + static final String NBP_URL = "https://static.nbp.pl/dane/rynek-nieruchomosci/ceny_mieszkan.xlsx"; + private static final long MAX_DOWNLOAD_BYTES = 20L * 1024 * 1024; + private static final int GUS_FROM_YEAR = 2010; + private static final Logger log = LoggerFactory.getLogger(ExternalPriceImportService.class); + + private final ExternalQuarterPriceRepository repository; + private final ListingRepository listingRepository; + private final GusBdlClient gusClient; + private final TransactionTemplate transaction; + private final HttpClient http = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(15)) + .followRedirects(HttpClient.Redirect.NORMAL) + .build(); + + public ExternalPriceImportService(ExternalQuarterPriceRepository repository, ListingRepository listingRepository, + GusBdlClient gusClient, TransactionTemplate transaction) { + this.repository = repository; + this.listingRepository = listingRepository; + this.gusClient = gusClient; + this.transaction = transaction; + } + + @Async + @EventListener(ApplicationReadyEvent.class) + public void importOnStartupIfStale() { + Instant weekAgo = Instant.now().minus(7, ChronoUnit.DAYS); + if (repository.findLastImport(Source.NBP).map(last -> last.isBefore(weekAgo)).orElse(true)) { + importNbp(); + } + if (repository.findLastImport(Source.GUS).map(last -> last.isBefore(weekAgo)).orElse(true)) { + importGus(); + } + } + + @Scheduled(cron = "0 10 4 * * MON", zone = "Europe/Warsaw") + public void importWeekly() { + importNbp(); + importGus(); + } + + public int importNbp() { + try { + List rows; + try (InputStream xlsx = download(NBP_URL)) { + rows = NbpXlsxParser.parse(xlsx); + } + if (rows.isEmpty()) { + log.warn("NBP: w pliku nie znaleziono cen transakcyjnych - zostawiam poprzednie dane"); + return 0; + } + Instant now = Instant.now(); + transaction.executeWithoutResult(status -> { + repository.deleteBySource(Source.NBP); + repository.saveAll(rows.stream() + .map(row -> new ExternalQuarterPrice(Source.NBP, row.year(), row.quarter(), row.market(), + row.city(), row.pricePerM2(), now)) + .toList()); + }); + log.info("NBP: zaimportowano {} cen kwartalnych", rows.size()); + return rows.size(); + } catch (IOException | RuntimeException ex) { + log.warn("NBP: import nieudany ({}) - zostawiam poprzednie dane", ex.getMessage()); + return 0; + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + return 0; + } + } + + /** GUS dla miast z ogloszen i miast z danych NBP - tylko miasta na prawach powiatu maja wlasne dane. */ + public int importGus() { + int imported = 0; + try { + List variables = gusClient.variables(); + int toYear = PriceSnapshotService.today().getYear(); + for (String city : citiesToTrack()) { + try { + String unitId = gusClient.cityUnitId(city); + if (unitId != null) { + List values = gusClient.quarterlyPrices(unitId, variables, GUS_FROM_YEAR, toYear); + if (!values.isEmpty()) { + Instant now = Instant.now(); + transaction.executeWithoutResult(status -> { + repository.deleteBySourceAndCityKey(Source.GUS, PriceSnapshotService.key(city)); + repository.saveAll(values.stream() + .map(value -> new ExternalQuarterPrice(Source.GUS, value.year(), value.quarter(), + value.market(), city, value.pricePerM2(), now)) + .toList()); + }); + imported += values.size(); + } + } + // Publiczne API bez klucza ma limity zapytan - nie spieszymy sie. + Thread.sleep(700); + } catch (IOException | RuntimeException ex) { + log.warn("GUS: nie udalo sie pobrac danych dla {} ({})", city, ex.getMessage()); + } + } + log.info("GUS: zaimportowano {} cen kwartalnych", imported); + } catch (IOException | RuntimeException ex) { + log.warn("GUS: import nieudany ({}) - zostawiam poprzednie dane", ex.getMessage()); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + return imported; + } + + private List citiesToTrack() { + Map cities = new LinkedHashMap<>(); + repository.findAll().stream() + .filter(price -> price.getSource() == Source.NBP) + .forEach(price -> cities.putIfAbsent(price.getCityKey(), price.getCity())); + listingRepository.findAll().stream() + .filter(listing -> listing.getStatus() == ListingStatus.APPROVED) + .map(PropertyListing::getCity) + .filter(city -> city != null && !city.isBlank()) + .forEach(city -> cities.putIfAbsent(PriceSnapshotService.key(city), city.trim())); + return List.copyOf(cities.values()); + } + + private InputStream download(String url) throws IOException, InterruptedException { + HttpRequest request = HttpRequest.newBuilder(URI.create(url)).timeout(Duration.ofSeconds(60)).GET().build(); + HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofByteArray()); + if (response.statusCode() != 200) { + throw new IOException("serwer odpowiedzial " + response.statusCode()); + } + if (response.body().length > MAX_DOWNLOAD_BYTES) { + throw new IOException("plik jest za duzy"); + } + return new java.io.ByteArrayInputStream(response.body()); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/pricehistory/ExternalQuarterPrice.java b/backend/src/main/java/pl/polskalokalnie/pricehistory/ExternalQuarterPrice.java new file mode 100644 index 0000000..282d11d --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/pricehistory/ExternalQuarterPrice.java @@ -0,0 +1,97 @@ +package pl.polskalokalnie.pricehistory; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.Instant; + +/** + * Kwartalna srednia cena transakcyjna za m2 mieszkan z zewnetrznego zrodla: + * NBP (baza BaRN, 16 miast wojewodzkich i Gdynia) albo GUS (Bank Danych Lokalnych, miasta na prawach powiatu). + * Rynek: "Pierwotny", "Wtórny" albo "" (oba razem - tylko GUS). + */ +@Entity +@Table(name = "external_quarter_prices", + uniqueConstraints = @UniqueConstraint(columnNames = {"source", "year", "quarter", "market", "cityKey"})) +public class ExternalQuarterPrice { + + public enum Source { NBP, GUS } + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 8) + private Source source; + + @Column(nullable = false) + private int year; + + @Column(nullable = false) + private int quarter; + + @Column(nullable = false, length = 32) + private String market; + + @Column(nullable = false, length = 120) + private String cityKey; + + @Column(nullable = false, length = 120) + private String city; + + @Column(nullable = false) + private long pricePerM2; + + @Column(nullable = false) + private Instant importedAt; + + protected ExternalQuarterPrice() { + } + + public ExternalQuarterPrice(Source source, int year, int quarter, String market, String city, long pricePerM2, + Instant importedAt) { + this.source = source; + this.year = year; + this.quarter = quarter; + this.market = market; + this.city = city; + this.cityKey = PriceSnapshotService.key(city); + this.pricePerM2 = pricePerM2; + this.importedAt = importedAt; + } + + public Source getSource() { + return source; + } + + public int getYear() { + return year; + } + + public int getQuarter() { + return quarter; + } + + public String getMarket() { + return market; + } + + public String getCity() { + return city; + } + + public String getCityKey() { + return cityKey; + } + + public long getPricePerM2() { + return pricePerM2; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/pricehistory/ExternalQuarterPriceRepository.java b/backend/src/main/java/pl/polskalokalnie/pricehistory/ExternalQuarterPriceRepository.java new file mode 100644 index 0000000..db214df --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/pricehistory/ExternalQuarterPriceRepository.java @@ -0,0 +1,26 @@ +package pl.polskalokalnie.pricehistory; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import pl.polskalokalnie.pricehistory.ExternalQuarterPrice.Source; + +public interface ExternalQuarterPriceRepository extends JpaRepository { + + List findByCityKeyAndMarketOrderByYearAscQuarterAsc(String cityKey, String market); + + @Query("select max(p.importedAt) from ExternalQuarterPrice p where p.source = :source") + Optional findLastImport(@Param("source") Source source); + + @Modifying + @Query("delete from ExternalQuarterPrice p where p.source = :source") + void deleteBySource(@Param("source") Source source); + + @Modifying + @Query("delete from ExternalQuarterPrice p where p.source = :source and p.cityKey = :cityKey") + void deleteBySourceAndCityKey(@Param("source") Source source, @Param("cityKey") String cityKey); +} diff --git a/backend/src/main/java/pl/polskalokalnie/pricehistory/GusBdlClient.java b/backend/src/main/java/pl/polskalokalnie/pricehistory/GusBdlClient.java new file mode 100644 index 0000000..f769260 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/pricehistory/GusBdlClient.java @@ -0,0 +1,134 @@ +package pl.polskalokalnie.pricehistory; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.io.IOException; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.text.Normalizer; +import java.time.Duration; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.springframework.stereotype.Component; + +/** + * Publiczne API GUS - Bank Danych Lokalnych (bdl.stat.gov.pl, bez klucza). + * Temat P3794: srednia cena za 1 m2 lokali mieszkalnych sprzedanych w ramach transakcji rynkowych + * (dane kwartalne), poziom powiatu - miasta na prawach powiatu maja wlasne dane. + */ +@Component +public class GusBdlClient { + + static final String SUBJECT = "P3794"; + private static final String API = "https://bdl.stat.gov.pl/api/v1"; + private static final Pattern QUARTER = Pattern.compile("^(\\d)\\s*kwarta"); + + /** Zmienna BDL: kwartal i rynek ("" = oba rynki razem). */ + public record Variable(long id, int quarter, String market) { + } + + public record QuarterValue(int year, int quarter, String market, long pricePerM2) { + } + + private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(15)).build(); + private final ObjectMapper mapper = new ObjectMapper(); + + /** Id jednostki "Powiat m. " / "Powiat m.st. " albo null, gdy miasto nie jest na prawach powiatu. */ + public String cityUnitId(String city) throws IOException, InterruptedException { + JsonNode results = get("/units/search?level=5&format=json&page-size=20&name=" + + URLEncoder.encode(city, StandardCharsets.UTF_8)).path("results"); + return matchCityUnit(results, city); + } + + public List variables() throws IOException, InterruptedException { + return parseVariables(get("/variables?subject-id=" + SUBJECT + "&format=json&page-size=100").path("results")); + } + + public List quarterlyPrices(String unitId, List variables, int fromYear, int toYear) + throws IOException, InterruptedException { + StringBuilder query = new StringBuilder("/data/by-unit/").append(unitId).append("?format=json"); + variables.forEach(variable -> query.append("&var-id=").append(variable.id())); + for (int year = fromYear; year <= toYear; year++) { + query.append("&year=").append(year); + } + return parseUnitData(get(query.toString()), variables); + } + + static String matchCityUnit(JsonNode results, String city) { + String wanted = normalize(city); + for (JsonNode unit : results) { + String name = normalize(unit.path("name").asText()); + // "Powiat m. Kraków", "Powiat m. st. Warszawa" - bez powiatow ziemskich wokol miasta. + if (name.equals("powiat m. " + wanted) || name.equals("powiat m. st. " + wanted) + || name.equals("powiat m.st. " + wanted)) { + return unit.path("id").asText(); + } + } + return null; + } + + // Tylko "ogolem" wg powierzchni lokalu - dla kazdego kwartalu i rynku jedna wartosc. + static List parseVariables(JsonNode results) { + List variables = new ArrayList<>(); + for (JsonNode variable : results) { + Matcher quarter = QUARTER.matcher(variable.path("n1").asText()); + String market = switch (normalize(variable.path("n2").asText())) { + case "ogółem" -> ""; + case "rynek pierwotny" -> "Pierwotny"; + case "rynek wtórny" -> "Wtórny"; + default -> null; + }; + if (quarter.find() && market != null && "ogółem".equals(normalize(variable.path("n3").asText()))) { + variables.add(new Variable(variable.path("id").asLong(), Integer.parseInt(quarter.group(1)), market)); + } + } + return variables; + } + + static List parseUnitData(JsonNode response, List variables) { + Map byId = new LinkedHashMap<>(); + variables.forEach(variable -> byId.put(variable.id(), variable)); + List values = new ArrayList<>(); + for (JsonNode result : response.path("results")) { + Variable variable = byId.get(result.path("id").asLong()); + if (variable == null) { + continue; + } + for (JsonNode value : result.path("values")) { + double price = value.path("val").asDouble(0); + int year = value.path("year").asInt(0); + if (price > 0 && year > 0) { + values.add(new QuarterValue(year, variable.quarter(), variable.market(), Math.round(price))); + } + } + } + return values; + } + + private JsonNode get(String path) throws IOException, InterruptedException { + HttpRequest request = HttpRequest.newBuilder(URI.create(API + path)) + .timeout(Duration.ofSeconds(30)) + .header("Accept", "application/json") + .GET() + .build(); + HttpResponse response = http.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + if (response.statusCode() != 200) { + throw new IOException("GUS BDL odpowiedzial " + response.statusCode() + " dla " + path); + } + return mapper.readTree(response.body()); + } + + private static String normalize(String value) { + return Normalizer.normalize(value == null ? "" : value.trim(), Normalizer.Form.NFC) + .toLowerCase(Locale.ROOT).replaceAll("\\s+", " "); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/pricehistory/NbpXlsxParser.java b/backend/src/main/java/pl/polskalokalnie/pricehistory/NbpXlsxParser.java new file mode 100644 index 0000000..02acff9 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/pricehistory/NbpXlsxParser.java @@ -0,0 +1,256 @@ +package pl.polskalokalnie.pricehistory; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import javax.xml.XMLConstants; +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +/** + * Odczyt cen transakcyjnych z pliku NBP ceny_mieszkan.xlsx bez zewnetrznych bibliotek (XLSX to ZIP z XML). + * Z arkuszy "Rynek pierwotny" i "Rynek wtórny" bierze blok "Ceny transakcyjne": kolumne kwartalu + * ("II 2026") i kolumny miast; pomija srednie zbiorcze ("7 miast") oraz indeksy cen. + */ +public final class NbpXlsxParser { + + public record Row(int year, int quarter, String market, String city, long pricePerM2) { + } + + private static final long MAX_ENTRY_BYTES = 20L * 1024 * 1024; + private static final Pattern QUARTER = Pattern.compile("^(IV|III|II|I)\\s+(\\d{4})$"); + private static final Pattern CELL_REF = Pattern.compile("^([A-Z]+)\\d+$"); + private static final Map SHEET_MARKETS = Map.of( + "Rynek pierwotny", "Pierwotny", + "Rynek wtórny", "Wtórny"); + + private NbpXlsxParser() { + } + + public static List parse(InputStream xlsx) throws IOException { + Map entries = unzip(xlsx); + try { + List sharedStrings = sharedStrings(entries.get("xl/sharedStrings.xml")); + Map sheetFiles = sheetFiles(entries); + List rows = new ArrayList<>(); + for (Map.Entry sheet : SHEET_MARKETS.entrySet()) { + String file = sheetFiles.get(sheet.getKey()); + if (file != null && entries.containsKey(file)) { + rows.addAll(transactionPrices(read(entries.get(file)), sharedStrings, sheet.getValue())); + } + } + return rows; + } catch (IOException ex) { + throw ex; + } catch (Exception ex) { + throw new IOException("Nie udało się odczytać pliku NBP: " + ex.getMessage(), ex); + } + } + + private static List transactionPrices(Document sheet, List shared, String market) { + List> table = new ArrayList<>(); + NodeList rowNodes = sheet.getElementsByTagName("row"); + for (int i = 0; i < rowNodes.getLength(); i++) { + Map cells = new LinkedHashMap<>(); + NodeList cellNodes = ((Element) rowNodes.item(i)).getElementsByTagName("c"); + for (int j = 0; j < cellNodes.getLength(); j++) { + Element cell = (Element) cellNodes.item(j); + Matcher ref = CELL_REF.matcher(cell.getAttribute("r")); + String value = cellValue(cell, shared); + if (ref.matches() && value != null && !value.isBlank()) { + cells.put(ref.group(1), value.trim()); + } + } + table.add(cells); + } + + String transactionColumn = findColumn(table, "Ceny transakcyjne"); + Map header = table.stream() + .filter(row -> row.values().stream().filter("Kwartał"::equals).count() >= 2) + .findFirst() + .orElse(null); + if (transactionColumn == null || header == null) { + return List.of(); + } + // Kolumna kwartalu bloku transakcyjnego: ostatnia "Kwartał" nie dalej niz naglowek bloku. + String quarterColumn = null; + for (Map.Entry cell : header.entrySet()) { + if ("Kwartał".equals(cell.getValue()) && columnIndex(cell.getKey()) <= columnIndex(transactionColumn)) { + quarterColumn = cell.getKey(); + } + } + if (quarterColumn == null) { + return List.of(); + } + Map cityColumns = new LinkedHashMap<>(); + for (int index = columnIndex(quarterColumn) + 1; ; index++) { + String name = header.get(columnName(index)); + if (name == null || name.toLowerCase().contains("miast")) { + break; + } + cityColumns.put(columnName(index), name.replace("*", "").trim()); + } + + List result = new ArrayList<>(); + for (Map row : table) { + Matcher quarter = QUARTER.matcher(row.getOrDefault(quarterColumn, "")); + if (!quarter.matches()) { + continue; + } + int quarterNumber = switch (quarter.group(1)) { + case "I" -> 1; + case "II" -> 2; + case "III" -> 3; + default -> 4; + }; + int year = Integer.parseInt(quarter.group(2)); + cityColumns.forEach((column, city) -> { + Double price = number(row.get(column)); + if (price != null && price > 0) { + result.add(new Row(year, quarterNumber, market, city, Math.round(price))); + } + }); + } + return result; + } + + private static String findColumn(List> table, String text) { + for (Map row : table) { + for (Map.Entry cell : row.entrySet()) { + if (text.equals(cell.getValue())) { + return cell.getKey(); + } + } + } + return null; + } + + private static String cellValue(Element cell, List shared) { + String type = cell.getAttribute("t"); + if ("inlineStr".equals(type)) { + return textOf(cell); + } + NodeList values = cell.getElementsByTagName("v"); + if (values.getLength() == 0) { + return null; + } + String raw = values.item(0).getTextContent(); + if ("s".equals(type)) { + int index = Integer.parseInt(raw.trim()); + return index < shared.size() ? shared.get(index) : null; + } + return raw; + } + + private static List sharedStrings(byte[] xml) throws Exception { + List strings = new ArrayList<>(); + if (xml == null) { + return strings; + } + NodeList items = read(xml).getElementsByTagName("si"); + for (int i = 0; i < items.getLength(); i++) { + strings.add(textOf((Element) items.item(i))); + } + return strings; + } + + private static String textOf(Element element) { + StringBuilder text = new StringBuilder(); + NodeList parts = element.getElementsByTagName("t"); + for (int i = 0; i < parts.getLength(); i++) { + text.append(parts.item(i).getTextContent()); + } + return text.toString(); + } + + // Nazwa arkusza -> sciezka pliku w ZIP (workbook.xml + relacje). + private static Map sheetFiles(Map entries) throws Exception { + Map targets = new HashMap<>(); + NodeList relations = read(entries.get("xl/_rels/workbook.xml.rels")).getElementsByTagName("Relationship"); + for (int i = 0; i < relations.getLength(); i++) { + Element relation = (Element) relations.item(i); + String target = relation.getAttribute("Target"); + targets.put(relation.getAttribute("Id"), target.startsWith("/") ? target.substring(1) : "xl/" + target); + } + Map files = new HashMap<>(); + NodeList sheets = read(entries.get("xl/workbook.xml")).getElementsByTagName("sheet"); + for (int i = 0; i < sheets.getLength(); i++) { + Element sheet = (Element) sheets.item(i); + files.put(sheet.getAttribute("name").trim(), targets.get(sheet.getAttribute("r:id"))); + } + return files; + } + + private static Map unzip(InputStream input) throws IOException { + Map entries = new HashMap<>(); + try (ZipInputStream zip = new ZipInputStream(input)) { + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + if (entry.isDirectory()) { + continue; + } + byte[] data = zip.readNBytes((int) MAX_ENTRY_BYTES + 1); + if (data.length > MAX_ENTRY_BYTES) { + throw new IOException("Plik NBP ma nieoczekiwanie duzy element: " + entry.getName()); + } + entries.put(entry.getName(), data); + } + } + if (!entries.containsKey("xl/workbook.xml")) { + throw new IOException("To nie jest plik XLSX"); + } + return entries; + } + + private static Document read(byte[] xml) throws Exception { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + // Plik z zewnatrz - bez encji zewnetrznych i DTD (ochrona przed XXE). + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, ""); + factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, ""); + factory.setExpandEntityReferences(false); + DocumentBuilder builder = factory.newDocumentBuilder(); + return builder.parse(new ByteArrayInputStream(xml)); + } + + private static Double number(String value) { + if (value == null) { + return null; + } + try { + return Double.parseDouble(value.replace(',', '.')); + } catch (NumberFormatException ex) { + return null; + } + } + + private static int columnIndex(String column) { + int index = 0; + for (char letter : column.toCharArray()) { + index = index * 26 + (letter - 'A' + 1); + } + return index; + } + + private static String columnName(int index) { + StringBuilder name = new StringBuilder(); + while (index > 0) { + int remainder = (index - 1) % 26; + name.insert(0, (char) ('A' + remainder)); + index = (index - 1) / 26; + } + return name.toString(); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/pricehistory/PriceHistoryController.java b/backend/src/main/java/pl/polskalokalnie/pricehistory/PriceHistoryController.java new file mode 100644 index 0000000..cc24fa0 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/pricehistory/PriceHistoryController.java @@ -0,0 +1,81 @@ +package pl.polskalokalnie.pricehistory; + +import java.time.LocalDate; +import java.util.List; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import pl.polskalokalnie.listing.OfferType; +import pl.polskalokalnie.listing.PropertyType; + +/** Publiczna historia srednich cen ofertowych za m2 dla miasta lub dzielnicy. */ +@RestController +@RequestMapping("/api/price-history") +public class PriceHistoryController { + + private final PriceSnapshotRepository snapshotRepository; + private final PriceSnapshotService snapshotService; + private final ExternalQuarterPriceRepository externalRepository; + + public PriceHistoryController(PriceSnapshotRepository snapshotRepository, PriceSnapshotService snapshotService, + ExternalQuarterPriceRepository externalRepository) { + this.snapshotRepository = snapshotRepository; + this.snapshotService = snapshotService; + this.externalRepository = externalRepository; + } + + public record PricePoint(LocalDate date, long avgPricePerM2, int listingsCount) { + } + + public record QuarterPoint(int year, int quarter, long pricePerM2) { + } + + /** + * points - dzienne zapisy od "from"; current - srednia z ogloszen w tej chwili (null, gdy brak); + * trackedSince - od kiedy serwis w ogole zbiera historie (null, gdy jeszcze nic nie zapisano); + * nbp / gus - kwartalne ceny transakcyjne mieszkan dla calego miasta (puste, gdy zrodlo go nie obejmuje); + * nbpMarket - rynek, ktorego dotyczy nbp (NBP nie podaje obu rynkow razem - wtedy wtorny). + */ + public record PriceHistoryResponse(List points, PricePoint current, LocalDate trackedSince, + List nbp, List gus, String nbpMarket) { + } + + @GetMapping + public PriceHistoryResponse history( + @RequestParam String city, + @RequestParam(required = false, defaultValue = "") String district, + @RequestParam(required = false, defaultValue = "SALE") OfferType offerType, + @RequestParam(required = false, defaultValue = "APARTMENT") PropertyType propertyType, + @RequestParam(required = false, defaultValue = "") String market, + @RequestParam(required = false) LocalDate from + ) { + LocalDate since = from != null ? from : PriceSnapshotService.today().minusYears(1); + String wantedMarket = market.trim(); + List points = snapshotRepository + .findByCityKeyAndDistrictKeyAndOfferTypeAndPropertyTypeAndMarketAndSnapshotDateGreaterThanEqualOrderBySnapshotDateAsc( + PriceSnapshotService.key(city), PriceSnapshotService.key(district), offerType, propertyType, + wantedMarket, since) + .stream() + .map(snapshot -> new PricePoint(snapshot.getSnapshotDate(), snapshot.getAvgPricePerM2(), snapshot.getListingsCount())) + .toList(); + PriceSnapshotService.Average now = snapshotService.currentAverage(offerType, propertyType, wantedMarket, city, district); + PricePoint current = now == null ? null + : new PricePoint(PriceSnapshotService.today(), now.avgPricePerM2(), now.listingsCount()); + // NBP i GUS podaja ceny transakcyjne mieszkan - dla domow i najmu nie ma porownania. + boolean comparable = offerType == OfferType.SALE && propertyType == PropertyType.APARTMENT; + String nbpMarket = wantedMarket.isEmpty() ? "Wtórny" : wantedMarket; + String cityKey = PriceSnapshotService.key(city); + List nbp = comparable ? quarters(cityKey, nbpMarket, ExternalQuarterPrice.Source.NBP) : List.of(); + List gus = comparable ? quarters(cityKey, wantedMarket, ExternalQuarterPrice.Source.GUS) : List.of(); + return new PriceHistoryResponse(points, current, snapshotRepository.findFirstSnapshotDate().orElse(null), + nbp, gus, nbpMarket); + } + + private List quarters(String cityKey, String market, ExternalQuarterPrice.Source source) { + return externalRepository.findByCityKeyAndMarketOrderByYearAscQuarterAsc(cityKey, market).stream() + .filter(price -> price.getSource() == source) + .map(price -> new QuarterPoint(price.getYear(), price.getQuarter(), price.getPricePerM2())) + .toList(); + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/pricehistory/PriceSnapshot.java b/backend/src/main/java/pl/polskalokalnie/pricehistory/PriceSnapshot.java new file mode 100644 index 0000000..8d00730 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/pricehistory/PriceSnapshot.java @@ -0,0 +1,94 @@ +package pl.polskalokalnie.pricehistory; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.LocalDate; +import pl.polskalokalnie.listing.OfferType; +import pl.polskalokalnie.listing.PropertyType; + +/** + * Dzienny zapis sredniej ceny ofertowej za m2 z opublikowanych ogloszen - zrodlo historii cen. + * Jeden wiersz na (dzien, typ oferty, typ nieruchomosci, rynek, miasto, dzielnica). + * Pusta dzielnica = cale miasto, pusty rynek = wtorny i pierwotny razem. + * Klucze (cityKey, districtKey) sa znormalizowane, zeby "Kraków" i " kraków" trafialy w to samo. + */ +@Entity +@Table(name = "price_snapshots", + uniqueConstraints = @UniqueConstraint(columnNames = { + "snapshotDate", "offerType", "propertyType", "market", "cityKey", "districtKey"}), + indexes = @Index(columnList = "cityKey, districtKey, offerType, propertyType, market, snapshotDate")) +public class PriceSnapshot { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private LocalDate snapshotDate; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private OfferType offerType; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private PropertyType propertyType; + + @Column(nullable = false, length = 32) + private String market; + + @Column(nullable = false, length = 120) + private String cityKey; + + @Column(nullable = false, length = 120) + private String districtKey; + + @Column(nullable = false, length = 120) + private String city; + + @Column(nullable = false, length = 120) + private String district; + + @Column(nullable = false) + private int listingsCount; + + @Column(nullable = false) + private long avgPricePerM2; + + protected PriceSnapshot() { + } + + public PriceSnapshot(LocalDate snapshotDate, OfferType offerType, PropertyType propertyType, String market, + String city, String district, int listingsCount, long avgPricePerM2) { + this.snapshotDate = snapshotDate; + this.offerType = offerType; + this.propertyType = propertyType; + this.market = market; + this.city = city; + this.district = district; + this.cityKey = PriceSnapshotService.key(city); + this.districtKey = PriceSnapshotService.key(district); + this.listingsCount = listingsCount; + this.avgPricePerM2 = avgPricePerM2; + } + + public LocalDate getSnapshotDate() { + return snapshotDate; + } + + public int getListingsCount() { + return listingsCount; + } + + public long getAvgPricePerM2() { + return avgPricePerM2; + } +} diff --git a/backend/src/main/java/pl/polskalokalnie/pricehistory/PriceSnapshotRepository.java b/backend/src/main/java/pl/polskalokalnie/pricehistory/PriceSnapshotRepository.java new file mode 100644 index 0000000..2def3d7 --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/pricehistory/PriceSnapshotRepository.java @@ -0,0 +1,26 @@ +package pl.polskalokalnie.pricehistory; + +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import pl.polskalokalnie.listing.OfferType; +import pl.polskalokalnie.listing.PropertyType; + +public interface PriceSnapshotRepository extends JpaRepository { + + boolean existsBySnapshotDate(LocalDate snapshotDate); + + @Modifying + @Query("delete from PriceSnapshot s where s.snapshotDate = :date") + void deleteBySnapshotDate(@Param("date") LocalDate date); + + List findByCityKeyAndDistrictKeyAndOfferTypeAndPropertyTypeAndMarketAndSnapshotDateGreaterThanEqualOrderBySnapshotDateAsc( + String cityKey, String districtKey, OfferType offerType, PropertyType propertyType, String market, LocalDate from); + + @Query("select min(s.snapshotDate) from PriceSnapshot s") + Optional findFirstSnapshotDate(); +} diff --git a/backend/src/main/java/pl/polskalokalnie/pricehistory/PriceSnapshotService.java b/backend/src/main/java/pl/polskalokalnie/pricehistory/PriceSnapshotService.java new file mode 100644 index 0000000..64aa67e --- /dev/null +++ b/backend/src/main/java/pl/polskalokalnie/pricehistory/PriceSnapshotService.java @@ -0,0 +1,160 @@ +package pl.polskalokalnie.pricehistory; + +import java.text.Normalizer; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +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.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import pl.polskalokalnie.listing.ListingRepository; +import pl.polskalokalnie.listing.ListingStatus; +import pl.polskalokalnie.listing.OfferType; +import pl.polskalokalnie.listing.PropertyListing; +import pl.polskalokalnie.listing.PropertyType; + +/** + * Historia cen: codzienny zapis srednich cen ofertowych za m2 z opublikowanych ogloszen. + * Danych sprzed pierwszego zapisu nie odtwarzamy - historia rosnie z kazdym dniem. + */ +@Service +public class PriceSnapshotService { + + public static final ZoneId ZONE = ZoneId.of("Europe/Warsaw"); + /** Wartosc rynku oznaczajaca "wtorny i pierwotny razem". */ + public static final String ALL_MARKETS = ""; + + private static final Logger log = LoggerFactory.getLogger(PriceSnapshotService.class); + + private final ListingRepository listingRepository; + private final PriceSnapshotRepository snapshotRepository; + + public PriceSnapshotService(ListingRepository listingRepository, PriceSnapshotRepository snapshotRepository) { + this.listingRepository = listingRepository; + this.snapshotRepository = snapshotRepository; + } + + /** Srednia na dany moment - ta sama grupa, co w zapisie dziennym. */ + public record Average(long avgPricePerM2, int listingsCount) { + } + + private record GroupKey(OfferType offerType, PropertyType propertyType, String market, String city, String district) { + } + + // Co noc zapisujemy stan z konca dnia; przy starcie uzupelniamy dzisiejszy zapis, jesli go brakuje. + // Transakcja na tych metodach, bo wywolanie recordToday() z wnetrza klasy omija proxy Springa. + @Scheduled(cron = "0 30 2 * * *", zone = "Europe/Warsaw") + @Transactional + public void recordDaily() { + recordToday(); + } + + @EventListener(ApplicationReadyEvent.class) + @Transactional + public void recordOnStartupIfMissing() { + if (!snapshotRepository.existsBySnapshotDate(today())) { + recordToday(); + } + } + + /** Zapis dzisiejszych srednich; ponowne wywolanie w tym samym dniu nadpisuje dzisiejsze wiersze. */ + @Transactional + public int recordToday() { + LocalDate date = today(); + snapshotRepository.deleteBySnapshotDate(date); + Map> groups = group(pricedListings()); + List rows = new ArrayList<>(); + groups.forEach((key, prices) -> rows.add(new PriceSnapshot(date, key.offerType(), key.propertyType(), key.market(), + key.city(), key.district(), prices.size(), Math.round(average(prices))))); + snapshotRepository.saveAll(rows); + log.info("Historia cen: zapisano {} srednich za {}", rows.size(), date); + return rows.size(); + } + + /** Aktualna srednia liczona wprost z ogloszen - "dzisiaj" na wykresie jest zawsze swieze. */ + public Average currentAverage(OfferType offerType, PropertyType propertyType, String market, String city, String district) { + String wantedCity = key(city); + String wantedDistrict = key(district); + List prices = pricedListings().stream() + .filter(listing -> listing.getOfferType() == offerType && listing.getPropertyType() == propertyType) + .filter(listing -> key(listing.getCity()).equals(wantedCity)) + .filter(listing -> wantedDistrict.isEmpty() || key(listing.getDistrict()).equals(wantedDistrict)) + .filter(listing -> market.isEmpty() || market.equalsIgnoreCase(trim(listing.getMarket()))) + .map(PriceSnapshotService::pricePerM2) + .toList(); + return prices.isEmpty() ? null : new Average(Math.round(average(prices)), prices.size()); + } + + private List pricedListings() { + return listingRepository.findAll().stream() + .filter(listing -> listing.getStatus() == ListingStatus.APPROVED) + .filter(listing -> listing.getOfferType() != null && listing.getPropertyType() != null) + .filter(listing -> listing.getPrice() != null && listing.getPrice().doubleValue() > 0) + .filter(listing -> listing.getArea() != null && listing.getArea() > 0) + .filter(listing -> !trim(listing.getCity()).isEmpty()) + .toList(); + } + + // Kazde ogloszenie liczy sie do miasta i (jesli ma) do dzielnicy - dla wszystkich rynkow i dla swojego rynku. + private static Map> group(List listings) { + Map> groups = new LinkedHashMap<>(); + for (PropertyListing listing : listings) { + double price = pricePerM2(listing); + String city = trim(listing.getCity()); + String district = trim(listing.getDistrict()); + String market = trim(listing.getMarket()); + List markets = market.isEmpty() ? List.of(ALL_MARKETS) : List.of(ALL_MARKETS, market); + List districts = district.isEmpty() ? List.of("") : List.of("", district); + for (String groupMarket : markets) { + for (String groupDistrict : districts) { + GroupKey key = new GroupKey(listing.getOfferType(), listing.getPropertyType(), groupMarket, + city, groupDistrict); + groups.computeIfAbsent(canonical(groups, key), ignored -> new ArrayList<>()).add(price); + } + } + } + return groups; + } + + // Ta sama grupa niezaleznie od wielkosci liter i spacji - pierwsza napotkana pisownia zostaje nazwa. + private static GroupKey canonical(Map> groups, GroupKey candidate) { + for (GroupKey existing : groups.keySet()) { + if (existing.offerType() == candidate.offerType() && existing.propertyType() == candidate.propertyType() + && existing.market().equalsIgnoreCase(candidate.market()) + && key(existing.city()).equals(key(candidate.city())) + && key(existing.district()).equals(key(candidate.district()))) { + return existing; + } + } + return candidate; + } + + public static LocalDate today() { + return LocalDate.now(ZONE); + } + + /** Klucz porownania nazw: male litery, bez nadmiarowych spacji (polskie znaki zostaja). */ + public static String key(String value) { + return Normalizer.normalize(trim(value), Normalizer.Form.NFC).toLowerCase(Locale.ROOT).replaceAll("\\s+", " "); + } + + private static String trim(String value) { + return value == null ? "" : value.trim(); + } + + private static double pricePerM2(PropertyListing listing) { + return listing.getPrice().doubleValue() / listing.getArea(); + } + + private static double average(List values) { + return values.stream().mapToDouble(Double::doubleValue).average().orElse(0); + } +} diff --git a/backend/src/test/java/pl/polskalokalnie/pricehistory/GusBdlClientTest.java b/backend/src/test/java/pl/polskalokalnie/pricehistory/GusBdlClientTest.java new file mode 100644 index 0000000..4c23aab --- /dev/null +++ b/backend/src/test/java/pl/polskalokalnie/pricehistory/GusBdlClientTest.java @@ -0,0 +1,49 @@ +package pl.polskalokalnie.pricehistory; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.junit.jupiter.api.Test; + +// Probki w formacie odpowiedzi bdl.stat.gov.pl/api/v1 (sprawdzonym 2026-09-22). +class GusBdlClientTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + void matchesOnlyCityWithPowiatRights() throws Exception { + var units = mapper.readTree(""" + [{"id":"071412865000","name":"Powiat m. st. Warszawa"}, + {"id":"011212106000","name":"Powiat krakowski"}, + {"id":"011212161000","name":"Powiat m. Kraków"}]"""); + + assertThat(GusBdlClient.matchCityUnit(units, "Warszawa")).isEqualTo("071412865000"); + assertThat(GusBdlClient.matchCityUnit(units, "kraków")).isEqualTo("011212161000"); + assertThat(GusBdlClient.matchCityUnit(units, "Zabierzów")).isNull(); + } + + @Test + void readsQuarterlyTotalsPerMarket() throws Exception { + var variables = GusBdlClient.parseVariables(mapper.readTree(""" + [{"id":634007,"n1":"1 kwartał","n2":"ogółem","n3":"ogółem"}, + {"id":634008,"n1":"1 kwartał","n2":"ogółem","n3":"do 40 m2"}, + {"id":634012,"n1":"1 kwartał","n2":"rynek pierwotny","n3":"ogółem"}, + {"id":634032,"n1":"2 kwartał","n2":"rynek wtórny","n3":"ogółem"}]""")); + + assertThat(variables).containsExactly( + new GusBdlClient.Variable(634007, 1, ""), + new GusBdlClient.Variable(634012, 1, "Pierwotny"), + new GusBdlClient.Variable(634032, 2, "Wtórny")); + + List values = GusBdlClient.parseUnitData(mapper.readTree(""" + {"results":[ + {"id":634007,"values":[{"year":"2024","val":13210.4},{"year":"2025","val":0}]}, + {"id":634032,"values":[{"year":"2024","val":14135}]}, + {"id":999999,"values":[{"year":"2024","val":1}]}]}"""), variables); + + assertThat(values).containsExactly( + new GusBdlClient.QuarterValue(2024, 1, "", 13210), + new GusBdlClient.QuarterValue(2024, 2, "Wtórny", 14135)); + } +} diff --git a/backend/src/test/java/pl/polskalokalnie/pricehistory/NbpXlsxParserTest.java b/backend/src/test/java/pl/polskalokalnie/pricehistory/NbpXlsxParserTest.java new file mode 100644 index 0000000..73a2772 --- /dev/null +++ b/backend/src/test/java/pl/polskalokalnie/pricehistory/NbpXlsxParserTest.java @@ -0,0 +1,41 @@ +package pl.polskalokalnie.pricehistory; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.InputStream; +import java.util.List; +import org.junit.jupiter.api.Test; + +class NbpXlsxParserTest { + + // Plik NBP (ceny_mieszkan.xlsx) pobrany 2026-09-22 - prawdziwy uklad arkuszy. + private List parseFixture() throws Exception { + try (InputStream xlsx = getClass().getResourceAsStream("/nbp/ceny_mieszkan.xlsx")) { + return NbpXlsxParser.parse(xlsx); + } + } + + @Test + void readsTransactionPricesPerCityAndMarket() throws Exception { + List rows = parseFixture(); + + // Wartosci z arkusza "Rynek pierwotny", blok "Ceny transakcyjne", III kw. 2006. + assertThat(rows).contains( + new NbpXlsxParser.Row(2006, 3, "Pierwotny", "Kraków", 6816), + new NbpXlsxParser.Row(2006, 3, "Pierwotny", "Białystok", 3008)); + // Oba rynki, "Gdynia**" bez gwiazdek, najnowszy kwartal w pliku. + assertThat(rows).anyMatch(row -> row.market().equals("Wtórny") && row.city().equals("Warszawa") + && row.year() == 2026 && row.quarter() == 2); + assertThat(rows).anyMatch(row -> row.city().equals("Gdynia")); + } + + @Test + void skipsAggregatesAndOfferPrices() throws Exception { + List rows = parseFixture(); + + assertThat(rows).noneMatch(row -> row.city().contains("miast")); + // Cena ofertowa Krakowa na rynku pierwotnym w III kw. 2006 to 6021 - nie moze trafic do transakcyjnych. + assertThat(rows).doesNotContain(new NbpXlsxParser.Row(2006, 3, "Pierwotny", "Kraków", 6021)); + assertThat(rows.stream().map(NbpXlsxParser.Row::city).distinct()).hasSize(17); + } +} diff --git a/backend/src/test/java/pl/polskalokalnie/pricehistory/PriceSnapshotServiceTest.java b/backend/src/test/java/pl/polskalokalnie/pricehistory/PriceSnapshotServiceTest.java new file mode 100644 index 0000000..a79024f --- /dev/null +++ b/backend/src/test/java/pl/polskalokalnie/pricehistory/PriceSnapshotServiceTest.java @@ -0,0 +1,94 @@ +package pl.polskalokalnie.pricehistory; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import pl.polskalokalnie.listing.ListingRepository; +import pl.polskalokalnie.listing.ListingStatus; +import pl.polskalokalnie.listing.OfferType; +import pl.polskalokalnie.listing.PropertyListing; +import pl.polskalokalnie.listing.PropertyType; + +class PriceSnapshotServiceTest { + + private final ListingRepository listings = mock(ListingRepository.class); + private final PriceSnapshotRepository snapshots = mock(PriceSnapshotRepository.class); + private final PriceSnapshotService service = new PriceSnapshotService(listings, snapshots); + + @Test + @SuppressWarnings("unchecked") + void recordsCityDistrictAndMarketAverages() { + when(listings.findAll()).thenReturn(List.of( + listing("Kraków", "Podgórze", "Wtórny", 1_000_000, 100, ListingStatus.APPROVED), // 10 000 zl/m2 + listing(" kraków ", "podgórze", "Pierwotny", 600_000, 50, ListingStatus.APPROVED), // 12 000 zl/m2 + listing("Kraków", null, null, 800_000, 100, ListingStatus.APPROVED), // 8 000 zl/m2 + listing("Kraków", "Podgórze", "Wtórny", 9_000_000, 10, ListingStatus.PENDING))); // nieopublikowane + + service.recordToday(); + + ArgumentCaptor> saved = ArgumentCaptor.forClass(List.class); + verify(snapshots).saveAll(saved.capture()); + List rows = saved.getValue(); + + // Cale miasto, wszystkie rynki: (10 000 + 12 000 + 8 000) / 3. + assertThat(find(rows, "", "")).extracting(PriceSnapshot::getAvgPricePerM2, PriceSnapshot::getListingsCount) + .containsExactly(10_000L, 3); + // Dzielnica bez wzgledu na wielkosc liter: (10 000 + 12 000) / 2. + assertThat(find(rows, "podgórze", "")).extracting(PriceSnapshot::getAvgPricePerM2, PriceSnapshot::getListingsCount) + .containsExactly(11_000L, 2); + // Tylko rynek wtorny w miescie. + assertThat(find(rows, "", "Wtórny")).extracting(PriceSnapshot::getAvgPricePerM2, PriceSnapshot::getListingsCount) + .containsExactly(10_000L, 1); + } + + @Test + void currentAverageUsesOnlyMatchingPublishedListings() { + when(listings.findAll()).thenReturn(List.of( + listing("Kraków", "Podgórze", "Wtórny", 1_000_000, 100, ListingStatus.APPROVED), + listing("Kraków", "Podgórze", "Wtórny", 9_000_000, 10, ListingStatus.PAUSED), + listing("Warszawa", "Mokotów", "Wtórny", 2_000_000, 100, ListingStatus.APPROVED))); + + PriceSnapshotService.Average average = service.currentAverage( + OfferType.SALE, PropertyType.APARTMENT, "", "KRAKÓW", ""); + + assertThat(average).isEqualTo(new PriceSnapshotService.Average(10_000L, 1)); + assertThat(service.currentAverage(OfferType.SALE, PropertyType.APARTMENT, "", "Gdańsk", "")).isNull(); + } + + private static PriceSnapshot find(List rows, String districtKey, String market) { + return rows.stream() + .filter(row -> readField(row, "districtKey").equals(districtKey) && readField(row, "market").equals(market)) + .findFirst() + .orElseThrow(() -> new AssertionError("brak wiersza " + districtKey + "/" + market)); + } + + private static String readField(PriceSnapshot row, String name) { + try { + var field = PriceSnapshot.class.getDeclaredField(name); + field.setAccessible(true); + return (String) field.get(row); + } catch (ReflectiveOperationException ex) { + throw new IllegalStateException(ex); + } + } + + private static PropertyListing listing(String city, String district, String market, int price, double area, + ListingStatus status) { + PropertyListing listing = new PropertyListing(); + listing.setOfferType(OfferType.SALE); + listing.setPropertyType(PropertyType.APARTMENT); + listing.setCity(city); + listing.setDistrict(district); + listing.setMarket(market); + listing.setPrice(BigDecimal.valueOf(price)); + listing.setArea(area); + listing.setStatus(status); + return listing; + } +} diff --git a/backend/src/test/resources/nbp/ceny_mieszkan.xlsx b/backend/src/test/resources/nbp/ceny_mieszkan.xlsx new file mode 100644 index 0000000..6e2ea8e Binary files /dev/null and b/backend/src/test/resources/nbp/ceny_mieszkan.xlsx differ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index fa39f8e..8f7f9d2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -20938,204 +20938,208 @@ function MegaSearch({ filters, onOpenMap, onSearch, mode = 'buy' }: { type HistoryPropertyType = 'Mieszkanie' | 'Dom' | 'Lokal użytkowy'; type HistoryMarketType = 'Wtórny i pierwotny' | 'Wtórny' | 'Pierwotny'; -type HistoryPeriod = '2026'; +type HistoryPeriod = '2026' | '5y' | 'all'; type PriceHistoryMarket = { key: string; label: string; city: string; region: string; - apartmentM2: number; - trend24: number; - volatility: number; aliases?: string[]; }; const HISTORY_PRICE_MARKETS: PriceHistoryMarket[] = [ - { key: 'warszawa-mokotow', label: 'Warszawa, Mokotów', city: 'Warszawa', region: 'mazowieckie', apartmentM2: 16842, trend24: 0.086, volatility: 0.032, aliases: ['mokotów'] }, - { key: 'warszawa-srodmiescie', label: 'Warszawa, Śródmieście', city: 'Warszawa', region: 'mazowieckie', apartmentM2: 18932, trend24: 0.094, volatility: 0.036, aliases: ['śródmieście warszawa'] }, - { key: 'warszawa-wola', label: 'Warszawa, Wola', city: 'Warszawa', region: 'mazowieckie', apartmentM2: 16123, trend24: 0.083, volatility: 0.031, aliases: ['wola'] }, - { key: 'warszawa-praga-poludnie', label: 'Warszawa, Praga-Południe', city: 'Warszawa', region: 'mazowieckie', apartmentM2: 13254, trend24: 0.071, volatility: 0.029, aliases: ['praga południe', 'praga-poludnie'] }, - { key: 'warszawa-bielany', label: 'Warszawa, Bielany', city: 'Warszawa', region: 'mazowieckie', apartmentM2: 12876, trend24: 0.067, volatility: 0.027, aliases: ['bielany'] }, - { key: 'krakow-zablocie', label: 'Kraków, Zabłocie', city: 'Kraków', region: 'małopolskie', apartmentM2: 15840, trend24: 0.091, volatility: 0.034, aliases: ['zabłocie'] }, - { key: 'krakow-stare-miasto', label: 'Kraków, Stare Miasto', city: 'Kraków', region: 'małopolskie', apartmentM2: 17490, trend24: 0.088, volatility: 0.037 }, - { key: 'krakow-krowodrza', label: 'Kraków, Krowodrza', city: 'Kraków', region: 'małopolskie', apartmentM2: 14320, trend24: 0.079, volatility: 0.031 }, - { key: 'wroclaw-krzyki', label: 'Wrocław, Krzyki', city: 'Wrocław', region: 'dolnośląskie', apartmentM2: 13480, trend24: 0.082, volatility: 0.032, aliases: ['krzyki'] }, - { key: 'wroclaw-stare-miasto', label: 'Wrocław, Stare Miasto', city: 'Wrocław', region: 'dolnośląskie', apartmentM2: 14750, trend24: 0.084, volatility: 0.034 }, - { key: 'gdansk-wrzeszcz', label: 'Gdańsk, Wrzeszcz', city: 'Gdańsk', region: 'pomorskie', apartmentM2: 14320, trend24: 0.087, volatility: 0.036, aliases: ['wrzeszcz'] }, - { key: 'gdansk-srodmiescie', label: 'Gdańsk, Śródmieście', city: 'Gdańsk', region: 'pomorskie', apartmentM2: 16080, trend24: 0.089, volatility: 0.039 }, - { key: 'gdynia-redlowo', label: 'Gdynia, Redłowo', city: 'Gdynia', region: 'pomorskie', apartmentM2: 13960, trend24: 0.075, volatility: 0.034 }, - { key: 'sopot-centrum', label: 'Sopot, Centrum', city: 'Sopot', region: 'pomorskie', apartmentM2: 18850, trend24: 0.079, volatility: 0.043 }, - { key: 'poznan-jezyce', label: 'Poznań, Jeżyce', city: 'Poznań', region: 'wielkopolskie', apartmentM2: 11980, trend24: 0.064, volatility: 0.028, aliases: ['jeżyce'] }, - { key: 'poznan-grunwald', label: 'Poznań, Grunwald', city: 'Poznań', region: 'wielkopolskie', apartmentM2: 11150, trend24: 0.058, volatility: 0.026 }, - { key: 'lodz-srodmiescie', label: 'Łódź, Śródmieście', city: 'Łódź', region: 'łódzkie', apartmentM2: 9350, trend24: 0.054, volatility: 0.028 }, - { key: 'lodz-widzew', label: 'Łódź, Widzew', city: 'Łódź', region: 'łódzkie', apartmentM2: 8420, trend24: 0.047, volatility: 0.026 }, - { key: 'katowice-koszutka', label: 'Katowice, Koszutka', city: 'Katowice', region: 'śląskie', apartmentM2: 10320, trend24: 0.059, volatility: 0.029 }, - { key: 'rzeszow-centrum', label: 'Rzeszów, Centrum', city: 'Rzeszów', region: 'podkarpackie', apartmentM2: 10380, trend24: 0.069, volatility: 0.031 }, - { key: 'lublin-centrum', label: 'Lublin, Centrum', city: 'Lublin', region: 'lubelskie', apartmentM2: 10120, trend24: 0.061, volatility: 0.028 }, - { key: 'bialystok-centrum', label: 'Białystok, Centrum', city: 'Białystok', region: 'podlaskie', apartmentM2: 9580, trend24: 0.053, volatility: 0.025 }, - { key: 'szczecin-centrum', label: 'Szczecin, Centrum', city: 'Szczecin', region: 'zachodniopomorskie', apartmentM2: 9840, trend24: 0.056, volatility: 0.027 }, - { key: 'bydgoszcz-srodmiescie', label: 'Bydgoszcz, Śródmieście', city: 'Bydgoszcz', region: 'kujawsko-pomorskie', apartmentM2: 8230, trend24: 0.044, volatility: 0.025 }, - { key: 'torun-centrum', label: 'Toruń, Centrum', city: 'Toruń', region: 'kujawsko-pomorskie', apartmentM2: 9140, trend24: 0.049, volatility: 0.026 }, - { key: 'olsztyn-centrum', label: 'Olsztyn, Centrum', city: 'Olsztyn', region: 'warmińsko-mazurskie', apartmentM2: 9120, trend24: 0.05, volatility: 0.026 }, - { key: 'opole-centrum', label: 'Opole, Centrum', city: 'Opole', region: 'opolskie', apartmentM2: 8840, trend24: 0.047, volatility: 0.025 }, - { key: 'kielce-centrum', label: 'Kielce, Centrum', city: 'Kielce', region: 'świętokrzyskie', apartmentM2: 8060, trend24: 0.042, volatility: 0.024 }, - { key: 'zielona-gora-centrum', label: 'Zielona Góra, Centrum', city: 'Zielona Góra', region: 'lubuskie', apartmentM2: 8120, trend24: 0.043, volatility: 0.025 }, - { key: 'zakopane-centrum', label: 'Zakopane, Centrum', city: 'Zakopane', region: 'małopolskie', apartmentM2: 17200, trend24: 0.081, volatility: 0.045 }, + { key: 'warszawa-mokotow', label: 'Warszawa, Mokotów', city: 'Warszawa', region: 'mazowieckie', aliases: ['mokotów'] }, + { key: 'warszawa-srodmiescie', label: 'Warszawa, Śródmieście', city: 'Warszawa', region: 'mazowieckie', aliases: ['śródmieście warszawa'] }, + { key: 'warszawa-wola', label: 'Warszawa, Wola', city: 'Warszawa', region: 'mazowieckie', aliases: ['wola'] }, + { key: 'warszawa-praga-poludnie', label: 'Warszawa, Praga-Południe', city: 'Warszawa', region: 'mazowieckie', aliases: ['praga południe', 'praga-poludnie'] }, + { key: 'warszawa-bielany', label: 'Warszawa, Bielany', city: 'Warszawa', region: 'mazowieckie', aliases: ['bielany'] }, + { key: 'krakow-zablocie', label: 'Kraków, Zabłocie', city: 'Kraków', region: 'małopolskie', aliases: ['zabłocie'] }, + { key: 'krakow-stare-miasto', label: 'Kraków, Stare Miasto', city: 'Kraków', region: 'małopolskie' }, + { key: 'krakow-krowodrza', label: 'Kraków, Krowodrza', city: 'Kraków', region: 'małopolskie' }, + { key: 'wroclaw-krzyki', label: 'Wrocław, Krzyki', city: 'Wrocław', region: 'dolnośląskie', aliases: ['krzyki'] }, + { key: 'wroclaw-stare-miasto', label: 'Wrocław, Stare Miasto', city: 'Wrocław', region: 'dolnośląskie' }, + { key: 'gdansk-wrzeszcz', label: 'Gdańsk, Wrzeszcz', city: 'Gdańsk', region: 'pomorskie', aliases: ['wrzeszcz'] }, + { key: 'gdansk-srodmiescie', label: 'Gdańsk, Śródmieście', city: 'Gdańsk', region: 'pomorskie' }, + { key: 'gdynia-redlowo', label: 'Gdynia, Redłowo', city: 'Gdynia', region: 'pomorskie' }, + { key: 'sopot-centrum', label: 'Sopot, Centrum', city: 'Sopot', region: 'pomorskie' }, + { key: 'poznan-jezyce', label: 'Poznań, Jeżyce', city: 'Poznań', region: 'wielkopolskie', aliases: ['jeżyce'] }, + { key: 'poznan-grunwald', label: 'Poznań, Grunwald', city: 'Poznań', region: 'wielkopolskie' }, + { key: 'lodz-srodmiescie', label: 'Łódź, Śródmieście', city: 'Łódź', region: 'łódzkie' }, + { key: 'lodz-widzew', label: 'Łódź, Widzew', city: 'Łódź', region: 'łódzkie' }, + { key: 'katowice-koszutka', label: 'Katowice, Koszutka', city: 'Katowice', region: 'śląskie' }, + { key: 'rzeszow-centrum', label: 'Rzeszów, Centrum', city: 'Rzeszów', region: 'podkarpackie' }, + { key: 'lublin-centrum', label: 'Lublin, Centrum', city: 'Lublin', region: 'lubelskie' }, + { key: 'bialystok-centrum', label: 'Białystok, Centrum', city: 'Białystok', region: 'podlaskie' }, + { key: 'szczecin-centrum', label: 'Szczecin, Centrum', city: 'Szczecin', region: 'zachodniopomorskie' }, + { key: 'bydgoszcz-srodmiescie', label: 'Bydgoszcz, Śródmieście', city: 'Bydgoszcz', region: 'kujawsko-pomorskie' }, + { key: 'torun-centrum', label: 'Toruń, Centrum', city: 'Toruń', region: 'kujawsko-pomorskie' }, + { key: 'olsztyn-centrum', label: 'Olsztyn, Centrum', city: 'Olsztyn', region: 'warmińsko-mazurskie' }, + { key: 'opole-centrum', label: 'Opole, Centrum', city: 'Opole', region: 'opolskie' }, + { key: 'kielce-centrum', label: 'Kielce, Centrum', city: 'Kielce', region: 'świętokrzyskie' }, + { key: 'zielona-gora-centrum', label: 'Zielona Góra, Centrum', city: 'Zielona Góra', region: 'lubuskie' }, + { key: 'zakopane-centrum', label: 'Zakopane, Centrum', city: 'Zakopane', region: 'małopolskie' }, ]; -const HISTORY_CITY_BASE_PRICES: Record = { - Warszawa: 16000, Kraków: 14500, Wrocław: 12600, Gdańsk: 13600, Gdynia: 12700, Sopot: 17800, - Poznań: 10800, Łódź: 8500, Katowice: 9700, Rzeszów: 9800, Lublin: 9300, Białystok: 8900, - Szczecin: 9200, Bydgoszcz: 7600, Toruń: 8500, Kielce: 7400, Olsztyn: 8500, Opole: 8200, - Zielona: 7800, 'Zielona Góra': 7800, Częstochowa: 6900, Radom: 6700, Gliwice: 7800, - Zabrze: 6100, Rybnik: 6200, Tychy: 7600, Bielsko: 8600, 'Bielsko-Biała': 8600, - Płock: 7100, Tarnów: 7200, Koszalin: 7900, Kalisz: 7100, Legnica: 7300, Słupsk: 7400, - Nowy: 7600, 'Nowy Sącz': 7600, Jelenia: 7400, 'Jelenia Góra': 7400, Piaseczno: 11200, - Pruszków: 10400, Legionowo: 9400, Wieliczka: 9300, Zakopane: 16000, Kołobrzeg: 12200, -}; - const HISTORY_POPULAR_MARKETS = ['Warszawa, Mokotów', 'Kraków, Zabłocie', 'Wrocław, Krzyki', 'Gdańsk, Wrzeszcz', 'Poznań, Jeżyce']; -const HISTORY_PROPERTY_MULTIPLIERS: Record = { Mieszkanie: 1, Dom: 0.78, 'Lokal użytkowy': 1.12 }; -const HISTORY_MARKET_MULTIPLIERS: Record = { 'Wtórny i pierwotny': 1, Wtórny: 0.97, Pierwotny: 1.08 }; -const HISTORY_LOCATION_TYPE_MULTIPLIERS: Record = { - dzielnica: 1, - miasto: 0.96, - town: 0.86, - miejscowość: 0.78, - gmina: 0.76, - powiat: 0.74, - wieś: 0.62, - przysiółek: 0.56, + +// Historia cen z API (/api/price-history) - dzienne srednie ceny ofertowe z ogloszen serwisu. +type ApiPricePoint = { date: string; avgPricePerM2: number; listingsCount: number }; +type ApiQuarterPoint = { year: number; quarter: number; pricePerM2: number }; +// nbp / gus - kwartalne ceny transakcyjne mieszkan dla calego miasta; nbpMarket - rynek serii NBP. +type ApiPriceHistory = { + points: ApiPricePoint[]; + current: ApiPricePoint | null; + trackedSince: string | null; + nbp: ApiQuarterPoint[]; + gus: ApiQuarterPoint[]; + nbpMarket: string; }; + +// Ogloszenia serwisu to mieszkania i domy - lokali uzytkowych nie ma z czego liczyc. +const HISTORY_PROPERTY_API: Record = { + Mieszkanie: 'APARTMENT', + Dom: 'HOUSE', + 'Lokal użytkowy': null, +}; +// Pusty rynek = wtorny i pierwotny razem (tak zapisuje backend). +const HISTORY_MARKET_API: Record = { + 'Wtórny i pierwotny': '', + Wtórny: 'Wtórny', + Pierwotny: 'Pierwotny', +}; + +// "Warszawa, Mokotów" -> miasto Warszawa, dzielnica Mokotów; sama nazwa miasta -> cale miasto. +function priceHistoryApiPath(market: PriceHistoryMarket, propertyType: HistoryPropertyType, + marketType: HistoryMarketType, from: string): string { + // Dzielnica to druga czesc etykiety, o ile nie jest nazwa regionu (lokalizacje zapamietane + // przez starsza wersje mialy np. "Kraków, małopolskie"). + const district = market.label.split(',').map((part) => part.trim()).slice(1) + .find((part) => part && !isHistoryRegionName(part) && normalizeHistoryText(part) !== normalizeHistoryText(market.region)) ?? ''; + const params = new URLSearchParams({ + city: market.city, + district, + propertyType: HISTORY_PROPERTY_API[propertyType] ?? 'APARTMENT', + market: HISTORY_MARKET_API[marketType], + from, + }); + return `/price-history?${params.toString()}`; +} + const HISTORY_YEAR = 2026; const HISTORY_YEAR_MONTHS = ['sty', 'lut', 'mar', 'kwi', 'maj', 'cze', 'lip', 'sie', 'wrz', 'paź', 'lis', 'gru']; +// "od maja", "od września" - odmiana do opisu zmiany ceny. +const HISTORY_MONTHS_GENITIVE = ['stycznia', 'lutego', 'marca', 'kwietnia', 'maja', 'czerwca', 'lipca', 'sierpnia', 'września', 'października', 'listopada', 'grudnia']; const HISTORY_PERIOD_OPTIONS: { value: HistoryPeriod; label: string }[] = [ + { value: '5y', label: 'Ostatnie 5 lat' }, + { value: 'all', label: 'Od 2006 (NBP)' }, { value: '2026', label: '2026 do dziś' }, ]; +// Pierwszy kwartal w danych NBP (baza BaRN). +const HISTORY_NBP_FIRST_YEAR = 2006; +const HISTORY_NBP_FIRST_QUARTER = 3; +const HISTORY_ROMAN_QUARTERS = ['I', 'II', 'III', 'IV']; + +// Przedzial na osi wykresu: miesiac (okres 2026) albo kwartal (dluzsze okresy). +type HistorySlot = { axisLabel: string; label: string; sinceLabel: string; year: number; month: number | null; quarter: number }; + +function buildHistorySlots(period: HistoryPeriod): HistorySlot[] { + if (period === '2026') { + return historyMonthLabels().map((label, month) => ({ + axisLabel: label, + label: `${label} ${HISTORY_YEAR}`, + sinceLabel: HISTORY_MONTHS_GENITIVE[month], + year: HISTORY_YEAR, + month, + quarter: Math.floor(month / 3) + 1, + })); + } + const now = new Date(); + const currentYear = now.getFullYear(); + const currentQuarter = Math.floor(now.getMonth() / 3) + 1; + const startYear = period === '5y' ? currentYear - 4 : HISTORY_NBP_FIRST_YEAR; + const slots: HistorySlot[] = []; + for (let year = startYear; year <= currentYear; year += 1) { + for (let quarter = 1; quarter <= 4; quarter += 1) { + if (year === currentYear && quarter > currentQuarter) { + break; + } + if (period === 'all' && year === HISTORY_NBP_FIRST_YEAR && quarter < HISTORY_NBP_FIRST_QUARTER) { + continue; + } + const label = `${HISTORY_ROMAN_QUARTERS[quarter - 1]} kw. ${year}`; + // Podpis roku przy pierwszym kwartale; przy 20 latach co drugi rok, zeby sie nie zlewaly. + const showYear = (quarter === 1 || slots.length === 0) && (period === '5y' || year % 2 === 0); + slots.push({ axisLabel: showYear ? String(year) : '', label, sinceLabel: label, year, month: null, quarter }); + } + } + return slots; +} + +function historySlotContains(slot: HistorySlot, date: string): boolean { + const year = Number(date.slice(0, 4)); + const month = Number(date.slice(5, 7)) - 1; + return year === slot.year && (slot.month != null ? month === slot.month : Math.floor(month / 3) + 1 === slot.quarter); +} + +// Wartosc kwartalna na osi: w widoku kwartalnym wprost, w miesiecznym w srodkowym miesiacu kwartalu. +function historyQuarterValue(points: ApiQuarterPoint[], slot: HistorySlot): number | null { + if (slot.month != null && slot.month % 3 !== 1) { + return null; + } + return points.find((point) => point.year === slot.year && point.quarter === slot.quarter)?.pricePerM2 ?? null; +} + +// Poczatek okresu dla zapytania o historie. +function historyFromDate(slots: HistorySlot[]): string { + const first = slots[0]; + if (!first) { + return `${HISTORY_YEAR}-01-01`; + } + const month = first.month != null ? first.month + 1 : (first.quarter - 1) * 3 + 1; + return `${first.year}-${String(month).padStart(2, '0')}-01`; +} function normalizeHistoryText(value: string) { return value.toLowerCase().normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/ł/g, 'l').replace(/[^a-z0-9]+/g, ' ').trim(); } -function historyLocationTypeFromText(value: string, city?: string | null) { - const normalized = normalizeHistoryText(value); - if (normalized.includes('dzielnica') || normalized.includes('osiedle')) { - return 'dzielnica'; - } - if (normalized.includes('wies') || normalized.includes('przysiolek') || normalized.includes('hamlet') || normalized.includes('osada')) { - return normalized.includes('przysiolek') || normalized.includes('hamlet') ? 'przysiółek' : 'wieś'; - } - if (normalized.includes('powiat')) { - return 'powiat'; - } - if (normalized.includes('gmina')) { - return 'gmina'; - } - if (normalized.includes('miejscowosc')) { - return 'miejscowość'; - } - if (normalized.includes('miasto') || city) { - return 'miasto'; - } - return 'miejscowość'; -} - -function historyFindValuationCity(value: string) { - const normalized = normalizeHistoryText(value); - return Object.keys(VALUATION_PRICE_PER_SQM_BY_CITY).find((city) => { - const normalizedCity = normalizeHistoryText(city); - return normalized === normalizedCity || normalized.includes(normalizedCity) || normalizedCity.includes(normalized); - }) ?? null; -} - -function historyFindKnownCity(value: string) { - const normalized = normalizeHistoryText(value); - return POLISH_CITIES.find((city) => { - const normalizedCity = normalizeHistoryText(city); - return normalized === normalizedCity || normalized.includes(normalizedCity) || normalizedCity.includes(normalized); - }) ?? null; -} - -function historyFindRegion(value: string) { - const normalized = normalizeHistoryText(value); - return Object.keys(VALUATION_PRICE_PER_SQM_BY_REGION).find((region) => normalized.includes(normalizeHistoryText(region))) ?? null; -} - -function estimateHistoryLocationPrice(label: string, sublabel = '') { - const locationText = [label, sublabel].filter(Boolean).join(', '); - const normalized = normalizeHistoryText(locationText); - const valuationCity = historyFindValuationCity(locationText); - const knownCity = valuationCity ?? historyFindKnownCity(locationText); - const region = historyFindRegion(locationText); - const locationType = historyLocationTypeFromText(locationText, knownCity); - const locationMultiplier = HISTORY_LOCATION_TYPE_MULTIPLIERS[locationType] ?? 0.78; - const seededAdjustment = 0.96 + (normalized.split('').reduce((sum, char) => sum + char.charCodeAt(0), 0) % 9) / 100; - - if (valuationCity) { - const exactPrice = VALUATION_PRICE_PER_SQM_BY_CITY[valuationCity]; - const cityMultiplier = locationType === 'dzielnica' ? 1 : locationType === 'miasto' ? 1 : locationMultiplier; - return { - price: Math.round((exactPrice * cityMultiplier * seededAdjustment) / 50) * 50, - region: region ?? 'Polska', - locationType, - }; - } - - const cityIndex = knownCity ? POLISH_CITIES.findIndex((city) => normalizeHistoryText(city) === normalizeHistoryText(knownCity)) : -1; - if (cityIndex >= 0) { - const regionalBase = region ? VALUATION_PRICE_PER_SQM_BY_REGION[region] : VALUATION_NATIONAL_BASE_PER_SQM; - const cityTierMultiplier = cityIndex < 8 ? 1.24 : cityIndex < 30 ? 1.12 : cityIndex < 80 ? 1.02 : 0.92; - return { - price: Math.round((regionalBase * cityTierMultiplier * seededAdjustment) / 50) * 50, - region: region ?? 'Polska', - locationType: 'miasto', - }; - } - - if (region) { - return { - price: Math.round((VALUATION_PRICE_PER_SQM_BY_REGION[region] * locationMultiplier * seededAdjustment) / 50) * 50, - region, - locationType, - }; - } - - const cityExact = Object.entries(HISTORY_CITY_BASE_PRICES).find(([city]) => normalizeHistoryText(city) === normalized || normalized.startsWith(`${normalizeHistoryText(city)} `)); - if (cityExact) { - return { price: cityExact[1], region: 'Polska', locationType: 'miasto' }; - } - - return { - price: Math.round((VALUATION_NATIONAL_BASE_PER_SQM * locationMultiplier * seededAdjustment) / 50) * 50, - region: 'Polska', - locationType, - }; -} - +// Tylko dokladna nazwa lub alias ze slownika. Dopasowanie po samym miescie wybieralo pierwsza +// z brzegu dzielnice ("Warszawa" -> "Warszawa, Mokotów"), a historia cen jest liczona per dzielnica. function findHistoryMarket(label: string) { const normalized = normalizeHistoryText(label); - return HISTORY_PRICE_MARKETS.find((market) => { - const names = [market.label, market.city, ...(market.aliases ?? [])].map(normalizeHistoryText); - return names.some((name) => normalized === name || normalized.includes(name) || name.includes(normalized)); - }); + return HISTORY_PRICE_MARKETS.find((market) => ( + [market.label, ...(market.aliases ?? [])].some((name) => normalizeHistoryText(name) === normalized) + )); } +const HISTORY_VOIVODESHIPS = [ + 'dolnośląskie', 'kujawsko-pomorskie', 'lubelskie', 'lubuskie', 'łódzkie', 'małopolskie', 'mazowieckie', 'opolskie', + 'podkarpackie', 'podlaskie', 'pomorskie', 'śląskie', 'świętokrzyskie', 'warmińsko-mazurskie', 'wielkopolskie', + 'zachodniopomorskie', +].map(normalizeHistoryText); + +function isHistoryRegionName(value: string) { + const normalized = normalizeHistoryText(value.replace(/^województwo\s+/i, '')); + return normalized === 'polska' || HISTORY_VOIVODESHIPS.includes(normalized); +} + +// Lokalizacja spoza slownika (wpisana albo z podpowiedzi mapy). Ceny liczy backend z ogloszen - tu tylko nazwy: +// "Kraków, Podgórze" -> dzielnica Podgórze w Krakowie; "Kraków" (+ województwo w opisie) -> cale miasto. function createHistoryMarketFromPlace(label: string, sublabel = 'Polska'): PriceHistoryMarket { - const city = label.split(',')[0].trim() || label; - const estimate = estimateHistoryLocationPrice(label, sublabel); - const normalized = normalizeHistoryText(`${label}-${sublabel}`) || 'polska'; - const seed = normalized.split('').reduce((sum, char) => sum + char.charCodeAt(0), 0); - const sublabelParts = sublabel.split(','); - const sublabelTail = sublabelParts[sublabelParts.length - 1]?.trim(); + const labelParts = label.split(',').map((part) => part.trim()).filter(Boolean); + const city = labelParts[0] || label.trim(); + const district = labelParts.slice(1).find((part) => !isHistoryRegionName(part)) ?? ''; + const regionFromPlace = sublabel.split(',').map((part) => part.trim()).find(isHistoryRegionName); + const knownRegion = HISTORY_PRICE_MARKETS.find((market) => normalizeHistoryText(market.city) === normalizeHistoryText(city))?.region; + const region = knownRegion + ?? (regionFromPlace && normalizeHistoryText(regionFromPlace) !== 'polska' ? regionFromPlace.replace(/^województwo\s+/i, '') : 'Polska'); + const normalized = normalizeHistoryText([city, district].filter(Boolean).join('-')) || 'polska'; return { key: `custom-${normalized.replace(/\s+/g, '-')}`, - label: [label, sublabel.includes('województwo') ? '' : sublabelTail].filter(Boolean).join(', '), + label: [city, district].filter(Boolean).join(', '), city, - region: estimate.region, - apartmentM2: Math.round(estimate.price * (0.98 + (seed % 5) / 100)), - trend24: 0.035 + (seed % 52) / 1000, - volatility: 0.02 + (seed % 22) / 1000, + region, }; } @@ -21221,7 +21225,7 @@ function PriceHistoryPage({ user }: { user: AuthUser | null }) { const [selectedMarket, setSelectedMarket] = useState(null); const [propertyType, setPropertyType] = useState('Mieszkanie'); const [marketType, setMarketType] = useState('Wtórny i pierwotny'); - const [period, setPeriod] = useState('2026'); + const [period, setPeriod] = useState('5y'); const [createAlertFeedback, setCreateAlertFeedback] = useState(null); const [cityAlertSubscriptions, setCityAlertSubscriptions] = useState([]); const [suggestions, setSuggestions] = useState([]); @@ -21244,15 +21248,65 @@ function PriceHistoryPage({ user }: { user: AuthUser | null }) { const hasSelectedMarket = selectedMarket != null; const activeLocationLabel = selectedMarket?.label ?? 'wybranej lokalizacji'; const todayLabel = historyTodayLabel(); - const monthLabels = historyMonthLabels(); - // Historia cen nie ma jeszcze zrodla danych - wykres i statystyki zostaja puste do czasu podpiecia API. - const priceHistoryPoints = Array.from({ length: monthLabels.length }, () => 0); - const hasHistoryData = priceHistoryPoints.some((price) => price > 0); - const minPrice = Math.min(...priceHistoryPoints); - const maxPrice = Math.max(...priceHistoryPoints); - const tickValues = historyTicks(minPrice, maxPrice); - const chartValues = priceHistoryPoints; - const chartTicks = tickValues; + const historySlots = useMemo(() => buildHistorySlots(period), [period]); + // Historia z /api/price-history: dzienne zapisy srednich cen ofertowych z ogloszen serwisu + // oraz kwartalne ceny transakcyjne NBP i GUS dla calego miasta. + const [history, setHistory] = useState(null); + const [historyLoading, setHistoryLoading] = useState(false); + // Aktualna srednia dla lokalizacji z porownania (klucz lokalizacji -> cena za m2 albo null). + const [comparisonPrices, setComparisonPrices] = useState>({}); + const historyPropertyAvailable = HISTORY_PROPERTY_API[propertyType] != null; + // Nasza srednia w przedziale (miesiac albo kwartal); ostatni przedzial to swieza srednia z ogloszen. + // Przedzial bez zapisow zostaje pusty - niczego nie dopowiadamy. + const offerPrices: (number | null)[] = historySlots.map((slot, slotIndex) => { + if (!history) { + return null; + } + if (slotIndex === historySlots.length - 1 && history.current) { + return history.current.avgPricePerM2; + } + const inSlot = history.points.filter((point) => historySlotContains(slot, point.date)); + return inSlot.length > 0 + ? Math.round(inSlot.reduce((sum, point) => sum + point.avgPricePerM2, 0) / inSlot.length) + : null; + }); + const nbpPrices = historySlots.map((slot) => historyQuarterValue(history?.nbp ?? [], slot)); + const gusPrices = historySlots.map((slot) => historyQuarterValue(history?.gus ?? [], slot)); + const knownPrices = offerPrices + .map((price, slotIndex) => ({ price, slotIndex })) + .filter((entry): entry is { price: number; slotIndex: number } => entry.price != null); + const hasOfferData = knownPrices.length > 0; + const hasNbpData = nbpPrices.some((price) => price != null); + const hasGusData = gusPrices.some((price) => price != null); + const hasHistoryData = hasOfferData || hasNbpData || hasGusData; + const firstKnown = knownPrices[0] ?? null; + const latestKnown = knownPrices[knownPrices.length - 1] ?? null; + const lowestKnown = knownPrices.reduce<{ price: number; slotIndex: number } | null>((lowest, entry) => (!lowest || entry.price < lowest.price ? entry : lowest), null); + const highestKnown = knownPrices.reduce<{ price: number; slotIndex: number } | null>((highest, entry) => (!highest || entry.price > highest.price ? entry : highest), null); + const averageKnown = hasOfferData ? Math.round(knownPrices.reduce((sum, entry) => sum + entry.price, 0) / knownPrices.length) : null; + // Zmiana ma sens dopiero przy dwoch roznych przedzialach z danymi. + const changePercent = firstKnown && latestKnown && knownPrices.length > 1 && firstKnown.price > 0 + ? ((latestKnown.price - firstKnown.price) / firstKnown.price) * 100 + : null; + // Ostatnie wartosci zrodel zewnetrznych (tez spoza wybranego okresu) - do porownania z ogloszeniami. + const latestNbp = history?.nbp?.[history.nbp.length - 1] ?? null; + const latestGus = history?.gus?.[history.gus.length - 1] ?? null; + const nbpMarketLabel = (history?.nbpMarket ?? 'Wtórny').toLowerCase(); + // GUS podaje tez oba rynki razem - wtedy porownanie z NBP (jeden rynek) jest tylko orientacyjne. + const gusMarketLabel = marketType === 'Wtórny i pierwotny' ? 'oba rynki' : `rynek ${marketType.toLowerCase()}`; + // Najnowszy kwartal, dla ktorego sa oba zrodla - tam porownujemy NBP z GUS. + const commonExternalQuarter = [...(history?.nbp ?? [])].reverse() + .map((nbp) => ({ nbp, gus: history?.gus.find((gus) => gus.year === nbp.year && gus.quarter === nbp.quarter) })) + .find((pair): pair is { nbp: ApiQuarterPoint; gus: ApiQuarterPoint } => pair.gus != null) ?? null; + const quarterName = (point: ApiQuarterPoint) => `${HISTORY_ROMAN_QUARTERS[point.quarter - 1]} kw. ${point.year}`; + const percentDiff = (value: number, reference: number) => { + const diff = ((value - reference) / reference) * 100; + return `${diff >= 0 ? '+' : ''}${diff.toFixed(1).replace('.', ',')}%`; + }; + const allValues = [...offerPrices, ...nbpPrices, ...gusPrices].filter((price): price is number => price != null); + const minPrice = allValues.length ? Math.min(...allValues) : 0; + const maxPrice = allValues.length ? Math.max(...allValues) : 0; + const chartTicks = historyTicks(minPrice, maxPrice); const chartAxisMin = minPrice; const chartAxisMax = maxPrice; // Tabela porownania to ostatnie sprawdzone lokalizacje tego konta - bez podpowiedzi z listy. @@ -21260,41 +21314,103 @@ function PriceHistoryPage({ user }: { user: AuthUser | null }) { const chartWidth = 920; const chartHeight = 250; const chartPaddingTop = 86; - const chartPaddingRight = 22; const chartPaddingBottom = 28; - const chartPaddingLeft = 52; - const chartInnerWidth = chartWidth - chartPaddingLeft - chartPaddingRight; const chartInnerHeight = chartHeight - chartPaddingTop - chartPaddingBottom; - const pointCoords = chartValues.map((price, index) => { - const x = chartPaddingLeft + (index / (chartValues.length - 1)) * chartInnerWidth; + // Srodek kolumny przedzialu - w tym samym miejscu co podpis pod wykresem. + const slotX = (slotIndex: number) => ((slotIndex + 0.5) / historySlots.length) * chartWidth; + const priceY = (price: number) => { const range = chartAxisMax - chartAxisMin || 1; - const y = chartPaddingTop + (1 - (price - chartAxisMin) / range) * chartInnerHeight; - return { x, y, price, sourcePrice: priceHistoryPoints[index] }; - }); - const linePath = pointCoords.map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(1)} ${point.y.toFixed(1)}`).join(' '); + return chartAxisMax === chartAxisMin + ? chartPaddingTop + chartInnerHeight / 2 + : chartPaddingTop + (1 - (price - chartAxisMin) / range) * chartInnerHeight; + }; + const seriesCoords = (prices: (number | null)[]) => prices + .map((price, slotIndex) => (price == null ? null : { x: slotX(slotIndex), y: priceY(price), price, slotIndex })) + .filter((point): point is { x: number; y: number; price: number; slotIndex: number } => point != null); + const seriesPath = (coords: { x: number; y: number }[]) => coords + .map((point, index) => `${index === 0 ? 'M' : 'L'} ${point.x.toFixed(1)} ${point.y.toFixed(1)}`).join(' '); + const pointCoords = seriesCoords(offerPrices); + const nbpCoords = seriesCoords(nbpPrices); + const gusCoords = seriesCoords(gusPrices); + const linePath = seriesPath(pointCoords); + const nbpPath = seriesPath(nbpCoords); + const gusPath = seriesPath(gusCoords); const chartBaseY = chartHeight - chartPaddingBottom; - const areaPath = `${linePath} L ${pointCoords[pointCoords.length - 1].x.toFixed(1)} ${chartBaseY} L ${chartPaddingLeft} ${chartBaseY} Z`; - const hoveredPoint = hoveredHistoryIndex != null ? pointCoords[hoveredHistoryIndex] : null; - const hoveredDate = hoveredHistoryIndex != null ? `${monthLabels[hoveredHistoryIndex]} ${HISTORY_YEAR}${hoveredHistoryIndex === monthLabels.length - 1 ? ` (do ${todayLabel})` : ''}` : ''; - const tooltipWidth = 132; - const tooltipHeight = 54; + const areaPath = pointCoords.length > 1 + ? `${linePath} L ${pointCoords[pointCoords.length - 1].x.toFixed(1)} ${chartBaseY} L ${pointCoords[0].x.toFixed(1)} ${chartBaseY} Z` + : ''; + // Dymek: wszystkie zrodla dla przedzialu pod kursorem. + const hoveredSlot = hoveredHistoryIndex != null ? historySlots[hoveredHistoryIndex] ?? null : null; + const hoveredValues = hoveredHistoryIndex == null ? [] : [ + { label: 'Ogłoszenia', price: offerPrices[hoveredHistoryIndex] }, + { label: `NBP (${nbpMarketLabel})`, price: nbpPrices[hoveredHistoryIndex] }, + { label: `GUS (${gusMarketLabel})`, price: gusPrices[hoveredHistoryIndex] }, + ].filter((entry): entry is { label: string; price: number } => entry.price != null); + const hoveredPoint = hoveredSlot && hoveredValues.length > 0 + ? { x: slotX(hoveredHistoryIndex!), y: priceY(Math.max(...hoveredValues.map((entry) => entry.price))) } + : null; + const hoveredDate = hoveredSlot + ? `${hoveredSlot.label}${hoveredHistoryIndex === historySlots.length - 1 ? ` (do ${todayLabel})` : ''}` + : ''; + const tooltipWidth = 190; + const tooltipHeight = 30 + hoveredValues.length * 20; const tooltipX = hoveredPoint ? Math.min(chartWidth - tooltipWidth - 8, Math.max(8, hoveredPoint.x - tooltipWidth / 2)) : 0; const tooltipY = hoveredPoint ? Math.max(8, hoveredPoint.y - tooltipHeight - 14) : 0; const selectedMarketAlreadyAlerted = selectedMarket ? cityAlertSubscriptions.some((item) => item.key === selectedMarket.key) : false; + // Dymek przy najblizszym punkcie z danymi. const handleHistoryChartMouseMove = (event: ReactMouseEvent) => { - if (!hasSelectedMarket) { + const slotsWithData = historySlots + .map((_, slotIndex) => slotIndex) + .filter((slotIndex) => offerPrices[slotIndex] != null || nbpPrices[slotIndex] != null || gusPrices[slotIndex] != null); + if (!hasSelectedMarket || slotsWithData.length === 0) { return; } const bounds = event.currentTarget.getBoundingClientRect(); const svgX = ((event.clientX - bounds.left) / bounds.width) * chartWidth; - const rawIndex = Math.round(((svgX - chartPaddingLeft) / chartInnerWidth) * (pointCoords.length - 1)); - const nextIndex = Math.min(pointCoords.length - 1, Math.max(0, rawIndex)); - setHoveredHistoryIndex(nextIndex); + const nearest = slotsWithData.reduce((best, slotIndex) => ( + Math.abs(slotX(slotIndex) - svgX) < Math.abs(slotX(best) - svgX) ? slotIndex : best + ), slotsWithData[0]); + setHoveredHistoryIndex(nearest); }; + useEffect(() => { + if (!selectedMarket || !historyPropertyAvailable) { + setHistory(null); + return; + } + let cancelled = false; + setHistoryLoading(true); + setHoveredHistoryIndex(null); + apiFetch(priceHistoryApiPath(selectedMarket, propertyType, marketType, historyFromDate(historySlots))) + .then((data) => { if (!cancelled) setHistory(data); }) + .catch(() => { if (!cancelled) setHistory(null); }) + .finally(() => { if (!cancelled) setHistoryLoading(false); }); + return () => { cancelled = true; }; + }, [selectedMarket, propertyType, marketType, historyPropertyAvailable, historySlots]); + + // Porownanie: aktualna srednia dla kazdej zapamietanej lokalizacji (te same filtry co wykres). + useEffect(() => { + if (!historyPropertyAvailable || recentMarkets.length === 0) { + setComparisonPrices({}); + return; + } + let cancelled = false; + const today = new Date().toISOString().slice(0, 10); + Promise.all(recentMarkets.map((market) => ( + apiFetch(priceHistoryApiPath(market, propertyType, marketType, today)) + .then((data) => [market.key, data.current?.avgPricePerM2 ?? null] as const) + .catch(() => [market.key, null] as const) + ))).then((entries) => { + if (!cancelled) { + setComparisonPrices(Object.fromEntries(entries)); + } + }); + return () => { cancelled = true; }; + }, [recentMarkets, propertyType, marketType, historyPropertyAvailable]); + useEffect(() => { if (!createAlertFeedback) { return; @@ -21384,11 +21500,11 @@ function PriceHistoryPage({ user }: { user: AuthUser | null }) { return; } const typeParam = historyParams.get('typ'); - if (typeParam && typeParam in HISTORY_PROPERTY_MULTIPLIERS) { + if (typeParam && typeParam in HISTORY_PROPERTY_API) { setPropertyType(typeParam as HistoryPropertyType); } const marketParam = historyParams.get('rynek'); - if (marketParam && marketParam in HISTORY_MARKET_MULTIPLIERS) { + if (marketParam && marketParam in HISTORY_MARKET_API) { setMarketType(marketParam as HistoryMarketType); } applyMarket(resolveHistoryMarket(label)); @@ -21421,7 +21537,7 @@ function PriceHistoryPage({ user }: { user: AuthUser | null }) { setSuggestOpen(false); setPropertyType('Mieszkanie'); setMarketType('Wtórny i pierwotny'); - setPeriod('2026'); + setPeriod('5y'); }; const createCityAlert = () => { @@ -21529,7 +21645,7 @@ function PriceHistoryPage({ user }: { user: AuthUser | null }) {