""" vpo.py — El precio máximo de venta de una vivienda protegida, por metros. En una vivienda protegida el precio de venta está topado: cada año se publica un módulo en euros por metro cuadrado útil, y el precio máximo es Σ m² útiles de cada pieza × lo que computa × coeficiente × módulo con un coeficiente para la vivienda y el garaje vinculado y otro para el trastero y los demás anejos, según el régimen (en Navarra, para una VPO de régimen general, 1,30 y 0,52 veces el módulo sin ponderar). En segundas y posteriores ventas vale el módulo del día de la venta, así que el tope sube (o baja) cada 1 de enero. Ficha (activo["inmueble"]) de una vivienda protegida: protegida True tope «metros» (lo de arriba) o «fijo» (una cifra: precio_maximo) piezas [{nombre, m2, clase («vivienda» o «anejo»), computa (%)}] regimen vpo, vpo_especial, vpt u otro (con coef_vivienda y coef_anejo) modulos {«AAAA»: €/m²} los que apunta el usuario; mandan sobre los de serie_vpo.json (que trae los oficiales de Navarra) El módulo de un año sin dato (porque aún no se ha publicado o porque falta en la tabla) se estima con el último conocido movido con el IPC general del INE (de diciembre a diciembre) y se marca como estimado. """ from __future__ import annotations import datetime as dt import json from functools import lru_cache from pathlib import Path SERIE = Path(__file__).resolve().parent / "serie_vpo.json" FICHERO_IPC = "serie_ipc.json" # junto a los ajustes, el IPC que se descarga al actualizar precios URL_IPC = "https://servicios.ine.es/wstempus/js/ES/DATOS_SERIE/IPC290751?nult=300" FUENTE = "VPO" # los valores que apunta la cartera: el precio máximo de cada año CLASES_PIEZA = {"vivienda": "Vivienda o garaje vinculado", "anejo": "Trastero u otro anejo"} PIEZAS_INICIALES = [ {"nombre": "Vivienda", "clase": "vivienda", "computa": 100}, {"nombre": "Terraza", "clase": "vivienda", "computa": 50}, {"nombre": "Garaje", "clase": "vivienda", "computa": 100}, {"nombre": "Trastero", "clase": "anejo", "computa": 100}, ] @lru_cache(maxsize=4) def _leer(ruta: str, _mtime: float) -> dict: return json.loads(Path(ruta).read_text(encoding="utf-8")) def serie(carpeta: Path | None = None) -> dict: """Módulos, regímenes e IPC; con el IPC descargado si es más reciente.""" s = _leer(str(SERIE), SERIE.stat().st_mtime) if carpeta is not None: otra = Path(carpeta) / FICHERO_IPC try: ipc = _leer(str(otra), otra.stat().st_mtime) if ipc["datos"] and ipc["datos"][-1][0] > s["ipc"]["datos"][-1][0]: s = {**s, "ipc": ipc} except (OSError, ValueError, KeyError, IndexError, TypeError): pass return s def regimenes(s: dict | None = None) -> dict: return (s or serie())["regimenes"] def es_por_metros(f: dict) -> bool: return bool(f.get("protegida")) and f.get("tope") == "metros" def coeficientes(f: dict, s: dict | None = None) -> tuple[float, float]: """(vivienda, anejo) del régimen de la ficha.""" r = regimenes(s).get(f.get("regimen") or "vpo") or regimenes(s)["vpo"] if r["vivienda"] is None: return _f(f.get("coef_vivienda")), _f(f.get("coef_anejo")) return r["vivienda"], r["anejo"] def _diciembre(ipc: list, año: int) -> float | None: clave = f"{año}-12" return next((v for m, v in ipc if m == clave), None) def _factor_ipc(ipc: list, año: int) -> float: """Lo que subió el IPC el año anterior a «año» (diciembre sobre diciembre). Si aún no está el de diciembre, los últimos doce meses publicados.""" a, b = _diciembre(ipc, año - 1), _diciembre(ipc, año - 2) if a and b: return a / b if len(ipc) >= 13 and ipc[-13][1]: return ipc[-1][1] / ipc[-13][1] return 1.0 def modulo(f: dict, año: int, s: dict | None = None) -> tuple[float, str] | None: """(€/m², de dónde) del módulo de ese año: «tuyo» si lo apuntó el usuario, «oficial» si viene en la tabla, «estimado» si sale del IPC. None si no hay ningún módulo del que partir.""" s = s or serie() propios = {int(k): _f(v) for k, v in (f.get("modulos") or {}).items() if _f(v) > 0} oficiales = {int(k): _f(v["valor"]) for k, v in (s["modulos"].get(f.get("comunidad") or "") or {}).items()} if año in propios: return propios[año], "tuyo" if año in oficiales: return oficiales[año], "oficial" conocidos = {**oficiales, **propios} if not conocidos: return None antes = [a for a in conocidos if a < año] if not antes: return conocidos[min(conocidos)], "estimado" base = max(antes) valor = conocidos[base] for a in range(base + 1, año + 1): valor *= _factor_ipc(s["ipc"]["datos"], a) return round(valor, 2), "estimado" def superficie(f: dict) -> tuple[float, float]: """(m² que computan como vivienda, m² que computan como anejo).""" viv = anejo = 0.0 for p in f.get("piezas") or []: m2 = _f(p.get("m2")) * _f(p.get("computa", 100)) / 100 if p.get("clase") == "anejo": anejo += m2 else: viv += m2 return viv, anejo def desglose(f: dict, año: int, s: dict | None = None) -> dict | None: """El precio máximo de ese año y de dónde sale (para la ficha).""" s = s or serie() m = modulo(f, año, s) if m is None: return None cv, ca = coeficientes(f, s) viv, anejo = superficie(f) piezas = [] for p in f.get("piezas") or []: coef = ca if p.get("clase") == "anejo" else cv m2 = _f(p.get("m2")) * _f(p.get("computa", 100)) / 100 piezas.append({"nombre": p.get("nombre") or "", "m2": _f(p.get("m2")), "computa": _f(p.get("computa", 100)), "clase": p.get("clase") or "vivienda", "importe": round(m2 * coef * m[0], 2)}) return {"año": año, "modulo": m[0], "origen": m[1], "coef_vivienda": cv, "coef_anejo": ca, "m2_vivienda": round(viv, 2), "m2_anejo": round(anejo, 2), "piezas": piezas, "total": round((viv * cv + anejo * ca) * m[0], 2)} def tope(f: dict, dia: dt.date | str, s: dict | None = None) -> float | None: """El precio máximo de venta en esa fecha: por metros con el módulo de su año, o la cifra fija. None si no es protegida o no se puede calcular.""" if not f.get("protegida"): return None if not es_por_metros(f): return _f(f.get("precio_maximo")) or None año = (dia if isinstance(dia, dt.date) else dt.date.fromisoformat(dia)).year d = desglose(f, año, s) return d["total"] if d and d["total"] > 0 else None def estimaciones(f: dict, inicio: str, fin: dt.date, s: dict | None = None) -> list[dict]: """El valor de una vivienda protegida por metros: su precio máximo, que cambia cada 1 de enero, desde el año siguiente a la compra hasta «fin».""" salida = [] for año in range(int(inicio[:4]) + 1, fin.year + 1): v = tope(f, dt.date(año, 1, 1), s) if v: salida.append({"fecha": f"{año}-01-01", "valor_total": v}) return salida def descargar_ipc(sesion) -> dict: """El IPC general del INE recién descargado, con la forma de serie_vpo.json.""" r = sesion.get(URL_IPC, timeout=60) r.raise_for_status() datos = sorted([f"{x['Anyo']}-{x['FK_Periodo']:02d}", round(float(x["Valor"]), 3)] for x in r.json().get("Data", []) if x.get("Valor") is not None and 1 <= x.get("FK_Periodo", 0) <= 12) if len(datos) < 24: raise ValueError("la respuesta del INE no tiene la forma esperada") return {**{k: v for k, v in serie()["ipc"].items() if k != "datos"}, "descargado": dt.date.today().isoformat(), "datos": datos} def _f(v) -> float: try: return float(v or 0) except (TypeError, ValueError): return 0.0