SPB Git forge

spb/vrai-prix

Public

Vrai-Prix — l'évaluation du vrai prix des propriétés résidentielles au Québec.

60commits 1branches 0releases
12.3 MBsize
maindefault branch
17 days agolast push
TypeScript 90.2% JavaScript 3.5% Python 3.4% CSS 1.9% HTML 0.6%
5.9 KB · 96 lines typescript
Raw Blame History
1// Auteur : Simon-Pierre Boucher — contact@spboucher.ai2/**3 * Imports manuels (aucun scraping) :4 *  - Altus Group — Canadian Cost Guide : benchmarks $/pi² saisis après5 *    téléchargement légal du guide (formulaire) → cost_benchmarks ;6 *  - RSMeans / Gordian : données sous licence, import CSV/XLSX→CSV autorisé7 *    seulement → cost_item_prices (price_kind « reference », source rsmeans),8 *    jamais redistribuées telles quelles (seul le prix canonique agrégé sert).9 */10import type Database from "better-sqlite3";11import { getCostDb, sourceIdByKey } from "../db";12import { convertPrice } from "../units";13import type { CanonicalUnit } from "../types";1415export const BENCHMARK_TYPES = ["single_family", "townhouse", "plex", "condo"] as const;16export type BenchmarkType = (typeof BENCHMARK_TYPES)[number];1718export interface BenchmarkImportRow { source: string; building_type: BenchmarkType; market: string; unit?: string; low: number; high: number; year: number; notes?: string | null }1920/** CSV simple (séparateur , ou ;) → objets ; guillemets gérés. */21export function parseCsv(text: string): Record<string, string>[] {22  const lines = text.replace(/\r/g, "").split("\n").filter((l) => l.trim());23  if (lines.length < 2) return [];24  const sep = lines[0].includes(";") && !lines[0].includes(",") ? ";" : ",";25  const split = (l: string): string[] => {26    const out: string[] = [];27    let cur = "";28    let q = false;29    for (const ch of l) {30      if (ch === '"') q = !q;31      else if (ch === sep && !q) { out.push(cur.trim()); cur = ""; }32      else cur += ch;33    }34    out.push(cur.trim());35    return out;36  };37  const head = split(lines[0]).map((h) => h.toLowerCase().replace(/\s+/g, "_"));38  return lines.slice(1).map((l) => Object.fromEntries(split(l).map((v, i) => [head[i] ?? `c${i}`, v])));39}4041export function normalizeBenchmarkRows(input: unknown): { rows: BenchmarkImportRow[]; errors: string[] } {42  const raw: Record<string, unknown>[] = typeof input === "string" ? parseCsv(input) : Array.isArray(input) ? (input as Record<string, unknown>[]) : [];43  const rows: BenchmarkImportRow[] = [];44  const errors: string[] = [];45  raw.forEach((r, i) => {46    const bt = String(r.building_type ?? "").trim() as BenchmarkType;47    const low = Number(String(r.low ?? "").replace(",", "."));48    const high = Number(String(r.high ?? "").replace(",", "."));49    const year = Number(r.year);50    if (!BENCHMARK_TYPES.includes(bt)) { errors.push(`ligne ${i + 1} : building_type invalide « ${bt} »`); return; }51    if (!(low > 0 && high >= low && high < 5000)) { errors.push(`ligne ${i + 1} : bornes invalides`); return; }52    if (!(year >= 2015 && year <= 2035)) { errors.push(`ligne ${i + 1} : année invalide`); return; }53    if (!r.source || !r.market) { errors.push(`ligne ${i + 1} : source/market requis`); return; }54    rows.push({ source: String(r.source).trim(), building_type: bt, market: String(r.market).trim(), unit: String(r.unit ?? "$/pi2"), low, high, year, notes: r.notes ? String(r.notes) : null });55  });56  return { rows, errors };57}5859export function importBenchmarks(input: unknown, d: Database.Database = getCostDb()): { imported: number; errors: string[] } {60  const { rows, errors } = normalizeBenchmarkRows(input);61  const altusId = sourceIdByKey("altus", d);62  const rsId = sourceIdByKey("rsmeans", d);63  const st = d.prepare(`INSERT INTO cost_benchmarks(source,source_id,building_type,market,unit,low,high,midpoint,year,notes,is_demo) VALUES(?,?,?,?,?,?,?,?,?,?,0)64    ON CONFLICT(source,building_type,market,year) DO UPDATE SET low=excluded.low, high=excluded.high, midpoint=excluded.midpoint, unit=excluded.unit, notes=excluded.notes, source_id=excluded.source_id`);65  const tx = d.transaction(() => { for (const r of rows) st.run(r.source, /altus/i.test(r.source) ? altusId : /rsmeans|gordian/i.test(r.source) ? rsId : null, r.building_type, r.market, r.unit, r.low, r.high, (r.low + r.high) / 2, r.year, r.notes ?? null); });66  tx();67  if (rows.length) d.prepare("UPDATE cost_sources SET last_successful_sync=datetime('now') WHERE key IN ('altus') AND EXISTS (SELECT 1 FROM cost_benchmarks WHERE source_id=cost_sources.id)").run();68  return { imported: rows.length, errors };69}7071/** Import RSMeans/Gordian (CSV : item_code, price, unit, [pack_qty], [date], [notes]) → prix de référence licenciés. */72export function importRsmeans(input: unknown, d: Database.Database = getCostDb()): { imported: number; errors: string[] } {73  const raw: Record<string, unknown>[] = typeof input === "string" ? parseCsv(input) : Array.isArray(input) ? (input as Record<string, unknown>[]) : [];74  const sid = sourceIdByKey("rsmeans", d);75  const errors: string[] = [];76  let imported = 0;77  const tx = d.transaction(() => {78    raw.forEach((r, i) => {79      const code = String(r.item_code ?? r.canonical_code ?? "").trim();80      const item = d.prepare("SELECT id, unit FROM cost_items WHERE canonical_code=?").get(code) as { id: number; unit: CanonicalUnit } | undefined;81      if (!item) { errors.push(`ligne ${i + 1} : article inconnu ${code}`); return; }82      const price = Number(String(r.price ?? "").replace(",", "."));83      if (!(price > 0)) { errors.push(`ligne ${i + 1} : prix invalide`); return; }84      const date = String(r.date ?? new Date().toISOString().slice(0, 10));85      try {86        const conv = convertPrice(price, String(r.unit ?? item.unit), item.unit, r.pack_qty ? Number(r.pack_qty) : null);87        d.prepare(`INSERT INTO cost_item_prices(cost_item_id,source_id,location_code,observation_date,price_kind,material_cost,total_cost,currency,source_unit,conversion_factor,is_regular_price,confidence_score,source_url,verified) VALUES(?,?,'QC',?,'reference',?,?,'CAD',?,?,1,70,?,1)`)88          .run(item.id, sid, date, conv.price, conv.price, conv.conversion.sourceUnit, conv.conversion.factor, r.notes ? String(r.notes) : null);89        imported++;90      } catch (e) { errors.push(`ligne ${i + 1} : ${(e as Error).message}`); }91    });92  });93  tx();94  return { imported, errors };95}96