nedviga-cli 0.1.0__py3-none-any.whl

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.
Files changed (71) hide show
  1. nedviga/__init__.py +46 -0
  2. nedviga/__main__.py +6 -0
  3. nedviga/_wm.py +93 -0
  4. nedviga/adaptive_selectors.py +100 -0
  5. nedviga/auditlog.py +77 -0
  6. nedviga/auto.py +88 -0
  7. nedviga/bargain.py +183 -0
  8. nedviga/browser_native.py +242 -0
  9. nedviga/callsheet.py +63 -0
  10. nedviga/chains.py +83 -0
  11. nedviga/checklist.py +119 -0
  12. nedviga/cian_card.py +1371 -0
  13. nedviga/cian_live.py +812 -0
  14. nedviga/cli.py +3118 -0
  15. nedviga/compare.py +204 -0
  16. nedviga/data/legal_flags.json +146 -0
  17. nedviga/data/release_pubkey.asc +22 -0
  18. nedviga/deck.py +115 -0
  19. nedviga/dedup.py +242 -0
  20. nedviga/developer.py +169 -0
  21. nedviga/digest.py +205 -0
  22. nedviga/doctor.py +128 -0
  23. nedviga/dossier.py +303 -0
  24. nedviga/eco.py +81 -0
  25. nedviga/errors.py +22 -0
  26. nedviga/export_html.py +280 -0
  27. nedviga/export_pdf.py +96 -0
  28. nedviga/family_card.py +670 -0
  29. nedviga/feedback.py +79 -0
  30. nedviga/geo_routing.py +214 -0
  31. nedviga/graph.py +149 -0
  32. nedviga/legal_triage.py +105 -0
  33. nedviga/litprecheck.py +116 -0
  34. nedviga/market.py +102 -0
  35. nedviga/mcp_server.py +195 -0
  36. nedviga/metro_directory.json +9836 -0
  37. nedviga/metro_vestibules.py +134 -0
  38. nedviga/models.py +164 -0
  39. nedviga/mosdata.py +95 -0
  40. nedviga/net_guard.py +997 -0
  41. nedviga/noise.py +156 -0
  42. nedviga/ops.py +94 -0
  43. nedviga/outcomes.py +35 -0
  44. nedviga/pii.py +85 -0
  45. nedviga/pipeline.py +239 -0
  46. nedviga/poi.py +180 -0
  47. nedviga/price_guard.py +73 -0
  48. nedviga/profiles.py +131 -0
  49. nedviga/projects.py +112 -0
  50. nedviga/purge.py +116 -0
  51. nedviga/py.typed +0 -0
  52. nedviga/risk.py +127 -0
  53. nedviga/roles.py +124 -0
  54. nedviga/routing_cache.py +72 -0
  55. nedviga/rush.py +139 -0
  56. nedviga/scorefit.py +127 -0
  57. nedviga/sources.py +55 -0
  58. nedviga/sources_avito.py +138 -0
  59. nedviga/sources_domclick.py +122 -0
  60. nedviga/stale.py +83 -0
  61. nedviga/textsearch.py +141 -0
  62. nedviga/tour.py +180 -0
  63. nedviga/watch.py +234 -0
  64. nedviga/wizard.py +106 -0
  65. nedviga_cli-0.1.0.dist-info/METADATA +130 -0
  66. nedviga_cli-0.1.0.dist-info/RECORD +71 -0
  67. nedviga_cli-0.1.0.dist-info/WHEEL +5 -0
  68. nedviga_cli-0.1.0.dist-info/entry_points.txt +2 -0
  69. nedviga_cli-0.1.0.dist-info/licenses/LICENSE +661 -0
  70. nedviga_cli-0.1.0.dist-info/licenses/NOTICE +1 -0
  71. nedviga_cli-0.1.0.dist-info/top_level.txt +1 -0
