sud-cli 0.1.1__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 (74) hide show
  1. sud_cli/__init__.py +3 -0
  2. sud_cli/__main__.py +4 -0
  3. sud_cli/_wm.py +118 -0
  4. sud_cli/agenda.py +90 -0
  5. sud_cli/autofill.py +192 -0
  6. sud_cli/autofill_markers.json +56 -0
  7. sud_cli/backup.py +256 -0
  8. sud_cli/calendar_ics.py +191 -0
  9. sud_cli/case_diff.py +165 -0
  10. sud_cli/case_search_bridge.py +1322 -0
  11. sud_cli/chain.py +226 -0
  12. sud_cli/cli.py +3721 -0
  13. sud_cli/completeness.py +105 -0
  14. sud_cli/config.py +20 -0
  15. sud_cli/conflict.py +210 -0
  16. sud_cli/conflicts.py +207 -0
  17. sud_cli/court_alerts.py +156 -0
  18. sud_cli/court_source.py +108 -0
  19. sud_cli/data/completeness_rules.json +18 -0
  20. sud_cli/data/key_rates.json +59 -0
  21. sud_cli/data/npa_historical_editions.json +19 -0
  22. sud_cli/db.py +2072 -0
  23. sud_cli/deadline_matrix.py +99 -0
  24. sud_cli/deadline_state.py +25 -0
  25. sud_cli/deadlines.py +8 -0
  26. sud_cli/deadlines_state.py +1 -0
  27. sud_cli/deferral.py +5 -0
  28. sud_cli/efrsb_manual.py +85 -0
  29. sud_cli/efrsb_public.py +210 -0
  30. sud_cli/egrul_nalog.py +150 -0
  31. sud_cli/explain.py +190 -0
  32. sud_cli/fees_extra.py +104 -0
  33. sud_cli/forms.py +204 -0
  34. sud_cli/fssp_manual.py +91 -0
  35. sud_cli/fssp_semi.py +285 -0
  36. sud_cli/fssp_stats.py +105 -0
  37. sud_cli/government_transfers.json +8 -0
  38. sud_cli/holidays_rf.json +62 -0
  39. sud_cli/idempotency.py +78 -0
  40. sud_cli/importing.py +575 -0
  41. sud_cli/interest.py +144 -0
  42. sud_cli/jurisdiction.py +177 -0
  43. sud_cli/jurisdiction_rules.json +265 -0
  44. sud_cli/legal_deadlines.py +278 -0
  45. sud_cli/legal_engine.py +558 -0
  46. sud_cli/legal_rules.json +1058 -0
  47. sud_cli/limitation.py +70 -0
  48. sud_cli/mcp_server.py +1594 -0
  49. sud_cli/metrics.py +142 -0
  50. sud_cli/npa_as_of.py +21 -0
  51. sud_cli/output.py +89 -0
  52. sud_cli/payment.py +24 -0
  53. sud_cli/pdn.py +259 -0
  54. sud_cli/plenum_corpus.py +134 -0
  55. sud_cli/plenums_seed.json +45 -0
  56. sud_cli/practice_status.py +136 -0
  57. sud_cli/pravo_publication.py +361 -0
  58. sud_cli/pretrial.py +151 -0
  59. sud_cli/production_calendar.py +233 -0
  60. sud_cli/providers.py +95 -0
  61. sud_cli/readiness.py +234 -0
  62. sud_cli/regional_holidays.py +37 -0
  63. sud_cli/reminders.py +92 -0
  64. sud_cli/reports.py +349 -0
  65. sud_cli/sud_netguard.py +943 -0
  66. sud_cli/timeline_audit.py +80 -0
  67. sud_cli/verify.py +201 -0
  68. sud_cli-0.1.1.dist-info/METADATA +119 -0
  69. sud_cli-0.1.1.dist-info/RECORD +74 -0
  70. sud_cli-0.1.1.dist-info/WHEEL +4 -0
  71. sud_cli-0.1.1.dist-info/entry_points.txt +2 -0
  72. sud_cli-0.1.1.dist-info/licenses/AUTHORS +5 -0
  73. sud_cli-0.1.1.dist-info/licenses/LICENSE.md +661 -0
  74. sud_cli-0.1.1.dist-info/licenses/NOTICE +7 -0
