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.
- sud_cli/__init__.py +3 -0
- sud_cli/__main__.py +4 -0
- sud_cli/_wm.py +118 -0
- sud_cli/agenda.py +90 -0
- sud_cli/autofill.py +192 -0
- sud_cli/autofill_markers.json +56 -0
- sud_cli/backup.py +256 -0
- sud_cli/calendar_ics.py +191 -0
- sud_cli/case_diff.py +165 -0
- sud_cli/case_search_bridge.py +1322 -0
- sud_cli/chain.py +226 -0
- sud_cli/cli.py +3721 -0
- sud_cli/completeness.py +105 -0
- sud_cli/config.py +20 -0
- sud_cli/conflict.py +210 -0
- sud_cli/conflicts.py +207 -0
- sud_cli/court_alerts.py +156 -0
- sud_cli/court_source.py +108 -0
- sud_cli/data/completeness_rules.json +18 -0
- sud_cli/data/key_rates.json +59 -0
- sud_cli/data/npa_historical_editions.json +19 -0
- sud_cli/db.py +2072 -0
- sud_cli/deadline_matrix.py +99 -0
- sud_cli/deadline_state.py +25 -0
- sud_cli/deadlines.py +8 -0
- sud_cli/deadlines_state.py +1 -0
- sud_cli/deferral.py +5 -0
- sud_cli/efrsb_manual.py +85 -0
- sud_cli/efrsb_public.py +210 -0
- sud_cli/egrul_nalog.py +150 -0
- sud_cli/explain.py +190 -0
- sud_cli/fees_extra.py +104 -0
- sud_cli/forms.py +204 -0
- sud_cli/fssp_manual.py +91 -0
- sud_cli/fssp_semi.py +285 -0
- sud_cli/fssp_stats.py +105 -0
- sud_cli/government_transfers.json +8 -0
- sud_cli/holidays_rf.json +62 -0
- sud_cli/idempotency.py +78 -0
- sud_cli/importing.py +575 -0
- sud_cli/interest.py +144 -0
- sud_cli/jurisdiction.py +177 -0
- sud_cli/jurisdiction_rules.json +265 -0
- sud_cli/legal_deadlines.py +278 -0
- sud_cli/legal_engine.py +558 -0
- sud_cli/legal_rules.json +1058 -0
- sud_cli/limitation.py +70 -0
- sud_cli/mcp_server.py +1594 -0
- sud_cli/metrics.py +142 -0
- sud_cli/npa_as_of.py +21 -0
- sud_cli/output.py +89 -0
- sud_cli/payment.py +24 -0
- sud_cli/pdn.py +259 -0
- sud_cli/plenum_corpus.py +134 -0
- sud_cli/plenums_seed.json +45 -0
- sud_cli/practice_status.py +136 -0
- sud_cli/pravo_publication.py +361 -0
- sud_cli/pretrial.py +151 -0
- sud_cli/production_calendar.py +233 -0
- sud_cli/providers.py +95 -0
- sud_cli/readiness.py +234 -0
- sud_cli/regional_holidays.py +37 -0
- sud_cli/reminders.py +92 -0
- sud_cli/reports.py +349 -0
- sud_cli/sud_netguard.py +943 -0
- sud_cli/timeline_audit.py +80 -0
- sud_cli/verify.py +201 -0
- sud_cli-0.1.1.dist-info/METADATA +119 -0
- sud_cli-0.1.1.dist-info/RECORD +74 -0
- sud_cli-0.1.1.dist-info/WHEEL +4 -0
- sud_cli-0.1.1.dist-info/entry_points.txt +2 -0
- sud_cli-0.1.1.dist-info/licenses/AUTHORS +5 -0
- sud_cli-0.1.1.dist-info/licenses/LICENSE.md +661 -0
- 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
|
+
}
|