nedviga/noise.py ADDED
@@ -0,0 +1,156 @@
1
+ """v0.5.11 (the task brief) noise monitoring."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import math
6
+ import os
7
+ import sqlite3
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Any, Optional
11
+
12
+ from .models import ListingCard
13
+
14
+
15
+ def db_path() -> Path:
16
+ base = Path(os.environ.get("NEDVIGA_STATE_DIR", str(Path.home() / ".local" / "share" / "nedviga")))
17
+ base.mkdir(parents=True, exist_ok=True)
18
+ return base / "noise.sqlite"
19
+
20
+
21
+ def _connect(db=None):
22
+ p = db or db_path()
23
+ p.parent.mkdir(parents=True, exist_ok=True)
24
+ conn = sqlite3.connect(str(p))
25
+ conn.row_factory = sqlite3.Row
26
+ conn.execute("""CREATE TABLE IF NOT EXISTS noise_measurements (
27
+ id INTEGER PRIMARY KEY, date TEXT, adm_area TEXT, district TEXT, lat REAL, lon REAL,
28
+ location TEXT, territory TEXT, category TEXT, results TEXT, raw TEXT)""")
29
+ conn.execute("""CREATE TABLE IF NOT EXISTS sync_state (cursor TEXT, last_sync TEXT)""")
30
+ conn.commit()
31
+ return conn
32
+
33
+
34
+ def _haversine(lat1, lon1, lat2, lon2):
35
+ R = 6371000.0
36
+ phi1 = math.radians(lat1); phi2 = math.radians(lat2)
37
+ dphi = math.radians(lat2-lat1); dl = math.radians(lon2-lon1)
38
+ x = math.sin(dphi/2)**2 + math.cos(phi1)*math.cos(phi2)*math.sin(dl/2)**2
39
+ return 2*R*math.asin(math.sqrt(x))
40
+
41
+
42
+ def _scalar(v):
43
+ """sqlite-безопасное значение: списки/структуры — в JSON-строку (v0.5.11 контроль:
44
+ live/mock Results бывают list — bind падал ProgrammingError)."""
45
+ if v is None or isinstance(v, (str, int, float, bytes)):
46
+ return v
47
+ import json as _j
48
+ try:
49
+ return _j.dumps(v, ensure_ascii=False)
50
+ except Exception:
51
+ return str(v)
52
+
53
+
54
+ def sync(limit=1000, db=None, rows_func=None) -> dict:
55
+ conn = _connect(db)
56
+ row = conn.execute("SELECT cursor FROM sync_state ORDER BY rowid DESC LIMIT 1").fetchone()
57
+ skip = int(row["cursor"]) if row and row["cursor"] else 0
58
+ if rows_func is None:
59
+ from .mosdata import rows as rows_func
60
+ total = 0
61
+ while total < limit:
62
+ top = min(1000, limit - total)
63
+ try:
64
+ data = rows_func("2449", None, top, skip)
65
+ except Exception:
66
+ break
67
+ items = data if isinstance(data, list) else (data.get("value") or data.get("rows") or [])
68
+ if not items:
69
+ break
70
+ for it in items:
71
+ # map fields
72
+ # v0.5.11 замечание realtor (верное): apidata v1 кладёт поля в Cells —
73
+ # без unwrap половина замеров теряла координаты. Cells в приоритете,
74
+ # верхний уровень — фолбэк (формат моков/экспортов).
75
+ c = it.get("Cells") if isinstance(it.get("Cells"), dict) else it
76
+ lat = c.get("Latitude_WGS84") or it.get("Latitude_WGS84") or c.get("lat")
77
+ lon = c.get("Longitude_WGS84") or it.get("Longitude_WGS84") or c.get("lon")
78
+ row_id = c.get("Id") or it.get("Id") or it.get("id")
79
+ if row_id is None:
80
+ continue
81
+ conn.execute("INSERT OR REPLACE INTO noise_measurements (id,date,adm_area,district,lat,lon,location,territory,category,results,raw) VALUES (?,?,?,?,?,?,?,?,?,?,?)",
82
+ (row_id, _scalar(c.get("Date") or it.get("Date") or it.get("date")),
83
+ _scalar(c.get("AdmArea") or it.get("AdmArea")), _scalar(c.get("District") or it.get("District")),
84
+ _scalar(lat), _scalar(lon),
85
+ _scalar(c.get("Location") or it.get("Location")),
86
+ _scalar(c.get("TerritoryType") or it.get("TerritoryType")),
87
+ _scalar(c.get("NoiseCategory") or it.get("NoiseCategory")),
88
+ _scalar(c.get("Results") or it.get("Results")), json.dumps(it, ensure_ascii=False)))
89
+ total += 1
90
+ if total >= limit:
91
+ break
92
+ skip += len(items)
93
+ conn.execute("INSERT INTO sync_state (cursor,last_sync) VALUES (?,?)",
94
+ (str(skip), datetime.now(timezone.utc).isoformat()))
95
+ conn.commit()
96
+ if len(items) < top:
97
+ break
98
+ conn.close()
99
+ return {"ok": True, "inserted": total, "cursor": str(skip)}
100
+
101
+
102
+ def nearest(lat, lon, limit=5) -> list[dict]:
103
+ conn = _connect()
104
+ rows = conn.execute("SELECT * FROM noise_measurements WHERE lat IS NOT NULL AND lon IS NOT NULL").fetchall()
105
+ conn.close()
106
+ items = []
107
+ for r in rows:
108
+ d = _haversine(lat, lon, r["lat"], r["lon"])
109
+ items.append({"distance_m": int(round(d)), "date": r["date"], "results": r["results"],
110
+ "territory": r["territory"], "source": "data.mos.ru 2449", "location": r["location"]})
111
+ items.sort(key=lambda x: x["distance_m"])
112
+ return items[:limit]
113
+
114
+
115
+ def noise_for_card(card, radius_m: int = 1500) -> dict:
116
+ """v0.5.11 контроль качества: принимает карточку ListingCard/dict-карточку ИЛИ
117
+ чистые координаты {"geo": {"lat":..,"lon":..}} (CLI --lat/--lon).
118
+ Прежняя версия падала ValidationError на чистых координатах."""
119
+ from .models import ListingCard
120
+ lat = lon = None
121
+ c = None
122
+ if isinstance(card, str):
123
+ try:
124
+ import json, pathlib
125
+ raw = json.loads(pathlib.Path(card).read_text(encoding="utf-8"))
126
+ if isinstance(raw, list):
127
+ raw = raw[0] if raw else {}
128
+ card = raw
129
+ except Exception:
130
+ return {"status": "no_coordinates", "measurements": []}
131
+ if isinstance(card, dict) and "geo" in card and "id" not in card:
132
+ g = card.get("geo") or {}
133
+ lat, lon = g.get("lat"), g.get("lon")
134
+ else:
135
+ c = card if isinstance(card, ListingCard) else (ListingCard(**card) if isinstance(card, dict) else None)
136
+ if c is not None:
137
+ g = c.geo
138
+ lat = g.lat if g else None
139
+ lon = g.lon if g else None
140
+ if lat is None or lon is None:
141
+ return {"status": "no_coordinates", "measurements": []}
142
+ items = [x for x in nearest(lat, lon, limit=5) if x["distance_m"] <= radius_m]
143
+ return {"status": "ok", "measurements": items}
144
+
145
+
146
+ DISCLAIMER = "точечные измерения подвижной лабораторией, покрытие фрагментарное; отсутствие записи ≠ низкий шум"
147
+
148
+
149
+ def render_text(result: dict) -> str:
150
+ if result.get("status") == "no_coordinates":
151
+ return "no_coordinates"
152
+ lines = []
153
+ for m in result.get("measurements", []):
154
+ lines.append(f"{m['distance_m']} м — {m['date']}: {m['results']} ({m['territory']}) [{m['source']}]")
155
+ lines.append(DISCLAIMER)
156
+ return "\n".join(lines)
nedviga/ops.py ADDED
@@ -0,0 +1,94 @@
1
+ """v0.5.7 (the task brief) ops panel."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ from datetime import datetime, timedelta, timezone
7
+ from pathlib import Path
8
+ from typing import Any, Optional
9
+
10
+ from .sources import list_sources
11
+ from . import pipeline
12
+ from .auditlog import read_entries
13
+
14
+
15
+ def _state_dir() -> Path:
16
+ return Path(os.environ.get("NEDVIGA_STATE_DIR", str(Path.home() / ".local" / "share" / "nedviga")))
17
+
18
+
19
+ def collect(days: int = 1, precheck: bool = False, live: bool = False) -> dict[str, Any]:
20
+ # sources
21
+ sources = []
22
+ for s in list_sources():
23
+ sources.append({"id": s["id"], "status": s["status"], "site_patterns": s["site_patterns"]})
24
+ # breaker state: try net_guard
25
+ breaker = {}
26
+ try:
27
+ from .net_guard import _DEFAULT_DB
28
+ breaker = {"db": str(_DEFAULT_DB)}
29
+ except Exception:
30
+ breaker = {}
31
+ # cache stats
32
+ poi_cache = _state_dir() / "poi_cache"
33
+ poi_count = len(list(poi_cache.glob("*.json"))) if poi_cache.exists() else 0
34
+ watch_count = 0
35
+ try:
36
+ conn = pipeline._connect()
37
+ watch_count = conn.execute("SELECT COUNT(*) FROM watch_snapshots").fetchone()[0]
38
+ conn.close()
39
+ except Exception:
40
+ pass
41
+ # errors top-n
42
+ entries = read_entries(days=days)
43
+ errors = [e for e in entries if e.get("rc", 0) != 0]
44
+ # aggregate by command
45
+ agg: dict[str, int] = {}
46
+ for e in errors:
47
+ c = e.get("command", "unknown")
48
+ agg[c] = agg.get(c, 0) + 1
49
+ top_errors = sorted(agg.items(), key=lambda x: x[1], reverse=True)[:10]
50
+
51
+ precheck_results = None
52
+ if precheck:
53
+ precheck_results = []
54
+ for s in sources:
55
+ if live:
56
+ # one ping via netguard
57
+ status = "ok"
58
+ try:
59
+ from .net_guard import guarded_get
60
+ # minimal HEAD not available; attempt GET root
61
+ resp = guarded_get(f"https://{s['site_patterns'][0]}", timeout=5.0)
62
+ if resp.status_code >= 400:
63
+ status = "error"
64
+ except Exception as exc:
65
+ msg = str(exc).lower()
66
+ if "challenge" in msg or "403" in msg:
67
+ status = "challenge"
68
+ elif "blocked" in msg:
69
+ status = "blocked"
70
+ else:
71
+ status = "error"
72
+ precheck_results.append({"source": s["id"], "status": status})
73
+ else:
74
+ precheck_results.append({"source": s["id"], "status": "ok"})
75
+
76
+ return {
77
+ "sources": sources,
78
+ "breaker": breaker,
79
+ "cache": {"poi_cache_entries": poi_count, "watch_entries": watch_count},
80
+ "errors_top": top_errors,
81
+ "precheck": precheck_results,
82
+ }
83
+
84
+
85
+ def render_text(data: dict) -> str:
86
+ lines = ["OPS"]
87
+ lines.append("sources: " + ", ".join(f"{s['id']}={s['status']}" for s in data["sources"]))
88
+ lines.append(f"poi_cache: {data['cache']['poi_cache_entries']}")
89
+ lines.append(f"watch: {data['cache']['watch_entries']}")
90
+ if data["errors_top"]:
91
+ lines.append("errors: " + ", ".join(f"{k}:{v}" for k, v in data["errors_top"]))
92
+ if data.get("precheck"):
93
+ lines.append("precheck: " + ", ".join(f"{p['source']}={p['status']}" for p in data["precheck"]))
94
+ return "\n".join(lines)
nedviga/outcomes.py ADDED
@@ -0,0 +1,35 @@
1
+ """Helpers for building the ApiOutcome JSON envelope."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timezone
6
+ from typing import Any, Literal
7
+
8
+ from .models import ApiOutcomeEnvelope
9
+
10
+
11
+ STATUS_EXIT_CODES = {
12
+ "ok": 0,
13
+ "degraded": 2,
14
+ "error": 3,
15
+ }
16
+ USAGE_ERROR_EXIT = 4
17
+
18
+
19
+ def make_envelope(
20
+ status: Literal["ok", "degraded", "error"],
21
+ reason: str | None = None,
22
+ source: str | None = None,
23
+ data: Any = None,
24
+ metadata: dict | None = None,
25
+ ) -> ApiOutcomeEnvelope:
26
+ """Build a valid ApiOutcome envelope."""
27
+ return ApiOutcomeEnvelope(
28
+ ok=(status == "ok"),
29
+ status=status,
30
+ reason=reason,
31
+ source=source,
32
+ parsed_at=datetime.now(timezone.utc),
33
+ data=data,
34
+ metadata=metadata or {},
35
+ )
nedviga/pii.py ADDED
@@ -0,0 +1,85 @@
1
+ """PII sanitisers for raw dumps and generated reports.
2
+
3
+ v0.4.6 (the task brief F-13): fail-closed маскирование телефонов по умолчанию
4
+ во всех форматах вывода CLI (search stdout/--json, HTML-экспорт).
5
+ Открытие контактов — только явным флагом ``--with-contacts`` с маркером
6
+ ``contacts_open`` в envelope/metadata.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ # Единый плейсхолдер маски (ТЗ §2.1).
14
+ PHONE_MASK = "+7 *** ***-**-**"
15
+
16
+ # Российские мобильные/городские: +7 / 7 / 8 + 10 цифр (разделители опциональны).
17
+ _PHONE_PREFIXED_RE = re.compile(
18
+ r"(?:\+7|7|8)[\s\-]?\(?\d{3}\)?[\s\-]?\d{3}[\s\-]?\d{2}[\s\-]?\d{2}"
19
+ )
20
+ # «Голый» группированный номер без кода страны: 495 123-45-67 / 916 123 45 67.
21
+ # Требует разделители внутри — слитные длинные числа (id, цены) не трогает.
22
+ _PHONE_BARE_GROUPED_RE = re.compile(
23
+ r"(?<![\dA-Za-z])\d{3}[\s\-]\d{3}[\s\-]\d{2}[\s\-]\d{2}(?!\d)"
24
+ )
25
+
26
+
27
+ def mask_phones(text: str) -> str:
28
+ """Replace phone numbers with a masked placeholder."""
29
+ masked = _PHONE_PREFIXED_RE.sub(PHONE_MASK, text)
30
+ masked = _PHONE_BARE_GROUPED_RE.sub(PHONE_MASK, masked)
31
+ return masked
32
+
33
+
34
+ def mask_phone_number(value: object) -> str:
35
+ """Mask a single phone slot.
36
+
37
+ Fail-closed: формат не распознан — значение полностью заменяется маской
38
+ (в слоте телефонов неизвестную строку считаем номером).
39
+ """
40
+ raw = str(value if value is not None else "")
41
+ if not raw.strip():
42
+ return PHONE_MASK
43
+ masked = mask_phones(raw)
44
+ if masked != raw:
45
+ return masked
46
+ return PHONE_MASK
47
+
48
+
49
+ def mask_phones_in_obj(obj: object) -> object:
50
+ """Recursively mask phone numbers inside strings in JSON-like structures."""
51
+ if isinstance(obj, str):
52
+ return mask_phones(obj)
53
+ if isinstance(obj, list):
54
+ return [mask_phones_in_obj(item) for item in obj]
55
+ if isinstance(obj, dict):
56
+ return {k: mask_phones_in_obj(v) for k, v in obj.items()}
57
+ return obj
58
+
59
+
60
+ def sanitize_contacts(obj: object) -> object:
61
+ """mask_phones_in_obj + fail-closed обработка телефонных слотов.
62
+
63
+ Ключи ``phones`` (seller.phones и любые списки телефонов) маскируются
64
+ поминутно через :func:`mask_phone_number`: нераспознанный формат
65
+ заменяется полной маской, а не остаётся сырым.
66
+ """
67
+ if isinstance(obj, dict):
68
+ out: dict = {}
69
+ for key, value in obj.items():
70
+ if key == "phones" and isinstance(value, list):
71
+ out[key] = [
72
+ mask_phone_number(item)
73
+ for item in value
74
+ if str(item if item is not None else "").strip()
75
+ ]
76
+ elif key in {"phone", "tel", "contact_phone"} and isinstance(value, str):
77
+ out[key] = mask_phone_number(value)
78
+ else:
79
+ out[key] = sanitize_contacts(value)
80
+ return out
81
+ if isinstance(obj, list):
82
+ return [sanitize_contacts(item) for item in obj]
83
+ if isinstance(obj, str):
84
+ return mask_phones(obj)
85
+ return obj
nedviga/pipeline.py ADDED
@@ -0,0 +1,239 @@
1
+ """v0.4.8 (the task brief) F-04: pipeline funnel storage."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import os
6
+ import sqlite3
7
+ import threading
8
+ from datetime import datetime, timezone
9
+ from pathlib import Path
10
+ from typing import Any, Optional
11
+
12
+ STATUSES = ["new", "candidate", "shortlist", "call_planned", "showing",
13
+ "feedback", "reserved", "rejected", "bought"]
14
+ TERMINAL = {"rejected", "bought"}
15
+
16
+ _ALLOWED: dict[str, set[str]] = {
17
+ "new": {"candidate", "rejected"},
18
+ "candidate": {"shortlist", "rejected"},
19
+ "shortlist": {"call_planned", "rejected"},
20
+ "call_planned": {"showing", "rejected"},
21
+ "showing": {"feedback", "rejected"},
22
+ "feedback": {"reserved", "rejected", "bought"},
23
+ "reserved": {"bought", "rejected"},
24
+ "rejected": set(),
25
+ "bought": set(),
26
+ }
27
+
28
+ _STATE_DIR = Path(os.environ.get("NEDVIGA_STATE_DIR", str(Path.home() / ".local" / "share" / "nedviga")))
29
+ _DB_PATH = _STATE_DIR / "pipeline.sqlite"
30
+ _lock = threading.Lock()
31
+
32
+
33
+ def state_dir() -> Path:
34
+ p = Path(os.environ.get("NEDVIGA_STATE_DIR", str(Path.home() / ".local" / "share" / "nedviga")))
35
+ p.mkdir(parents=True, exist_ok=True)
36
+ return p
37
+
38
+
39
+ def db_path() -> Path:
40
+ return state_dir() / "pipeline.sqlite"
41
+
42
+
43
+ def _connect(db: Optional[Path] = None) -> sqlite3.Connection:
44
+ p = db or db_path()
45
+ p.parent.mkdir(parents=True, exist_ok=True)
46
+ conn = sqlite3.connect(str(p), timeout=30.0)
47
+ conn.execute("PRAGMA journal_mode=WAL")
48
+ conn.execute("PRAGMA busy_timeout=5000")
49
+ conn.row_factory = sqlite3.Row
50
+ conn.execute(
51
+ "CREATE TABLE IF NOT EXISTS pipeline_items ("
52
+ " card_id TEXT PRIMARY KEY,"
53
+ " title TEXT,"
54
+ " price_snapshot REAL,"
55
+ " status TEXT NOT NULL,"
56
+ " updated_at TEXT NOT NULL,"
57
+ " note TEXT)"
58
+ )
59
+ conn.execute(
60
+ "CREATE TABLE IF NOT EXISTS status_history ("
61
+ " id INTEGER PRIMARY KEY AUTOINCREMENT,"
62
+ " card_id TEXT NOT NULL,"
63
+ " from_status TEXT,"
64
+ " to_status TEXT NOT NULL,"
65
+ " at TEXT NOT NULL,"
66
+ " note TEXT)"
67
+ )
68
+ conn.execute(
69
+ "CREATE TABLE IF NOT EXISTS watch_snapshots ("
70
+ " card_id TEXT PRIMARY KEY,"
71
+ " price REAL,"
72
+ " status TEXT,"
73
+ " floors_total INTEGER,"
74
+ " snapshot_at TEXT NOT NULL,"
75
+ " raw_json TEXT)"
76
+ )
77
+ conn.execute(
78
+ "CREATE TABLE IF NOT EXISTS watch_history ("
79
+ " id INTEGER PRIMARY KEY AUTOINCREMENT,"
80
+ " card_id TEXT NOT NULL,"
81
+ " price REAL,"
82
+ " status TEXT,"
83
+ " at TEXT NOT NULL)"
84
+ )
85
+ conn.commit()
86
+ return conn
87
+
88
+
89
+ def _now() -> str:
90
+ return datetime.now(timezone.utc).isoformat()
91
+
92
+
93
+ def _snapshot_from_file(card_id: str, from_file: Optional[str]) -> tuple[Optional[str], Optional[float]]:
94
+ if not from_file:
95
+ return None, None
96
+ try:
97
+ raw = json.loads(Path(from_file).read_text(encoding="utf-8"))
98
+ except Exception as exc: # v0.4.8 контроль качества: молчание = ложный «снапшота нет»
99
+ raise ValueError(f"snapshot_file_unreadable: {type(exc).__name__}") from exc
100
+ if isinstance(raw, dict):
101
+ if "cards" in raw and isinstance(raw["cards"], list):
102
+ raw = raw["cards"]
103
+ elif "data" in raw:
104
+ d = raw["data"]
105
+ raw = d if isinstance(d, list) else [d] if isinstance(d, dict) else []
106
+ else:
107
+ raw = [raw]
108
+ if not isinstance(raw, list):
109
+ raw = [raw]
110
+ for item in raw:
111
+ if not isinstance(item, dict):
112
+ continue
113
+ if item.get("id") == card_id:
114
+ title = item.get("title")
115
+ pricing = item.get("pricing") or {}
116
+ price = pricing.get("price_total_rub")
117
+ return title, price
118
+ # fallback first
119
+ if raw and isinstance(raw[0], dict):
120
+ item = raw[0]
121
+ title = item.get("title")
122
+ pricing = item.get("pricing") or {}
123
+ price = pricing.get("price_total_rub")
124
+ return title, price
125
+ return None, None
126
+
127
+
128
+ def has_phone(text: Optional[str]) -> bool:
129
+ if not text:
130
+ return False
131
+ from .pii import mask_phones
132
+ return mask_phones(text) != text
133
+
134
+
135
+ def set_status(
136
+ card_id: str,
137
+ status: str,
138
+ note: Optional[str] = None,
139
+ from_file: Optional[str] = None,
140
+ title: Optional[str] = None,
141
+ price: Optional[float] = None,
142
+ db: Optional[Path] = None,
143
+ ) -> dict[str, Any]:
144
+ if status not in STATUSES:
145
+ return {"ok": False, "reason": "invalid_status", "card_id": card_id}
146
+ snap_title, snap_price = _snapshot_from_file(card_id, from_file)
147
+ if title is not None:
148
+ snap_title = title
149
+ if price is not None:
150
+ snap_price = price
151
+
152
+ warning = None
153
+ if has_phone(note):
154
+ warning = "phone_detected_in_note"
155
+
156
+ with _lock:
157
+ conn = _connect(db)
158
+ try:
159
+ row = conn.execute("SELECT * FROM pipeline_items WHERE card_id=?", (card_id,)).fetchone()
160
+ now = _now()
161
+ if row is None:
162
+ conn.execute(
163
+ "INSERT INTO pipeline_items (card_id, title, price_snapshot, status, updated_at, note) VALUES (?,?,?,?,?,?)",
164
+ (card_id, snap_title, snap_price, status, now, note),
165
+ )
166
+ conn.execute(
167
+ "INSERT INTO status_history (card_id, from_status, to_status, at, note) VALUES (?,?,?,?,?)",
168
+ (card_id, None, status, now, note),
169
+ )
170
+ conn.commit()
171
+ return {"ok": True, "card_id": card_id, "status": status, "created": True,
172
+ "warning": warning}
173
+ current = row["status"]
174
+ if current == status:
175
+ # idempotent: no history
176
+ conn.execute(
177
+ "UPDATE pipeline_items SET updated_at=?, note=COALESCE(?, note), "
178
+ "title=COALESCE(?, title), price_snapshot=COALESCE(?, price_snapshot) WHERE card_id=?",
179
+ (now, note, snap_title, snap_price, card_id),
180
+ )
181
+ conn.commit()
182
+ return {"ok": True, "card_id": card_id, "status": status, "idempotent": True,
183
+ "warning": warning}
184
+ if status not in _ALLOWED.get(current, set()):
185
+ return {"ok": False, "reason": "invalid_transition", "from": current, "to": status,
186
+ "card_id": card_id, "warning": warning}
187
+ conn.execute(
188
+ "UPDATE pipeline_items SET status=?, updated_at=?, note=COALESCE(?, note), "
189
+ "title=COALESCE(?, title), price_snapshot=COALESCE(?, price_snapshot) WHERE card_id=?",
190
+ (status, now, note, snap_title, snap_price, card_id),
191
+ )
192
+ conn.execute(
193
+ "INSERT INTO status_history (card_id, from_status, to_status, at, note) VALUES (?,?,?,?,?)",
194
+ (card_id, current, status, now, note),
195
+ )
196
+ conn.commit()
197
+ return {"ok": True, "card_id": card_id, "status": status, "from": current,
198
+ "warning": warning}
199
+ finally:
200
+ conn.close()
201
+
202
+
203
+ def list_items(status: Optional[str] = None, db: Optional[Path] = None) -> list[dict[str, Any]]:
204
+ with _lock:
205
+ conn = _connect(db)
206
+ try:
207
+ if status:
208
+ rows = conn.execute("SELECT * FROM pipeline_items WHERE status=? ORDER BY updated_at DESC", (status,)).fetchall()
209
+ else:
210
+ rows = conn.execute("SELECT * FROM pipeline_items ORDER BY updated_at DESC").fetchall()
211
+ return [dict(r) for r in rows]
212
+ finally:
213
+ conn.close()
214
+
215
+
216
+ def show_history(card_id: str, db: Optional[Path] = None) -> dict[str, Any]:
217
+ with _lock:
218
+ conn = _connect(db)
219
+ try:
220
+ item = conn.execute("SELECT * FROM pipeline_items WHERE card_id=?", (card_id,)).fetchone()
221
+ hist = conn.execute("SELECT * FROM status_history WHERE card_id=? ORDER BY id", (card_id,)).fetchall()
222
+ return {
223
+ "card_id": card_id,
224
+ "item": dict(item) if item else None,
225
+ "history": [dict(r) for r in hist],
226
+ }
227
+ finally:
228
+ conn.close()
229
+
230
+
231
+ def clear(db: Optional[Path] = None) -> None:
232
+ with _lock:
233
+ conn = _connect(db)
234
+ try:
235
+ conn.execute("DELETE FROM pipeline_items")
236
+ conn.execute("DELETE FROM status_history")
237
+ conn.commit()
238
+ finally:
239
+ conn.close()