sud_cli/db.py ADDED
@@ -0,0 +1,2072 @@
1
+ """Isolated SQLite persistence layer for sud-cli."""
2
+
3
+ import datetime
4
+ import sqlite3
5
+ from pathlib import Path
6
+ from typing import Any, Dict, List, Optional, Tuple
7
+
8
+ SCHEMA_VERSION = "3"
9
+ DB_NAME = "store.db"
10
+
11
+
12
+ class SchemaError(Exception):
13
+ """Raised when the database schema version is unsupported."""
14
+
15
+
16
+ # Phase 1 (schema v1) -> Phase 3 (schema v2).
17
+ # Executed atomically inside a single explicit transaction. If any statement
18
+ # fails, the upgrade is rolled back and the database remains at its previous
19
+ # version. Schema objects for v2 are created before this migration runs, so the
20
+ # tuple only performs data movement and the final version bump.
21
+ _V1_TO_V2_MIGRATION = (
22
+ """
23
+ INSERT OR IGNORE INTO cases
24
+ (case_id, case_number, court, plaintiff, defendant,
25
+ source_name, source_record_id, fetched_at, source_url, imported_at)
26
+ SELECT
27
+ case_number, case_number, court, plaintiff, defendant,
28
+ 'local-json-court-migrated', record_id, imported_at, NULL, imported_at
29
+ FROM records;
30
+ """,
31
+ """
32
+ INSERT OR IGNORE INTO events
33
+ (event_id, case_id, event_date, event_type, doc_text,
34
+ source_name, source_record_id, fetched_at, source_url, imported_at)
35
+ SELECT
36
+ record_id, case_number, event_date, event_type, doc_text,
37
+ 'local-json-court-migrated', record_id, imported_at, NULL, imported_at
38
+ FROM records;
39
+ """,
40
+ "UPDATE meta SET value = '2' WHERE key = 'schema_version';",
41
+ )
42
+
43
+
44
+ def _ensure_case_documents_columns(conn):
45
+ """the wave §2: идемпотентная миграция case_documents -> +supersedes.
46
+
47
+ Как the wave для case_amounts: ALTER ADD COLUMN с NULL по умолчанию, старые
48
+ строки остаются supersedes=NULL — поведение существующих записей не меняется.
49
+ """
50
+ cols = {r["name"] for r in conn.execute("PRAGMA table_info(case_documents)").fetchall()}
51
+ if not cols:
52
+ return # таблицы ещё нет (fresh DB создаётся сразу с новой колонкой)
53
+ if "supersedes" not in cols:
54
+ conn.execute("ALTER TABLE case_documents ADD COLUMN supersedes INTEGER")
55
+
56
+
57
+ def _ensure_evidence_items_table(conn):
58
+ """the wave §1 (S-73): идемпотентное создание evidence_items без bump схемы.
59
+
60
+ SCHEMA_VERSION остаётся '3': таблица добавляется тем же ensure-шагом,
61
+ каким the wave добавлял колонки case_amounts — старые пины версии не плывут.
62
+ media — СВОБОДНЫЙ TEXT с проверкой значения на входе (валидация аргумента,
63
+ не enum в схеме).
64
+ """
65
+ conn.execute(
66
+ """
67
+ CREATE TABLE IF NOT EXISTS evidence_items (
68
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
69
+ case_id TEXT NOT NULL REFERENCES cases(case_id) ON DELETE CASCADE,
70
+ title TEXT NOT NULL,
71
+ volume TEXT,
72
+ sheet TEXT,
73
+ doc_ref INTEGER REFERENCES case_documents(id),
74
+ sha256 TEXT,
75
+ media TEXT,
76
+ added_at TEXT NOT NULL,
77
+ note TEXT
78
+ );
79
+ """
80
+ )
81
+ conn.execute(
82
+ "CREATE INDEX IF NOT EXISTS idx_evidence_items_case ON evidence_items(case_id);"
83
+ )
84
+
85
+
86
+ def _ensure_case_representatives_table(conn):
87
+ """the wave §2 (S-15): идемпотентное создание case_representatives без bump схемы.
88
+
89
+ Тот же ensure-паттерн, что the wave (case_amounts) и the wave (evidence_items):
90
+ SCHEMA_VERSION остаётся '3'. side — СВОБОДНЫЙ TEXT в схеме, проверяться
91
+ будет на входе (валидация аргумента, не выдуманный enum в DDL);
92
+ power_docs — свободный текст описания доверенности: sud не реестр
93
+ полномочий и автоматическую проверку не делает.
94
+ """
95
+ conn.execute(
96
+ """
97
+ CREATE TABLE IF NOT EXISTS case_representatives (
98
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
99
+ case_id TEXT NOT NULL REFERENCES cases(case_id) ON DELETE CASCADE,
100
+ side TEXT NOT NULL,
101
+ name TEXT NOT NULL,
102
+ authority_note TEXT,
103
+ power_docs TEXT,
104
+ added_at TEXT NOT NULL
105
+ );
106
+ """
107
+ )
108
+ conn.execute(
109
+ "CREATE INDEX IF NOT EXISTS idx_case_reps_case ON case_representatives(case_id);"
110
+ )
111
+
112
+
113
+ def _ensure_cases_columns(conn):
114
+ """the wave §1 (S-12): идемпотентная колонка cases.instance (без bump схемы).
115
+
116
+ Цепочка инстанций (§1) берёт instance из поля дела, как и матрица the wave
117
+ (`case.get('instance')`). В схеме W1 такой колонки не было — add-only,
118
+ старые строки остаются NULL (= unknown), поведение не меняется.
119
+ """
120
+ cols = {r["name"] for r in conn.execute("PRAGMA table_info(cases)").fetchall()}
121
+ if not cols:
122
+ return # таблицы ещё нет (fresh DB создаётся сразу с новой колонкой)
123
+ if "instance" not in cols:
124
+ conn.execute("ALTER TABLE cases ADD COLUMN instance TEXT")
125
+
126
+
127
+ def _ensure_case_amounts_columns(conn):
128
+ """the wave §1: идемпотентная миграция case_amounts -> +status/+paid_at.
129
+
130
+ kind остаётся СВОБОДНЫЙ TEXT (никаких выдуманных enum в схеме).
131
+ ALTER ADD COLUMN с константным DEFAULT заполняет старые строки
132
+ значением 'planned' и не трогает остальные колонки.
133
+ """
134
+ cols = {r["name"] for r in conn.execute("PRAGMA table_info(case_amounts)").fetchall()}
135
+ if not cols:
136
+ return # таблицы ещё нет (fresh DB создаётся сразу с новыми колонками)
137
+ if "status" not in cols:
138
+ conn.execute("ALTER TABLE case_amounts ADD COLUMN status TEXT DEFAULT 'planned'")
139
+ if "paid_at" not in cols:
140
+ conn.execute("ALTER TABLE case_amounts ADD COLUMN paid_at TEXT")
141
+
142
+
143
+ def _v2_to_v3_migration(conn):
144
+ cols = {r["name"] for r in conn.execute("PRAGMA table_info(cases)").fetchall()}
145
+ if "status" not in cols:
146
+ conn.execute("ALTER TABLE cases ADD COLUMN status TEXT;")
147
+ if "owner" not in cols:
148
+ conn.execute("ALTER TABLE cases ADD COLUMN owner TEXT;")
149
+ if "closed_at" not in cols:
150
+ conn.execute("ALTER TABLE cases ADD COLUMN closed_at TEXT;")
151
+ _ensure_case_amounts_columns(conn)
152
+ # the wave: те же идемпотентные ensure-шаги в миграции v2->v3 (свежая база
153
+ # получает их напрямую из init_db).
154
+ _ensure_evidence_items_table(conn)
155
+ _ensure_case_documents_columns(conn)
156
+ # the wave §1+§2: case_representatives (новая таблица) и cases.instance
157
+ # (новая колонка) — тем же ensure-паттерном, без bump SCHEMA_VERSION.
158
+ _ensure_case_representatives_table(conn)
159
+ _ensure_cases_columns(conn)
160
+ conn.execute("UPDATE meta SET value = '3' WHERE key = 'schema_version';")
161
+
162
+
163
+ _V2_TO_V3_MIGRATION = (_v2_to_v3_migration, _ensure_case_amounts_columns,
164
+ _ensure_evidence_items_table, _ensure_case_documents_columns,
165
+ _ensure_case_representatives_table, _ensure_cases_columns)
166
+
167
+
168
+ def _db_path(state_dir: Path) -> Path:
169
+ return state_dir / DB_NAME
170
+
171
+
172
+ def _get_schema_version(conn: sqlite3.Connection) -> Optional[str]:
173
+ try:
174
+ row = conn.execute(
175
+ "SELECT value FROM meta WHERE key = 'schema_version'"
176
+ ).fetchone()
177
+ except sqlite3.OperationalError:
178
+ # meta table may not exist yet on a completely empty database.
179
+ return None
180
+ return row["value"] if row else None
181
+
182
+
183
+ def _migrate(conn: sqlite3.Connection, from_version: str, to_version: str) -> None:
184
+ migrations = {
185
+ ("1", "2"): _V1_TO_V2_MIGRATION,
186
+ ("2", "3"): _V2_TO_V3_MIGRATION,
187
+ }
188
+ current = from_version
189
+ while current != to_version:
190
+ # step one version at a time
191
+ next_version = str(int(current) + 1)
192
+ key = (current, next_version)
193
+ if key not in migrations:
194
+ raise SchemaError(
195
+ f"no migration path from schema {current} to {next_version}"
196
+ )
197
+ for step in migrations[key]:
198
+ if callable(step):
199
+ step(conn)
200
+ else:
201
+ conn.execute(step)
202
+ current = next_version
203
+ if current == to_version:
204
+ break
205
+ if int(current) > int(to_version):
206
+ raise SchemaError(
207
+ f"no migration path from schema {from_version} to {to_version}"
208
+ )
209
+
210
+
211
+ def init_db(state_dir: Path) -> None:
212
+ state_dir.mkdir(parents=True, exist_ok=True)
213
+ conn = get_connection(state_dir)
214
+ # Use explicit transaction boundaries and individual execute() calls.
215
+ # sqlite3.Connection.executescript() issues an implicit COMMIT before it
216
+ # runs, which would break atomicity: schema objects created for an upgrade
217
+ # would survive a later migration failure. Begin an explicit transaction
218
+ # first so that every DDL and DML statement participates in the same
219
+ # transaction and can be rolled back on failure.
220
+ with conn:
221
+ conn.execute("BEGIN")
222
+ # v1 base schema.
223
+ conn.execute(
224
+ """
225
+ CREATE TABLE IF NOT EXISTS meta (
226
+ key TEXT PRIMARY KEY,
227
+ value TEXT NOT NULL
228
+ );
229
+ """
230
+ )
231
+ conn.execute(
232
+ """
233
+ CREATE TABLE IF NOT EXISTS records (
234
+ record_id TEXT PRIMARY KEY,
235
+ case_number TEXT NOT NULL,
236
+ court TEXT NOT NULL,
237
+ plaintiff TEXT,
238
+ defendant TEXT,
239
+ event_date TEXT,
240
+ event_type TEXT NOT NULL,
241
+ doc_text TEXT,
242
+ imported_at TEXT NOT NULL
243
+ );
244
+ """
245
+ )
246
+ conn.execute(
247
+ "CREATE INDEX IF NOT EXISTS idx_records_event_date ON records(event_date);"
248
+ )
249
+ conn.execute(
250
+ "CREATE INDEX IF NOT EXISTS idx_records_event_type ON records(event_type);"
251
+ )
252
+ conn.execute(
253
+ """
254
+ CREATE TABLE IF NOT EXISTS npa_docs (
255
+ doc_id TEXT NOT NULL,
256
+ edition TEXT NOT NULL,
257
+ title TEXT NOT NULL,
258
+ doc_date TEXT,
259
+ body TEXT,
260
+ imported_at TEXT NOT NULL,
261
+ PRIMARY KEY (doc_id, edition)
262
+ );
263
+ """
264
+ )
265
+ conn.execute(
266
+ "CREATE INDEX IF NOT EXISTS idx_npa_docs_doc_date ON npa_docs(doc_date);"
267
+ )
268
+
269
+ # v2 (Phase 3) schema. Created inside the same transaction so that an
270
+ # upgrade failure rolls these objects back along with any data changes.
271
+ conn.execute(
272
+ """
273
+ CREATE TABLE IF NOT EXISTS cases (
274
+ case_id TEXT PRIMARY KEY,
275
+ case_number TEXT NOT NULL,
276
+ court TEXT NOT NULL,
277
+ plaintiff TEXT,
278
+ defendant TEXT,
279
+ source_name TEXT NOT NULL,
280
+ source_record_id TEXT,
281
+ fetched_at TEXT,
282
+ source_url TEXT,
283
+ imported_at TEXT NOT NULL,
284
+ status TEXT,
285
+ owner TEXT,
286
+ closed_at TEXT,
287
+ instance TEXT
288
+ );
289
+ """
290
+ )
291
+ conn.execute(
292
+ """
293
+ CREATE TABLE IF NOT EXISTS case_amounts (
294
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
295
+ case_id TEXT NOT NULL REFERENCES cases(case_id) ON DELETE CASCADE,
296
+ kind TEXT NOT NULL,
297
+ amount_rub TEXT NOT NULL,
298
+ currency TEXT NOT NULL DEFAULT 'RUB',
299
+ note TEXT,
300
+ created_at TEXT NOT NULL,
301
+ status TEXT DEFAULT 'planned',
302
+ paid_at TEXT
303
+ );
304
+ """
305
+ )
306
+ conn.execute(
307
+ """
308
+ CREATE TABLE IF NOT EXISTS case_documents (
309
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
310
+ case_id TEXT NOT NULL REFERENCES cases(case_id) ON DELETE CASCADE,
311
+ path TEXT NOT NULL,
312
+ sha256 TEXT NOT NULL,
313
+ title TEXT,
314
+ added_at TEXT NOT NULL,
315
+ supersedes INTEGER
316
+ );
317
+ """
318
+ )
319
+ conn.execute(
320
+ """
321
+ CREATE TABLE IF NOT EXISTS case_notes (
322
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
323
+ case_id TEXT NOT NULL REFERENCES cases(case_id) ON DELETE CASCADE,
324
+ text TEXT NOT NULL,
325
+ author TEXT,
326
+ created_at TEXT NOT NULL,
327
+ supersedes INTEGER REFERENCES case_notes(id)
328
+ );
329
+ """
330
+ )
331
+ conn.execute(
332
+ """
333
+ CREATE TABLE IF NOT EXISTS deadline_states (
334
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
335
+ case_id TEXT NOT NULL REFERENCES cases(case_id) ON DELETE CASCADE,
336
+ deadline_key TEXT NOT NULL,
337
+ due_at TEXT,
338
+ basis_hash TEXT,
339
+ state TEXT NOT NULL,
340
+ confirmed_by TEXT,
341
+ confirmed_at TEXT,
342
+ evidence_note TEXT,
343
+ basis TEXT,
344
+ created_at TEXT NOT NULL
345
+ );
346
+ """
347
+ )
348
+ conn.execute(
349
+ "CREATE INDEX IF NOT EXISTS idx_deadline_states_case ON deadline_states(case_id, deadline_key);"
350
+ )
351
+ conn.execute(
352
+ """
353
+ CREATE TABLE IF NOT EXISTS events (
354
+ event_id TEXT PRIMARY KEY,
355
+ case_id TEXT NOT NULL REFERENCES cases(case_id) ON DELETE CASCADE,
356
+ event_date TEXT,
357
+ event_type TEXT NOT NULL,
358
+ doc_text TEXT,
359
+ source_name TEXT NOT NULL,
360
+ source_record_id TEXT NOT NULL,
361
+ fetched_at TEXT,
362
+ source_url TEXT,
363
+ imported_at TEXT NOT NULL
364
+ );
365
+ """
366
+ )
367
+ conn.execute(
368
+ "CREATE INDEX IF NOT EXISTS idx_events_case_id ON events(case_id);"
369
+ )
370
+ conn.execute(
371
+ "CREATE INDEX IF NOT EXISTS idx_events_event_date ON events(event_date);"
372
+ )
373
+ conn.execute(
374
+ "CREATE INDEX IF NOT EXISTS idx_events_event_id ON events(event_id);"
375
+ )
376
+
377
+ # the wave §1: колонки case_amounts должны появиться и на базе, уже
378
+ # заштампованной v3 (миграционный шаг там не повторится). Идемпотентно.
379
+ _ensure_case_amounts_columns(conn)
380
+ # the wave §1+§2: тот же ensure-паттерн для evidence_items (новая таблица)
381
+ # и case_documents.supersedes (новая колонка) — без bump схемы.
382
+ _ensure_evidence_items_table(conn)
383
+ _ensure_case_documents_columns(conn)
384
+ # the wave §1+§2: case_representatives (новая таблица) и cases.instance
385
+ # (новая колонка) — тем же ensure-паттерном, SCHEMA_VERSION = '3'.
386
+ _ensure_case_representatives_table(conn)
387
+ _ensure_cases_columns(conn)
388
+
389
+ current = _get_schema_version(conn)
390
+ if current is None:
391
+ # Fresh database: stamp it at the current schema version.
392
+ conn.execute(
393
+ "INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?)",
394
+ (SCHEMA_VERSION,),
395
+ )
396
+ elif current == SCHEMA_VERSION:
397
+ pass
398
+ elif current > SCHEMA_VERSION:
399
+ raise SchemaError(
400
+ f"database schema version {current} is newer than supported {SCHEMA_VERSION}"
401
+ )
402
+ else:
403
+ _migrate(conn, current, SCHEMA_VERSION)
404
+
405
+
406
+ def get_connection(state_dir: Path) -> sqlite3.Connection:
407
+ db = _db_path(state_dir)
408
+ conn = sqlite3.connect(str(db))
409
+ conn.row_factory = sqlite3.Row
410
+ return conn
411
+
412
+
413
+ def _doctor_metrics(state_dir: Path) -> Dict[str, Any]:
414
+ """история §5 (S-86): metrics-секция doctor — ленивый импорт и отказ-устойчивость.
415
+
416
+ doctor обязан остаться читаемым и на битой базе: падение счётчиков
417
+ превращается в {"error": ...}, а не в исключение из-под doctor.
418
+ """
419
+ try:
420
+ from .metrics import doctor_metrics
421
+
422
+ return doctor_metrics(state_dir)
423
+ except Exception as exc: # sqlite3.Error, OSError, всё то же самое
424
+ return {"error": f"{type(exc).__name__}: {exc}"}
425
+
426
+
427
+ def doctor_check(state_dir: Path) -> Dict[str, Any]:
428
+ from .production_calendar import calendar_check
429
+
430
+ calendar = calendar_check()
431
+ report: Dict[str, Any] = {
432
+ "state_dir": str(state_dir),
433
+ "exists": state_dir.exists(),
434
+ "writable": False,
435
+ "db_exists": False,
436
+ "schema_version": None,
437
+ "integrity_ok": False,
438
+ "healthy": False,
439
+ "calendar": calendar,
440
+ }
441
+
442
+ try:
443
+ report["writable"] = state_dir.exists() and _is_writable(state_dir)
444
+ except OSError:
445
+ report["writable"] = False
446
+
447
+ db = _db_path(state_dir)
448
+ report["db_exists"] = db.exists()
449
+
450
+ if not db.exists():
451
+ # A missing DB is not an error for doctor; init will create it.
452
+ report["integrity_ok"] = True
453
+ report["reminders_available"] = 0
454
+ # W6a §4: алерты живут файлами и не зависят от наличия БД — счётчик
455
+ # обязан работать и на этом пути тоже.
456
+ try:
457
+ from .court_alerts import count_pending_alerts
458
+ report["alerts_pending"] = count_pending_alerts(state_dir)
459
+ except Exception:
460
+ report["alerts_pending"] = None
461
+ # история §5 (S-86): metrics-секция аддитивна — пустой state даёт нули,
462
+ # а не отсутствие ключа; пины формата doctor не сдвинуты.
463
+ report["metrics"] = _doctor_metrics(state_dir)
464
+ report["freshness"] = _freshness_report(state_dir)
465
+ report["healthy"] = report["exists"] and report["writable"] and calendar.get("valid", False)
466
+ return report
467
+
468
+ try:
469
+ conn = get_connection(state_dir)
470
+ with conn:
471
+ row = conn.execute(
472
+ "SELECT value FROM meta WHERE key = 'schema_version'"
473
+ ).fetchone()
474
+ report["schema_version"] = row["value"] if row else None
475
+
476
+ integrity = conn.execute("PRAGMA quick_check").fetchone()
477
+ report["integrity_ok"] = integrity is not None and integrity[0] == "ok"
478
+
479
+ # the wave §2: сколько напоминаний реально доступно в типовом окне 7 дней.
480
+ # Только чтение локальной таблицы — шина не трогается.
481
+ try:
482
+ import datetime as _dt
483
+ from .reminders import due_items
484
+ report["reminders_available"] = len(
485
+ due_items(list_all_deadline_states(conn), _dt.date.today(), 7)
486
+ )
487
+ except sqlite3.Error:
488
+ report["reminders_available"] = None
489
+
490
+ # W6a §4: локальные алерты case-search, ещё не переданные в шину.
491
+ # Счётчик только читает файлы state-dir/cwd; шина не трогается.
492
+ try:
493
+ from .court_alerts import count_pending_alerts
494
+ report["alerts_pending"] = count_pending_alerts(state_dir)
495
+ except Exception:
496
+ report["alerts_pending"] = None
497
+
498
+ # история §5 (S-86): полная metrics-секция (дела по статусам, сроки по
499
+ # state, документы/ревизии, evidence, представители, reminders, last
500
+ # backup из meta) — additive, пины doctor формата не сдвинуты.
501
+ report["metrics"] = _doctor_metrics(state_dir)
502
+ except sqlite3.Error:
503
+ report["integrity_ok"] = False
504
+
505
+ report["freshness"] = _freshness_report(state_dir)
506
+ report["healthy"] = (
507
+ report["exists"]
508
+ and report["writable"]
509
+ and report["db_exists"]
510
+ and report["schema_version"] == SCHEMA_VERSION
511
+ and report["integrity_ok"]
512
+ and calendar.get("valid", False)
513
+ )
514
+ return report
515
+
516
+
517
+ def _is_writable(path: Path) -> bool:
518
+ if not path.is_dir():
519
+ return False
520
+ test_file = path / ".write_test"
521
+ try:
522
+ test_file.write_text("1")
523
+ test_file.unlink()
524
+ return True
525
+ except OSError:
526
+ return False
527
+
528
+
529
+ def row_to_dict(row: sqlite3.Row) -> Dict[str, Any]:
530
+ return {key: row[key] for key in row.keys()}
531
+
532
+
533
+ MAX_LIMIT = 100
534
+ DEFAULT_LIMIT = 20
535
+
536
+
537
+ class SearchError(Exception):
538
+ """Raised when a search cannot be executed safely."""
539
+
540
+
541
+ def _coerce_limit(limit: int) -> int:
542
+ try:
543
+ limit = int(limit)
544
+ except (TypeError, ValueError):
545
+ raise SearchError("limit must be an integer")
546
+ if limit <= 0:
547
+ raise SearchError("limit must be positive")
548
+ if limit > MAX_LIMIT:
549
+ raise SearchError(f"limit cannot exceed {MAX_LIMIT}")
550
+ return limit
551
+
552
+
553
+ def _validate_date_bounds(since: Optional[str], until: Optional[str]) -> None:
554
+ since_parsed = None
555
+ until_parsed = None
556
+ for date_value, name in ((since, "since"), (until, "until")):
557
+ if date_value is not None:
558
+ try:
559
+ parsed = datetime.date.fromisoformat(date_value)
560
+ except (TypeError, ValueError) as exc:
561
+ raise SearchError(f"{name} must be YYYY-MM-DD: {date_value}") from exc
562
+ if name == "since":
563
+ since_parsed = parsed
564
+ else:
565
+ until_parsed = parsed
566
+ if since_parsed is not None and until_parsed is not None and since_parsed > until_parsed:
567
+ raise SearchError("since must not be later than until")
568
+
569
+
570
+ def _like_pattern(query: Optional[str]) -> Optional[str]:
571
+ if query is None or query == "":
572
+ return None
573
+ # Escape SQL LIKE wildcards in the user query.
574
+ escaped = query.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
575
+ return f"%{escaped}%"
576
+
577
+
578
+ def _build_event_match_where(
579
+ like_pattern: Optional[str], since: Optional[str], until: Optional[str]
580
+ ) -> Tuple[List[str], List[Any]]:
581
+ where_clauses: List[str] = []
582
+ params: List[Any] = []
583
+ if like_pattern is not None:
584
+ where_clauses.append(
585
+ "("
586
+ "e.event_id LIKE ? ESCAPE '\\' OR "
587
+ "e.event_type LIKE ? ESCAPE '\\' OR "
588
+ "e.doc_text LIKE ? ESCAPE '\\'"
589
+ ")"
590
+ )
591
+ params.extend([like_pattern] * 3)
592
+ if since is not None:
593
+ where_clauses.append("e.event_date >= ?")
594
+ params.append(since)
595
+ if until is not None:
596
+ where_clauses.append("e.event_date <= ?")
597
+ params.append(until)
598
+ return where_clauses, params
599
+
600
+
601
+ def _build_case_match_where(like_pattern: Optional[str]) -> Tuple[List[str], List[Any]]:
602
+ where_clauses: List[str] = []
603
+ params: List[Any] = []
604
+ if like_pattern is not None:
605
+ where_clauses.append(
606
+ "("
607
+ "c.case_id LIKE ? ESCAPE '\\' OR "
608
+ "c.case_number LIKE ? ESCAPE '\\' OR "
609
+ "c.court LIKE ? ESCAPE '\\' OR "
610
+ "c.plaintiff LIKE ? ESCAPE '\\' OR "
611
+ "c.defendant LIKE ? ESCAPE '\\'"
612
+ ")"
613
+ )
614
+ params.extend([like_pattern] * 5)
615
+ return where_clauses, params
616
+
617
+
618
+ def search_records(
619
+ conn: sqlite3.Connection,
620
+ kind: str,
621
+ query: Optional[str] = None,
622
+ limit: int = DEFAULT_LIMIT,
623
+ since: Optional[str] = None,
624
+ until: Optional[str] = None,
625
+ ) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
626
+ if kind != "court":
627
+ raise SearchError(f"unsupported search kind: {kind}")
628
+
629
+ limit = _coerce_limit(limit)
630
+
631
+ for date_value, name in ((since, "since"), (until, "until")):
632
+ if date_value is not None:
633
+ try:
634
+ datetime.date.fromisoformat(date_value)
635
+ except (TypeError, ValueError) as exc:
636
+ raise SearchError(f"{name} must be YYYY-MM-DD: {date_value}") from exc
637
+
638
+ where_clauses: List[str] = []
639
+ params: List[Any] = []
640
+
641
+ like_pattern = _like_pattern(query)
642
+ if like_pattern is not None:
643
+ where_clauses.append(
644
+ "("
645
+ "case_number LIKE ? ESCAPE '\\' OR "
646
+ "court LIKE ? ESCAPE '\\' OR "
647
+ "event_type LIKE ? ESCAPE '\\' OR "
648
+ "doc_text LIKE ? ESCAPE '\\' OR "
649
+ "plaintiff LIKE ? ESCAPE '\\' OR "
650
+ "defendant LIKE ? ESCAPE '\\'"
651
+ ")"
652
+ )
653
+ params.extend([like_pattern] * 6)
654
+
655
+ if since is not None:
656
+ where_clauses.append("event_date >= ?")
657
+ params.append(since)
658
+ if until is not None:
659
+ where_clauses.append("event_date <= ?")
660
+ params.append(until)
661
+
662
+ where_sql = "WHERE " + " AND ".join(where_clauses) if where_clauses else ""
663
+
664
+ count_sql = f"SELECT COUNT(*) FROM records {where_sql}"
665
+ total = conn.execute(count_sql, params).fetchone()[0]
666
+
667
+ select_sql = (
668
+ f"SELECT * FROM records {where_sql} "
669
+ f"ORDER BY event_date ASC, record_id ASC LIMIT ?"
670
+ )
671
+ params.append(limit)
672
+ rows = conn.execute(select_sql, params).fetchall()
673
+ results = [row_to_dict(r) for r in rows]
674
+
675
+ meta = {
676
+ "limit": limit,
677
+ "total": total,
678
+ "truncated": total > limit,
679
+ }
680
+ return results, meta
681
+
682
+
683
+ def search_npa(
684
+ conn: sqlite3.Connection, query: Optional[str] = None, limit: int = DEFAULT_LIMIT
685
+ ) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
686
+ limit = _coerce_limit(limit)
687
+
688
+ where_clauses: List[str] = []
689
+ params: List[Any] = []
690
+
691
+ like_pattern = _like_pattern(query)
692
+ if like_pattern is not None:
693
+ where_clauses.append(
694
+ "(doc_id LIKE ? ESCAPE '\\' OR title LIKE ? ESCAPE '\\' OR body LIKE ? ESCAPE '\\')"
695
+ )
696
+ params.extend([like_pattern] * 3)
697
+
698
+ where_sql = "WHERE " + " AND ".join(where_clauses) if where_clauses else ""
699
+
700
+ total = conn.execute(
701
+ f"SELECT COUNT(*) FROM npa_docs {where_sql}", params
702
+ ).fetchone()[0]
703
+
704
+ select_sql = (
705
+ f"SELECT * FROM npa_docs {where_sql} "
706
+ f"ORDER BY doc_date ASC, doc_id ASC, edition ASC LIMIT ?"
707
+ )
708
+ params.append(limit)
709
+ rows = conn.execute(select_sql, params).fetchall()
710
+ results = [row_to_dict(r) for r in rows]
711
+
712
+ meta = {
713
+ "limit": limit,
714
+ "total": total,
715
+ "truncated": total > limit,
716
+ }
717
+ return results, meta
718
+
719
+
720
+ def compare_npa_editions(
721
+ conn: sqlite3.Connection, doc_id: str, edition1: str, edition2: str
722
+ ) -> Dict[str, Any]:
723
+ rows = conn.execute(
724
+ "SELECT * FROM npa_docs WHERE doc_id = ? AND edition IN (?, ?)",
725
+ (doc_id, edition1, edition2),
726
+ ).fetchall()
727
+ by_edition = {row["edition"]: row_to_dict(row) for row in rows}
728
+
729
+ if edition1 not in by_edition or edition2 not in by_edition:
730
+ missing = [e for e in (edition1, edition2) if e not in by_edition]
731
+ raise SearchError(f"editions not found for doc_id={doc_id}: {missing}")
732
+
733
+ doc1 = by_edition[edition1]
734
+ doc2 = by_edition[edition2]
735
+
736
+ return {
737
+ "doc_id": doc_id,
738
+ "edition1": edition1,
739
+ "edition2": edition2,
740
+ "title1": doc1["title"],
741
+ "title2": doc2["title"],
742
+ "date1": doc1["doc_date"],
743
+ "date2": doc2["doc_date"],
744
+ "body1_length": len(doc1.get("body") or ""),
745
+ "body2_length": len(doc2.get("body") or ""),
746
+ "same_body": doc1.get("body") == doc2.get("body"),
747
+ }
748
+
749
+
750
+ # ---------------------------------------------------------------------------
751
+ # Native court case/event timeline (Phase 3)
752
+ # ---------------------------------------------------------------------------
753
+
754
+
755
+ def search_court_cases(
756
+ conn: sqlite3.Connection,
757
+ query: Optional[str] = None,
758
+ limit: int = DEFAULT_LIMIT,
759
+ since: Optional[str] = None,
760
+ until: Optional[str] = None,
761
+ ) -> Tuple[List[Dict[str, Any]], Dict[str, Any]]:
762
+ """Search native court cases and events locally.
763
+
764
+ Returns one row per matching case with a bounded list of matching event
765
+ metadata (event_id, event_type, event_date, snippet). The full event
766
+ document text is never returned.
767
+ """
768
+ limit = _coerce_limit(limit)
769
+ _validate_date_bounds(since, until)
770
+
771
+ like_pattern = _like_pattern(query)
772
+ has_query = like_pattern is not None
773
+ has_date_filter = since is not None or until is not None
774
+
775
+ if not has_query and not has_date_filter:
776
+ raise SearchError("query is required unless a date bound is provided")
777
+
778
+ event_where, event_params = _build_event_match_where(like_pattern, since, until)
779
+ case_where, case_params = _build_case_match_where(like_pattern)
780
+
781
+ event_where_sql = " AND ".join(event_where) if event_where else "1=1"
782
+ case_where_sql = " AND ".join(case_where) if case_where else "0=1"
783
+
784
+ # DISTINCT matching case IDs with aggregate info. Cases that match only by
785
+ # metadata appear via LEFT JOIN with matching_event_count = 0.
786
+ select_sql = f"""
787
+ WITH
788
+ matching_events AS (
789
+ SELECT
790
+ e.event_id,
791
+ e.case_id,
792
+ e.event_date,
793
+ e.event_type,
794
+ substr(coalesce(e.doc_text, ''), 1, 200) AS snippet
795
+ FROM events e
796
+ WHERE {event_where_sql}
797
+ ),
798
+ matching_cases AS (
799
+ SELECT c.case_id
800
+ FROM cases c
801
+ WHERE {case_where_sql}
802
+ ),
803
+ all_matches AS (
804
+ SELECT case_id FROM matching_cases
805
+ UNION
806
+ SELECT case_id FROM matching_events
807
+ ),
808
+ case_summary AS (
809
+ SELECT
810
+ m.case_id,
811
+ COUNT(me.event_id) AS matching_event_count,
812
+ MAX(me.event_date) AS most_recent_event_date
813
+ FROM all_matches m
814
+ LEFT JOIN matching_events me ON me.case_id = m.case_id
815
+ GROUP BY m.case_id
816
+ )
817
+ SELECT
818
+ c.case_id, c.case_number, c.court, c.plaintiff, c.defendant,
819
+ c.source_name, c.source_record_id, c.fetched_at, c.source_url, c.imported_at,
820
+ s.matching_event_count, s.most_recent_event_date
821
+ FROM case_summary s
822
+ JOIN cases c ON c.case_id = s.case_id
823
+ ORDER BY
824
+ CASE WHEN s.most_recent_event_date IS NULL THEN 1 ELSE 0 END,
825
+ s.most_recent_event_date DESC,
826
+ c.case_id ASC
827
+ LIMIT ?
828
+ """
829
+ params = event_params + case_params + [limit]
830
+ rows = conn.execute(select_sql, params).fetchall()
831
+
832
+ results: List[Dict[str, Any]] = []
833
+ for row in rows:
834
+ case_dict: Dict[str, Any] = {
835
+ "case_id": row["case_id"],
836
+ "case_number": row["case_number"],
837
+ "court": row["court"],
838
+ "plaintiff": row["plaintiff"],
839
+ "defendant": row["defendant"],
840
+ "source_name": row["source_name"],
841
+ "source_record_id": row["source_record_id"],
842
+ "fetched_at": row["fetched_at"],
843
+ "source_url": row["source_url"],
844
+ "imported_at": row["imported_at"],
845
+ "matching_event_count": row["matching_event_count"],
846
+ }
847
+ # Fetch a short list of matching events for this case. This repeats the
848
+ # event match predicate so the returned list is bounded and consistent.
849
+ event_sql = f"""
850
+ SELECT
851
+ e.event_id,
852
+ e.event_date,
853
+ e.event_type,
854
+ substr(coalesce(e.doc_text, ''), 1, 200) AS snippet
855
+ FROM events e
856
+ WHERE e.case_id = ? AND ({event_where_sql})
857
+ ORDER BY
858
+ CASE WHEN e.event_date IS NULL THEN 1 ELSE 0 END,
859
+ e.event_date DESC,
860
+ e.event_id ASC
861
+ LIMIT 5
862
+ """
863
+ event_rows = conn.execute(event_sql, [row["case_id"]] + event_params).fetchall()
864
+ case_dict["matching_events"] = [
865
+ {
866
+ "event_id": er["event_id"],
867
+ "event_type": er["event_type"],
868
+ "event_date": er["event_date"],
869
+ "snippet": er["snippet"],
870
+ }
871
+ for er in event_rows
872
+ ]
873
+ results.append(case_dict)
874
+
875
+ # Total distinct matching cases for the truncation flag.
876
+ total_sql = f"""
877
+ WITH
878
+ matching_events AS (
879
+ SELECT e.case_id
880
+ FROM events e
881
+ WHERE {event_where_sql}
882
+ ),
883
+ matching_cases AS (
884
+ SELECT c.case_id
885
+ FROM cases c
886
+ WHERE {case_where_sql}
887
+ ),
888
+ all_matches AS (
889
+ SELECT case_id FROM matching_cases
890
+ UNION
891
+ SELECT case_id FROM matching_events
892
+ )
893
+ SELECT COUNT(*) FROM all_matches
894
+ """
895
+ total = conn.execute(total_sql, event_params + case_params).fetchone()[0]
896
+
897
+ meta = {
898
+ "limit": limit,
899
+ "total": total,
900
+ "truncated": total > limit,
901
+ }
902
+ return results, meta
903
+
904
+
905
+ def get_case(conn: sqlite3.Connection, case_id: str) -> Optional[Dict[str, Any]]:
906
+ row = conn.execute("SELECT * FROM cases WHERE case_id = ?", (case_id,)).fetchone()
907
+ if row is None:
908
+ return None
909
+ return row_to_dict(row)
910
+
911
+
912
+ def get_case_summary(conn: sqlite3.Connection, case_id: str) -> Optional[Dict[str, Any]]:
913
+ return get_case(conn, case_id)
914
+
915
+
916
+ def get_case_events(conn: sqlite3.Connection, case_id: str) -> List[Dict[str, Any]]:
917
+ rows = conn.execute(
918
+ """
919
+ SELECT event_id, case_id, event_date, event_type, doc_text,
920
+ source_name, source_record_id, fetched_at, source_url, imported_at
921
+ FROM events WHERE case_id = ?
922
+ ORDER BY event_date ASC, event_id ASC
923
+ """,
924
+ (case_id,),
925
+ ).fetchall()
926
+ return [row_to_dict(r) for r in rows]
927
+
928
+
929
+
930
+ _UNSET = object()
931
+
932
+ LEGAL_CASE_STATUSES = ["new", "review", "filed", "pending", "decided", "appealed", "closed", "unknown"]
933
+
934
+
935
+ class CaseError(Exception):
936
+ pass
937
+
938
+
939
+ class InvalidStatusError(CaseError, ValueError):
940
+ pass
941
+
942
+
943
+ class InvalidAmountError(CaseError, ValueError):
944
+ pass
945
+
946
+
947
+ class InvalidInstanceError(CaseError, ValueError):
948
+ """the wave §1: instance дела вне {apk, gpk, kas, unknown} — отказ на записи.
949
+
950
+ Матрица (the wave) уже отказывает при чтении с тем же набором значений;
951
+ отказ на записи не даёт завести дело, которое потом ничем не открыть.
952
+ """
953
+
954
+
955
+ class DocNotFoundError(CaseError, ValueError):
956
+ pass
957
+
958
+
959
+ class InvalidRepresentativeSideError(CaseError, ValueError):
960
+ """the wave §2: side вне {plaintiff, defendant} — отказ, а не догадка."""
961
+
962
+
963
+ class PurgeError(CaseError, ValueError):
964
+ """история §5 (S-07): purge без явного подтверждения."""
965
+
966
+
967
+ #: Поле дела «инстанция» (the wave читает его же): значения — из INSTANCE_TO_JURISDICTION.
968
+ CASE_INSTANCES = ("apk", "gpk", "kas", "unknown")
969
+
970
+ #: the wave §2 (S-15): стороны, для которых заводят представителя. Проверка
971
+ #: полномочий НЕ автоматическая — этот набор только про то, к какой стороне
972
+ #: строка относится.
973
+ CASE_SIDES = ("plaintiff", "defendant")
974
+
975
+ REPRESENTATIVE_HINT = (
976
+ "Полномочия проверяются вручную: sud-cli хранит описание доверенности как "
977
+ "текст и не сверяет её ни с каким реестром — ни автоматической проверки "
978
+ "полномочий, ни вывода о допустимости представителя здесь нет"
979
+ )
980
+
981
+
982
+ def _now_iso() -> str:
983
+ import datetime as _dt
984
+ return _dt.datetime.now(_dt.timezone.utc).replace(microsecond=0).isoformat()
985
+
986
+
987
+ def _new_case_id() -> str:
988
+ import uuid
989
+ return "case-" + uuid.uuid4().hex[:12]
990
+
991
+
992
+ def create_case(conn, case_number, court, plaintiff=None, defendant=None, owner=None, status=None, closed_at=None, instance=None, **extra):
993
+ if status is not None and status not in LEGAL_CASE_STATUSES:
994
+ raise InvalidStatusError("invalid_status")
995
+ for k in extra:
996
+ if k not in ("status", "owner", "closed_at", "instance"):
997
+ raise ValueError(f"unknown field: {k}")
998
+ if "status" in extra:
999
+ status = extra["status"]
1000
+ if status is not None and status not in LEGAL_CASE_STATUSES:
1001
+ raise InvalidStatusError("invalid_status")
1002
+ if "owner" in extra:
1003
+ owner = extra["owner"]
1004
+ if "closed_at" in extra:
1005
+ closed_at = extra["closed_at"]
1006
+ if "instance" in extra:
1007
+ instance = extra["instance"]
1008
+ # the wave §1: instance валидируется на записи тем же набором, что и матрица
1009
+ # при чтении (apk|gpk|kas|unknown) — NULL = «не указана».
1010
+ if instance is not None and str(instance).strip().lower() not in CASE_INSTANCES:
1011
+ raise InvalidInstanceError(f"invalid_instance: {instance}")
1012
+ row = conn.execute(
1013
+ "SELECT case_id FROM cases WHERE case_number = ? AND court = ?",
1014
+ (case_number, court),
1015
+ ).fetchone()
1016
+ if row is not None:
1017
+ return {
1018
+ "case_id": row["case_id"],
1019
+ "case_number": case_number,
1020
+ "court": court,
1021
+ "created": False,
1022
+ "duplicate": True,
1023
+ "status": "duplicate_window",
1024
+ }
1025
+ case_id = _new_case_id()
1026
+ conn.execute(
1027
+ """
1028
+ INSERT INTO cases
1029
+ (case_id, case_number, court, plaintiff, defendant, source_name,
1030
+ source_record_id, fetched_at, source_url, imported_at, status, owner,
1031
+ closed_at, instance)
1032
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1033
+ """,
1034
+ (case_id, case_number, court, plaintiff, defendant, "local-registry",
1035
+ None, None, None, _now_iso(), status, owner, closed_at, instance),
1036
+ )
1037
+ conn.commit()
1038
+ return {
1039
+ "case_id": case_id,
1040
+ "case_number": case_number,
1041
+ "court": court,
1042
+ "plaintiff": plaintiff,
1043
+ "defendant": defendant,
1044
+ "status": status,
1045
+ "owner": owner,
1046
+ "closed_at": closed_at,
1047
+ "created": True,
1048
+ "duplicate": False,
1049
+ }
1050
+
1051
+
1052
+ def set_case(conn, case_id, status=_UNSET, owner=_UNSET, closed_at=_UNSET,
1053
+ instance=_UNSET, **extra):
1054
+ allowed = {"status", "owner", "closed_at", "instance"}
1055
+ fields = {}
1056
+ if status is not _UNSET:
1057
+ fields["status"] = status
1058
+ if owner is not _UNSET:
1059
+ fields["owner"] = owner
1060
+ if closed_at is not _UNSET:
1061
+ fields["closed_at"] = closed_at
1062
+ if instance is not _UNSET:
1063
+ fields["instance"] = instance
1064
+ for k, v in extra.items():
1065
+ if k not in allowed:
1066
+ raise ValueError(f"unknown field: {k}")
1067
+ fields[k] = v
1068
+ if "status" in fields and fields["status"] is not None and fields["status"] not in LEGAL_CASE_STATUSES:
1069
+ raise InvalidStatusError("invalid_status")
1070
+ if "instance" in fields and fields["instance"] is not None \
1071
+ and str(fields["instance"]).strip().lower() not in CASE_INSTANCES:
1072
+ raise InvalidInstanceError(f"invalid_instance: {fields['instance']}")
1073
+ row = conn.execute("SELECT case_id FROM cases WHERE case_id = ?", (case_id,)).fetchone()
1074
+ if row is None:
1075
+ return None
1076
+ if fields:
1077
+ sets = ", ".join(f"{k} = ?" for k in fields)
1078
+ conn.execute(
1079
+ f"UPDATE cases SET {sets} WHERE case_id = ?",
1080
+ list(fields.values()) + [case_id],
1081
+ )
1082
+ conn.commit()
1083
+ return get_case(conn, case_id)
1084
+
1085
+
1086
+ def list_cases(conn, status=None, owner=None, court=None, party=None):
1087
+ where = []
1088
+ params = []
1089
+ if status is not None:
1090
+ where.append("status = ?")
1091
+ params.append(status)
1092
+ if owner is not None:
1093
+ where.append("owner = ?")
1094
+ params.append(owner)
1095
+ if court is not None:
1096
+ where.append("court LIKE ?")
1097
+ params.append(f"%{court}%")
1098
+ if party is not None:
1099
+ where.append("(plaintiff LIKE ? OR defendant LIKE ?)")
1100
+ params.extend([f"%{party}%", f"%{party}%"])
1101
+ sql = "SELECT * FROM cases"
1102
+ if where:
1103
+ sql += " WHERE " + " AND ".join(where)
1104
+ sql += " ORDER BY case_id ASC"
1105
+ rows = conn.execute(sql, params).fetchall()
1106
+ return [row_to_dict(r) for r in rows]
1107
+
1108
+
1109
+ def add_case_amount(conn, case_id, kind, amount, note=None, currency="RUB"):
1110
+ row = conn.execute("SELECT case_id FROM cases WHERE case_id = ?", (case_id,)).fetchone()
1111
+ if row is None:
1112
+ return None
1113
+ from decimal import Decimal, InvalidOperation
1114
+ text = amount.strip() if isinstance(amount, str) else str(amount)
1115
+ text = text.replace(",", ".")
1116
+ try:
1117
+ dec = Decimal(text)
1118
+ except (InvalidOperation, ValueError) as exc:
1119
+ raise InvalidAmountError("invalid_amount") from exc
1120
+ if not dec.is_finite():
1121
+ raise InvalidAmountError("invalid_amount")
1122
+ conn.execute(
1123
+ """
1124
+ INSERT INTO case_amounts (case_id, kind, amount_rub, currency, note, created_at)
1125
+ VALUES (?, ?, ?, ?, ?, ?)
1126
+ """,
1127
+ (case_id, kind, str(dec), currency, note, _now_iso()),
1128
+ )
1129
+ conn.commit()
1130
+ r = conn.execute(
1131
+ "SELECT * FROM case_amounts WHERE id = last_insert_rowid()"
1132
+ ).fetchone()
1133
+ return row_to_dict(r)
1134
+
1135
+
1136
+ def list_case_amounts(conn, case_id):
1137
+ rows = conn.execute(
1138
+ "SELECT * FROM case_amounts WHERE case_id = ? ORDER BY id ASC", (case_id,)
1139
+ ).fetchall()
1140
+ return [row_to_dict(r) for r in rows]
1141
+
1142
+
1143
+ # the wave §1: статусы платежей. Проверка значения на входе — это валидация
1144
+ # аргумента CLI, а NOT enum в схеме: kind и status остаются свободным TEXT.
1145
+ AMOUNT_STATUSES = ("planned", "paid", "refunded")
1146
+
1147
+
1148
+ def set_case_amount(conn, case_id, amount_id, status=None, paid_at=None):
1149
+ row = conn.execute(
1150
+ "SELECT * FROM case_amounts WHERE id = ? AND case_id = ?",
1151
+ (amount_id, case_id),
1152
+ ).fetchone()
1153
+ if row is None:
1154
+ return None
1155
+ if status is not None and status not in AMOUNT_STATUSES:
1156
+ raise InvalidAmountError("invalid_amount_status")
1157
+ sets, params = [], []
1158
+ if status is not None:
1159
+ sets.append("status = ?")
1160
+ params.append(status)
1161
+ if paid_at is not None:
1162
+ sets.append("paid_at = ?")
1163
+ params.append(paid_at)
1164
+ if sets:
1165
+ params.append(amount_id)
1166
+ conn.execute(
1167
+ f"UPDATE case_amounts SET {', '.join(sets)} WHERE id = ?", params
1168
+ )
1169
+ conn.commit()
1170
+ r = conn.execute("SELECT * FROM case_amounts WHERE id = ?", (amount_id,)).fetchone()
1171
+ return row_to_dict(r)
1172
+
1173
+
1174
+ def summarize_case_amounts(conn, case_id):
1175
+ """Decimal-точные суммы по kind x status (the wave §1).
1176
+
1177
+ amount_rub хранится строкой: переводим ровно в Decimal, float не
1178
+ участвует нигде — иначе 0.1+0.2 даст хвост двоичной дроби.
1179
+ """
1180
+ from decimal import Decimal
1181
+ rows = list_case_amounts(conn, case_id)
1182
+ groups = {}
1183
+ totals_by_status = {}
1184
+ for r in rows:
1185
+ status = r.get("status") or "planned"
1186
+ key = (r["kind"], status)
1187
+ dec = Decimal(str(r["amount_rub"]))
1188
+ cur, cnt = groups.get(key, (Decimal("0"), 0))
1189
+ groups[key] = (cur + dec, cnt + 1)
1190
+ cur, cnt = totals_by_status.get(status, (Decimal("0"), 0))
1191
+ totals_by_status[status] = (cur + dec, cnt + 1)
1192
+ out_rows = [
1193
+ {"kind": kind, "status": status, "total": str(total), "count": cnt}
1194
+ for (kind, status), (total, cnt) in sorted(groups.items(), key=lambda kv: (kv[0][0], kv[0][1]))
1195
+ ]
1196
+ return {
1197
+ "case_id": case_id,
1198
+ "rows": out_rows,
1199
+ "totals_by_status": {
1200
+ status: str(total)
1201
+ for status, (total, _cnt) in sorted(totals_by_status.items())
1202
+ },
1203
+ }
1204
+
1205
+
1206
+ def _file_sha256(path) -> str:
1207
+ """Chunked sha256 файла — один источник хэша для docs и evidence (the wave)."""
1208
+ import hashlib
1209
+ from pathlib import Path as _Path
1210
+ h = hashlib.sha256()
1211
+ with _Path(path).open("rb") as f:
1212
+ for chunk in iter(lambda: f.read(65536), b""):
1213
+ h.update(chunk)
1214
+ return h.hexdigest()
1215
+
1216
+
1217
+ def _superseded_ids(conn, case_id=None):
1218
+ """Множество id, на которые ссылается хотя бы одна supersedes-строка."""
1219
+ sql = "SELECT DISTINCT supersedes FROM case_documents WHERE supersedes IS NOT NULL"
1220
+ params: list = []
1221
+ if case_id is not None:
1222
+ sql += " AND case_id = ?"
1223
+ params.append(case_id)
1224
+ return {r["supersedes"] for r in conn.execute(sql, params).fetchall()}
1225
+
1226
+
1227
+ def current_case_documents(conn, case_id):
1228
+ """the wave §2: «текущий» документ = последняя не-superseded запись (added_at,id)."""
1229
+ rows = list_case_documents(conn, case_id)
1230
+ gone = _superseded_ids(conn)
1231
+ live = [r for r in rows if r["id"] not in gone]
1232
+ live.sort(key=lambda r: (str(r.get("added_at") or ""), int(r["id"])), reverse=True)
1233
+ return live
1234
+
1235
+
1236
+ def case_document_history(conn, case_id, path=None):
1237
+ """the wave §2: цепочка ревизий newest-first с хэшами; опц. фильтр по path.
1238
+
1239
+ Цепочка линейная: новая ревизия ссылается supersedes ровно на одну
1240
+ предыдущую, старые строки никогда не мутируются.
1241
+ """
1242
+ rows = list_case_documents(conn, case_id)
1243
+ if path is not None:
1244
+ rows = [r for r in rows if r["path"] == str(path)]
1245
+ rows.sort(key=lambda r: (str(r.get("added_at") or ""), int(r["id"])), reverse=True)
1246
+ gone = _superseded_ids(conn)
1247
+ out = []
1248
+ for i, r in enumerate(rows):
1249
+ r = dict(r)
1250
+ r["revision"] = len(rows) - i # 1 = первая ревизия
1251
+ r["current"] = r["id"] not in gone
1252
+ out.append(r)
1253
+ return out
1254
+
1255
+
1256
+ def add_case_document(conn, case_id, path, title=None, supersedes=None):
1257
+ """Добавить документ; the wave §2: повтор того же path = новая ревизия.
1258
+
1259
+ supersedes=None и файл уже добавлен этому делу -> строка-ревизия
1260
+ автоматически ссылается supersedes на текущую (head) запись того же path.
1261
+ Явный supersedes=<id> проверяется на существование и принадлежность делу.
1262
+ Существующие строки не меняются никогда.
1263
+ """
1264
+ row = conn.execute("SELECT case_id FROM cases WHERE case_id = ?", (case_id,)).fetchone()
1265
+ if row is None:
1266
+ return None
1267
+ from pathlib import Path as _Path
1268
+ p = _Path(path)
1269
+ if not p.is_file():
1270
+ raise DocNotFoundError("doc_not_found")
1271
+ if supersedes is not None:
1272
+ prev = conn.execute(
1273
+ "SELECT * FROM case_documents WHERE id = ?", (int(supersedes),)
1274
+ ).fetchone()
1275
+ if prev is None:
1276
+ raise DocNotFoundError("supersedes_id_not_found")
1277
+ if prev["case_id"] != case_id:
1278
+ raise DocNotFoundError("supersedes_other_case")
1279
+ else:
1280
+ head = [r for r in current_case_documents(conn, case_id)
1281
+ if r["path"] == str(path)]
1282
+ supersedes = head[0]["id"] if head else None
1283
+ conn.execute(
1284
+ """
1285
+ INSERT INTO case_documents (case_id, path, sha256, title, added_at, supersedes)
1286
+ VALUES (?, ?, ?, ?, ?, ?)
1287
+ """,
1288
+ (case_id, str(path), _file_sha256(path), title, _now_iso(), supersedes),
1289
+ )
1290
+ conn.commit()
1291
+ r = conn.execute(
1292
+ "SELECT * FROM case_documents WHERE id = last_insert_rowid()"
1293
+ ).fetchone()
1294
+ return row_to_dict(r)
1295
+
1296
+
1297
+ def list_case_documents(conn, case_id):
1298
+ rows = conn.execute(
1299
+ "SELECT * FROM case_documents WHERE case_id = ? ORDER BY id ASC", (case_id,)
1300
+ ).fetchall()
1301
+ return [row_to_dict(r) for r in rows]
1302
+
1303
+
1304
+ def get_case_document(conn, doc_id, case_id=None):
1305
+ """the wave §3: строка case_documents по id (опц. проверка принадлежности делу)."""
1306
+ row = conn.execute("SELECT * FROM case_documents WHERE id = ?", (int(doc_id),)).fetchone()
1307
+ if row is None:
1308
+ return None
1309
+ d = row_to_dict(row)
1310
+ if case_id is not None and d["case_id"] != case_id:
1311
+ return None
1312
+ return d
1313
+
1314
+
1315
+ # ---------------------------------------------------------------------------
1316
+ # the wave §1 (S-73): реестр доказательств — том/лист, sha256-целостность
1317
+ # ---------------------------------------------------------------------------
1318
+
1319
+ EVIDENCE_MEDIA = ("paper", "electronic")
1320
+
1321
+
1322
+ class EvidenceError(CaseError, ValueError):
1323
+ pass
1324
+
1325
+
1326
+ def _numeric_aware_key(value):
1327
+ """Ключ natural-sort: выделенные цифры — int, остальное — регистронезависимая строка.
1328
+
1329
+ '2' < '10' по числу, а не по лексикографии; None -> '' (первым). Смешанные
1330
+ chunk'и сравниваются флагом (0=число, 1=текст), чтобы не ронять py3-сравнение int/str.
1331
+ """
1332
+ import re
1333
+ text = "" if value is None else str(value)
1334
+ parts = re.split(r"(\d+)", text)
1335
+ key = []
1336
+ for part in parts:
1337
+ if part == "":
1338
+ continue
1339
+ if part.isdigit():
1340
+ key.append((0, int(part), ""))
1341
+ else:
1342
+ key.append((1, 0, part.lower()))
1343
+ return tuple(key)
1344
+
1345
+
1346
+ def add_case_evidence(conn, case_id, title, path=None, volume=None, sheet=None,
1347
+ media=None, note=None):
1348
+ """Добавить доказательство. --path => sha256 файла + doc_ref.
1349
+
1350
+ doc_ref — «если ещё не добавлен»: если текущая не-superseded строка
1351
+ case_documents с тем же path для этого дела уже есть, ссылка переиспользуется;
1352
+ иначе файл добавляется как документ (обычным add_case_document, с ревизиями).
1353
+ """
1354
+ row = conn.execute("SELECT case_id FROM cases WHERE case_id = ?", (case_id,)).fetchone()
1355
+ if row is None:
1356
+ return None
1357
+ if not title or not str(title).strip():
1358
+ raise EvidenceError("title is required")
1359
+ if media is not None and media not in EVIDENCE_MEDIA:
1360
+ raise EvidenceError(f"invalid_media: {media}")
1361
+ sha256 = None
1362
+ doc_ref = None
1363
+ if path is not None:
1364
+ from pathlib import Path as _Path
1365
+ p = _Path(path)
1366
+ if not p.is_file():
1367
+ raise DocNotFoundError("doc_not_found")
1368
+ sha256 = _file_sha256(p)
1369
+ existing = [r for r in current_case_documents(conn, case_id)
1370
+ if r["path"] == str(path)]
1371
+ if existing:
1372
+ doc_ref = existing[0]["id"]
1373
+ else:
1374
+ doc = add_case_document(conn, case_id, path)
1375
+ doc_ref = doc["id"]
1376
+ conn.execute(
1377
+ """
1378
+ INSERT INTO evidence_items
1379
+ (case_id, title, volume, sheet, doc_ref, sha256, media, added_at, note)
1380
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
1381
+ """,
1382
+ (case_id, title, volume, sheet, doc_ref, sha256, media, _now_iso(), note),
1383
+ )
1384
+ conn.commit()
1385
+ r = conn.execute(
1386
+ "SELECT * FROM evidence_items WHERE id = last_insert_rowid()"
1387
+ ).fetchone()
1388
+ return row_to_dict(r)
1389
+
1390
+
1391
+ def list_case_evidence(conn, case_id):
1392
+ """Список доказательств: сортировка том -> лист, numerical-aware, затем id."""
1393
+ rows = [row_to_dict(r) for r in conn.execute(
1394
+ "SELECT * FROM evidence_items WHERE case_id = ?", (case_id,)
1395
+ ).fetchall()]
1396
+ rows.sort(key=lambda r: (_numeric_aware_key(r.get("volume")),
1397
+ _numeric_aware_key(r.get("sheet")),
1398
+ int(r["id"])))
1399
+ return rows
1400
+
1401
+
1402
+ def verify_case_evidence(conn, case_id):
1403
+ """Пересчёт sha256 для items с path; verdict per item.
1404
+
1405
+ intact / CHANGED (хэш не совпал) / MISSING_FILE (путь пропал) /
1406
+ unchecked (item без path — бумажное: не провал, а «не проверено»).
1407
+ """
1408
+ from pathlib import Path as _Path
1409
+ items = []
1410
+ for r in list_case_evidence(conn, case_id):
1411
+ item = dict(r)
1412
+ path = None
1413
+ if r["doc_ref"] is not None:
1414
+ drow = conn.execute(
1415
+ "SELECT path FROM case_documents WHERE id = ?", (r["doc_ref"],)
1416
+ ).fetchone()
1417
+ path = drow["path"] if drow else None
1418
+ item["path"] = path
1419
+ if r["sha256"] is None or path is None:
1420
+ item["verdict"] = "unchecked"
1421
+ elif not _Path(path).is_file():
1422
+ item["verdict"] = "MISSING_FILE"
1423
+ else:
1424
+ try:
1425
+ actual = _file_sha256(path)
1426
+ except OSError:
1427
+ item["verdict"] = "MISSING_FILE"
1428
+ else:
1429
+ item["verdict"] = "intact" if actual == r["sha256"] else "CHANGED"
1430
+ items.append(item)
1431
+ return items
1432
+
1433
+
1434
+ def evidence_summary(items):
1435
+ counts = {"intact": 0, "CHANGED": 0, "MISSING_FILE": 0, "unchecked": 0}
1436
+ for i in items:
1437
+ counts[i["verdict"]] += 1
1438
+ problems = [i for i in items if i["verdict"] in ("CHANGED", "MISSING_FILE")]
1439
+ return {
1440
+ "total": len(items),
1441
+ "intact": counts["intact"],
1442
+ "changed": counts["CHANGED"],
1443
+ "missing_file": counts["MISSING_FILE"],
1444
+ "unchecked": counts["unchecked"],
1445
+ "problems": len(problems),
1446
+ "problem_items": problems,
1447
+ }
1448
+
1449
+
1450
+ # ---------------------------------------------------------------------------
1451
+ # the wave §2 (S-15): представители по делу
1452
+ # ---------------------------------------------------------------------------
1453
+
1454
+
1455
+ def add_case_representative(conn, case_id, side, name, authority_note=None,
1456
+ power_docs=None):
1457
+ """Завести представителя. Полномочия НЕ проверяются — это хранилище текста.
1458
+
1459
+ side валидируется на входе ({plaintiff, defendant}, регистр не важен):
1460
+ строка «side=третейский» потом читалась бы как участие в деле, которого
1461
+ никто не заводил. name обязателен — представитель без имени в карточке
1462
+ выглядит как подтверждённое лицо.
1463
+ """
1464
+ row = conn.execute("SELECT case_id FROM cases WHERE case_id = ?", (case_id,)).fetchone()
1465
+ if row is None:
1466
+ return None
1467
+ normalized = ("" if side is None else str(side).strip().lower())
1468
+ if normalized not in CASE_SIDES:
1469
+ raise InvalidRepresentativeSideError(
1470
+ f"invalid_side: {side!r} (допустимо: {', '.join(CASE_SIDES)})"
1471
+ )
1472
+ if not name or not str(name).strip():
1473
+ raise CaseError("name is required")
1474
+ conn.execute(
1475
+ """
1476
+ INSERT INTO case_representatives
1477
+ (case_id, side, name, authority_note, power_docs, added_at)
1478
+ VALUES (?, ?, ?, ?, ?, ?)
1479
+ """,
1480
+ (case_id, normalized, str(name).strip(), authority_note, power_docs,
1481
+ _now_iso()),
1482
+ )
1483
+ conn.commit()
1484
+ r = conn.execute(
1485
+ "SELECT * FROM case_representatives WHERE id = last_insert_rowid()"
1486
+ ).fetchone()
1487
+ return row_to_dict(r)
1488
+
1489
+
1490
+ def list_case_representatives(conn, case_id):
1491
+ """Представители дела: side -> id (детерминированный порядок, без магии)."""
1492
+ rows = conn.execute(
1493
+ "SELECT * FROM case_representatives WHERE case_id = ? ORDER BY side ASC, id ASC",
1494
+ (case_id,),
1495
+ ).fetchall()
1496
+ return [row_to_dict(r) for r in rows]
1497
+
1498
+
1499
+ def representatives_card_section(conn, case_id):
1500
+ """Секция card: список + явный hint, что проверка полномочий ручная."""
1501
+ items = list_case_representatives(conn, case_id)
1502
+ by_side = {side: 0 for side in CASE_SIDES}
1503
+ for item in items:
1504
+ by_side[item["side"]] = by_side.get(item["side"], 0) + 1
1505
+ return {
1506
+ "count": len(items),
1507
+ "by_side": by_side,
1508
+ "items": items,
1509
+ "hint": REPRESENTATIVE_HINT,
1510
+ }
1511
+
1512
+
1513
+ def add_case_note(conn, case_id, text, author=None, supersedes=None):
1514
+ row = conn.execute("SELECT case_id FROM cases WHERE case_id = ?", (case_id,)).fetchone()
1515
+ if row is None:
1516
+ return None
1517
+ conn.execute(
1518
+ """
1519
+ INSERT INTO case_notes (case_id, text, author, created_at, supersedes)
1520
+ VALUES (?, ?, ?, ?, ?)
1521
+ """,
1522
+ (case_id, text, author, _now_iso(), supersedes),
1523
+ )
1524
+ conn.commit()
1525
+ r = conn.execute(
1526
+ "SELECT * FROM case_notes WHERE id = last_insert_rowid()"
1527
+ ).fetchone()
1528
+ return row_to_dict(r)
1529
+
1530
+
1531
+ def list_case_notes(conn, case_id, limit=None):
1532
+ sql = "SELECT * FROM case_notes WHERE case_id = ? ORDER BY id ASC"
1533
+ params = [case_id]
1534
+ if limit is not None:
1535
+ sql = "SELECT * FROM case_notes WHERE case_id = ? ORDER BY id DESC LIMIT ?"
1536
+ params = [case_id, int(limit)]
1537
+ rows = conn.execute(sql, params).fetchall()
1538
+ return [row_to_dict(r) for r in reversed(rows)]
1539
+ rows = conn.execute(sql, params).fetchall()
1540
+ return [row_to_dict(r) for r in rows]
1541
+
1542
+
1543
+ def supersede_case_note(conn, note_id, text, author=None):
1544
+ row = conn.execute("SELECT * FROM case_notes WHERE id = ?", (note_id,)).fetchone()
1545
+ if row is None:
1546
+ return None
1547
+ case_id = row["case_id"]
1548
+ orig_author = row["author"]
1549
+ new_author = author if author is not None else orig_author
1550
+ return add_case_note(conn, case_id, text, author=new_author, supersedes=note_id)
1551
+
1552
+
1553
+ def get_case_card(conn, case_id, note_limit=5):
1554
+ case = get_case(conn, case_id)
1555
+ if case is None:
1556
+ return None
1557
+ amounts = list_case_amounts(conn, case_id)
1558
+ documents = list_case_documents(conn, case_id)
1559
+ notes = list_case_notes(conn, case_id, limit=note_limit)
1560
+ events = get_case_events(conn, case_id)
1561
+ source = {
1562
+ "source_name": case.get("source_name"),
1563
+ "source_record_id": case.get("source_record_id"),
1564
+ "fetched_at": case.get("fetched_at"),
1565
+ "source_url": case.get("source_url"),
1566
+ "imported_at": case.get("imported_at"),
1567
+ }
1568
+ return {
1569
+ "case_id": case.get("case_id"),
1570
+ "case_number": case.get("case_number"),
1571
+ "court": case.get("court"),
1572
+ "plaintiff": case.get("plaintiff"),
1573
+ "defendant": case.get("defendant"),
1574
+ "status": case.get("status"),
1575
+ "owner": case.get("owner"),
1576
+ "closed_at": case.get("closed_at"),
1577
+ "case": case,
1578
+ "parties": {"plaintiff": case.get("plaintiff"), "defendant": case.get("defendant")},
1579
+ "amounts": amounts,
1580
+ "documents": documents,
1581
+ # the wave §2: текущие (не-superseded) ревизии — карточка не доминируется
1582
+ # историей: полный список remains в documents, subset — здесь.
1583
+ "documents_current": current_case_documents(conn, case_id),
1584
+ "notes": notes,
1585
+ "events": events,
1586
+ "source": source,
1587
+ "provenance": source,
1588
+ "next_deadline": None,
1589
+ "deadlines": list_deadline_states(conn, case_id),
1590
+ # the wave §1: секция evidence — счётчик + проблемные items (пересчёт
1591
+ # integrity только для items с path; бумажные — unchecked).
1592
+ "evidence": _evidence_card_section(conn, case_id),
1593
+ # the wave §2 (S-15): секция представителей — список + счётчик по сторонам
1594
+ # + hint, что полномочия проверяются вручную (автоматической проверки
1595
+ # sud не делает и делать не будет).
1596
+ "representatives": representatives_card_section(conn, case_id),
1597
+ }
1598
+
1599
+
1600
+ def _evidence_card_section(conn, case_id):
1601
+ items = verify_case_evidence(conn, case_id)
1602
+ summary = evidence_summary(items)
1603
+ return {
1604
+ "count": summary["total"],
1605
+ "problems": summary["problem_items"],
1606
+ "summary": {k: v for k, v in summary.items() if k != "problem_items"},
1607
+ }
1608
+
1609
+
1610
+ # ---------------------------------------------------------------------------
1611
+ # Deadline confirmation state (v2.5.0 anti-laches)
1612
+ # ---------------------------------------------------------------------------
1613
+
1614
+ import json as _json
1615
+
1616
+
1617
+ def _deadline_now_iso() -> str:
1618
+ import datetime as _dt
1619
+ return _dt.datetime.now(_dt.timezone.utc).replace(microsecond=0).isoformat()
1620
+
1621
+
1622
+ def _basis_hash(basis) -> str:
1623
+ import hashlib
1624
+ raw = _json.dumps(basis, sort_keys=True, ensure_ascii=False, default=str)
1625
+ return hashlib.sha256(raw.encode("utf-8")).hexdigest()
1626
+
1627
+
1628
+ def _row_to_deadline_state(row) -> dict:
1629
+ if row is None:
1630
+ return None
1631
+ d = row_to_dict(row)
1632
+ if d.get("basis"):
1633
+ try:
1634
+ d["basis"] = _json.loads(d["basis"])
1635
+ except (TypeError, ValueError):
1636
+ pass
1637
+ return d
1638
+
1639
+
1640
+ def list_deadline_states(conn, case_id, deadline_key=None) -> list:
1641
+ sql = "SELECT * FROM deadline_states WHERE case_id = ?"
1642
+ params = [case_id]
1643
+ if deadline_key is not None:
1644
+ sql += " AND deadline_key = ?"
1645
+ params.append(deadline_key)
1646
+ sql += " ORDER BY id ASC"
1647
+ rows = conn.execute(sql, params).fetchall()
1648
+ return [_row_to_deadline_state(r) for r in rows]
1649
+
1650
+
1651
+ def list_all_deadline_states(conn) -> list:
1652
+ rows = conn.execute("SELECT * FROM deadline_states ORDER BY id ASC").fetchall()
1653
+ return [_row_to_deadline_state(r) for r in rows]
1654
+
1655
+
1656
+ def get_latest_deadline_state(conn, case_id, deadline_key):
1657
+ row = conn.execute(
1658
+ "SELECT * FROM deadline_states WHERE case_id = ? AND deadline_key = ? ORDER BY id DESC LIMIT 1",
1659
+ (case_id, deadline_key),
1660
+ ).fetchone()
1661
+ return _row_to_deadline_state(row)
1662
+
1663
+
1664
+ def get_latest_anchored_state(conn, case_id, deadline_key):
1665
+ rows = conn.execute(
1666
+ "SELECT * FROM deadline_states WHERE case_id = ? AND deadline_key = ? ORDER BY id DESC",
1667
+ (case_id, deadline_key),
1668
+ ).fetchall()
1669
+ for r in rows:
1670
+ d = _row_to_deadline_state(r)
1671
+ basis = d.get("basis") or {}
1672
+ if isinstance(basis, dict) and "method" in basis:
1673
+ return d
1674
+ return None
1675
+
1676
+
1677
+ def append_deadline_audit(state_dir, entry) -> None:
1678
+ from pathlib import Path
1679
+ p = Path(state_dir) / "deadline_audit.jsonl"
1680
+ Path(state_dir).mkdir(parents=True, exist_ok=True)
1681
+ with p.open("a", encoding="utf-8") as f:
1682
+ f.write(_json.dumps(entry, ensure_ascii=False, default=str) + "\n")
1683
+
1684
+
1685
+ def read_deadline_audit(state_dir, case_id=None) -> list:
1686
+ from pathlib import Path
1687
+ p = Path(state_dir) / "deadline_audit.jsonl"
1688
+ if not p.exists():
1689
+ return []
1690
+ out = []
1691
+ for line in p.read_text(encoding="utf-8").splitlines():
1692
+ line = line.strip()
1693
+ if not line:
1694
+ continue
1695
+ try:
1696
+ obj = _json.loads(line)
1697
+ except ValueError:
1698
+ continue
1699
+ if case_id is not None and obj.get("case") != case_id:
1700
+ continue
1701
+ out.append(obj)
1702
+ return out
1703
+
1704
+
1705
+ def insert_deadline_state(conn, case_id, deadline_key, due_at=None, basis=None,
1706
+ state="computed", confirmed_by=None, confirmed_at=None,
1707
+ evidence_note=None, state_dir=None, actor=None,
1708
+ action=None, before=None) -> dict:
1709
+ basis_json = _json.dumps(basis, ensure_ascii=False, default=str) if basis is not None else None
1710
+ bh = _basis_hash(basis) if basis is not None else None
1711
+ conn.execute(
1712
+ """
1713
+ INSERT INTO deadline_states
1714
+ (case_id, deadline_key, due_at, basis_hash, state, confirmed_by,
1715
+ confirmed_at, evidence_note, basis, created_at)
1716
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
1717
+ """,
1718
+ (case_id, deadline_key, due_at, bh, state, confirmed_by, confirmed_at,
1719
+ evidence_note, basis_json, _deadline_now_iso()),
1720
+ )
1721
+ conn.commit()
1722
+ row = conn.execute(
1723
+ "SELECT * FROM deadline_states WHERE id = last_insert_rowid()"
1724
+ ).fetchone()
1725
+ d = _row_to_deadline_state(row)
1726
+ if state_dir is not None and action is not None:
1727
+ append_deadline_audit(state_dir, {
1728
+ "ts": _deadline_now_iso(),
1729
+ "actor": actor if actor is not None else (confirmed_by if confirmed_by is not None else "system"),
1730
+ "case": case_id,
1731
+ "key": deadline_key,
1732
+ "action": action,
1733
+ "before": before,
1734
+ "after": d,
1735
+ })
1736
+ return d
1737
+
1738
+
1739
+ def anchor_deadline_state(conn, state_dir, case_id, deadline_key, event_date,
1740
+ method, evidence, actor=None) -> dict:
1741
+ if actor is None:
1742
+ actor = "cli"
1743
+ # правило «тексты только из реестра»: неизвестный ключ матрицы = отказ, а не молчаливый due_at=None
1744
+ from datetime import date as _date
1745
+ from .legal_deadlines import calculate_deadline
1746
+ from .legal_engine import load_rules
1747
+ rules = load_rules()
1748
+ matrix = getattr(rules, "deadline_matrix", None) or (rules.get("deadline_matrix") if isinstance(rules, dict) else None)
1749
+ if matrix is not None and deadline_key not in matrix:
1750
+ raise ValueError(f"unknown_deadline_key: {deadline_key}")
1751
+ due_at = None
1752
+ try:
1753
+ res = calculate_deadline(deadline_key=deadline_key, event_date=_date.fromisoformat(event_date), rules=rules)
1754
+ if getattr(res, "status", None) == "ok" and getattr(res, "deadline", None) is not None:
1755
+ due_at = res.deadline.isoformat()
1756
+ except Exception:
1757
+ due_at = None
1758
+ before_latest = get_latest_deadline_state(conn, case_id, deadline_key)
1759
+ basis = {
1760
+ "event_date": event_date,
1761
+ "method": method,
1762
+ "evidence": evidence,
1763
+ "actor": actor,
1764
+ "ts": _deadline_now_iso(),
1765
+ }
1766
+ return insert_deadline_state(
1767
+ conn, case_id, deadline_key, due_at=due_at, basis=basis, state="computed",
1768
+ evidence_note=evidence,
1769
+ state_dir=state_dir, actor=actor, action="anchor", before=before_latest,
1770
+ )
1771
+
1772
+
1773
+ def confirm_deadline_state(conn, state_dir, case_id, deadline_key, actor,
1774
+ evidence=None, waive=False, norm=None) -> dict:
1775
+ if actor is None:
1776
+ actor = "cli"
1777
+ state = "waived" if waive else "confirmed"
1778
+ note = norm if norm is not None else evidence
1779
+ if norm is not None and evidence is not None:
1780
+ note = f"{norm}; {evidence}"
1781
+ latest = get_latest_deadline_state(conn, case_id, deadline_key)
1782
+ before = latest
1783
+ now = _deadline_now_iso()
1784
+ if latest is not None:
1785
+ conn.execute(
1786
+ """
1787
+ UPDATE deadline_states
1788
+ SET state = ?, confirmed_by = ?, confirmed_at = ?, evidence_note = ?
1789
+ WHERE id = ?
1790
+ """,
1791
+ (state, actor, now, note, latest["id"]),
1792
+ )
1793
+ conn.commit()
1794
+ row = conn.execute(
1795
+ "SELECT * FROM deadline_states WHERE id = ?", (latest["id"],)
1796
+ ).fetchone()
1797
+ after = _row_to_deadline_state(row)
1798
+ else:
1799
+ basis = None
1800
+ due_at = None
1801
+ after = insert_deadline_state(
1802
+ conn, case_id, deadline_key, due_at=due_at, basis=basis, state=state,
1803
+ confirmed_by=actor, confirmed_at=now, evidence_note=note,
1804
+ state_dir=None, actor=actor, action=None,
1805
+ )
1806
+ append_deadline_audit(state_dir, {
1807
+ "ts": now,
1808
+ "actor": actor,
1809
+ "case": case_id,
1810
+ "key": deadline_key,
1811
+ "action": "waive" if waive else "confirm",
1812
+ "before": before,
1813
+ "after": after,
1814
+ })
1815
+ return after
1816
+
1817
+
1818
+ # aliases
1819
+ def waive_deadline_state(conn, state_dir, case_id, deadline_key, actor, norm, evidence=None):
1820
+ return confirm_deadline_state(conn, state_dir, case_id, deadline_key, actor,
1821
+ evidence=evidence, waive=True, norm=norm)
1822
+
1823
+
1824
+ def deadline_states_for_case(conn, case_id, deadline_key=None):
1825
+ return list_deadline_states(conn, case_id, deadline_key=deadline_key)
1826
+
1827
+
1828
+ def _freshness_report(state_dir) -> dict:
1829
+ import json as _j
1830
+ import time as _t
1831
+ from datetime import date as _date, datetime as _datetime
1832
+ from pathlib import Path
1833
+
1834
+ state_dir = Path(state_dir)
1835
+ out = {"warnings": []}
1836
+ warnings = []
1837
+
1838
+ # key_rates.json age
1839
+ kr_path = Path(__file__).resolve().parent / "data" / "key_rates.json"
1840
+ kr = {}
1841
+ try:
1842
+ kr_text = kr_path.read_text(encoding="utf-8")
1843
+ kr = _j.loads(kr_text)
1844
+ mtime = kr_path.stat().st_mtime
1845
+ age_days = (_t.time() - mtime) / 86400.0
1846
+ data_age = None
1847
+ for field in ("as_of", "coverage_latest_change", "generated_at", "verified_at"):
1848
+ if field in kr:
1849
+ try:
1850
+ data_age = (_date.today() - _date.fromisoformat(str(kr[field])[:10])).days
1851
+ except ValueError:
1852
+ pass
1853
+ if data_age is not None:
1854
+ break
1855
+ stale = bool(age_days > 180 or (data_age is not None and data_age > 180))
1856
+ if stale:
1857
+ warnings.append("key_rates.json older than 180 days")
1858
+ kr_info = {
1859
+ "age_days": round(age_days, 1),
1860
+ "data_age_days": data_age,
1861
+ "stale": stale,
1862
+ }
1863
+ if "provenance" in kr:
1864
+ kr_info["provenance"] = kr["provenance"]
1865
+ except Exception as exc:
1866
+ kr_info = {"error": str(exc), "stale": True}
1867
+ warnings.append("key_rates.json unavailable")
1868
+ out["key_rates"] = kr_info
1869
+
1870
+ # holidays transferred verified_through
1871
+ hol_path = Path(__file__).resolve().parent / "holidays_rf.json"
1872
+ try:
1873
+ hol = _j.loads(hol_path.read_text(encoding="utf-8"))
1874
+ vt = hol.get("verified_through")
1875
+ years = hol.get("years_with_verified_transfers")
1876
+ current_year = _date.today().year
1877
+ stale_h = vt is None or (isinstance(vt, int) and vt < current_year)
1878
+ if stale_h:
1879
+ warnings.append("holidays transferred verified_through older than current year")
1880
+ out["holidays"] = {
1881
+ "verified_through": vt,
1882
+ "transferred_verified_through": vt,
1883
+ "years_with_verified_transfers": years,
1884
+ "stale": bool(stale_h),
1885
+ }
1886
+ except Exception as exc:
1887
+ out["holidays"] = {"error": str(exc), "stale": True}
1888
+ warnings.append("holidays file unavailable")
1889
+
1890
+ # rules version stamps
1891
+ rules_path = Path(__file__).resolve().parent / "legal_rules.json"
1892
+ try:
1893
+ rules = _j.loads(rules_path.read_text(encoding="utf-8"))
1894
+ out["rules"] = {
1895
+ "version": rules.get("version"),
1896
+ "spec_version": rules.get("spec_version"),
1897
+ "tariff_version": rules.get("tariff_version"),
1898
+ "legal_status": rules.get("legal_status"),
1899
+ }
1900
+ except Exception as exc:
1901
+ out["rules"] = {"error": str(exc)}
1902
+
1903
+ # deadline audit age
1904
+ audit_path = state_dir / "deadline_audit.jsonl"
1905
+ if audit_path.exists():
1906
+ try:
1907
+ age = (_t.time() - audit_path.stat().st_mtime) / 86400.0
1908
+ count = sum(1 for line in audit_path.read_text(encoding="utf-8").splitlines() if line.strip())
1909
+ out["deadline_audit"] = {"entries": count, "age_days": round(age, 1)}
1910
+ except Exception as exc:
1911
+ out["deadline_audit"] = {"error": str(exc)}
1912
+ else:
1913
+ out["deadline_audit"] = {"entries": 0, "age_days": None}
1914
+
1915
+ out["warnings"] = warnings
1916
+ return out
1917
+
1918
+
1919
+ # ---------------------------------------------------------------------------
1920
+ # история §5 (S-07): метаданные state (meta) + экспорт/уничтожение данных дела
1921
+ # ---------------------------------------------------------------------------
1922
+
1923
+
1924
+ def set_meta(conn, key, value):
1925
+ """Одна строка в meta: используется для last_backup (S-85/S-86)."""
1926
+ conn.execute(
1927
+ "INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)", (str(key), str(value))
1928
+ )
1929
+ conn.commit()
1930
+
1931
+
1932
+ def get_meta(conn, key, default=None):
1933
+ row = conn.execute("SELECT value FROM meta WHERE key = ?", (str(key),)).fetchone()
1934
+ if row is None:
1935
+ return default
1936
+ return row["value"]
1937
+
1938
+
1939
+ #: Таблицы с case_id, которые трогает purge. Порядок фиксирован: превью и
1940
+ #: отчёт должны быть детерминированы, а не зависеть обхода схемы.
1941
+ PURGE_TABLES = (
1942
+ "events",
1943
+ "case_amounts",
1944
+ "case_documents",
1945
+ "case_notes",
1946
+ "deadline_states",
1947
+ "evidence_items",
1948
+ "case_representatives",
1949
+ "cases",
1950
+ )
1951
+
1952
+
1953
+ def case_row_counts(conn, case_id):
1954
+ """Счётчики строк дела по таблицам — то же число превью и факта purge."""
1955
+ counts = {}
1956
+ for table in PURGE_TABLES:
1957
+ row = conn.execute(
1958
+ f"SELECT COUNT(*) AS n FROM {table} WHERE case_id = ?", (case_id,)
1959
+ ).fetchone()
1960
+ counts[table] = int(row["n"])
1961
+ return counts
1962
+
1963
+
1964
+ def case_file_paths(conn, case_id):
1965
+ """Физические файлы дела: только case_documents.path этого дела, unique.
1966
+
1967
+ events.source_url не трогаем: это URL карточки, а не локальный файл, —
1968
+ «удалить» его значит выдумывать действие.
1969
+ """
1970
+ rows = conn.execute(
1971
+ "SELECT DISTINCT path FROM case_documents WHERE case_id = ? ORDER BY path ASC",
1972
+ (case_id,),
1973
+ ).fetchall()
1974
+ return [r["path"] for r in rows]
1975
+
1976
+
1977
+ def scrub_deadline_audit(state_dir, case_id) -> int:
1978
+ """Убрать из журнала правок строки уничтоженного дела; вернуть их число.
1979
+
1980
+ Журнал по обыкновению append-only, но purge — право субъекта (pdn_policy
1981
+ .subject_rights): оставить в audit строки дела с evidence-текстами после
1982
+ уничтожения означало бы, что «полное уничтожение данных дела» неполное.
1983
+ Файл переписывается атомарно (tmp+replace), чужие строки не трогаются.
1984
+ """
1985
+ from pathlib import Path
1986
+
1987
+ p = Path(state_dir) / "deadline_audit.jsonl"
1988
+ if not p.exists():
1989
+ return 0
1990
+ kept, dropped = [], 0
1991
+ for line in p.read_text(encoding="utf-8").splitlines():
1992
+ if not line.strip():
1993
+ continue
1994
+ try:
1995
+ obj = _json.loads(line)
1996
+ except ValueError:
1997
+ kept.append(line)
1998
+ continue
1999
+ if isinstance(obj, dict) and obj.get("case") == case_id:
2000
+ dropped += 1
2001
+ continue
2002
+ kept.append(line)
2003
+ tmp = p.with_name(p.name + ".tmp")
2004
+ tmp.write_text("".join(line + "\n" for line in kept), encoding="utf-8")
2005
+ tmp.replace(p)
2006
+ return dropped
2007
+
2008
+
2009
+ def purge_case(conn, state_dir, case_id, actor="cli", remove_files=True):
2010
+ """Полное уничтожение данных дела (право субъекта, pdn_policy.subject_rights).
2011
+
2012
+ Границы:
2013
+ - каскад строк ВСЕХ таблиц дела (PURGE_TABLES) + физфайлы из case_documents;
2014
+ - в audit попадает только ФАКТ: id/дата/actor/счётчики. Ни имён сторон, ни
2015
+ текстов документов, ни path'ов — ПДн в audit быть не должно;
2016
+ - подтверждения (--yes / confirm) отвечают вызывающему слою: здесь функция
2017
+ исполняет, а не спрашивает.
2018
+ """
2019
+ from pathlib import Path
2020
+
2021
+ counts = case_row_counts(conn, case_id)
2022
+ paths = case_file_paths(conn, case_id)
2023
+ deleted_files, missing_files, failed_files, outside_files = [], [], [], []
2024
+ root = Path(state_dir).resolve()
2025
+ if remove_files:
2026
+ for raw in paths:
2027
+ p = Path(raw)
2028
+ try:
2029
+ rp = p.resolve()
2030
+ except OSError:
2031
+ failed_files.append(str(p))
2032
+ continue
2033
+ if not (rp == root or root in rp.parents):
2034
+ # containment: purge удаляет только файлы внутри state_dir (audit 25.10 #2)
2035
+ outside_files.append(str(p))
2036
+ continue
2037
+ try:
2038
+ if p.is_file():
2039
+ p.unlink()
2040
+ deleted_files.append(str(p))
2041
+ else:
2042
+ missing_files.append(str(p))
2043
+ except OSError:
2044
+ failed_files.append(str(p))
2045
+ for table in PURGE_TABLES:
2046
+ conn.execute(f"DELETE FROM {table} WHERE case_id = ?", (case_id,))
2047
+ conn.commit()
2048
+ # Строки журнала этого дела уходят вместе с делом: в purge-запись
2049
+ # попадает только счётчик сожжённых строк, а не их содержимое.
2050
+ scrubbed = scrub_deadline_audit(state_dir, case_id)
2051
+ entry = {
2052
+ "ts": _now_iso(),
2053
+ "actor": actor,
2054
+ "case": case_id,
2055
+ "action": "purge_case",
2056
+ "removed_rows": counts,
2057
+ "files_deleted": len(deleted_files),
2058
+ "files_missing": len(missing_files),
2059
+ "files_failed": len(failed_files),
2060
+ "files_outside_statedir": len(outside_files),
2061
+ "audit_lines_scrubbed": scrubbed,
2062
+ }
2063
+ append_deadline_audit(state_dir, entry)
2064
+ return {
2065
+ **entry,
2066
+ "files": {
2067
+ "deleted": len(deleted_files),
2068
+ "missing": len(missing_files),
2069
+ "failed": len(failed_files),
2070
+ "outside_statedir": len(outside_files),
2071
+ },
2072
+ }