@cenogram/mcp-server 0.1.7 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -24
- package/dist/api-client.d.ts +687 -7
- package/dist/api-client.js +202 -25
- package/dist/auth-dispatch.d.ts +59 -0
- package/dist/auth-dispatch.js +141 -0
- package/dist/client-id.js +0 -5
- package/dist/error-messages.d.ts +12 -0
- package/dist/error-messages.js +80 -0
- package/dist/formatters.d.ts +21 -1
- package/dist/formatters.js +1133 -24
- package/dist/index.d.ts +1 -0
- package/dist/index.js +149 -44
- package/dist/mappings.d.ts +12 -5
- package/dist/mappings.js +135 -17
- package/dist/oauth-jwt.d.ts +19 -0
- package/dist/oauth-jwt.js +63 -0
- package/dist/request-context.d.ts +5 -0
- package/dist/request-context.js +2 -0
- package/dist/sentry-scrub.d.ts +2 -0
- package/dist/sentry-scrub.js +14 -0
- package/dist/sentry.d.ts +2 -0
- package/dist/sentry.js +29 -0
- package/dist/tools.d.ts +1 -0
- package/dist/tools.js +544 -81
- package/dist/transport-mode.d.ts +2 -0
- package/dist/transport-mode.js +6 -0
- package/package.json +16 -7
package/dist/formatters.js
CHANGED
|
@@ -1,5 +1,50 @@
|
|
|
1
|
-
import { PROPERTY_TYPES, MARKET_TYPES } from "./mappings.js";
|
|
2
|
-
|
|
1
|
+
import { PROPERTY_TYPES, MARKET_TYPES, BUILDING_TYPES, OWNERSHIP_TYPES, PARTY_TYPES, LAND_USES } from "./mappings.js";
|
|
2
|
+
const BUILDING_BREAKDOWN_TIP = "Tip: call get_building_breakdown(transaction_id) for per-building detail (footprint, storeys, est. area).";
|
|
3
|
+
const FLOOD_BREAKDOWN_TIP = "Tip: call get_transaction_flood(transaction_id) for the per-parcel flood-zone breakdown (scenario, hazard source, share in zone).";
|
|
4
|
+
const FLOOD_RISK_NOTE = {
|
|
5
|
+
high: "~1-in-10-year",
|
|
6
|
+
medium: "~1-in-100-year",
|
|
7
|
+
low: "~1-in-500-year",
|
|
8
|
+
};
|
|
9
|
+
const HERITAGE_BREAKDOWN_TIP = "Tip: call get_transaction_heritage(transaction_id) for the per-parcel heritage-listing breakdown (entries, category, share in protected area).";
|
|
10
|
+
const HERITAGE_STATUS_NOTE = {
|
|
11
|
+
listed: "protected monument on/at the parcel",
|
|
12
|
+
zone: "within a protected urban layout or monument surroundings",
|
|
13
|
+
};
|
|
14
|
+
const HERITAGE_DISCLAIMER = "Indicative data — the regional heritage conservator makes the final, binding determination.";
|
|
15
|
+
const LANDSLIDE_BREAKDOWN_TIP = "Tip: call get_transaction_landslide(transaction_id) for the per-parcel landslide-zone breakdown (category, share in zone, source-record version date).";
|
|
16
|
+
const LANDSLIDE_RISK_NOTE = {
|
|
17
|
+
landslide: "a mapped landslide area",
|
|
18
|
+
threatened: "an area threatened by mass movements",
|
|
19
|
+
};
|
|
20
|
+
function areaBucketSuffix(bucket) {
|
|
21
|
+
return bucket && bucket !== "all" ? ` (${bucket} m2)` : "";
|
|
22
|
+
}
|
|
23
|
+
function windowSuffix(w) {
|
|
24
|
+
return w.from && w.to ? ` (${w.from} to ${w.to})` : "";
|
|
25
|
+
}
|
|
26
|
+
function offerDateSuffix(date) {
|
|
27
|
+
return date ? ` (as of ${date})` : "";
|
|
28
|
+
}
|
|
29
|
+
function formatPercentileLadder(p) {
|
|
30
|
+
if ([p.p10, p.p25, p.p50, p.p75, p.p90].every((v) => v == null))
|
|
31
|
+
return null;
|
|
32
|
+
const f = (v) => (v == null ? "—" : formatPLN(v));
|
|
33
|
+
return `p10 ${f(p.p10)} · p25 ${f(p.p25)} · p50 ${f(p.p50)} · p75 ${f(p.p75)} · p90 ${f(p.p90)} /m2`;
|
|
34
|
+
}
|
|
35
|
+
function distributionLines(asking, tx) {
|
|
36
|
+
const ask = formatPercentileLadder(asking);
|
|
37
|
+
const txLadder = formatPercentileLadder(tx);
|
|
38
|
+
if (!ask && !txLadder)
|
|
39
|
+
return [];
|
|
40
|
+
const out = ["", "Distribution (price per m2):"];
|
|
41
|
+
if (ask)
|
|
42
|
+
out.push(` Asking: ${ask}`);
|
|
43
|
+
if (txLadder)
|
|
44
|
+
out.push(` Transaction: ${txLadder}`);
|
|
45
|
+
return out;
|
|
46
|
+
}
|
|
47
|
+
export const MARKET_CAVEAT = "Note: median/average prices are market-based — fractional ownership shares and non-market deeds (public tenders, foreclosures, privileged/subsidized sales) are excluded from price aggregates. Transaction counts and coverage stay complete.";
|
|
3
48
|
export function formatPLN(value) {
|
|
4
49
|
if (value == null)
|
|
5
50
|
return "N/A";
|
|
@@ -21,34 +66,83 @@ export function formatNumber(value) {
|
|
|
21
66
|
return "N/A";
|
|
22
67
|
return new Intl.NumberFormat("pl-PL").format(value);
|
|
23
68
|
}
|
|
69
|
+
function formatPLNExact(value) {
|
|
70
|
+
if (value == null)
|
|
71
|
+
return "N/A";
|
|
72
|
+
return new Intl.NumberFormat("pl-PL", {
|
|
73
|
+
style: "currency",
|
|
74
|
+
currency: "PLN",
|
|
75
|
+
maximumFractionDigits: 2,
|
|
76
|
+
}).format(value);
|
|
77
|
+
}
|
|
24
78
|
function formatTransactionCore(f) {
|
|
25
79
|
const parts = [];
|
|
26
|
-
|
|
27
|
-
const addr = [f.street, f.building_number].filter(Boolean).join(" ");
|
|
80
|
+
const streetAddr = [f.street, f.building_number].filter(Boolean).join(" ");
|
|
28
81
|
const district = f.district || f.city;
|
|
29
|
-
const region = [f.county_name ? `
|
|
30
|
-
const loc =
|
|
82
|
+
const region = [f.county_name ? `county: ${f.county_name}` : null, f.voivodeship_name ? `voivodeship: ${f.voivodeship_name}` : null].filter(Boolean).join(", ");
|
|
83
|
+
const loc = f.street
|
|
84
|
+
? [streetAddr, district].filter(Boolean).join(", ")
|
|
85
|
+
: [district, f.building_number].filter(Boolean).join(" ");
|
|
86
|
+
const streetApprox = f.street != null && (f.address_source === "approx_high" || f.address_source === "approx_low");
|
|
87
|
+
const approxTag = streetApprox ? " [street approximate — derived, not from deed]" : "";
|
|
31
88
|
if (loc && region)
|
|
32
|
-
parts.push(`${loc} (${region})`);
|
|
89
|
+
parts.push(`${loc} (${region})${approxTag}`);
|
|
33
90
|
else if (loc)
|
|
34
|
-
parts.push(loc);
|
|
35
|
-
// Metadata line
|
|
91
|
+
parts.push(`${loc}${approxTag}`);
|
|
36
92
|
const meta = [];
|
|
37
93
|
meta.push(`Date: ${f.transaction_date}`);
|
|
38
|
-
|
|
94
|
+
let typeLabel = PROPERTY_TYPES[f.property_type] || `Type ${f.property_type}`;
|
|
95
|
+
if (f.property_type_reclassed)
|
|
96
|
+
typeLabel += " [shown as a unit — registry recorded land, the deed is a residential unit]";
|
|
97
|
+
else if (f.property_type_inferred)
|
|
98
|
+
typeLabel += " [type inferred from transaction structure — not stated in the registry]";
|
|
99
|
+
meta.push(typeLabel);
|
|
39
100
|
meta.push(MARKET_TYPES[f.market_type] || `Market ${f.market_type}`);
|
|
40
101
|
parts.push(meta.join(" | "));
|
|
41
|
-
// Price line
|
|
42
102
|
const price = [];
|
|
43
103
|
price.push(`Price: ${formatPLN(f.price_gross)}`);
|
|
44
|
-
if (f.usable_area_m2 != null)
|
|
45
|
-
|
|
104
|
+
if (f.usable_area_m2 != null) {
|
|
105
|
+
const areaNote = f.area_basis === "building" ? " [whole garage building, not the parking space]" : "";
|
|
106
|
+
price.push(`Area: ${formatArea(f.usable_area_m2)}${areaNote}`);
|
|
107
|
+
}
|
|
46
108
|
if (f.price_per_m2 != null)
|
|
47
109
|
price.push(`Price/m\u00B2: ${formatPLN(f.price_per_m2)}`);
|
|
48
|
-
if (f.parcel_area != null && f.usable_area_m2 == null)
|
|
49
|
-
|
|
110
|
+
if (f.parcel_area != null && f.usable_area_m2 == null) {
|
|
111
|
+
const pNotes = [];
|
|
112
|
+
if (f.parcel_count != null && f.parcel_count >= 2)
|
|
113
|
+
pNotes.push(`sum of ${f.parcel_count} parcels`);
|
|
114
|
+
if (f.area_is_ha_converted)
|
|
115
|
+
pNotes.push("converted from hectares — county reports area in ha");
|
|
116
|
+
const pTag = pNotes.length > 0 ? ` [${pNotes.join("; ")}]` : "";
|
|
117
|
+
price.push(`Parcel: ${formatArea(f.parcel_area)}${pTag}`);
|
|
118
|
+
}
|
|
119
|
+
if (f.share_basis === "fraction")
|
|
120
|
+
price.push("fractional share (excluded from market median)");
|
|
50
121
|
parts.push(price.join(" | "));
|
|
51
|
-
|
|
122
|
+
if (f.building_count != null) {
|
|
123
|
+
const bld = [];
|
|
124
|
+
if (f.footprint_area_m2 != null)
|
|
125
|
+
bld.push(`Building footprint: ${formatArea(f.footprint_area_m2)}`);
|
|
126
|
+
if (f.building_storeys != null)
|
|
127
|
+
bld.push(`Storeys: ${f.building_storeys}`);
|
|
128
|
+
if (f.est_total_area_m2 != null) {
|
|
129
|
+
bld.push(`Est. total floor area: ${formatArea(f.est_total_area_m2)} [estimate: footprint × storeys, not from deed]`);
|
|
130
|
+
}
|
|
131
|
+
if (bld.length > 0)
|
|
132
|
+
parts.push(bld.join(" | "));
|
|
133
|
+
}
|
|
134
|
+
if (f.flood_risk) {
|
|
135
|
+
const note = FLOOD_RISK_NOTE[f.flood_risk];
|
|
136
|
+
parts.push(`Flood risk: ${f.flood_risk}${note ? ` [mapped flood-hazard zone — ${note}]` : ""}`);
|
|
137
|
+
}
|
|
138
|
+
if (f.heritage_status) {
|
|
139
|
+
const note = HERITAGE_STATUS_NOTE[f.heritage_status];
|
|
140
|
+
parts.push(`Heritage listing: ${f.heritage_status}${note ? ` [${note}]` : ""}`);
|
|
141
|
+
}
|
|
142
|
+
if (f.landslide_risk) {
|
|
143
|
+
const note = LANDSLIDE_RISK_NOTE[f.landslide_risk];
|
|
144
|
+
parts.push(`Landslide risk: ${f.landslide_risk}${note ? ` [${note} — parcel intersects a mapped hazard area, 1:10,000-scale maps]` : ""}`);
|
|
145
|
+
}
|
|
52
146
|
const extra = [];
|
|
53
147
|
if (f.parcel_number)
|
|
54
148
|
extra.push(`Plot no: ${f.parcel_number}`);
|
|
@@ -56,15 +150,34 @@ function formatTransactionCore(f) {
|
|
|
56
150
|
extra.push(`Rooms: ${f.rooms}`);
|
|
57
151
|
if (f.floor != null)
|
|
58
152
|
extra.push(`Floor: ${f.floor}`);
|
|
153
|
+
if (f.ownership_type != null)
|
|
154
|
+
extra.push(`Ownership: ${OWNERSHIP_TYPES[f.ownership_type] || `Type ${f.ownership_type}`}`);
|
|
155
|
+
if (f.share_basis === "fraction" && f.ownership_share)
|
|
156
|
+
extra.push(`Share: ${f.ownership_share}`);
|
|
157
|
+
if (f.seller_type != null)
|
|
158
|
+
extra.push(`Seller: ${PARTY_TYPES[f.seller_type] || `Party type ${f.seller_type}`}`);
|
|
159
|
+
if (f.buyer_type != null)
|
|
160
|
+
extra.push(`Buyer: ${PARTY_TYPES[f.buyer_type] || `Party type ${f.buyer_type}`}`);
|
|
161
|
+
if (f.land_use)
|
|
162
|
+
extra.push(`Land use: ${LAND_USES[f.land_use] || f.land_use}`);
|
|
163
|
+
if (f.property_type === 4 && f.unit_price != null && Number(f.unit_price) > 0 && Number(f.unit_price) !== Number(f.price_gross)) {
|
|
164
|
+
extra.push(`Deed unit price (not per-m²): ${formatPLN(Number(f.unit_price))}`);
|
|
165
|
+
}
|
|
166
|
+
if (f.vat != null && f.vat !== "") {
|
|
167
|
+
const vatNum = Number(f.vat);
|
|
168
|
+
if (Number.isFinite(vatNum))
|
|
169
|
+
extra.push(`VAT (as recorded — rate % or amount): ${formatNumber(vatNum)}`);
|
|
170
|
+
}
|
|
59
171
|
if (f.coordinates) {
|
|
60
172
|
const [lng, lat] = f.coordinates;
|
|
61
173
|
extra.push(`Location: ${lat?.toFixed(4)}\u00B0N, ${lng?.toFixed(4)}\u00B0E`);
|
|
62
174
|
}
|
|
175
|
+
if (f.id)
|
|
176
|
+
extra.push(`id: ${f.id}`);
|
|
63
177
|
if (extra.length > 0)
|
|
64
178
|
parts.push(extra.join(" | "));
|
|
65
179
|
return parts.join("\n ");
|
|
66
180
|
}
|
|
67
|
-
// ── Transaction formatting ──────────────────────────────────────────
|
|
68
181
|
export function formatTransaction(tx) {
|
|
69
182
|
return formatTransactionCore({
|
|
70
183
|
...tx,
|
|
@@ -93,9 +206,20 @@ export function formatTransactionList(res, summary) {
|
|
|
93
206
|
if (parts.length > 0)
|
|
94
207
|
lines.push(`\nSummary: ${parts.join(" | ")}`);
|
|
95
208
|
}
|
|
209
|
+
if (data.some((tx) => tx.building_count != null)) {
|
|
210
|
+
lines.push(`\n${BUILDING_BREAKDOWN_TIP}`);
|
|
211
|
+
}
|
|
212
|
+
if (data.some((tx) => tx.flood_risk != null)) {
|
|
213
|
+
lines.push(`\n${FLOOD_BREAKDOWN_TIP}`);
|
|
214
|
+
}
|
|
215
|
+
if (data.some((tx) => tx.heritage_status != null)) {
|
|
216
|
+
lines.push(`\n${HERITAGE_BREAKDOWN_TIP}`);
|
|
217
|
+
}
|
|
218
|
+
if (data.some((tx) => tx.landslide_risk != null)) {
|
|
219
|
+
lines.push(`\n${LANDSLIDE_BREAKDOWN_TIP}`);
|
|
220
|
+
}
|
|
96
221
|
return lines.join("\n");
|
|
97
222
|
}
|
|
98
|
-
// ── Stats formatting ────────────────────────────────────────────────
|
|
99
223
|
export function formatMarketOverview(stats) {
|
|
100
224
|
const lines = [];
|
|
101
225
|
lines.push("Polish Real Estate Transaction Database \u2014 Cenogram.pl\n");
|
|
@@ -124,6 +248,7 @@ export function formatMarketOverview(stats) {
|
|
|
124
248
|
lines.push(` ${i + 1}. ${d.district} \u2014 ${formatNumber(d.transaction_count)} transactions`);
|
|
125
249
|
});
|
|
126
250
|
}
|
|
251
|
+
lines.push(`\n${MARKET_CAVEAT}`);
|
|
127
252
|
return lines.join("\n");
|
|
128
253
|
}
|
|
129
254
|
export function formatPriceStats(rows, location) {
|
|
@@ -136,7 +261,6 @@ export function formatPriceStats(rows, location) {
|
|
|
136
261
|
? `Price statistics for "${location}" (residential units only):\n`
|
|
137
262
|
: "Price statistics by location (residential units only):\n";
|
|
138
263
|
const lines = [header];
|
|
139
|
-
// Sort by median descending
|
|
140
264
|
const sorted = [...rows].sort((a, b) => b.median_price_m2 - a.median_price_m2);
|
|
141
265
|
const shown = sorted.slice(0, 30);
|
|
142
266
|
lines.push("Location | Median PLN/m\u00B2 | Avg PLN/m\u00B2 | Transactions");
|
|
@@ -147,6 +271,7 @@ export function formatPriceStats(rows, location) {
|
|
|
147
271
|
if (sorted.length > 30) {
|
|
148
272
|
lines.push(`\n...and ${sorted.length - 30} more locations.`);
|
|
149
273
|
}
|
|
274
|
+
lines.push(`\n${MARKET_CAVEAT}`);
|
|
150
275
|
return lines.join("\n");
|
|
151
276
|
}
|
|
152
277
|
export function formatHistogram(bins) {
|
|
@@ -161,9 +286,9 @@ export function formatHistogram(bins) {
|
|
|
161
286
|
: "";
|
|
162
287
|
lines.push(`${formatPLN(bin.range_min).padStart(15)} - ${formatPLN(bin.range_max).padEnd(15)} | ${bar} ${formatNumber(bin.count)}`);
|
|
163
288
|
}
|
|
289
|
+
lines.push(`\n${MARKET_CAVEAT}`);
|
|
164
290
|
return lines.join("\n");
|
|
165
291
|
}
|
|
166
|
-
// ── Parcel search formatting ───────────────────────────────────────
|
|
167
292
|
export function formatParcelResults(res, query) {
|
|
168
293
|
if (res.results.length === 0) {
|
|
169
294
|
return `No parcels found matching "${query}".`;
|
|
@@ -172,12 +297,33 @@ export function formatParcelResults(res, query) {
|
|
|
172
297
|
for (const [i, p] of res.results.entries()) {
|
|
173
298
|
const district = p.district ?? "Unknown";
|
|
174
299
|
const area = p.area_m2 != null ? formatArea(p.area_m2) : "N/A";
|
|
175
|
-
|
|
176
|
-
lines.push(
|
|
300
|
+
const location = `${p.lat.toFixed(4)}\u00B0N, ${p.lng.toFixed(4)}\u00B0E`;
|
|
301
|
+
lines.push(`${i + 1}. ${p.parcel_id ?? "(parcel number requires a paid plan)"}`);
|
|
302
|
+
lines.push(` District: ${district} | Area: ${area} | Location: ${location}`);
|
|
303
|
+
}
|
|
304
|
+
return lines.join("\n");
|
|
305
|
+
}
|
|
306
|
+
export function formatParcelResolve(res) {
|
|
307
|
+
if (res.coverage === "not_covered" || res.matches.length === 0) {
|
|
308
|
+
return "No parcel matched. The identifier or 'name + number' is not in our cadastral copy (the credit is refunded). Check the spelling of the locality name, or use search_parcels to look up a parcel id by prefix.";
|
|
309
|
+
}
|
|
310
|
+
const lines = [`Found ${res.matches.length} parcel${res.matches.length === 1 ? "" : "s"}:\n`];
|
|
311
|
+
for (const [i, m] of res.matches.entries()) {
|
|
312
|
+
const id = m.parcel_id ?? "(parcel id requires a paid plan)";
|
|
313
|
+
const district = m.district ?? "Unknown";
|
|
314
|
+
const area = m.area_m2 != null ? formatArea(m.area_m2) : "N/A";
|
|
315
|
+
const location = m.centroid ? `${m.centroid.lat.toFixed(4)}°N, ${m.centroid.lng.toFixed(4)}°E` : "no geometry";
|
|
316
|
+
lines.push(`${i + 1}. ${id}`);
|
|
317
|
+
lines.push(` District: ${district} | Area: ${area} | Location: ${location}`);
|
|
318
|
+
}
|
|
319
|
+
if (res.truncated) {
|
|
320
|
+
lines.push(`\nMore matches exist than shown — narrow the locality name or provide the full parcel id.`);
|
|
321
|
+
}
|
|
322
|
+
if (res.as_of) {
|
|
323
|
+
lines.push(`\nCadastral copy as of ${res.as_of.split("T")[0]}.`);
|
|
177
324
|
}
|
|
178
325
|
return lines.join("\n");
|
|
179
326
|
}
|
|
180
|
-
// ── Spatial search formatting ──────────────────────────────────────
|
|
181
327
|
function formatSpatialFeature(f) {
|
|
182
328
|
return formatTransactionCore({
|
|
183
329
|
...f.properties,
|
|
@@ -204,9 +350,421 @@ export function formatSpatialResults(res) {
|
|
|
204
350
|
if (res.features.length > displayCap) {
|
|
205
351
|
lines.push(`\n...and ${res.features.length - displayCap} more in response (not displayed). Use a smaller limit or narrower polygon.`);
|
|
206
352
|
}
|
|
353
|
+
if (res.features.some((f) => f.properties.building_count != null)) {
|
|
354
|
+
lines.push(`\n${BUILDING_BREAKDOWN_TIP}`);
|
|
355
|
+
}
|
|
356
|
+
return lines.join("\n");
|
|
357
|
+
}
|
|
358
|
+
export function formatBuildingBreakdown(res) {
|
|
359
|
+
const { data, truncated } = res;
|
|
360
|
+
if (data.length === 0) {
|
|
361
|
+
return "No per-building data available for this transaction.";
|
|
362
|
+
}
|
|
363
|
+
const lines = [`Per-building breakdown (${data.length} building${data.length === 1 ? "" : "s"}):`, ""];
|
|
364
|
+
data.forEach((b, i) => {
|
|
365
|
+
const cells = [];
|
|
366
|
+
const typeLabel = b.building_type != null
|
|
367
|
+
? (BUILDING_TYPES[b.building_type] ?? `Type ${b.building_type}`)
|
|
368
|
+
: "Building";
|
|
369
|
+
cells.push(typeLabel);
|
|
370
|
+
if (b.footprint_area_m2 != null) {
|
|
371
|
+
const alt = b.footprint_divergent === true && b.footprint_area_alt_m2 != null
|
|
372
|
+
? ` (alt. measurement ${formatArea(b.footprint_area_alt_m2)} — diverge)`
|
|
373
|
+
: "";
|
|
374
|
+
cells.push(`footprint ${formatArea(b.footprint_area_m2)}${alt}`);
|
|
375
|
+
}
|
|
376
|
+
if (b.storeys != null)
|
|
377
|
+
cells.push(`storeys ${b.storeys}`);
|
|
378
|
+
if (b.est_total_area_m2 != null) {
|
|
379
|
+
cells.push(`est. total floor area ${formatArea(b.est_total_area_m2)} [estimate: footprint × storeys, not from deed]`);
|
|
380
|
+
}
|
|
381
|
+
if (b.match_confidence)
|
|
382
|
+
cells.push(`match confidence: ${b.match_confidence}`);
|
|
383
|
+
lines.push(`${i + 1}. ${cells.join(" | ")}`);
|
|
384
|
+
});
|
|
385
|
+
if (truncated) {
|
|
386
|
+
lines.push("", "Showing the first 500 buildings (the transaction has more).");
|
|
387
|
+
}
|
|
388
|
+
return lines.join("\n");
|
|
389
|
+
}
|
|
390
|
+
export function formatFloodBreakdown(res) {
|
|
391
|
+
const { data, truncated } = res;
|
|
392
|
+
if (data.length === 0) {
|
|
393
|
+
return "No mapped flood-hazard zone is recorded for this transaction's land (or the id was not found). Absence of a mapped zone is not a guarantee of safety — it is never asserted as 'no risk'.";
|
|
394
|
+
}
|
|
395
|
+
const lines = [
|
|
396
|
+
`Per-parcel flood-zone breakdown (${data.length} parcel${data.length === 1 ? "" : "s"} in a mapped flood-hazard zone):`,
|
|
397
|
+
"",
|
|
398
|
+
];
|
|
399
|
+
data.forEach((r, i) => {
|
|
400
|
+
const cells = [];
|
|
401
|
+
const note = r.flood_risk ? FLOOD_RISK_NOTE[r.flood_risk] : undefined;
|
|
402
|
+
cells.push(`risk: ${r.flood_risk ?? "—"}${note ? ` (${note})` : ""}`);
|
|
403
|
+
if (r.source)
|
|
404
|
+
cells.push(`source: ${r.source}`);
|
|
405
|
+
if (r.pct_in_zone != null) {
|
|
406
|
+
const pct = Number(r.pct_in_zone);
|
|
407
|
+
if (Number.isFinite(pct))
|
|
408
|
+
cells.push(`${Math.round(pct)}% of the parcel in the worst-scenario zone`);
|
|
409
|
+
}
|
|
410
|
+
if (Array.isArray(r.scenarios) && r.scenarios.length > 0) {
|
|
411
|
+
const labels = r.scenarios.map((s) => s.scenario).filter((s) => !!s);
|
|
412
|
+
if (labels.length > 0)
|
|
413
|
+
cells.push(`scenarios: ${labels.join("; ")}`);
|
|
414
|
+
}
|
|
415
|
+
lines.push(`${i + 1}. ${cells.join(" | ")}`);
|
|
416
|
+
});
|
|
417
|
+
if (truncated) {
|
|
418
|
+
lines.push("", "Showing the first 500 parcels (the transaction is linked to more).");
|
|
419
|
+
}
|
|
420
|
+
return lines.join("\n");
|
|
421
|
+
}
|
|
422
|
+
export function formatHeritageBreakdown(res) {
|
|
423
|
+
const { data, truncated } = res;
|
|
424
|
+
if (data.length === 0) {
|
|
425
|
+
return "No heritage-listing records found for this transaction's parcels (or the id was not found). This is not a statement that the property is free of heritage protection — absence of a detection is never asserted as 'not listed'.";
|
|
426
|
+
}
|
|
427
|
+
const lines = [
|
|
428
|
+
`Per-parcel heritage-listing breakdown (${data.length} parcel${data.length === 1 ? "" : "s"} with a detected listing):`,
|
|
429
|
+
"",
|
|
430
|
+
];
|
|
431
|
+
data.forEach((r, i) => {
|
|
432
|
+
const cells = [];
|
|
433
|
+
const note = r.heritage_status ? HERITAGE_STATUS_NOTE[r.heritage_status] : undefined;
|
|
434
|
+
cells.push(`status: ${r.heritage_status ?? "—"}${note ? ` (${note})` : ""}`);
|
|
435
|
+
if (r.site_count != null)
|
|
436
|
+
cells.push(`entries: ${r.site_count}`);
|
|
437
|
+
if (r.pct_in_zone != null) {
|
|
438
|
+
const pct = Number(r.pct_in_zone);
|
|
439
|
+
if (Number.isFinite(pct))
|
|
440
|
+
cells.push(`${Math.round(pct)}% of the parcel in the protected area`);
|
|
441
|
+
}
|
|
442
|
+
lines.push(`${i + 1}. ${cells.join(" | ")}`);
|
|
443
|
+
if (Array.isArray(r.sites)) {
|
|
444
|
+
for (const s of r.sites) {
|
|
445
|
+
const detail = [s.category];
|
|
446
|
+
if (s.name)
|
|
447
|
+
detail.push(s.name);
|
|
448
|
+
if (s.function)
|
|
449
|
+
detail.push(`function: ${s.function}`);
|
|
450
|
+
if (s.period)
|
|
451
|
+
detail.push(`period: ${s.period}`);
|
|
452
|
+
if (s.entry_date)
|
|
453
|
+
detail.push(`entered: ${s.entry_date.split("T")[0]}`);
|
|
454
|
+
lines.push(` - ${detail.join(" | ")}`);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
});
|
|
458
|
+
if (truncated) {
|
|
459
|
+
lines.push("", "Showing the first 500 parcels (the transaction is linked to more).");
|
|
460
|
+
}
|
|
461
|
+
lines.push("", HERITAGE_DISCLAIMER);
|
|
462
|
+
return lines.join("\n");
|
|
463
|
+
}
|
|
464
|
+
export function formatLandslideBreakdown(res) {
|
|
465
|
+
const { data, truncated } = res;
|
|
466
|
+
if (data.length === 0) {
|
|
467
|
+
return "No mapped landslide-hazard zone intersects this transaction's parcels (or the id was not found). Absence of mapped data is not a guarantee of safety — it is never asserted as 'no risk'.";
|
|
468
|
+
}
|
|
469
|
+
const lines = [
|
|
470
|
+
`Per-parcel landslide-zone breakdown (${data.length} parcel${data.length === 1 ? "" : "s"} intersecting a mapped landslide-hazard zone):`,
|
|
471
|
+
"",
|
|
472
|
+
];
|
|
473
|
+
data.forEach((r, i) => {
|
|
474
|
+
const cells = [];
|
|
475
|
+
const note = r.landslide_risk ? LANDSLIDE_RISK_NOTE[r.landslide_risk] : undefined;
|
|
476
|
+
cells.push(`risk: ${r.landslide_risk ?? "—"}${note ? ` (${note})` : ""}`);
|
|
477
|
+
if (r.pct_in_zone != null) {
|
|
478
|
+
const pct = Number(r.pct_in_zone);
|
|
479
|
+
if (Number.isFinite(pct))
|
|
480
|
+
cells.push(`${Math.round(pct)}% of the parcel in mapped zones`);
|
|
481
|
+
}
|
|
482
|
+
if (Array.isArray(r.zones) && r.zones.length > 0) {
|
|
483
|
+
const labels = r.zones
|
|
484
|
+
.map((z) => {
|
|
485
|
+
if (!z.kind)
|
|
486
|
+
return null;
|
|
487
|
+
return z.source_version_date ? `${z.kind} (record version date: ${z.source_version_date})` : z.kind;
|
|
488
|
+
})
|
|
489
|
+
.filter((s) => !!s);
|
|
490
|
+
if (labels.length > 0)
|
|
491
|
+
cells.push(`zones: ${labels.join("; ")}`);
|
|
492
|
+
}
|
|
493
|
+
lines.push(`${i + 1}. ${cells.join(" | ")}`);
|
|
494
|
+
});
|
|
495
|
+
if (truncated) {
|
|
496
|
+
lines.push("", "Showing the first 500 parcels (the transaction is linked to more).");
|
|
497
|
+
}
|
|
498
|
+
lines.push("", "Note: based on official landslide-hazard maps (1:10,000 scale). An intersection means the parcel overlaps a mapped hazard area, not that the parcel itself is a landslide.");
|
|
499
|
+
return lines.join("\n");
|
|
500
|
+
}
|
|
501
|
+
const SURROUNDINGS_CATEGORIES = [
|
|
502
|
+
{ key: "cemetery_distance_m", label: "cemetery", radiusLabel: "1 km" },
|
|
503
|
+
{ key: "landfill_distance_m", label: "landfill (waste disposal)", radiusLabel: "3 km" },
|
|
504
|
+
{ key: "sewage_treatment_distance_m", label: "sewage treatment plant", radiusLabel: "2 km" },
|
|
505
|
+
{ key: "industrial_area_distance_m", label: "industrial/storage area", radiusLabel: "1 km" },
|
|
506
|
+
{ key: "industrial_plant_distance_m", label: "large industrial plant", radiusLabel: "3 km" },
|
|
507
|
+
{ key: "livestock_farm_distance_m", label: "intensive livestock farm", radiusLabel: "3 km" },
|
|
508
|
+
];
|
|
509
|
+
export function formatSurroundings(res) {
|
|
510
|
+
const { data, truncated } = res;
|
|
511
|
+
if (data.length === 0) {
|
|
512
|
+
return "No surroundings data is available for this transaction (no linked plots, or the id was not found).";
|
|
513
|
+
}
|
|
514
|
+
const lines = [
|
|
515
|
+
`Per-parcel surroundings (${data.length} plot${data.length === 1 ? "" : "s"}; distance from the plot boundary to the nearest mapped object, "~" = approximate):`,
|
|
516
|
+
"",
|
|
517
|
+
];
|
|
518
|
+
data.forEach((r, i) => {
|
|
519
|
+
if (!r.assessed) {
|
|
520
|
+
lines.push(`${i + 1}. not assessed yet — this plot has not been evaluated (no statement either way)`);
|
|
521
|
+
return;
|
|
522
|
+
}
|
|
523
|
+
const cells = SURROUNDINGS_CATEGORIES.map(({ key, label, radiusLabel }) => {
|
|
524
|
+
const raw = r[key];
|
|
525
|
+
const dist = raw == null ? null : Number(raw);
|
|
526
|
+
if (dist == null || !Number.isFinite(dist)) {
|
|
527
|
+
return `${label}: none within ${radiusLabel}`;
|
|
528
|
+
}
|
|
529
|
+
if (dist === 0)
|
|
530
|
+
return `${label}: on or adjoining the plot`;
|
|
531
|
+
return `${label}: ~${Math.round(dist)} m`;
|
|
532
|
+
});
|
|
533
|
+
lines.push(`${i + 1}. ${cells.join(" | ")}`);
|
|
534
|
+
});
|
|
535
|
+
if (truncated) {
|
|
536
|
+
lines.push("", "Showing the first 500 plots (the transaction is linked to more).");
|
|
537
|
+
}
|
|
538
|
+
return lines.join("\n");
|
|
539
|
+
}
|
|
540
|
+
const TRANSIT_COVERAGE_NOTE = "Note: distances are from open public-transport schedules (GTFS format); coverage is cities and national rail, not every rural area. A mode missing above means no stop of that mode was found within its distance cap — never read as 'no public transport access'.";
|
|
541
|
+
export function formatTransitBreakdown(res) {
|
|
542
|
+
const { data, truncated } = res;
|
|
543
|
+
if (data.length === 0) {
|
|
544
|
+
return `No public transport stop is recorded near this transaction's land in any mode (or the id was not found). ${TRANSIT_COVERAGE_NOTE}`;
|
|
545
|
+
}
|
|
546
|
+
const lines = [
|
|
547
|
+
`Per-parcel public transport access (${data.length} parcel${data.length === 1 ? "" : "s"} with a stop nearby, from open GTFS data):`,
|
|
548
|
+
"",
|
|
549
|
+
];
|
|
550
|
+
data.forEach((r, i) => {
|
|
551
|
+
const cells = [];
|
|
552
|
+
if (r.rail_distance_m != null)
|
|
553
|
+
cells.push(`Rail: ${r.rail_distance_m} m${r.rail_stop_name ? ` (${r.rail_stop_name})` : ""}`);
|
|
554
|
+
if (r.metro_distance_m != null)
|
|
555
|
+
cells.push(`Metro: ${r.metro_distance_m} m${r.metro_stop_name ? ` (${r.metro_stop_name})` : ""}`);
|
|
556
|
+
if (r.tram_distance_m != null)
|
|
557
|
+
cells.push(`Tram: ${r.tram_distance_m} m${r.tram_stop_name ? ` (${r.tram_stop_name})` : ""}`);
|
|
558
|
+
if (r.bus_distance_m != null)
|
|
559
|
+
cells.push(`Bus: ${r.bus_distance_m} m${r.bus_stop_name ? ` (${r.bus_stop_name})` : ""}`);
|
|
560
|
+
lines.push(`${i + 1}. ${cells.join(" | ")}`);
|
|
561
|
+
});
|
|
562
|
+
if (truncated) {
|
|
563
|
+
lines.push("", "Showing the first 500 parcels (the transaction is linked to more).");
|
|
564
|
+
}
|
|
565
|
+
lines.push("", TRANSIT_COVERAGE_NOTE);
|
|
566
|
+
return lines.join("\n");
|
|
567
|
+
}
|
|
568
|
+
const PERMITS_EMPTY_NOTE = "No positively-resolved building permit or works notification is on record for this transaction's parcels (or the id was not found). The register covers cases resolved since 2016, matched by the parcel's current identifier — an empty list is never a statement that nothing was ever planned.";
|
|
569
|
+
export function formatPermitsBreakdown(res) {
|
|
570
|
+
const { data, truncated } = res;
|
|
571
|
+
if (data.length === 0) {
|
|
572
|
+
return PERMITS_EMPTY_NOTE;
|
|
573
|
+
}
|
|
574
|
+
const lines = [
|
|
575
|
+
`Building permits & notifications on record for this transaction's parcels (${data.length} record${data.length === 1 ? "" : "s"}):`,
|
|
576
|
+
"",
|
|
577
|
+
];
|
|
578
|
+
data.forEach((r, i) => {
|
|
579
|
+
const cells = [];
|
|
580
|
+
cells.push(r.record_kind);
|
|
581
|
+
if (r.intent_type)
|
|
582
|
+
cells.push(`intent: ${r.intent_type}`);
|
|
583
|
+
if (r.works_type)
|
|
584
|
+
cells.push(`works: ${r.works_type}`);
|
|
585
|
+
if (r.object_category)
|
|
586
|
+
cells.push(`category: ${r.object_category}`);
|
|
587
|
+
if (r.status)
|
|
588
|
+
cells.push(`status: ${r.status}`);
|
|
589
|
+
const date = r.decision_date ?? r.intake_date;
|
|
590
|
+
if (date)
|
|
591
|
+
cells.push(`date: ${date}`);
|
|
592
|
+
if (r.authority)
|
|
593
|
+
cells.push(`authority: ${r.authority}`);
|
|
594
|
+
const addr = [r.address_street, r.address_number].filter(Boolean).join(" ");
|
|
595
|
+
const addrFull = [addr, r.address_city].filter(Boolean).join(", ");
|
|
596
|
+
if (addrFull)
|
|
597
|
+
cells.push(`address: ${addrFull}`);
|
|
598
|
+
if (r.volume_m3 != null && Number.isFinite(Number(r.volume_m3))) {
|
|
599
|
+
cells.push(`volume: ${Math.round(Number(r.volume_m3))} m³`);
|
|
600
|
+
}
|
|
601
|
+
lines.push(`${i + 1}. ${cells.join(" | ")}`);
|
|
602
|
+
});
|
|
603
|
+
if (truncated) {
|
|
604
|
+
lines.push("", "Showing the first 500 records (the transaction's parcels have more).");
|
|
605
|
+
}
|
|
606
|
+
return lines.join("\n");
|
|
607
|
+
}
|
|
608
|
+
const PLANNING_OVERLAY_LABEL = {
|
|
609
|
+
infill_area: "Infill development area (obszar uzupełnienia zabudowy)",
|
|
610
|
+
downtown_area: "Central development area (obszar zabudowy śródmiejskiej)",
|
|
611
|
+
};
|
|
612
|
+
function planningNum(raw) {
|
|
613
|
+
if (raw == null)
|
|
614
|
+
return null;
|
|
615
|
+
const n = Number(raw);
|
|
616
|
+
return Number.isFinite(n) ? n : null;
|
|
617
|
+
}
|
|
618
|
+
function planningParam(raw, unit) {
|
|
619
|
+
const n = planningNum(raw);
|
|
620
|
+
if (n == null)
|
|
621
|
+
return null;
|
|
622
|
+
return `${String(n)}${unit}`;
|
|
623
|
+
}
|
|
624
|
+
export function formatPlanningBreakdown(res) {
|
|
625
|
+
const { data, coverage, truncated } = res;
|
|
626
|
+
if (data.length === 0) {
|
|
627
|
+
if (coverage === "covered_no_data") {
|
|
628
|
+
return "This transaction's municipality has an adopted general plan (plan ogólny), but no planning-zone data covers these parcels in our sources yet.";
|
|
629
|
+
}
|
|
630
|
+
return "No published general plan (plan ogólny) data is available for this transaction's municipality yet — this is NOT a statement that no plan exists. General plans are still being adopted across Poland, so coverage grows over time.";
|
|
631
|
+
}
|
|
632
|
+
const zones = data.filter((r) => r.kind === "zone");
|
|
633
|
+
const overlays = data.filter((r) => r.kind !== "zone");
|
|
634
|
+
const counts = [];
|
|
635
|
+
if (zones.length > 0)
|
|
636
|
+
counts.push(`${zones.length} planning zone${zones.length === 1 ? "" : "s"}`);
|
|
637
|
+
if (overlays.length > 0)
|
|
638
|
+
counts.push(`${overlays.length} overlay area${overlays.length === 1 ? "" : "s"}`);
|
|
639
|
+
const lines = [
|
|
640
|
+
`General plan (plan ogólny) zoning for this transaction's land (${counts.join(", ")}):`,
|
|
641
|
+
"",
|
|
642
|
+
];
|
|
643
|
+
const byParcel = new Map();
|
|
644
|
+
for (const r of data) {
|
|
645
|
+
const list = byParcel.get(r.parcel_ord);
|
|
646
|
+
if (list)
|
|
647
|
+
list.push(r);
|
|
648
|
+
else
|
|
649
|
+
byParcel.set(r.parcel_ord, [r]);
|
|
650
|
+
}
|
|
651
|
+
const multiParcel = byParcel.size > 1;
|
|
652
|
+
let n = 0;
|
|
653
|
+
for (const [ord, prows] of byParcel) {
|
|
654
|
+
if (multiParcel) {
|
|
655
|
+
if (n > 0)
|
|
656
|
+
lines.push("");
|
|
657
|
+
lines.push(`Parcel ${ord} of ${byParcel.size}:`);
|
|
658
|
+
}
|
|
659
|
+
for (const r of prows.filter((x) => x.kind === "zone")) {
|
|
660
|
+
n += 1;
|
|
661
|
+
const cells = [];
|
|
662
|
+
const label = r.zone_symbol
|
|
663
|
+
? `${r.zone_symbol}${r.zone_name ? ` — ${r.zone_name}` : ""}`
|
|
664
|
+
: r.zone_name ?? "planning zone";
|
|
665
|
+
cells.push(label);
|
|
666
|
+
const pct = planningNum(r.pct_of_parcel);
|
|
667
|
+
if (pct != null)
|
|
668
|
+
cells.push(`${Math.round(pct)}% of the parcel`);
|
|
669
|
+
const params = [];
|
|
670
|
+
const height = planningParam(r.max_building_height_m, " m");
|
|
671
|
+
if (height)
|
|
672
|
+
params.push(`max building height: ${height}`);
|
|
673
|
+
const intensity = planningParam(r.max_development_intensity, "");
|
|
674
|
+
if (intensity)
|
|
675
|
+
params.push(`max development intensity: ${intensity}`);
|
|
676
|
+
const coveragePct = planningParam(r.max_built_up_coverage_pct, "%");
|
|
677
|
+
if (coveragePct)
|
|
678
|
+
params.push(`max built-up coverage: ${coveragePct}`);
|
|
679
|
+
const bioPct = planningParam(r.min_bio_active_area_pct, "%");
|
|
680
|
+
if (bioPct)
|
|
681
|
+
params.push(`min biologically active area: ${bioPct}`);
|
|
682
|
+
if (params.length > 0)
|
|
683
|
+
cells.push(params.join(", "));
|
|
684
|
+
lines.push(`${n}. ${cells.join(" | ")}`);
|
|
685
|
+
if (r.params_mixed) {
|
|
686
|
+
lines.push(" - note: this symbol merges sub-zones with differing building parameters; only values that agreed across them are shown, the rest are omitted as ambiguous (not 'no limit').");
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
for (const r of prows.filter((x) => x.kind !== "zone")) {
|
|
690
|
+
n += 1;
|
|
691
|
+
const label = PLANNING_OVERLAY_LABEL[r.kind] ?? "development overlay area";
|
|
692
|
+
const pct = planningNum(r.pct_of_parcel);
|
|
693
|
+
lines.push(`${n}. ${label} — overlay${pct != null ? `, ${Math.round(pct)}% of the parcel` : ""}`);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
if (multiParcel) {
|
|
697
|
+
lines.push("", "Note: this transaction covers several land parcels. Zones are listed per parcel, so the same symbol may appear under more than one parcel — that is not a duplicate.");
|
|
698
|
+
}
|
|
699
|
+
if (truncated) {
|
|
700
|
+
lines.push("", "Showing the first 500 rows (this transaction's land carries more zones/overlays).");
|
|
701
|
+
}
|
|
702
|
+
lines.push("", "Note: shares are relative to the cadastral parcel geometry; base-zone and overlay shares are independent (overlays may sit on top of zones), so they need not add up to 100%.");
|
|
703
|
+
return lines.join("\n");
|
|
704
|
+
}
|
|
705
|
+
export function formatFarmland(res) {
|
|
706
|
+
const { data, truncated, parcels_total, parcels_with_data, as_of } = res;
|
|
707
|
+
if (data.length === 0) {
|
|
708
|
+
const asOfNote = as_of ? ` (reference data as of ${as_of})` : "";
|
|
709
|
+
return `No eligible agricultural area found for the linked parcels (or the id was not found)${asOfNote}. This is not a statement that the property is non-agricultural — absence of a match is never asserted as "not agricultural".`;
|
|
710
|
+
}
|
|
711
|
+
const lines = [
|
|
712
|
+
`Per-parcel agricultural land-eligibility (${parcels_with_data} of ${parcels_total} linked parcel${parcels_total === 1 ? "" : "s"} with a matched eligible area):`,
|
|
713
|
+
"",
|
|
714
|
+
];
|
|
715
|
+
data.forEach((r, i) => {
|
|
716
|
+
const cells = [];
|
|
717
|
+
cells.push(`eligible agricultural area: ${formatArea(r.eligible_area_m2)}`);
|
|
718
|
+
if (r.pct_of_parcel != null) {
|
|
719
|
+
const pct = Number(r.pct_of_parcel);
|
|
720
|
+
if (Number.isFinite(pct))
|
|
721
|
+
cells.push(`${Math.round(pct)}% of the parcel`);
|
|
722
|
+
}
|
|
723
|
+
if (r.feature_count != null && Number(r.feature_count) > 1) {
|
|
724
|
+
cells.push(`${Number(r.feature_count)} features`);
|
|
725
|
+
}
|
|
726
|
+
lines.push(`${i + 1}. ${cells.join(" | ")}`);
|
|
727
|
+
});
|
|
728
|
+
if (truncated) {
|
|
729
|
+
lines.push("", "Showing the first 500 parcels (the transaction is linked to more).");
|
|
730
|
+
}
|
|
731
|
+
if (as_of) {
|
|
732
|
+
lines.push("", `Official nationwide agricultural land-eligibility data (updated weekly); this snapshot as of ${as_of}.`);
|
|
733
|
+
}
|
|
734
|
+
return lines.join("\n");
|
|
735
|
+
}
|
|
736
|
+
const LEVEL_TIPS = {
|
|
737
|
+
voivodeship: "Use a 2-digit code as 'parent' to browse counties.",
|
|
738
|
+
county: "Use a 4-digit code as 'parent' to browse municipalities.",
|
|
739
|
+
municipality: "Use a 6-digit code as 'parent' to browse precincts, or use any code with 'teryt' in search_transactions.",
|
|
740
|
+
precinct: "Use these precinct codes with 'teryt' in search_transactions for precise area filtering.",
|
|
741
|
+
};
|
|
742
|
+
export function formatLocationHierarchy(items, parent) {
|
|
743
|
+
if (items.length === 0) {
|
|
744
|
+
if (parent) {
|
|
745
|
+
if (parent.length >= 6) {
|
|
746
|
+
return `No sub-locations found for TERYT code '${parent}'. This may be a leaf code - use it directly with search_transactions(teryt='${parent}').`;
|
|
747
|
+
}
|
|
748
|
+
return `No sub-locations found for TERYT code '${parent}'. Verify the code is correct using list_locations.`;
|
|
749
|
+
}
|
|
750
|
+
return "No locations available.";
|
|
751
|
+
}
|
|
752
|
+
const level = items[0].level;
|
|
753
|
+
const header = parent
|
|
754
|
+
? `TERYT location hierarchy (parent: ${parent}, level: ${level}):`
|
|
755
|
+
: `TERYT location hierarchy (Poland, level: ${level}):`;
|
|
756
|
+
const plural = { voivodeship: "voivodeships", county: "counties", municipality: "municipalities", precinct: "precincts" };
|
|
757
|
+
const lines = [header, "", `Found ${items.length} ${plural[level] ?? `${level}s`}:`, ""];
|
|
758
|
+
for (const item of items) {
|
|
759
|
+
const typeSuffix = item.typeName ? ` (${item.typeName})` : "";
|
|
760
|
+
lines.push(` ${item.code} - ${item.name}${typeSuffix}`);
|
|
761
|
+
}
|
|
762
|
+
const tip = LEVEL_TIPS[level];
|
|
763
|
+
if (tip) {
|
|
764
|
+
lines.push("", `Tip: ${tip}`);
|
|
765
|
+
}
|
|
207
766
|
return lines.join("\n");
|
|
208
767
|
}
|
|
209
|
-
// ── Compare locations formatting ───────────────────────────────────
|
|
210
768
|
export function formatCompareResults(res) {
|
|
211
769
|
const districts = Object.keys(res);
|
|
212
770
|
if (districts.length === 0) {
|
|
@@ -233,5 +791,556 @@ export function formatCompareResults(res) {
|
|
|
233
791
|
lines.push(`Note: ${s}`);
|
|
234
792
|
}
|
|
235
793
|
}
|
|
794
|
+
const withDemo = districts.filter((name) => {
|
|
795
|
+
const d = res[name]?.demographics;
|
|
796
|
+
return d && Object.keys(d).length > 0;
|
|
797
|
+
});
|
|
798
|
+
if (withDemo.length > 0) {
|
|
799
|
+
lines.push("", "Demographics (GUS BDL, county-level):");
|
|
800
|
+
for (const name of withDemo) {
|
|
801
|
+
lines.push("", `${name}:`);
|
|
802
|
+
for (const [slug, ind] of Object.entries(res[name].demographics)) {
|
|
803
|
+
const unit = ind.unit ? ` ${ind.unit}` : "";
|
|
804
|
+
const year = ind.year != null ? ` (${ind.year})` : "";
|
|
805
|
+
const flags = [ind.derived ? "derived" : null, ind.cross_source ? "cross-source" : null].filter(Boolean);
|
|
806
|
+
const flagSuffix = flags.length > 0 ? ` [${flags.join(", ")}]` : "";
|
|
807
|
+
const value = ind.value != null ? `${formatNumber(ind.value)}${unit}` : "N/A";
|
|
808
|
+
lines.push(` - ${slug}: ${value}${year}${flagSuffix}`);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
const missing = districts.filter((name) => !withDemo.includes(name));
|
|
812
|
+
if (missing.length > 0) {
|
|
813
|
+
lines.push("", `Note: no demographic data for ${missing.join(", ")} (not resolved to a county).`);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
lines.push(`\n${MARKET_CAVEAT}`);
|
|
817
|
+
return lines.join("\n");
|
|
818
|
+
}
|
|
819
|
+
const DEMOGRAPHICS_CATEGORY_LABELS = {
|
|
820
|
+
demographics: "Demographics",
|
|
821
|
+
economy: "Economy",
|
|
822
|
+
economy_macro: "Macro-economy (GDP, NUTS3/region)",
|
|
823
|
+
housing: "Housing",
|
|
824
|
+
planning: "Spatial planning (MPZP zoning)",
|
|
825
|
+
infrastructure: "Infrastructure",
|
|
826
|
+
environment: "Environment",
|
|
827
|
+
safety: "Safety",
|
|
828
|
+
re_market: "Real estate market (historical)",
|
|
829
|
+
education: "Education",
|
|
830
|
+
prices: "Prices (CPI)",
|
|
831
|
+
};
|
|
832
|
+
const DEMOGRAPHICS_CATEGORY_ORDER = Object.keys(DEMOGRAPHICS_CATEGORY_LABELS);
|
|
833
|
+
function formatDemographicsIndicator(ind) {
|
|
834
|
+
const years = Object.keys(ind.values).map(Number).filter((n) => Number.isFinite(n)).sort((a, b) => a - b);
|
|
835
|
+
const unit = ind.unit ? ` ${ind.unit}` : "";
|
|
836
|
+
const flags = [ind.derived ? "derived" : null, ind.snapshot ? "snapshot" : null].filter(Boolean);
|
|
837
|
+
const flagSuffix = flags.length > 0 ? ` [${flags.join(", ")}]` : "";
|
|
838
|
+
const noteSuffix = ind.note ? ` — ${ind.note}` : "";
|
|
839
|
+
let valueStr;
|
|
840
|
+
if (years.length === 0) {
|
|
841
|
+
valueStr = "N/A";
|
|
842
|
+
}
|
|
843
|
+
else if (years.length === 1) {
|
|
844
|
+
const y = years[0];
|
|
845
|
+
valueStr = `${formatNumber(ind.values[String(y)])}${unit} (${y})`;
|
|
846
|
+
}
|
|
847
|
+
else if (years.length <= 5) {
|
|
848
|
+
valueStr = years.map((y) => `${y}: ${formatNumber(ind.values[String(y)])}`).join(", ") + unit;
|
|
849
|
+
}
|
|
850
|
+
else {
|
|
851
|
+
const first = years[0];
|
|
852
|
+
const last = years[years.length - 1];
|
|
853
|
+
valueStr = `${formatNumber(ind.values[String(last)])}${unit} (${last}); ${years.length} yrs ${first}→${last}, from ${formatNumber(ind.values[String(first)])}`;
|
|
854
|
+
}
|
|
855
|
+
return ` - ${ind.name}: ${valueStr}${flagSuffix}${noteSuffix}`;
|
|
856
|
+
}
|
|
857
|
+
export function formatDemographics(r) {
|
|
858
|
+
const loc = r.location;
|
|
859
|
+
const title = loc.name ?? `TERYT ${loc.teryt}`;
|
|
860
|
+
const lines = [`Demographics & local statistics — ${title} (${loc.level}, teryt ${loc.teryt})`];
|
|
861
|
+
const asOf = r.meta.as_of ? ` · as of ${r.meta.as_of}` : "";
|
|
862
|
+
lines.push(`Source: ${r.meta.data_source}${asOf}`);
|
|
863
|
+
if (r.coverage === "no_data" || Object.keys(r.indicators).length === 0) {
|
|
864
|
+
lines.push("", `No GUS BDL indicators are available for this location (teryt ${loc.teryt}).`, "Tip: a city/county name resolves to powiat (county) level — pass a 6/7-digit teryt for gmina-level data, or use list_locations to find a valid code.");
|
|
865
|
+
return lines.join("\n");
|
|
866
|
+
}
|
|
867
|
+
const byCategory = new Map();
|
|
868
|
+
for (const ind of Object.values(r.indicators)) {
|
|
869
|
+
const arr = byCategory.get(ind.category) ?? [];
|
|
870
|
+
arr.push(ind);
|
|
871
|
+
byCategory.set(ind.category, arr);
|
|
872
|
+
}
|
|
873
|
+
const orderedCats = [
|
|
874
|
+
...DEMOGRAPHICS_CATEGORY_ORDER.filter((c) => byCategory.has(c)),
|
|
875
|
+
...[...byCategory.keys()].filter((c) => !DEMOGRAPHICS_CATEGORY_ORDER.includes(c)),
|
|
876
|
+
];
|
|
877
|
+
for (const cat of orderedCats) {
|
|
878
|
+
lines.push("", DEMOGRAPHICS_CATEGORY_LABELS[cat] ?? cat);
|
|
879
|
+
for (const ind of byCategory.get(cat))
|
|
880
|
+
lines.push(formatDemographicsIndicator(ind));
|
|
881
|
+
}
|
|
882
|
+
const levels = [...new Set(Object.values(r.indicators).map((i) => i.level))];
|
|
883
|
+
if (levels.length > 1) {
|
|
884
|
+
lines.push("", `Note: indicators draw from multiple administrative levels (${levels.join(", ")}); each line's level is where GUS publishes that metric.`);
|
|
885
|
+
}
|
|
886
|
+
return lines.join("\n");
|
|
887
|
+
}
|
|
888
|
+
const INFRA_CATEGORY_LABELS = {
|
|
889
|
+
sewerage: "Sewerage",
|
|
890
|
+
water_supply: "Water supply",
|
|
891
|
+
roads: "Roads",
|
|
892
|
+
lighting: "Street lighting",
|
|
893
|
+
gas: "Gas network",
|
|
894
|
+
cycling: "Cycling infrastructure",
|
|
895
|
+
};
|
|
896
|
+
const INFRA_VALUE_KIND_LABELS = {
|
|
897
|
+
estimated: "estimated value",
|
|
898
|
+
winning_bid: "winning bid",
|
|
899
|
+
contract: "contract value",
|
|
900
|
+
};
|
|
901
|
+
export function formatInfrastructureSignals(r) {
|
|
902
|
+
const loc = r.location;
|
|
903
|
+
const title = loc.name ?? `TERYT ${loc.teryt}`;
|
|
904
|
+
const scope = loc.level === "powiat" ? "aggregated over every municipality in this county" : "this municipality";
|
|
905
|
+
const lines = [
|
|
906
|
+
`Infrastructure signals — ${title} (${loc.level}, teryt ${loc.teryt})`,
|
|
907
|
+
`Scope: ${scope}. Coverage: ${r.coverage}.`,
|
|
908
|
+
];
|
|
909
|
+
if (r.coverage === "no_data") {
|
|
910
|
+
lines.push("", "No infrastructure signals are recorded for this location.", "This does NOT mean the municipality is not investing — the tender feed carries below-EU-threshold contracts only (from 2021), and the other two sources may simply not list it.", r.meta.coverage_note);
|
|
911
|
+
return lines.join("\n");
|
|
912
|
+
}
|
|
913
|
+
const cats = Object.entries(r.tenders.by_category).sort((a, b) => b[1] - a[1]);
|
|
914
|
+
lines.push("", `Public tenders, last ${r.tenders.window_months} months (municipal contracting authorities only)`);
|
|
915
|
+
if (cats.length === 0)
|
|
916
|
+
lines.push(" None recorded in this window.");
|
|
917
|
+
else
|
|
918
|
+
for (const [cat, n] of cats)
|
|
919
|
+
lines.push(` - ${INFRA_CATEGORY_LABELS[cat] ?? cat}: ${n}`);
|
|
920
|
+
if (r.tenders.recent.length > 0) {
|
|
921
|
+
lines.push("", "Recent notices (all contracting authorities)");
|
|
922
|
+
for (const t of r.tenders.recent) {
|
|
923
|
+
const value = t.value_pln == null
|
|
924
|
+
? ""
|
|
925
|
+
: ` · ${formatNumber(t.value_pln)} PLN (${INFRA_VALUE_KIND_LABELS[t.value_kind ?? ""] ?? t.value_kind ?? "value"})`;
|
|
926
|
+
const attribution = t.attribution_confidence === "high" ? "" : " · authority based here, works may be elsewhere";
|
|
927
|
+
lines.push(` - [${t.published_at}] ${INFRA_CATEGORY_LABELS[t.category] ?? t.category}: ${t.title}${value}${attribution}`);
|
|
928
|
+
}
|
|
929
|
+
if (r.tenders.truncated)
|
|
930
|
+
lines.push(` … list truncated at ${r.tenders.recent.length} notices.`);
|
|
931
|
+
}
|
|
932
|
+
lines.push("", "National urban waste-water treatment programme");
|
|
933
|
+
if (r.kposk.in_agglomeration) {
|
|
934
|
+
lines.push(" In a designated agglomeration — collective sewerage exists or is planned here.");
|
|
935
|
+
for (const a of r.kposk.agglomerations) {
|
|
936
|
+
const rlm = a.rlm == null ? "" : ` (${formatNumber(a.rlm)} population equivalent)`;
|
|
937
|
+
lines.push(` - ${a.name}${rlm}`);
|
|
938
|
+
}
|
|
939
|
+
if (r.kposk.truncated)
|
|
940
|
+
lines.push(` … list truncated at ${r.kposk.agglomerations.length} agglomerations.`);
|
|
941
|
+
}
|
|
942
|
+
else {
|
|
943
|
+
lines.push(" Not listed in a designated agglomeration.");
|
|
944
|
+
}
|
|
945
|
+
const years = Object.entries(r.capex.by_year).sort(([a], [b]) => a.localeCompare(b));
|
|
946
|
+
lines.push("", "Planned capital expenditure (municipal multi-year financial forecast)");
|
|
947
|
+
if (years.length === 0)
|
|
948
|
+
lines.push(" No forecast rows recorded for this location.");
|
|
949
|
+
for (const [year, c] of years) {
|
|
950
|
+
const across = c.gmina_count > 1 ? ` · summed across ${c.gmina_count} municipalities` : "";
|
|
951
|
+
const adopted = c.resolution_date ? ` · adopted ${c.resolution_date}` : "";
|
|
952
|
+
lines.push(` - ${year}: ${formatNumber(c.value_pln)} PLN${across}${adopted}`);
|
|
953
|
+
}
|
|
954
|
+
lines.push("", r.meta.coverage_note);
|
|
955
|
+
if (r.meta.as_of)
|
|
956
|
+
lines.push(`Most recent tender notice: ${r.meta.as_of}.`);
|
|
957
|
+
return lines.join("\n");
|
|
958
|
+
}
|
|
959
|
+
const RENTAL_YIELD_LOCATIONS_PATH = "rental-yield/locations";
|
|
960
|
+
export function formatRentalYield(r) {
|
|
961
|
+
const { rent, transaction: tx } = r.inputs;
|
|
962
|
+
const q = r.quality;
|
|
963
|
+
const lines = [`Gross rental yield — ${r.location.name}${areaBucketSuffix(r.segment.area_bucket)}`, ""];
|
|
964
|
+
lines.push(r.result.gross_yield_pct != null
|
|
965
|
+
? `Gross yield: ${r.result.gross_yield_pct}% per year`
|
|
966
|
+
: `Gross yield: N/A (coverage: ${q.coverage})`);
|
|
967
|
+
lines.push("");
|
|
968
|
+
lines.push("Calculation (gross, top-line — no vacancy/management/tax/maintenance):");
|
|
969
|
+
lines.push(rent.median_monthly_asking_per_m2 != null && rent.annualized_per_m2 != null
|
|
970
|
+
? ` Annualized rent: ${formatPLNExact(rent.median_monthly_asking_per_m2)}/m²/mo × 12 = ${formatPLN(rent.annualized_per_m2)}/m²/yr`
|
|
971
|
+
: " Annualized rent: N/A");
|
|
972
|
+
lines.push(tx.median_price_per_m2 != null
|
|
973
|
+
? ` Median transaction price: ${formatPLN(tx.median_price_per_m2)}/m² (${r.segment.market_type} market)`
|
|
974
|
+
: ` Median transaction price: N/A (${r.segment.market_type} market)`);
|
|
975
|
+
lines.push(" (market median — fractional shares & non-market deeds excluded)");
|
|
976
|
+
lines.push("");
|
|
977
|
+
const rentN = rent.sample_n != null
|
|
978
|
+
? `${formatNumber(rent.sample_n)} rent offer${rent.sample_n === 1 ? "" : "s"}${offerDateSuffix(rent.snapshot_date)}`
|
|
979
|
+
: "no rent data";
|
|
980
|
+
const txN = tx.sample_n != null
|
|
981
|
+
? `${formatNumber(tx.sample_n)} transaction${tx.sample_n === 1 ? "" : "s"}${windowSuffix(tx.window)}`
|
|
982
|
+
: "no transaction data";
|
|
983
|
+
lines.push(`Samples: ${rentN}, ${txN}`);
|
|
984
|
+
lines.push(`Coverage: ${q.coverage} | Confidence: ${q.confidence}${q.stale ? " | transaction data lags publication" : ""}`);
|
|
985
|
+
if (q.as_of)
|
|
986
|
+
lines.push(`Transaction data as of: ${q.as_of}`);
|
|
987
|
+
lines.push(...distributionLines(r.distribution.asking_rent_monthly_per_m2, r.distribution.transaction_price_per_m2));
|
|
988
|
+
const visibleNotes = q.notes.filter((n) => !n.includes(RENTAL_YIELD_LOCATIONS_PATH));
|
|
989
|
+
if (visibleNotes.length > 0) {
|
|
990
|
+
lines.push("", "Notes:");
|
|
991
|
+
for (const n of visibleNotes)
|
|
992
|
+
lines.push(` - ${n}`);
|
|
993
|
+
}
|
|
994
|
+
if (q.coverage === "no_rental_data") {
|
|
995
|
+
lines.push("", "Tip: call list_rental_yield_locations to see which cities have rental-yield coverage.");
|
|
996
|
+
}
|
|
997
|
+
return lines.join("\n");
|
|
998
|
+
}
|
|
999
|
+
export function formatRentalYieldLocations(r) {
|
|
1000
|
+
const { data, meta } = r;
|
|
1001
|
+
if (data.length === 0) {
|
|
1002
|
+
return "No rental-yield-covered locations match.";
|
|
1003
|
+
}
|
|
1004
|
+
const dateSuffix = meta.snapshot_date ? `, data from ${meta.snapshot_date}` : "";
|
|
1005
|
+
const lines = [
|
|
1006
|
+
`Rental-yield coverage — ${meta.total} location${meta.total === 1 ? "" : "s"}${dateSuffix}`,
|
|
1007
|
+
"",
|
|
1008
|
+
];
|
|
1009
|
+
for (const loc of data) {
|
|
1010
|
+
lines.push(`- ${loc.location} (teryt ${loc.county_code}, ${loc.voivodeship}, ${loc.type}) — n=${formatNumber(loc.rent_sample_n)}, ${loc.confidence} confidence`);
|
|
1011
|
+
}
|
|
1012
|
+
return lines.join("\n");
|
|
1013
|
+
}
|
|
1014
|
+
const PRICE_SPREAD_LOCATIONS_PATH = "price-spread/locations";
|
|
1015
|
+
export function formatPriceSpread(r) {
|
|
1016
|
+
const { asking, transaction: tx } = r.inputs;
|
|
1017
|
+
const q = r.quality;
|
|
1018
|
+
const spread = r.result.spread_pct;
|
|
1019
|
+
const lines = [`Asking-vs-transaction price spread — ${r.location.name}${areaBucketSuffix(r.segment.area_bucket)}`, ""];
|
|
1020
|
+
lines.push(spread != null
|
|
1021
|
+
? `Spread: ${spread > 0 ? "+" : ""}${spread}% (asking ${spread >= 0 ? "above" : "below"} transaction)`
|
|
1022
|
+
: `Spread: N/A (coverage: ${q.coverage})`);
|
|
1023
|
+
lines.push("");
|
|
1024
|
+
lines.push("Calculation ((asking − transaction) / transaction × 100):");
|
|
1025
|
+
lines.push(asking.median_price_per_m2 != null
|
|
1026
|
+
? ` Median asking price: ${formatPLN(asking.median_price_per_m2)}/m² (apartments for sale)`
|
|
1027
|
+
: " Median asking price: N/A");
|
|
1028
|
+
lines.push(tx.median_price_per_m2 != null
|
|
1029
|
+
? ` Median transaction price: ${formatPLN(tx.median_price_per_m2)}/m² (${r.segment.market_type} market)`
|
|
1030
|
+
: ` Median transaction price: N/A (${r.segment.market_type} market)`);
|
|
1031
|
+
lines.push(" (market median — fractional shares & non-market deeds excluded)");
|
|
1032
|
+
lines.push("");
|
|
1033
|
+
const askN = asking.sample_n != null
|
|
1034
|
+
? `${formatNumber(asking.sample_n)} sale offer${asking.sample_n === 1 ? "" : "s"}${offerDateSuffix(asking.snapshot_date)}`
|
|
1035
|
+
: "no asking data";
|
|
1036
|
+
const txN = tx.sample_n != null
|
|
1037
|
+
? `${formatNumber(tx.sample_n)} transaction${tx.sample_n === 1 ? "" : "s"}${windowSuffix(tx.window)}`
|
|
1038
|
+
: "no transaction data";
|
|
1039
|
+
lines.push(`Samples: ${askN}, ${txN}`);
|
|
1040
|
+
lines.push(`Coverage: ${q.coverage} | Confidence: ${q.confidence}${q.stale ? " | transaction data lags publication" : ""}`);
|
|
1041
|
+
if (q.as_of)
|
|
1042
|
+
lines.push(`Transaction data as of: ${q.as_of}`);
|
|
1043
|
+
lines.push(...distributionLines(r.distribution.asking_sale_per_m2, r.distribution.transaction_price_per_m2));
|
|
1044
|
+
const visibleNotes = q.notes.filter((n) => !n.includes(PRICE_SPREAD_LOCATIONS_PATH));
|
|
1045
|
+
if (visibleNotes.length > 0) {
|
|
1046
|
+
lines.push("", "Notes:");
|
|
1047
|
+
for (const n of visibleNotes)
|
|
1048
|
+
lines.push(` - ${n}`);
|
|
1049
|
+
}
|
|
1050
|
+
if (q.coverage === "no_asking_data") {
|
|
1051
|
+
lines.push("", "Tip: call list_price_spread_locations to see which cities have asking-price coverage.");
|
|
1052
|
+
}
|
|
1053
|
+
return lines.join("\n");
|
|
1054
|
+
}
|
|
1055
|
+
export function formatPriceSpreadLocations(r) {
|
|
1056
|
+
const { data, meta } = r;
|
|
1057
|
+
if (data.length === 0) {
|
|
1058
|
+
return "No price-spread-covered locations match.";
|
|
1059
|
+
}
|
|
1060
|
+
const dateSuffix = meta.snapshot_date ? `, data from ${meta.snapshot_date}` : "";
|
|
1061
|
+
const lines = [
|
|
1062
|
+
`Price-spread coverage — ${meta.total} location${meta.total === 1 ? "" : "s"}${dateSuffix}`,
|
|
1063
|
+
"",
|
|
1064
|
+
];
|
|
1065
|
+
for (const loc of data) {
|
|
1066
|
+
lines.push(`- ${loc.location} (teryt ${loc.county_code}, ${loc.voivodeship}, ${loc.type}) — n=${formatNumber(loc.asking_sample_n)}, ${loc.confidence} confidence`);
|
|
1067
|
+
}
|
|
1068
|
+
return lines.join("\n");
|
|
1069
|
+
}
|
|
1070
|
+
function valuationCompLine(c) {
|
|
1071
|
+
const parts = [`${formatNumber(c.distance_m)} m`, c.transaction_date, formatArea(c.area_m2), `${formatPLN(c.price_per_m2)}/m²`];
|
|
1072
|
+
if (c.market_type)
|
|
1073
|
+
parts.push(c.market_type);
|
|
1074
|
+
if (c.district)
|
|
1075
|
+
parts.push(c.district);
|
|
1076
|
+
return ` - ${parts.join(" · ")}`;
|
|
1077
|
+
}
|
|
1078
|
+
export function formatValuation(r) {
|
|
1079
|
+
const { result: res, inputs, quality: q, segment } = r ?? {};
|
|
1080
|
+
const loc = r?.location;
|
|
1081
|
+
if (!res || !inputs || !q || !segment || !loc) {
|
|
1082
|
+
return "Unexpected response from the Cenogram API — the valuation could not be rendered. Try again shortly.";
|
|
1083
|
+
}
|
|
1084
|
+
const where = loc.lat != null && loc.lng != null
|
|
1085
|
+
? `near ${loc.lat}, ${loc.lng}`
|
|
1086
|
+
: loc.county_code
|
|
1087
|
+
? `county ${loc.county_code}`
|
|
1088
|
+
: "the requested point";
|
|
1089
|
+
const lines = [`Apartment value estimate — ${formatArea(segment.area_m2)} ${where}`, ""];
|
|
1090
|
+
if (res.estimated_value == null) {
|
|
1091
|
+
const parcelUnresolved = q.coverage !== "not_covered" && loc.lat == null && loc.lng == null;
|
|
1092
|
+
lines.push(q.coverage === "not_covered"
|
|
1093
|
+
? "No estimate: outside the covered property type (v1 covers apartments only)."
|
|
1094
|
+
: parcelUnresolved
|
|
1095
|
+
? "No estimate: that parcel could not be resolved (unknown id, or no geometry on record). The credit is refunded — check the id, or address the apartment by lat/lng."
|
|
1096
|
+
: "No estimate: too few comparable transactions near this point (credit refunded). Try a point in a denser urban area.");
|
|
1097
|
+
if (q.note)
|
|
1098
|
+
lines.push("", q.note);
|
|
1099
|
+
return lines.join("\n");
|
|
1100
|
+
}
|
|
1101
|
+
lines.push(`Estimated value: ${formatPLN(res.estimated_value)}${res.price_per_m2 != null ? ` (${formatPLN(res.price_per_m2)}/m²)` : ""}`);
|
|
1102
|
+
const likely = res.value_range_likely;
|
|
1103
|
+
const wide = res.value_range_wide;
|
|
1104
|
+
if (likely?.low != null && likely.high != null)
|
|
1105
|
+
lines.push(`Likely range: ${formatPLN(likely.low)} – ${formatPLN(likely.high)}`);
|
|
1106
|
+
if (wide?.low != null && wide.high != null)
|
|
1107
|
+
lines.push(`Wide range: ${formatPLN(wide.low)} – ${formatPLN(wide.high)}`);
|
|
1108
|
+
if (res.confidence_band)
|
|
1109
|
+
lines.push(`Confidence: ${res.confidence_band}${res.confidence != null ? ` (${res.confidence})` : ""}`);
|
|
1110
|
+
lines.push("");
|
|
1111
|
+
const radius = inputs.radius_m != null ? ` within ${formatNumber(inputs.radius_m)} m` : "";
|
|
1112
|
+
lines.push(`Based on ${formatNumber(inputs.comps_total)} comparable transaction${inputs.comps_total === 1 ? "" : "s"}${radius}, last ${inputs.window_months} months.`);
|
|
1113
|
+
if (q.as_of)
|
|
1114
|
+
lines.push(`Transaction data as of: ${q.as_of} (varies by county — publication lag)`);
|
|
1115
|
+
if (Array.isArray(inputs.comparables) && inputs.comparables.length > 0) {
|
|
1116
|
+
const shown = inputs.comparables.slice(0, 5);
|
|
1117
|
+
lines.push("", `Comparables (nearest ${shown.length}):`);
|
|
1118
|
+
for (const c of shown)
|
|
1119
|
+
lines.push(valuationCompLine(c));
|
|
1120
|
+
}
|
|
1121
|
+
if (q.note)
|
|
1122
|
+
lines.push("", q.note);
|
|
1123
|
+
return lines.join("\n");
|
|
1124
|
+
}
|
|
1125
|
+
function fourStateGloss(coverage) {
|
|
1126
|
+
switch (coverage) {
|
|
1127
|
+
case "covered": return "covered";
|
|
1128
|
+
case "covered_no_data": return "covered_no_data (checked — nothing found, still billed)";
|
|
1129
|
+
case "not_covered": return "not_covered (outside our data — refunded)";
|
|
1130
|
+
case "not_computed": return "not_computed (could not finish in time — refunded, retry)";
|
|
1131
|
+
default: return coverage;
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
function toNum(v) {
|
|
1135
|
+
if (v == null)
|
|
1136
|
+
return null;
|
|
1137
|
+
const n = typeof v === "number" ? v : Number(v);
|
|
1138
|
+
return Number.isFinite(n) ? n : null;
|
|
1139
|
+
}
|
|
1140
|
+
function nearestDistances(entries) {
|
|
1141
|
+
return entries
|
|
1142
|
+
.filter((d) => d[1] != null)
|
|
1143
|
+
.sort((a, b) => a[1] - b[1]);
|
|
1144
|
+
}
|
|
1145
|
+
function reportSectionDetail(name, s) {
|
|
1146
|
+
const covered = s.coverage === "covered";
|
|
1147
|
+
switch (name) {
|
|
1148
|
+
case "flood": {
|
|
1149
|
+
if (!covered)
|
|
1150
|
+
return "";
|
|
1151
|
+
const risk = typeof s.flood_risk === "string" ? s.flood_risk : null;
|
|
1152
|
+
const pct = toNum(s.pct_in_zone);
|
|
1153
|
+
return risk ? `${risk} risk${pct != null ? `, ${pct}% of the parcel in the mapped zone` : ""}` : "";
|
|
1154
|
+
}
|
|
1155
|
+
case "heritage": {
|
|
1156
|
+
if (!covered)
|
|
1157
|
+
return "";
|
|
1158
|
+
const status = typeof s.heritage_status === "string" ? s.heritage_status : null;
|
|
1159
|
+
const sites = toNum(s.site_count);
|
|
1160
|
+
return status ? `${status}${sites != null ? `, ${sites} listing(s)` : ""}` : "";
|
|
1161
|
+
}
|
|
1162
|
+
case "landslide": {
|
|
1163
|
+
if (!covered)
|
|
1164
|
+
return "";
|
|
1165
|
+
const risk = typeof s.landslide_risk === "string" ? s.landslide_risk : null;
|
|
1166
|
+
return risk ? LANDSLIDE_RISK_NOTE[risk] ?? risk : "";
|
|
1167
|
+
}
|
|
1168
|
+
case "surroundings": {
|
|
1169
|
+
if (!covered)
|
|
1170
|
+
return "";
|
|
1171
|
+
const dists = nearestDistances([
|
|
1172
|
+
["cemetery", toNum(s.cemetery_distance_m)],
|
|
1173
|
+
["landfill", toNum(s.landfill_distance_m)],
|
|
1174
|
+
["sewage treatment", toNum(s.sewage_treatment_distance_m)],
|
|
1175
|
+
["industrial area", toNum(s.industrial_area_distance_m)],
|
|
1176
|
+
["industrial plant", toNum(s.industrial_plant_distance_m)],
|
|
1177
|
+
["livestock farm", toNum(s.livestock_farm_distance_m)],
|
|
1178
|
+
]);
|
|
1179
|
+
if (dists.length === 0)
|
|
1180
|
+
return "no mapped nuisance object within range";
|
|
1181
|
+
return dists.slice(0, 3).map(([k, m]) => `${k} ${Math.round(m)} m`).join(", ");
|
|
1182
|
+
}
|
|
1183
|
+
case "transit": {
|
|
1184
|
+
if (!covered)
|
|
1185
|
+
return "";
|
|
1186
|
+
const modes = nearestDistances([
|
|
1187
|
+
["rail", toNum(s.rail_distance_m)],
|
|
1188
|
+
["metro", toNum(s.metro_distance_m)],
|
|
1189
|
+
["tram", toNum(s.tram_distance_m)],
|
|
1190
|
+
["bus", toNum(s.bus_distance_m)],
|
|
1191
|
+
]);
|
|
1192
|
+
if (modes.length === 0)
|
|
1193
|
+
return "";
|
|
1194
|
+
return modes.map(([k, m]) => `${k} ${Math.round(m)} m`).join(", ");
|
|
1195
|
+
}
|
|
1196
|
+
case "planning": {
|
|
1197
|
+
if (!covered)
|
|
1198
|
+
return "";
|
|
1199
|
+
const rows = Array.isArray(s.data) ? s.data : [];
|
|
1200
|
+
const symbols = [...new Set(rows.map((r) => r.zone_symbol).filter((x) => typeof x === "string"))];
|
|
1201
|
+
return symbols.length > 0 ? `zones: ${symbols.join(", ")}` : `${rows.length} zone row(s)`;
|
|
1202
|
+
}
|
|
1203
|
+
case "buildings": {
|
|
1204
|
+
if (!covered)
|
|
1205
|
+
return "";
|
|
1206
|
+
const rows = Array.isArray(s.data) ? s.data : [];
|
|
1207
|
+
return `${rows.length} building(s) on the parcel`;
|
|
1208
|
+
}
|
|
1209
|
+
case "permits": {
|
|
1210
|
+
if (!covered)
|
|
1211
|
+
return "";
|
|
1212
|
+
const rows = Array.isArray(s.data) ? s.data : [];
|
|
1213
|
+
return `${rows.length} registered case(s)`;
|
|
1214
|
+
}
|
|
1215
|
+
case "farmland": {
|
|
1216
|
+
if (!covered)
|
|
1217
|
+
return "";
|
|
1218
|
+
const area = toNum(s.eligible_area_m2);
|
|
1219
|
+
const pct = toNum(s.pct_of_parcel);
|
|
1220
|
+
return area != null ? `${formatArea(area)} eligible${pct != null ? ` (${pct}% of parcel)` : ""}` : "";
|
|
1221
|
+
}
|
|
1222
|
+
default:
|
|
1223
|
+
return "";
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
function reportTxLine(r) {
|
|
1227
|
+
const date = typeof r.transaction_date === "string" ? r.transaction_date.split("T")[0] : "?";
|
|
1228
|
+
const type = PROPERTY_TYPES[Number(r.property_type)] || `Type ${r.property_type}`;
|
|
1229
|
+
const market = MARKET_TYPES[Number(r.market_type)] || `Market ${r.market_type}`;
|
|
1230
|
+
const price = formatPLN(toNum(r.price_gross));
|
|
1231
|
+
const area = toNum(r.usable_area_m2);
|
|
1232
|
+
const ppm2 = toNum(r.price_per_m2);
|
|
1233
|
+
const tail = [area != null ? formatArea(area) : null, ppm2 != null ? `${formatPLN(ppm2)}/m2` : null].filter(Boolean).join(", ");
|
|
1234
|
+
return ` - ${date} — ${type}, ${market} — ${price}${tail ? ` (${tail})` : ""}`;
|
|
1235
|
+
}
|
|
1236
|
+
function reportMarketLine(label, lvl) {
|
|
1237
|
+
if (lvl.coverage === "no_data")
|
|
1238
|
+
return ` - ${label}: no data`;
|
|
1239
|
+
if (lvl.median_price_per_m2 == null)
|
|
1240
|
+
return ` - ${label}: withheld (only ${lvl.n} sale(s) — too few to publish)`;
|
|
1241
|
+
const flag = lvl.coverage === "low_sample" ? " [small sample]" : "";
|
|
1242
|
+
return ` - ${label}: ${formatPLN(lvl.median_price_per_m2)}/m2 (n=${lvl.n})${flag}`;
|
|
1243
|
+
}
|
|
1244
|
+
function reportBillingFooter(billing) {
|
|
1245
|
+
const why = {
|
|
1246
|
+
full: "billed in full — at least one enrichment layer had data",
|
|
1247
|
+
core_floor: "resolved, but no enrichment layer had data — only the parcel-core floor is billed, the rest refunded",
|
|
1248
|
+
total_miss_refund: "fully refunded — the parcel could not be resolved",
|
|
1249
|
+
not_computed_refund: "fully refunded — no layer could be computed right now (retry-worthy)",
|
|
1250
|
+
disabled: "fully refunded — the composite report is temporarily unavailable",
|
|
1251
|
+
demo: "no charge (demo / web session)",
|
|
1252
|
+
};
|
|
1253
|
+
const reason = why[billing.rule] ?? billing.rule;
|
|
1254
|
+
return `Billing: ${billing.charged} charged, ${billing.refunded} refunded — ${reason}`;
|
|
1255
|
+
}
|
|
1256
|
+
const REPORT_LAYER_ORDER = [
|
|
1257
|
+
["flood", "Flood risk"],
|
|
1258
|
+
["heritage", "Heritage listing"],
|
|
1259
|
+
["landslide", "Landslide risk"],
|
|
1260
|
+
["surroundings", "Nuisance surroundings"],
|
|
1261
|
+
["transit", "Public transport"],
|
|
1262
|
+
["planning", "Planning (general plan)"],
|
|
1263
|
+
["buildings", "Buildings"],
|
|
1264
|
+
["permits", "Building activity"],
|
|
1265
|
+
["farmland", "Agricultural land"],
|
|
1266
|
+
];
|
|
1267
|
+
export function formatParcelReport(res) {
|
|
1268
|
+
const p = res.parcel;
|
|
1269
|
+
const id = p.parcel_id ?? p.parcel_key ?? "(parcel id requires a paid plan)";
|
|
1270
|
+
if (res.coverage !== "covered") {
|
|
1271
|
+
const head = res.coverage === "not_covered"
|
|
1272
|
+
? `Parcel ${id} could not be resolved — it is not in our cadastral copy.`
|
|
1273
|
+
: res.billing.rule === "disabled"
|
|
1274
|
+
? `The composite report is temporarily unavailable for parcel ${id}.`
|
|
1275
|
+
: `Parcel ${id} could not be resolved right now (a live lookup did not finish — retry).`;
|
|
1276
|
+
return `${head}\n\n---\n${reportBillingFooter(res.billing)}`;
|
|
1277
|
+
}
|
|
1278
|
+
const lines = [`Parcel report: ${id}`];
|
|
1279
|
+
const place = [p.district, p.county_name, p.voivodeship_name].filter(Boolean).join(", ");
|
|
1280
|
+
if (place)
|
|
1281
|
+
lines.push(place);
|
|
1282
|
+
const facts = [
|
|
1283
|
+
p.area_m2 != null ? `Area: ${formatArea(p.area_m2)}` : null,
|
|
1284
|
+
p.land_use ? `Land use: ${p.land_use}` : null,
|
|
1285
|
+
p.mpzp_designation ? `Plan designation: ${p.mpzp_designation}` : null,
|
|
1286
|
+
].filter(Boolean);
|
|
1287
|
+
if (facts.length > 0)
|
|
1288
|
+
lines.push(facts.join(" | "));
|
|
1289
|
+
const asOf = res.as_of ? ` (as of ${res.as_of.split("T")[0]})` : "";
|
|
1290
|
+
lines.push(`Core: covered${asOf}`);
|
|
1291
|
+
lines.push("", "Enrichment layers:");
|
|
1292
|
+
const sections = res.sections;
|
|
1293
|
+
for (const [key, label] of REPORT_LAYER_ORDER) {
|
|
1294
|
+
const s = sections[key];
|
|
1295
|
+
const detail = reportSectionDetail(key, s);
|
|
1296
|
+
lines.push(`- ${label}: ${fourStateGloss(s.coverage)}${detail ? ` — ${detail}` : ""}`);
|
|
1297
|
+
}
|
|
1298
|
+
const tx = sections.transactions;
|
|
1299
|
+
const total = toNum(tx.total) ?? 0;
|
|
1300
|
+
const rows = Array.isArray(tx.data) ? tx.data : [];
|
|
1301
|
+
lines.push("", `Transaction history: ${fourStateGloss(tx.coverage)}`);
|
|
1302
|
+
if (tx.coverage === "covered") {
|
|
1303
|
+
lines.push(` ${total} recorded${rows.length < total ? ` (showing newest ${rows.length})` : ""}:`);
|
|
1304
|
+
for (const r of rows)
|
|
1305
|
+
lines.push(reportTxLine(r));
|
|
1306
|
+
if (rows.length < total)
|
|
1307
|
+
lines.push(` … call search_transactions(parcelId="${p.parcel_id ?? id}") for the full history.`);
|
|
1308
|
+
}
|
|
1309
|
+
const m = sections.market_context;
|
|
1310
|
+
lines.push("", "Local price context (median zł/m², last 12 months):");
|
|
1311
|
+
if (m.coverage === "no_data") {
|
|
1312
|
+
lines.push(" - no data for this location");
|
|
1313
|
+
}
|
|
1314
|
+
else {
|
|
1315
|
+
lines.push(reportMarketLine("County", m.county));
|
|
1316
|
+
lines.push(reportMarketLine(m.locality.district ? `Locality (${m.locality.district})` : "Locality", m.locality));
|
|
1317
|
+
}
|
|
1318
|
+
const loc = sections.location_context;
|
|
1319
|
+
lines.push("", `Municipal context${loc.gmina_teryt ? ` (gmina ${loc.gmina_teryt})` : ""}:`);
|
|
1320
|
+
const demo = loc.demographics;
|
|
1321
|
+
if (demo.coverage === "no_data") {
|
|
1322
|
+
lines.push(" - Demographics: no data");
|
|
1323
|
+
}
|
|
1324
|
+
else {
|
|
1325
|
+
const inds = Object.values(demo.indicators).slice(0, 4);
|
|
1326
|
+
const indText = inds.map((i) => {
|
|
1327
|
+
const years = Object.keys(i.values);
|
|
1328
|
+
const latest = years.length > 0 ? i.values[years[years.length - 1]] : null;
|
|
1329
|
+
return latest != null ? `${i.name} ${formatNumber(latest)} ${i.unit}`.trim() : i.name;
|
|
1330
|
+
});
|
|
1331
|
+
lines.push(` - Demographics${demo.name ? ` (${demo.name})` : ""}: ${indText.length > 0 ? indText.join("; ") : "—"}`);
|
|
1332
|
+
}
|
|
1333
|
+
const infra = loc.infra_signals;
|
|
1334
|
+
if (infra.coverage === "no_data") {
|
|
1335
|
+
lines.push(" - Infrastructure signals: no data");
|
|
1336
|
+
}
|
|
1337
|
+
else {
|
|
1338
|
+
const tenderTotal = Object.values(infra.tenders.by_category).reduce((a, b) => a + b, 0);
|
|
1339
|
+
const kposk = infra.kposk.in_agglomeration ? "in a collective-sewerage agglomeration" : "not in a collective-sewerage agglomeration";
|
|
1340
|
+
lines.push(` - Infrastructure signals: ${tenderTotal} municipal tender(s) in the last ${infra.tenders.window_months} months; ${kposk}`);
|
|
1341
|
+
}
|
|
1342
|
+
if (res.note)
|
|
1343
|
+
lines.push("", res.note);
|
|
1344
|
+
lines.push("", "---", reportBillingFooter(res.billing));
|
|
236
1345
|
return lines.join("\n");
|
|
237
1346
|
}
|