Wycena na prawdziwych danych zamiast zmyslonej tabeli cen
Deploy Polska Lokalnie / deploy (push) Successful in 11s
Deploy Polska Lokalnie / deploy (push) Successful in 11s
Stawka bazowa za m2 pochodzila z tabeli opisanej jako "SonarHome 2026" i zmyslonych mnoznikow dzielnic. Teraz korzysta z /api/price-history: - najnowsza cena transakcyjna mieszkan NBP dla miasta i rynku, potem GUS (dane najwyzej 3-letnie), a bez nich srednia cen ofertowych z ogloszen; - korekta dzielnicy liczona z ogloszen (min. 3 w dzielnicy i 5 w miescie); - domy: srednia z ogloszen domow, gdy sa co najmniej 3, inaczej ceny mieszkan z korekta modelu - opisane przy wyniku; - zrodlo stawki w podgladzie, zalozeniach i raporcie PDF; - brak danych dla lokalizacji to komunikat zamiast wymyslonej kwoty. Korekty modelu (stan, pietro, rok budowy, udogodnienia) bez zmian. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+190
-143
@@ -17130,6 +17130,10 @@ type ValuationResult = {
|
|||||||
validUntil: string;
|
validUntil: string;
|
||||||
details: { icon: string; label: string; value: string }[];
|
details: { icon: string; label: string; value: string }[];
|
||||||
mainFactors: string[];
|
mainFactors: string[];
|
||||||
|
// Dlaczego nie ma wyniku, gdy sa juz podstawowe dane (np. brak danych rynkowych dla lokalizacji).
|
||||||
|
unavailableReason?: string;
|
||||||
|
// Zrodlo stawki bazowej - do wyniku i raportu PDF.
|
||||||
|
sourceLabel?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const INITIAL_VALUATION_FORM: ValuationForm = {
|
const INITIAL_VALUATION_FORM: ValuationForm = {
|
||||||
@@ -17173,14 +17177,121 @@ const VALUATION_HOUSE_BUILDING_TYPES = ['Wolnostojący', 'Bliźniak', 'Szeregowy
|
|||||||
const VALUATION_COMMERCIAL_BUILDING_TYPES = ['Lokal handlowy', 'Biuro', 'Usługi', 'Gastronomia', 'Magazyn'];
|
const VALUATION_COMMERCIAL_BUILDING_TYPES = ['Lokal handlowy', 'Biuro', 'Usługi', 'Gastronomia', 'Magazyn'];
|
||||||
const VALUATION_COMMERCIAL_PURPOSES = ['Handel', 'Biuro', 'Usługi', 'Gastronomia', 'Medyczny', 'Magazyn'];
|
const VALUATION_COMMERCIAL_PURPOSES = ['Handel', 'Biuro', 'Usługi', 'Gastronomia', 'Medyczny', 'Magazyn'];
|
||||||
|
|
||||||
type ValuationMarketReference = {
|
// Stawka bazowa wyceny z prawdziwych danych: cena transakcyjna NBP/GUS dla miasta albo srednia
|
||||||
label: string;
|
// cen ofertowych z ogloszen serwisu. sourceLabel trafia do wyniku i raportu PDF.
|
||||||
|
type ValuationBase = {
|
||||||
basePerMeter: number;
|
basePerMeter: number;
|
||||||
precision: 'city' | 'region' | 'national';
|
sourceLabel: string;
|
||||||
locationType: string;
|
precisionLabel: string;
|
||||||
|
// false, gdy stawka juz dotyczy wycenianego typu (np. srednia z ogloszen domow).
|
||||||
|
appliesPropertyMultiplier: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const VALUATION_2026_SOURCE_NOTE = 'model 2026: ceny transakcyjne SonarHome 2026-06-01 + korekty regionalne';
|
// Dane rynkowe dla wyceny - odpowiedzi /api/price-history dla miasta i dzielnicy.
|
||||||
|
type ValuationMarketData = {
|
||||||
|
city: string;
|
||||||
|
district: string;
|
||||||
|
apartmentCity: ApiPriceHistory | null;
|
||||||
|
apartmentDistrict: ApiPriceHistory | null;
|
||||||
|
houseCity: ApiPriceHistory | null;
|
||||||
|
houseDistrict: ApiPriceHistory | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const VALUATION_MIN_DISTRICT_LISTINGS = 3;
|
||||||
|
const VALUATION_MIN_CITY_LISTINGS_FOR_RATIO = 5;
|
||||||
|
// GUS publikuje z opoznieniem - starszych danych nie uzywamy jako stawki.
|
||||||
|
const VALUATION_MAX_GUS_AGE_YEARS = 3;
|
||||||
|
|
||||||
|
const VALUATION_PLACE_TYPE_WORDS = new Set(['miasto', 'wies', 'dzielnica', 'osiedle', 'miejscowosc', 'gmina', 'powiat', 'przysiolek', 'kolonia', 'osada']);
|
||||||
|
|
||||||
|
// "Kraków, Podgórze" -> miasto Kraków, dzielnica Podgórze. Podpowiedzi mapy maja postac
|
||||||
|
// "Kraków, miasto, Kraków, małopolskie" - opisy typu, powiatu i wojewodztwa pomijamy.
|
||||||
|
function parseValuationLocation(location: string): { city: string; district: string } {
|
||||||
|
const parts = location.split(',').map((part) => part.trim()).filter(Boolean);
|
||||||
|
const city = parts[0] ?? '';
|
||||||
|
const district = parts.slice(1).find((part) => {
|
||||||
|
const normalized = normalizeHistoryText(part.replace(/^powiat\s+/i, ''));
|
||||||
|
return !VALUATION_PLACE_TYPE_WORDS.has(normalized) && !isHistoryRegionName(part)
|
||||||
|
&& normalized !== normalizeHistoryText(city);
|
||||||
|
}) ?? '';
|
||||||
|
return { city, district };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dom albo lokal liczony od cen mieszkan - zaznaczamy to w opisie zrodla.
|
||||||
|
function buildValuationBase(form: ValuationForm, data: ValuationMarketData | null): ValuationBase | null {
|
||||||
|
const base = buildValuationBaseFromData(form, data);
|
||||||
|
if (base && base.appliesPropertyMultiplier && (form.propertyType === 'Dom' || form.propertyType === 'Lokal użytkowy')) {
|
||||||
|
const what = form.propertyType === 'Dom' ? 'domu' : 'lokalu użytkowego';
|
||||||
|
return { ...base, sourceLabel: `${base.sourceLabel}, przeliczona dla ${what} wg modelu` };
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildValuationBaseFromData(form: ValuationForm, data: ValuationMarketData | null): ValuationBase | null {
|
||||||
|
if (!data || !data.city) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const where = [data.city, data.district].filter(Boolean).join(', ');
|
||||||
|
const cityListings = data.apartmentCity?.current ?? null;
|
||||||
|
const districtListings = data.apartmentDistrict?.current ?? null;
|
||||||
|
// Korekta dzielnicy z naszych ogloszen - tylko przy wystarczajacej liczbie ogloszen w obu grupach.
|
||||||
|
const districtRatio = data.district && districtListings && cityListings
|
||||||
|
&& districtListings.listingsCount >= VALUATION_MIN_DISTRICT_LISTINGS
|
||||||
|
&& cityListings.listingsCount >= VALUATION_MIN_CITY_LISTINGS_FOR_RATIO
|
||||||
|
? clampNumber(districtListings.avgPricePerM2 / cityListings.avgPricePerM2, 0.7, 1.4)
|
||||||
|
: null;
|
||||||
|
const districtNote = districtRatio != null
|
||||||
|
? `, korekta dzielnicy wg ogłoszeń ${districtRatio >= 1 ? '+' : ''}${((districtRatio - 1) * 100).toFixed(1).replace('.', ',')}%`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
if (form.propertyType === 'Dom') {
|
||||||
|
const houseListings = [data.houseDistrict?.current, data.houseCity?.current]
|
||||||
|
.find((current) => current && current.listingsCount >= VALUATION_MIN_DISTRICT_LISTINGS) ?? null;
|
||||||
|
if (houseListings) {
|
||||||
|
return {
|
||||||
|
basePerMeter: houseListings.avgPricePerM2,
|
||||||
|
sourceLabel: `średnia cen ofertowych domów z ${houseListings.listingsCount} ogłoszeń w serwisie (${where})`,
|
||||||
|
precisionLabel: 'Dane: ogłoszenia domów w tej lokalizacji',
|
||||||
|
appliesPropertyMultiplier: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nbp = data.apartmentCity?.nbp?.[data.apartmentCity.nbp.length - 1] ?? null;
|
||||||
|
const gusAll = data.apartmentCity?.gus ?? [];
|
||||||
|
const gus = gusAll[gusAll.length - 1] ?? null;
|
||||||
|
const gusFresh = gus && gus.year >= new Date().getFullYear() - VALUATION_MAX_GUS_AGE_YEARS ? gus : null;
|
||||||
|
const transaction = nbp
|
||||||
|
? { price: nbp.pricePerM2, label: `cena transakcyjna mieszkań NBP, rynek ${(data.apartmentCity?.nbpMarket ?? 'Wtórny').toLowerCase()}, ${HISTORY_ROMAN_QUARTERS[nbp.quarter - 1]} kw. ${nbp.year} (${data.city})` }
|
||||||
|
: gusFresh
|
||||||
|
? { price: gusFresh.pricePerM2, label: `cena transakcyjna mieszkań GUS, ${HISTORY_ROMAN_QUARTERS[gusFresh.quarter - 1]} kw. ${gusFresh.year} (${data.city})` }
|
||||||
|
: null;
|
||||||
|
if (transaction) {
|
||||||
|
return {
|
||||||
|
basePerMeter: Math.round(transaction.price * (districtRatio ?? 1)),
|
||||||
|
sourceLabel: `${transaction.label}${districtNote}`,
|
||||||
|
precisionLabel: districtRatio != null ? 'Dokładność: dzielnica' : 'Dokładność: całe miasto',
|
||||||
|
appliesPropertyMultiplier: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bez cen transakcyjnych - srednia cen ofertowych mieszkan z naszych ogloszen.
|
||||||
|
const listings = districtListings && districtListings.listingsCount >= VALUATION_MIN_DISTRICT_LISTINGS
|
||||||
|
? { current: districtListings, place: where }
|
||||||
|
: cityListings ? { current: cityListings, place: data.city } : null;
|
||||||
|
if (listings) {
|
||||||
|
return {
|
||||||
|
basePerMeter: listings.current.avgPricePerM2,
|
||||||
|
sourceLabel: `średnia cen ofertowych mieszkań z ${listings.current.listingsCount} ogłoszeń w serwisie (${listings.place})`,
|
||||||
|
precisionLabel: listings.current.listingsCount < VALUATION_MIN_DISTRICT_LISTINGS
|
||||||
|
? 'Dokładność: orientacyjna (mało ogłoszeń)'
|
||||||
|
: 'Dokładność: ogłoszenia w tej lokalizacji',
|
||||||
|
appliesPropertyMultiplier: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const VALUATION_PRICE_PER_SQM_BY_CITY: Record<string, number> = {
|
const VALUATION_PRICE_PER_SQM_BY_CITY: Record<string, number> = {
|
||||||
Warszawa: 15700,
|
Warszawa: 15700,
|
||||||
@@ -17230,53 +17341,6 @@ const VALUATION_PRICE_PER_SQM_BY_CITY: Record<string, number> = {
|
|||||||
Ostrołęka: 7000,
|
Ostrołęka: 7000,
|
||||||
};
|
};
|
||||||
|
|
||||||
const VALUATION_PRICE_PER_SQM_BY_REGION: Record<string, number> = {
|
|
||||||
mazowieckie: 8200,
|
|
||||||
małopolskie: 8300,
|
|
||||||
pomorskie: 8600,
|
|
||||||
dolnośląskie: 7900,
|
|
||||||
wielkopolskie: 7400,
|
|
||||||
łódzkie: 6500,
|
|
||||||
śląskie: 6700,
|
|
||||||
podkarpackie: 6600,
|
|
||||||
lubelskie: 6300,
|
|
||||||
zachodniopomorskie: 7000,
|
|
||||||
'kujawsko-pomorskie': 6100,
|
|
||||||
podlaskie: 6200,
|
|
||||||
'warmińsko-mazurskie': 6500,
|
|
||||||
świętokrzyskie: 5900,
|
|
||||||
opolskie: 6100,
|
|
||||||
lubuskie: 6100,
|
|
||||||
};
|
|
||||||
|
|
||||||
const VALUATION_NATIONAL_BASE_PER_SQM = 6900;
|
|
||||||
|
|
||||||
const VALUATION_DISTRICT_MULTIPLIERS: { keyword: string; multiplier: number }[] = [
|
|
||||||
{ keyword: 'śródmieście', multiplier: 1.18 },
|
|
||||||
{ keyword: 'stare miasto', multiplier: 1.16 },
|
|
||||||
{ keyword: 'mokotów', multiplier: 1.13 },
|
|
||||||
{ keyword: 'żoliborz', multiplier: 1.12 },
|
|
||||||
{ keyword: 'wola', multiplier: 1.1 },
|
|
||||||
{ keyword: 'wilanów', multiplier: 1.1 },
|
|
||||||
{ keyword: 'ursynów', multiplier: 1.05 },
|
|
||||||
{ keyword: 'zabłocie', multiplier: 1.08 },
|
|
||||||
{ keyword: 'podgórze', multiplier: 1.05 },
|
|
||||||
{ keyword: 'kazimierz', multiplier: 1.12 },
|
|
||||||
{ keyword: 'krowodrza', multiplier: 1.04 },
|
|
||||||
{ keyword: 'oliwa', multiplier: 1.09 },
|
|
||||||
{ keyword: 'wrzeszcz', multiplier: 1.06 },
|
|
||||||
{ keyword: 'przymorze', multiplier: 1.04 },
|
|
||||||
{ keyword: 'krzyki', multiplier: 1.05 },
|
|
||||||
{ keyword: 'nadodrze', multiplier: 1.03 },
|
|
||||||
{ keyword: 'jeżyce', multiplier: 1.04 },
|
|
||||||
{ keyword: 'łazarz', multiplier: 1.02 },
|
|
||||||
{ keyword: 'bemowo', multiplier: 1.02 },
|
|
||||||
{ keyword: 'praga', multiplier: 0.98 },
|
|
||||||
{ keyword: 'nowa huta', multiplier: 0.92 },
|
|
||||||
{ keyword: 'bielany', multiplier: 0.99 },
|
|
||||||
{ keyword: 'targówek', multiplier: 0.94 },
|
|
||||||
];
|
|
||||||
|
|
||||||
const VALUATION_CONDITION_OPTIONS = [
|
const VALUATION_CONDITION_OPTIONS = [
|
||||||
{ icon: 'paint', title: 'Do remontu', text: 'Nieruchomość wymaga generalnego remontu.', multiplier: 0.88 },
|
{ icon: 'paint', title: 'Do remontu', text: 'Nieruchomość wymaga generalnego remontu.', multiplier: 0.88 },
|
||||||
{ icon: 'hammer', title: 'Do odświeżenia', text: 'Nieruchomość wymaga odświeżenia lub drobnych napraw.', multiplier: 0.95 },
|
{ icon: 'hammer', title: 'Do odświeżenia', text: 'Nieruchomość wymaga odświeżenia lub drobnych napraw.', multiplier: 0.95 },
|
||||||
@@ -17353,78 +17417,6 @@ function parsePositiveNumber(value: string): number | null {
|
|||||||
return parsed != null && parsed > 0 ? parsed : null;
|
return parsed != null && parsed > 0 ? parsed : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function findValuationCity(location: string): string | null {
|
|
||||||
const normalizedLocation = location.toLowerCase();
|
|
||||||
return Object.keys(VALUATION_PRICE_PER_SQM_BY_CITY).find((city) => normalizedLocation.includes(city.toLowerCase())) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getLocationTypeAdjustment(location: string): { multiplier: number; label: string } {
|
|
||||||
const normalizedLocation = location.toLowerCase();
|
|
||||||
if (normalizedLocation.includes('dzielnica') || normalizedLocation.includes('osiedle')) {
|
|
||||||
return { multiplier: 1, label: 'dzielnica' };
|
|
||||||
}
|
|
||||||
if (normalizedLocation.includes('wieś') || normalizedLocation.includes('hamlet') || normalizedLocation.includes('osada')) {
|
|
||||||
return { multiplier: 0.72, label: 'wieś' };
|
|
||||||
}
|
|
||||||
if (normalizedLocation.includes('powiat')) {
|
|
||||||
return { multiplier: 0.78, label: 'powiat' };
|
|
||||||
}
|
|
||||||
if (normalizedLocation.includes('miejscowość')) {
|
|
||||||
return { multiplier: 0.82, label: 'miejscowość' };
|
|
||||||
}
|
|
||||||
if (normalizedLocation.includes('miasto')) {
|
|
||||||
return { multiplier: 0.9, label: 'miasto' };
|
|
||||||
}
|
|
||||||
return { multiplier: 0.86, label: 'lokalizacja' };
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveValuationMarket(location: string): ValuationMarketReference | null {
|
|
||||||
const trimmedLocation = location.trim();
|
|
||||||
if (trimmedLocation.length < 2) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const city = findValuationCity(trimmedLocation);
|
|
||||||
if (city) {
|
|
||||||
return {
|
|
||||||
label: city,
|
|
||||||
basePerMeter: VALUATION_PRICE_PER_SQM_BY_CITY[city],
|
|
||||||
precision: 'city',
|
|
||||||
locationType: 'miasto',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const normalizedLocation = trimmedLocation.toLowerCase();
|
|
||||||
const region = Object.keys(VALUATION_PRICE_PER_SQM_BY_REGION).find((regionName) => normalizedLocation.includes(regionName));
|
|
||||||
const locationType = getLocationTypeAdjustment(trimmedLocation);
|
|
||||||
if (region) {
|
|
||||||
return {
|
|
||||||
label: `${region}, ${locationType.label}`,
|
|
||||||
basePerMeter: Math.round(VALUATION_PRICE_PER_SQM_BY_REGION[region] * locationType.multiplier / 50) * 50,
|
|
||||||
precision: 'region',
|
|
||||||
locationType: locationType.label,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const knownCity = POLISH_CITIES.find((cityName) => normalizedLocation.includes(cityName.toLowerCase()));
|
|
||||||
if (knownCity) {
|
|
||||||
return {
|
|
||||||
label: knownCity,
|
|
||||||
basePerMeter: Math.round(VALUATION_NATIONAL_BASE_PER_SQM * 1.04 / 50) * 50,
|
|
||||||
precision: 'region',
|
|
||||||
locationType: 'miasto',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
label: `Polska, ${locationType.label}`,
|
|
||||||
basePerMeter: Math.round(VALUATION_NATIONAL_BASE_PER_SQM * locationType.multiplier / 50) * 50,
|
|
||||||
precision: 'national',
|
|
||||||
locationType: locationType.label,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDistrictMultiplier(location: string): number {
|
|
||||||
const normalizedLocation = location.toLowerCase();
|
|
||||||
return VALUATION_DISTRICT_MULTIPLIERS.find((item) => normalizedLocation.includes(item.keyword))?.multiplier ?? 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAreaMultiplier(area: number): number {
|
function getAreaMultiplier(area: number): number {
|
||||||
if (area < 35) {
|
if (area < 35) {
|
||||||
return 1.08;
|
return 1.08;
|
||||||
@@ -17586,15 +17578,16 @@ type ValuationDisplay = {
|
|||||||
formatPricePerAreaNumber: (plnPerSquareMeter: number) => string;
|
formatPricePerAreaNumber: (plnPerSquareMeter: number) => string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function calculateApartmentValuation(form: ValuationForm, display: ValuationDisplay): ValuationResult {
|
// base - stawka z danych rynkowych (null: jeszcze sie wczytuje albo brak danych dla lokalizacji).
|
||||||
|
function calculateApartmentValuation(form: ValuationForm, display: ValuationDisplay, base: ValuationBase | null,
|
||||||
|
baseLoading: boolean): ValuationResult {
|
||||||
// Metraz przychodzi w jednostce wybranej przez uzytkownika, a caly model cen operuje na m² -
|
// Metraz przychodzi w jednostce wybranej przez uzytkownika, a caly model cen operuje na m² -
|
||||||
// dlatego normalizujemy go tutaj, zanim trafi do progow i mnoznikow.
|
// dlatego normalizujemy go tutaj, zanim trafi do progow i mnoznikow.
|
||||||
const areaInput = parsePositiveNumber(form.area);
|
const areaInput = parsePositiveNumber(form.area);
|
||||||
const area = areaInput ? display.toSquareMeters(areaInput) : null;
|
const area = areaInput ? display.toSquareMeters(areaInput) : null;
|
||||||
const plotAreaInput = parsePositiveNumber(form.plotArea);
|
const plotAreaInput = parsePositiveNumber(form.plotArea);
|
||||||
const plotArea = plotAreaInput ? display.toSquareMeters(plotAreaInput) : null;
|
const plotArea = plotAreaInput ? display.toSquareMeters(plotAreaInput) : null;
|
||||||
const marketReference = resolveValuationMarket(form.location);
|
if (!area || !base) {
|
||||||
if (!area || !marketReference) {
|
|
||||||
return {
|
return {
|
||||||
ready: false,
|
ready: false,
|
||||||
minValue: 0,
|
minValue: 0,
|
||||||
@@ -17606,13 +17599,20 @@ function calculateApartmentValuation(form: ValuationForm, display: ValuationDisp
|
|||||||
validUntil: '',
|
validUntil: '',
|
||||||
details: [],
|
details: [],
|
||||||
mainFactors: [],
|
mainFactors: [],
|
||||||
|
unavailableReason: area && form.location.trim().length >= 2
|
||||||
|
? baseLoading
|
||||||
|
? 'Pobieramy dane rynkowe dla tej lokalizacji…'
|
||||||
|
: 'Brak danych rynkowych dla tej lokalizacji - nie mamy cen transakcyjnych NBP/GUS ani ogłoszeń w serwisie. Spróbuj wpisać większe miasto w formacie „Miasto, dzielnica”.'
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const basePerMeter = marketReference.basePerMeter;
|
const basePerMeter = base.basePerMeter;
|
||||||
const conditionMultiplier = VALUATION_CONDITION_OPTIONS.find((option) => option.title === form.condition)?.multiplier ?? 1;
|
const conditionMultiplier = VALUATION_CONDITION_OPTIONS.find((option) => option.title === form.condition)?.multiplier ?? 1;
|
||||||
const marketMultiplier = form.market === 'Pierwotny' ? 1.07 : 1;
|
// NBP podaje rynek wprost - korekta rynku tylko, gdy stawka nie dotyczy juz wybranego rynku.
|
||||||
const propertyMultiplier = form.propertyType === 'Dom' ? 0.86 : form.propertyType === 'Lokal użytkowy' ? 0.78 : 1;
|
const marketMultiplier = form.market === 'Pierwotny' && !base.sourceLabel.includes('pierwotny') ? 1.07 : 1;
|
||||||
|
const propertyMultiplier = !base.appliesPropertyMultiplier ? 1
|
||||||
|
: form.propertyType === 'Dom' ? 0.86 : form.propertyType === 'Lokal użytkowy' ? 0.78 : 1;
|
||||||
const buildingMultiplier = getBuildingTypeMultiplier(form);
|
const buildingMultiplier = getBuildingTypeMultiplier(form);
|
||||||
const materialMultiplier = form.buildingMaterial === 'Cegła' ? 1.025 : form.buildingMaterial === 'Żelbet' ? 1.015 : form.buildingMaterial === 'Wielka płyta' ? 0.975 : 1;
|
const materialMultiplier = form.buildingMaterial === 'Cegła' ? 1.025 : form.buildingMaterial === 'Żelbet' ? 1.015 : form.buildingMaterial === 'Wielka płyta' ? 0.975 : 1;
|
||||||
const kitchenMultiplier = form.propertyType !== 'Lokal użytkowy' && form.kitchen === 'Oddzielna' && area >= 50 ? 1.01 : 1;
|
const kitchenMultiplier = form.propertyType !== 'Lokal użytkowy' && form.kitchen === 'Oddzielna' && area >= 50 ? 1.01 : 1;
|
||||||
@@ -17621,7 +17621,6 @@ function calculateApartmentValuation(form: ValuationForm, display: ValuationDisp
|
|||||||
const rentMultiplier = rent && area ? clampNumber(1 - Math.max(0, rent / area - 16) * 0.002, 0.97, 1.01) : 1;
|
const rentMultiplier = rent && area ? clampNumber(1 - Math.max(0, rent / area - 16) * 0.002, 0.97, 1.01) : 1;
|
||||||
|
|
||||||
const adjustedPerMeter = basePerMeter
|
const adjustedPerMeter = basePerMeter
|
||||||
* getDistrictMultiplier(form.location)
|
|
||||||
* marketMultiplier
|
* marketMultiplier
|
||||||
* propertyMultiplier
|
* propertyMultiplier
|
||||||
* getAreaMultiplier(area)
|
* getAreaMultiplier(area)
|
||||||
@@ -17689,13 +17688,13 @@ function calculateApartmentValuation(form: ValuationForm, display: ValuationDisp
|
|||||||
{ icon: 'money', label: form.propertyType === 'Dom' ? 'Koszty utrzymania' : form.propertyType === 'Lokal użytkowy' ? 'Czynsz / koszty' : 'Czynsz administracyjny', value: rent ? `${formatPln(rent)} / mies.` : 'Nie podano' },
|
{ icon: 'money', label: form.propertyType === 'Dom' ? 'Koszty utrzymania' : form.propertyType === 'Lokal użytkowy' ? 'Czynsz / koszty' : 'Czynsz administracyjny', value: rent ? `${formatPln(rent)} / mies.` : 'Nie podano' },
|
||||||
],
|
],
|
||||||
mainFactors: [
|
mainFactors: [
|
||||||
`${marketReference.label}: baza ${display.formatPricePerAreaNumber(basePerMeter)} za ${display.areaSuffix}`,
|
`Stawka bazowa: ${display.formatPricePerAreaNumber(basePerMeter)} za ${display.areaSuffix} - ${base.sourceLabel}`,
|
||||||
marketReference.precision === 'city' ? 'Dokładność: miasto' : marketReference.precision === 'region' ? 'Dokładność: region i typ lokalizacji' : 'Dokładność: średnia krajowa',
|
base.precisionLabel,
|
||||||
`Lokalizacja: x${getDistrictMultiplier(form.location).toFixed(2)}`,
|
|
||||||
`Stan: x${conditionMultiplier.toFixed(2)}`,
|
`Stan: x${conditionMultiplier.toFixed(2)}`,
|
||||||
`Rok, budynek i typ: x${(getYearMultiplier(form.yearBuilt) * buildingMultiplier * materialMultiplier * getPropertyTypeDetailMultiplier(form, plotArea)).toFixed(2)}`,
|
`Rok, budynek i typ: x${(getYearMultiplier(form.yearBuilt) * buildingMultiplier * materialMultiplier * getPropertyTypeDetailMultiplier(form, plotArea)).toFixed(2)}`,
|
||||||
VALUATION_2026_SOURCE_NOTE,
|
'Korekty (stan, piętro, rok budowy, udogodnienia) to model orientacyjny, nie operat szacunkowy.',
|
||||||
],
|
],
|
||||||
|
sourceLabel: base.sourceLabel,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -17798,7 +17797,7 @@ async function downloadValuationReportPdf(form: ValuationForm, valuation: Valuat
|
|||||||
},
|
},
|
||||||
{ text: 'Raport szacunkowej wyceny nieruchomości', style: 'title' },
|
{ text: 'Raport szacunkowej wyceny nieruchomości', style: 'title' },
|
||||||
{
|
{
|
||||||
text: 'Dokument przygotowany automatycznie na podstawie danych podanych w formularzu oraz modelu cenowego 2026. Raport ma charakter informacyjny i nie jest operatem szacunkowym.',
|
text: `Dokument przygotowany automatycznie na podstawie danych z formularza. Stawka bazowa: ${valuation.sourceLabel ?? 'dane rynkowe serwisu'}. Raport ma charakter informacyjny i nie jest operatem szacunkowym.`,
|
||||||
style: 'lead',
|
style: 'lead',
|
||||||
margin: [0, 0, 0, 18],
|
margin: [0, 0, 0, 18],
|
||||||
},
|
},
|
||||||
@@ -18539,8 +18538,55 @@ function ValuationPage() {
|
|||||||
const [valuationLocationSuggestOpen, setValuationLocationSuggestOpen] = useState(false);
|
const [valuationLocationSuggestOpen, setValuationLocationSuggestOpen] = useState(false);
|
||||||
const [valuationLocationSuggestions, setValuationLocationSuggestions] = useState<PlaceSuggestion[]>([]);
|
const [valuationLocationSuggestions, setValuationLocationSuggestions] = useState<PlaceSuggestion[]>([]);
|
||||||
const [valuationLocationSuggestLoading, setValuationLocationSuggestLoading] = useState(false);
|
const [valuationLocationSuggestLoading, setValuationLocationSuggestLoading] = useState(false);
|
||||||
const valuation = calculateApartmentValuation(valuationForm, { toSquareMeters, areaSuffix, formatAreaNumber, formatPricePerAreaNumber });
|
// Dane rynkowe dla lokalizacji z formularza - te same co w Historii cen (ogloszenia, NBP, GUS).
|
||||||
const valuationMarketReference = resolveValuationMarket(valuationForm.location);
|
const [valuationMarketData, setValuationMarketData] = useState<ValuationMarketData | null>(null);
|
||||||
|
const [valuationMarketLoading, setValuationMarketLoading] = useState(false);
|
||||||
|
const valuationPlace = parseValuationLocation(valuationForm.location);
|
||||||
|
useEffect(() => {
|
||||||
|
const { city, district } = valuationPlace;
|
||||||
|
if (city.length < 2) {
|
||||||
|
setValuationMarketData(null);
|
||||||
|
setValuationMarketLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
setValuationMarketLoading(true);
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const load = (propertyType: 'APARTMENT' | 'HOUSE', withDistrict: boolean) => {
|
||||||
|
if (withDistrict && !district) {
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
city,
|
||||||
|
district: withDistrict ? district : '',
|
||||||
|
propertyType,
|
||||||
|
market: valuationForm.market,
|
||||||
|
from: today,
|
||||||
|
});
|
||||||
|
return apiFetch<ApiPriceHistory>(`/price-history?${params.toString()}`).catch(() => null);
|
||||||
|
};
|
||||||
|
const timer = window.setTimeout(() => {
|
||||||
|
const wantsHouses = valuationForm.propertyType === 'Dom';
|
||||||
|
Promise.all([
|
||||||
|
load('APARTMENT', false),
|
||||||
|
load('APARTMENT', true),
|
||||||
|
wantsHouses ? load('HOUSE', false) : Promise.resolve(null),
|
||||||
|
wantsHouses ? load('HOUSE', true) : Promise.resolve(null),
|
||||||
|
]).then(([apartmentCity, apartmentDistrict, houseCity, houseDistrict]) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setValuationMarketData({ city, district, apartmentCity, apartmentDistrict, houseCity, houseDistrict });
|
||||||
|
setValuationMarketLoading(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 400);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [valuationPlace.city, valuationPlace.district, valuationForm.market, valuationForm.propertyType]);
|
||||||
|
const valuationBase = buildValuationBase(valuationForm, valuationMarketData);
|
||||||
|
const valuation = calculateApartmentValuation(valuationForm, { toSquareMeters, areaSuffix, formatAreaNumber, formatPricePerAreaNumber },
|
||||||
|
valuationBase, valuationMarketLoading);
|
||||||
const isValuationDetailsStep = step === 1;
|
const isValuationDetailsStep = step === 1;
|
||||||
const isValuationConditionStep = step === 2;
|
const isValuationConditionStep = step === 2;
|
||||||
const isValuationResultStep = step === 3;
|
const isValuationResultStep = step === 3;
|
||||||
@@ -18550,7 +18596,7 @@ function ValuationPage() {
|
|||||||
const valuationBuildingTypeOptions = isHouseValuation ? VALUATION_HOUSE_BUILDING_TYPES : isCommercialValuation ? VALUATION_COMMERCIAL_BUILDING_TYPES : VALUATION_APARTMENT_BUILDING_TYPES;
|
const valuationBuildingTypeOptions = isHouseValuation ? VALUATION_HOUSE_BUILDING_TYPES : isCommercialValuation ? VALUATION_COMMERCIAL_BUILDING_TYPES : VALUATION_APARTMENT_BUILDING_TYPES;
|
||||||
const visibleValuationAmenityOptions = getValuationAmenityOptions(valuationForm.propertyType);
|
const visibleValuationAmenityOptions = getValuationAmenityOptions(valuationForm.propertyType);
|
||||||
const nextStepLabel = isValuationConditionStep ? 'Dalej: Wynik wyceny' : isValuationDetailsStep ? 'Dalej: Stan i udogodnienia' : 'Dalej: Szczegóły nieruchomości';
|
const nextStepLabel = isValuationConditionStep ? 'Dalej: Wynik wyceny' : isValuationDetailsStep ? 'Dalej: Stan i udogodnienia' : 'Dalej: Szczegóły nieruchomości';
|
||||||
const isLocationComplete = Boolean(valuationMarketReference && valuationForm.market && valuationForm.propertyType);
|
const isLocationComplete = Boolean(valuationPlace.city.length >= 2 && valuationForm.market && valuationForm.propertyType);
|
||||||
const isDetailsComplete = Boolean(
|
const isDetailsComplete = Boolean(
|
||||||
parsePositiveNumber(valuationForm.area)
|
parsePositiveNumber(valuationForm.area)
|
||||||
&& valuationForm.rooms
|
&& valuationForm.rooms
|
||||||
@@ -18593,8 +18639,8 @@ function ValuationPage() {
|
|||||||
{ icon: 'target', label: 'Powtórz wycenę' },
|
{ icon: 'target', label: 'Powtórz wycenę' },
|
||||||
];
|
];
|
||||||
const valuationMissingItems = [
|
const valuationMissingItems = [
|
||||||
!valuationMarketReference
|
valuationPlace.city.length < 2
|
||||||
? { icon: 'pin', title: 'Podaj lokalizację', text: 'Wpisz miasto, dzielnicę, miejscowość albo wieś i wybierz podpowiedź z map.' }
|
? { icon: 'pin', title: 'Podaj lokalizację', text: 'Wpisz miasto, najlepiej z dzielnicą, np. „Kraków, Podgórze”.' }
|
||||||
: null,
|
: null,
|
||||||
!parsePositiveNumber(valuationForm.area)
|
!parsePositiveNumber(valuationForm.area)
|
||||||
? { icon: 'house', title: `Podaj metraż ${valuationPropertyLabel}`, text: 'Wycena startuje dopiero po wpisaniu powierzchni w m².' }
|
? { icon: 'house', title: `Podaj metraż ${valuationPropertyLabel}`, text: 'Wycena startuje dopiero po wpisaniu powierzchni w m².' }
|
||||||
@@ -18764,7 +18810,7 @@ function ValuationPage() {
|
|||||||
<p><span>Szacowana wartość maksymalna</span><strong>{valuation.ready ? formatPrice(valuation.maxValue) : '---'}</strong></p>
|
<p><span>Szacowana wartość maksymalna</span><strong>{valuation.ready ? formatPrice(valuation.maxValue) : '---'}</strong></p>
|
||||||
<p><span>Średnia wartość</span><strong>{valuation.ready ? formatPrice(valuation.averageValue) : '---'}</strong></p>
|
<p><span>Średnia wartość</span><strong>{valuation.ready ? formatPrice(valuation.averageValue) : '---'}</strong></p>
|
||||||
<p><span>Cena za {areaSuffix} (min - max)</span><strong>{valuation.ready ? `${formatPricePerAreaNumber(valuation.minPerMeter)} - ${formatPricePerArea(valuation.maxPerMeter)}` : '---'}</strong></p>
|
<p><span>Cena za {areaSuffix} (min - max)</span><strong>{valuation.ready ? `${formatPricePerAreaNumber(valuation.minPerMeter)} - ${formatPricePerArea(valuation.maxPerMeter)}` : '---'}</strong></p>
|
||||||
<small>{valuation.ready ? `Wycena ważna do ${valuation.validUntil}` : 'Podaj dane, aby obliczyć wycenę'}</small>
|
<small>{valuation.ready ? `Wycena ważna do ${valuation.validUntil}` : valuation.unavailableReason ?? 'Podaj dane, aby obliczyć wycenę'}</small>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="valuation-preview-card valuation-next-card">
|
<section className="valuation-preview-card valuation-next-card">
|
||||||
@@ -18978,7 +19024,8 @@ function ValuationPage() {
|
|||||||
{valuation.ready ? <strong className="valuation-price-range">{formatPrice(valuation.minValue)} - {formatPrice(valuation.maxValue)}</strong> : <strong>0</strong>}
|
{valuation.ready ? <strong className="valuation-price-range">{formatPrice(valuation.minValue)} - {formatPrice(valuation.maxValue)}</strong> : <strong>0</strong>}
|
||||||
<small>Przedział cenowy</small>
|
<small>Przedział cenowy</small>
|
||||||
<em>{valuation.ready ? `${formatPricePerAreaNumber(valuation.minPerMeter)} - ${formatPricePerArea(valuation.maxPerMeter)}` : formatPricePerArea(0)}</em>
|
<em>{valuation.ready ? `${formatPricePerAreaNumber(valuation.minPerMeter)} - ${formatPricePerArea(valuation.maxPerMeter)}` : formatPricePerArea(0)}</em>
|
||||||
<p>{valuation.ready ? `Średnio: ${formatPrice(valuation.averageValue)} (${formatPricePerArea(valuation.averagePerMeter)}).` : 'Podaj miasto oraz powierzchnię, aby zobaczyć wynik wyceny.'}</p>
|
<p>{valuation.ready ? `Średnio: ${formatPrice(valuation.averageValue)} (${formatPricePerArea(valuation.averagePerMeter)}).` : valuation.unavailableReason ?? 'Podaj miasto oraz powierzchnię, aby zobaczyć wynik wyceny.'}</p>
|
||||||
|
{valuation.ready && valuation.sourceLabel && <small className="valuation-source-note">Na podstawie: {valuation.sourceLabel}.</small>}
|
||||||
</div>
|
</div>
|
||||||
<div className="valuation-house-graphic" aria-hidden="true">
|
<div className="valuation-house-graphic" aria-hidden="true">
|
||||||
<span><Icon name="house" /></span>
|
<span><Icon name="house" /></span>
|
||||||
|
|||||||
@@ -35099,3 +35099,13 @@ a.listing-detail-back {
|
|||||||
position: static;
|
position: static;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Wycena: zrodlo stawki bazowej pod wynikiem (NBP / GUS / ogloszenia). */
|
||||||
|
.valuation-source-note {
|
||||||
|
color: #5f6f84;
|
||||||
|
display: block;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user