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/completeness.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""the wave §3 (S-71): структурный чек-лист комплектности — справка, не валиация.
|
|
2
|
+
|
|
3
|
+
Границы:
|
|
4
|
+
- правила живут в data/completeness_rules.json как СПИСОК СТРУКТУРНЫХ маркеров
|
|
5
|
+
по kinds (isk_apk/claim_apk/appeal_apk/hodat); файл помечен format-checklist
|
|
6
|
+
и несёт disclaimer: это НЕ юридическая проверка достаточности;
|
|
7
|
+
- ни в данных, ни в выводе не бывает нормативных цитат («ст.» с номером) —
|
|
8
|
+
тесты гейтят это регуляркой по файлу и по выводу;
|
|
9
|
+
- вывод всегда exit 0: чек-лист справочный; бинарный файл без извлечённого
|
|
10
|
+
текста = need-text hint, а не провал.
|
|
11
|
+
"""
|
|
12
|
+
import json
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
RULES_PATH = Path(__file__).resolve().parent / "data" / "completeness_rules.json"
|
|
16
|
+
|
|
17
|
+
#: kinds правил; синхронизированы с forms.CHECKLIST_KIND_FOR_FORM (§4).
|
|
18
|
+
DEFAULT_KIND = "isk_apk"
|
|
19
|
+
|
|
20
|
+
VERDICT_FULL = "структурно полный"
|
|
21
|
+
VERDICT_INCOMPLETE = "структурно неполный"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def load_rules():
|
|
25
|
+
rules = json.loads(RULES_PATH.read_text(encoding="utf-8"))
|
|
26
|
+
if rules.get("meta", {}).get("format") != "format-checklist":
|
|
27
|
+
raise ValueError("completeness_rules.json: meta.format must be format-checklist")
|
|
28
|
+
if not rules.get("meta", {}).get("disclaimer"):
|
|
29
|
+
raise ValueError("completeness_rules.json: meta.disclaimer is required")
|
|
30
|
+
return rules
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def resolve_kind(kind_arg, title, rules):
|
|
34
|
+
"""kind документа: явный > совпадение в заголовке > DEFAULT_KIND.
|
|
35
|
+
|
|
36
|
+
Никакой магии угадывания: если kind не передан и не читается из title,
|
|
37
|
+
источник в выводе честно помечен 'default'.
|
|
38
|
+
"""
|
|
39
|
+
kinds = rules.get("kinds", {})
|
|
40
|
+
if kind_arg:
|
|
41
|
+
if kind_arg not in kinds:
|
|
42
|
+
raise ValueError(f"unknown checklist kind: {kind_arg}")
|
|
43
|
+
return kind_arg, "explicit"
|
|
44
|
+
text = (title or "").lower()
|
|
45
|
+
for kind in kinds:
|
|
46
|
+
if kind.lower() in text:
|
|
47
|
+
return kind, "title"
|
|
48
|
+
if "апелл" in text:
|
|
49
|
+
return "appeal_apk", "title"
|
|
50
|
+
return DEFAULT_KIND, "default"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def extract_text(path):
|
|
54
|
+
"""(text|None, hint). Plain-read utf-8; бинарь/нет файла -> None + hint."""
|
|
55
|
+
p = Path(path)
|
|
56
|
+
if not p.is_file():
|
|
57
|
+
return None, "need-text: файл документа исчез с пути — список не извлечь"
|
|
58
|
+
try:
|
|
59
|
+
return p.read_text(encoding="utf-8"), None
|
|
60
|
+
except (UnicodeDecodeError, ValueError):
|
|
61
|
+
return None, ("need-text: файл не является plain-text (docx/pdf/скан) — "
|
|
62
|
+
"сначала извлеките текст (extract-text) и добавьте его как документ")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def evaluate(text, kind, rules):
|
|
66
|
+
"""Чек-лист {маркер: found|missing}: регистронезависимый подстрочный поиск."""
|
|
67
|
+
hay = (text or "").casefold()
|
|
68
|
+
markers = rules["kinds"][kind]
|
|
69
|
+
checklist = {m: ("found" if m.casefold() in hay else "missing") for m in markers}
|
|
70
|
+
missing = [m for m, state in checklist.items() if state == "missing"]
|
|
71
|
+
return {
|
|
72
|
+
"kind": kind,
|
|
73
|
+
"checklist": checklist,
|
|
74
|
+
"missing": missing,
|
|
75
|
+
"verdict": VERDICT_INCOMPLETE if missing else VERDICT_FULL,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def checklist_payload(doc, kind_arg, rules=None):
|
|
80
|
+
"""Полный конверт команды: данные + disclaimer; found/need_text — честные флаги."""
|
|
81
|
+
rules = rules or load_rules()
|
|
82
|
+
base = {
|
|
83
|
+
"doc_id": doc.get("id") if doc else None,
|
|
84
|
+
"case_id": doc.get("case_id") if doc else None,
|
|
85
|
+
"path": doc.get("path") if doc else None,
|
|
86
|
+
"title": doc.get("title") if doc else None,
|
|
87
|
+
"disclaimer": rules["meta"]["disclaimer"],
|
|
88
|
+
"format": rules["meta"]["format"],
|
|
89
|
+
}
|
|
90
|
+
if doc is None:
|
|
91
|
+
base.update({"found": False, "need_text": False, "kind": None,
|
|
92
|
+
"kind_source": None, "checklist": {}, "missing": [],
|
|
93
|
+
"verdict": "документ не найден"})
|
|
94
|
+
return base
|
|
95
|
+
kind, kind_source = resolve_kind(kind_arg, doc.get("title"), rules)
|
|
96
|
+
base.update({"found": True, "kind": kind, "kind_source": kind_source})
|
|
97
|
+
text, hint = extract_text(doc.get("path"))
|
|
98
|
+
if text is None:
|
|
99
|
+
base.update({"need_text": True, "hint": hint, "checklist": {}, "missing": [],
|
|
100
|
+
"verdict": "текст не извлечён"})
|
|
101
|
+
return base
|
|
102
|
+
base["need_text"] = False
|
|
103
|
+
base.update(evaluate(text, kind, rules))
|
|
104
|
+
base["kind"] = kind
|
|
105
|
+
return base
|
sud_cli/config.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Application state directory configuration."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
ENV_VAR = "SUD_CLI_STATE_DIR"
|
|
8
|
+
DEFAULT_DIR_NAME = "sud-cli"
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def get_state_dir() -> Path:
|
|
12
|
+
"""Return the sud-cli state directory.
|
|
13
|
+
|
|
14
|
+
Resolution order:
|
|
15
|
+
1. SUD_CLI_STATE_DIR environment variable.
|
|
16
|
+
2. ~/.local/share/sud-cli
|
|
17
|
+
"""
|
|
18
|
+
if ENV_VAR in os.environ:
|
|
19
|
+
return Path(os.environ[ENV_VAR]).expanduser().resolve()
|
|
20
|
+
return Path.home() / ".local" / "share" / DEFAULT_DIR_NAME
|
sud_cli/conflict.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Conflict-of-interest heuristic for sud-cli (v2.4.1).
|
|
2
|
+
|
|
3
|
+
Pure local heuristic: name-exact intersection of party names across the local
|
|
4
|
+
case registry. No external parties/affiliates are known, so absence of a hit
|
|
5
|
+
does NOT mean absence of a conflict.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from collections import defaultdict
|
|
9
|
+
from typing import Any, Dict, List
|
|
10
|
+
|
|
11
|
+
METHOD = "name_exact"
|
|
12
|
+
COVERAGE = "local registry names only"
|
|
13
|
+
DISCLAIMER = (
|
|
14
|
+
"Отсутствие совпадений не означает отсутствие конфликта: покрытие — "
|
|
15
|
+
"только имена локального реестра; внешние стороны и аффилированные лица "
|
|
16
|
+
"не охвачены. Решение остаётся за человеком (boundary IX-3)."
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def normalize_name(value: Any) -> str:
|
|
21
|
+
if value is None:
|
|
22
|
+
return ""
|
|
23
|
+
text = str(value)
|
|
24
|
+
for q in ('"', "'", "«", "»", "„", "“", "”", "`"):
|
|
25
|
+
text = text.replace(q, "")
|
|
26
|
+
text = text.replace("ё", "е").replace("Ё", "Е")
|
|
27
|
+
text = " ".join(text.split())
|
|
28
|
+
return text.upper()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Aliases for callers that expect other names.
|
|
32
|
+
normalize = normalize_name
|
|
33
|
+
normalize_party = normalize_name
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _case_id(case: Any, fallback: Any = None) -> Any:
|
|
37
|
+
if isinstance(case, dict):
|
|
38
|
+
for key in ("case_id", "id", "case_number", "number"):
|
|
39
|
+
v = case.get(key)
|
|
40
|
+
if v is not None and str(v).strip() != "":
|
|
41
|
+
return v
|
|
42
|
+
return fallback
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _owner_key(case: Any) -> Any:
|
|
46
|
+
if not isinstance(case, dict):
|
|
47
|
+
return None
|
|
48
|
+
val = case.get("owner")
|
|
49
|
+
if val is None or str(val).strip() == "":
|
|
50
|
+
return None
|
|
51
|
+
return normalize_name(val)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _iter_roles(case: Any):
|
|
55
|
+
if not isinstance(case, dict):
|
|
56
|
+
return
|
|
57
|
+
for role in ("plaintiff", "defendant", "owner"):
|
|
58
|
+
val = case.get(role)
|
|
59
|
+
if val is not None and str(val).strip() != "":
|
|
60
|
+
yield normalize_name(val), role, val
|
|
61
|
+
parties = case.get("parties")
|
|
62
|
+
if isinstance(parties, dict):
|
|
63
|
+
for role, val in parties.items():
|
|
64
|
+
if val is not None and str(val).strip() != "":
|
|
65
|
+
yield normalize_name(val), str(role), val
|
|
66
|
+
elif isinstance(parties, (list, tuple)):
|
|
67
|
+
for val in parties:
|
|
68
|
+
if val is not None and str(val).strip() != "":
|
|
69
|
+
yield normalize_name(val), "party", val
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _collect_candidate_parties(candidate: Any) -> List[str]:
|
|
73
|
+
parties: List[str] = []
|
|
74
|
+
if isinstance(candidate, dict):
|
|
75
|
+
raw = candidate.get("parties")
|
|
76
|
+
if isinstance(raw, (list, tuple)):
|
|
77
|
+
parties.extend(str(p) for p in raw)
|
|
78
|
+
elif isinstance(raw, dict):
|
|
79
|
+
parties.extend(str(v) for v in raw.values() if v is not None and str(v).strip() != "")
|
|
80
|
+
elif isinstance(raw, str):
|
|
81
|
+
parties.append(raw)
|
|
82
|
+
for key in ("plaintiff", "defendant", "party", "name", "owner"):
|
|
83
|
+
val = candidate.get(key)
|
|
84
|
+
if val is not None and str(val).strip() != "":
|
|
85
|
+
parties.append(str(val))
|
|
86
|
+
elif isinstance(candidate, (list, tuple)):
|
|
87
|
+
parties.extend(str(p) for p in candidate)
|
|
88
|
+
elif candidate is not None:
|
|
89
|
+
parties.append(str(candidate))
|
|
90
|
+
return parties
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def check(portfolio: Any, candidate: Any) -> List[Dict[str, Any]]:
|
|
94
|
+
cases = list(portfolio or [])
|
|
95
|
+
cand = candidate if isinstance(candidate, dict) else {"parties": candidate}
|
|
96
|
+
# Exclude the candidate's own case from the portfolio when identified.
|
|
97
|
+
cid = cand.get("case_id") if isinstance(cand, dict) else None
|
|
98
|
+
if cid is None and isinstance(cand, dict):
|
|
99
|
+
cid = cand.get("id")
|
|
100
|
+
if cid is not None:
|
|
101
|
+
cases = [c for c in cases if _case_id(c) != cid]
|
|
102
|
+
|
|
103
|
+
candidate_names = []
|
|
104
|
+
seen_names = set()
|
|
105
|
+
for p in _collect_candidate_parties(cand):
|
|
106
|
+
n = normalize_name(p)
|
|
107
|
+
if n and n not in seen_names:
|
|
108
|
+
seen_names.add(n)
|
|
109
|
+
candidate_names.append(n)
|
|
110
|
+
|
|
111
|
+
hits: List[Dict[str, Any]] = []
|
|
112
|
+
|
|
113
|
+
# (a) same party in cases owned by different owners
|
|
114
|
+
name_entries: Dict[str, List[Any]] = defaultdict(list)
|
|
115
|
+
for case in cases:
|
|
116
|
+
for norm, role, raw in _iter_roles(case):
|
|
117
|
+
name_entries[norm].append((case, role))
|
|
118
|
+
for name, entries in name_entries.items():
|
|
119
|
+
owners = {_owner_key(c) for c, _ in entries}
|
|
120
|
+
distinct = {o for o in owners if o is not None}
|
|
121
|
+
if len(distinct) >= 2:
|
|
122
|
+
case_ids = []
|
|
123
|
+
seen = set()
|
|
124
|
+
for case, _ in entries:
|
|
125
|
+
k = _case_id(case)
|
|
126
|
+
if k not in seen:
|
|
127
|
+
seen.add(k)
|
|
128
|
+
case_ids.append(k)
|
|
129
|
+
hits.append({
|
|
130
|
+
"type": "a",
|
|
131
|
+
"hit_type": "a",
|
|
132
|
+
"party": name,
|
|
133
|
+
"normalized": name,
|
|
134
|
+
"reason": "same party appears in cases owned by different owners",
|
|
135
|
+
"case_ids": case_ids,
|
|
136
|
+
"cases": case_ids,
|
|
137
|
+
"owners": sorted(distinct),
|
|
138
|
+
"method": METHOD,
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
# (b) adversary flip: plaintiff in one case, defendant in another, same owner
|
|
142
|
+
by_owner: Dict[Any, List[Any]] = defaultdict(list)
|
|
143
|
+
for case in cases:
|
|
144
|
+
by_owner[_owner_key(case)].append(case)
|
|
145
|
+
for owner_key, group in by_owner.items():
|
|
146
|
+
if owner_key is None:
|
|
147
|
+
continue
|
|
148
|
+
name_to_p: Dict[str, List[Any]] = defaultdict(list)
|
|
149
|
+
name_to_d: Dict[str, List[Any]] = defaultdict(list)
|
|
150
|
+
for case in group:
|
|
151
|
+
for norm, role, raw in _iter_roles(case):
|
|
152
|
+
if role == "plaintiff":
|
|
153
|
+
name_to_p[norm].append(case)
|
|
154
|
+
elif role == "defendant":
|
|
155
|
+
name_to_d[norm].append(case)
|
|
156
|
+
for name in name_to_p:
|
|
157
|
+
if name not in name_to_d:
|
|
158
|
+
continue
|
|
159
|
+
p_cases = name_to_p[name]
|
|
160
|
+
d_cases = name_to_d[name]
|
|
161
|
+
if not any(_case_id(pc) != _case_id(dc) for pc in p_cases for dc in d_cases):
|
|
162
|
+
continue
|
|
163
|
+
case_ids = []
|
|
164
|
+
seen = set()
|
|
165
|
+
for case in p_cases + d_cases:
|
|
166
|
+
k = _case_id(case)
|
|
167
|
+
if k not in seen:
|
|
168
|
+
seen.add(k)
|
|
169
|
+
case_ids.append(k)
|
|
170
|
+
hits.append({
|
|
171
|
+
"type": "b",
|
|
172
|
+
"hit_type": "b",
|
|
173
|
+
"party": name,
|
|
174
|
+
"normalized": name,
|
|
175
|
+
"reason": "party is plaintiff in one case and defendant in another within same owner portfolio",
|
|
176
|
+
"owner": owner_key,
|
|
177
|
+
"case_ids": case_ids,
|
|
178
|
+
"cases": case_ids,
|
|
179
|
+
"method": METHOD,
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
# (c) candidate party equals any party of any existing case
|
|
183
|
+
existing_names = set()
|
|
184
|
+
for case in cases:
|
|
185
|
+
for norm, role, raw in _iter_roles(case):
|
|
186
|
+
existing_names.add(norm)
|
|
187
|
+
for cand_name in candidate_names:
|
|
188
|
+
if cand_name not in existing_names:
|
|
189
|
+
continue
|
|
190
|
+
matched_ids = []
|
|
191
|
+
seen = set()
|
|
192
|
+
for case in cases:
|
|
193
|
+
if any(norm == cand_name for norm, role, raw in _iter_roles(case)):
|
|
194
|
+
k = _case_id(case)
|
|
195
|
+
if k not in seen:
|
|
196
|
+
seen.add(k)
|
|
197
|
+
matched_ids.append(k)
|
|
198
|
+
hits.append({
|
|
199
|
+
"type": "c",
|
|
200
|
+
"hit_type": "c",
|
|
201
|
+
"party": cand_name,
|
|
202
|
+
"name": cand_name,
|
|
203
|
+
"normalized": cand_name,
|
|
204
|
+
"reason": "candidate party equals a party of an existing case",
|
|
205
|
+
"case_ids": matched_ids,
|
|
206
|
+
"cases": matched_ids,
|
|
207
|
+
"method": METHOD,
|
|
208
|
+
})
|
|
209
|
+
|
|
210
|
+
return hits
|
sud_cli/conflicts.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""the wave §3 (S-27): конфликты расписания — слушания × сроки по разным делам.
|
|
2
|
+
|
|
3
|
+
Границы:
|
|
4
|
+
- набор event_type фиксирован (HEARING_EVENT_TYPES) и сравнивается без
|
|
5
|
+
регистра: «угадывать» по подстроке, что событие является заседанием, движок
|
|
6
|
+
не имеет права — недописанный тип остаётся вне детекта, а не внутри;
|
|
7
|
+
- conflict = пересечение НА ОДНУ ДАТУ событий/сроков по РАЗНЫМ делам;
|
|
8
|
+
- то же дело, срок + слушание в один день = НЕ конфликт, а подсказка
|
|
9
|
+
(same_case_hints): физически это один и тот же участник, два события в
|
|
10
|
+
один день — напоминание, а не коллизия;
|
|
11
|
+
- невалидные даты не валят команду: строка уходит в skipped с причиной;
|
|
12
|
+
- списки пустые выводятся явно []; порядок детерминирован.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from datetime import date as _date, timedelta
|
|
17
|
+
from typing import Any, Dict, List, Optional
|
|
18
|
+
|
|
19
|
+
#: Фиксированный набор типов слушаний (ниже — только точное сравнение lower()).
|
|
20
|
+
HEARING_EVENT_TYPES = frozenset({"слушание", "заседание", "предварительное"})
|
|
21
|
+
|
|
22
|
+
#: Сроки, участвующие в детекте: только вычисленные и подтверждённые.
|
|
23
|
+
DEADLINE_STATES = ("computed", "confirmed")
|
|
24
|
+
|
|
25
|
+
CONFLICTS_DISCLAIMER = (
|
|
26
|
+
"Детект справочный: он видит только слушания с event_type из фиксированного "
|
|
27
|
+
"набора {слушание, заседание, предварительное} и сроки computed|confirmed "
|
|
28
|
+
"локального реестра. Отсутствие пересечений не означает, что их нет: "
|
|
29
|
+
"командировки, несколько процессов у одного лица и время на дорогу не "
|
|
30
|
+
"покрыты данными. Решение — за человеком."
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _parse_date(raw: Any) -> Optional[_date]:
|
|
35
|
+
if raw is None or str(raw) == "":
|
|
36
|
+
return None
|
|
37
|
+
try:
|
|
38
|
+
return _date.fromisoformat(str(raw)[:10])
|
|
39
|
+
except ValueError:
|
|
40
|
+
return None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _latest_per_key(states: List[dict]) -> List[dict]:
|
|
44
|
+
"""Последнее состояние на пару (case_id, deadline_key).
|
|
45
|
+
|
|
46
|
+
Не вторая копия логики: тот же хелпер, что у reminders (the wave) — копия
|
|
47
|
+
окна/истории расходится с оригиналом при первой же правке.
|
|
48
|
+
"""
|
|
49
|
+
from .reminders import _latest_per_key as latest
|
|
50
|
+
|
|
51
|
+
return latest(states)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def collect_entries(conn, window_from: _date, window_to: _date):
|
|
55
|
+
"""Записи расписания в окне: слушания (фикс. набор типов) + сроки.
|
|
56
|
+
|
|
57
|
+
Возвращает (entries, skipped). entry = {kind, case_id, ref, on_date, label}.
|
|
58
|
+
"""
|
|
59
|
+
entries: List[dict] = []
|
|
60
|
+
skipped: List[dict] = []
|
|
61
|
+
|
|
62
|
+
for row in conn.execute("SELECT * FROM events ORDER BY event_id ASC").fetchall():
|
|
63
|
+
ev = dict(row)
|
|
64
|
+
etype = ("" if ev.get("event_type") is None else str(ev["event_type"]).strip().lower())
|
|
65
|
+
if etype not in HEARING_EVENT_TYPES:
|
|
66
|
+
continue
|
|
67
|
+
day = _parse_date(ev.get("event_date"))
|
|
68
|
+
if day is None:
|
|
69
|
+
skipped.append({
|
|
70
|
+
"kind": "hearing",
|
|
71
|
+
"case_id": ev.get("case_id"),
|
|
72
|
+
"ref": ev.get("event_id"),
|
|
73
|
+
"reason": f"нечитаемая дата события: {ev.get('event_date')!r}",
|
|
74
|
+
})
|
|
75
|
+
continue
|
|
76
|
+
if not (window_from <= day <= window_to):
|
|
77
|
+
continue
|
|
78
|
+
entries.append({
|
|
79
|
+
"kind": "hearing",
|
|
80
|
+
"case_id": ev.get("case_id"),
|
|
81
|
+
"ref": ev.get("event_id"),
|
|
82
|
+
"event_type": ev.get("event_type"),
|
|
83
|
+
"on_date": day.isoformat(),
|
|
84
|
+
"label": ev.get("doc_text"),
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
from .db import list_all_deadline_states
|
|
88
|
+
|
|
89
|
+
for s in _latest_per_key(list_all_deadline_states(conn)):
|
|
90
|
+
if s.get("state") not in DEADLINE_STATES:
|
|
91
|
+
continue
|
|
92
|
+
day = _parse_date(s.get("due_at"))
|
|
93
|
+
if day is None:
|
|
94
|
+
if s.get("due_at"):
|
|
95
|
+
skipped.append({
|
|
96
|
+
"kind": "deadline",
|
|
97
|
+
"case_id": s.get("case_id"),
|
|
98
|
+
"ref": s.get("deadline_key"),
|
|
99
|
+
"reason": f"нечитаемая due_at: {s.get('due_at')!r}",
|
|
100
|
+
})
|
|
101
|
+
continue
|
|
102
|
+
if not (window_from <= day <= window_to):
|
|
103
|
+
continue
|
|
104
|
+
entries.append({
|
|
105
|
+
"kind": "deadline",
|
|
106
|
+
"case_id": s.get("case_id"),
|
|
107
|
+
"ref": s.get("deadline_key"),
|
|
108
|
+
"state": s.get("state"),
|
|
109
|
+
"on_date": day.isoformat(),
|
|
110
|
+
"label": s.get("deadline_key"),
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
entries.sort(key=lambda e: (e["on_date"], str(e["case_id"]), e["kind"], str(e["ref"])))
|
|
114
|
+
skipped.sort(key=lambda e: (str(e["case_id"]), str(e["ref"])))
|
|
115
|
+
return entries, skipped
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def pair_conflicts(entries: List[dict]) -> List[dict]:
|
|
119
|
+
"""Конфликты: одна дата, разные дела. Пары дел — без дублей и без повторов."""
|
|
120
|
+
by_day: Dict[str, List[dict]] = {}
|
|
121
|
+
for e in entries:
|
|
122
|
+
by_day.setdefault(e["on_date"], []).append(e)
|
|
123
|
+
conflicts: List[dict] = []
|
|
124
|
+
for day in sorted(by_day):
|
|
125
|
+
items = by_day[day]
|
|
126
|
+
pairs: Dict[tuple, dict] = {}
|
|
127
|
+
for i in range(len(items)):
|
|
128
|
+
for j in range(i + 1, len(items)):
|
|
129
|
+
a, b = items[i], items[j]
|
|
130
|
+
if a["case_id"] == b["case_id"]:
|
|
131
|
+
continue
|
|
132
|
+
case_a, case_b = sorted([str(a["case_id"]), str(b["case_id"])])
|
|
133
|
+
pk = (day, case_a, case_b)
|
|
134
|
+
rec = pairs.setdefault(pk, {
|
|
135
|
+
"date": day,
|
|
136
|
+
"case_a": case_a,
|
|
137
|
+
"case_b": case_b,
|
|
138
|
+
"entries": [],
|
|
139
|
+
})
|
|
140
|
+
for e in (a, b):
|
|
141
|
+
token = (e["kind"], str(e["case_id"]), str(e["ref"]))
|
|
142
|
+
if token not in [tuple(x.get("_k", [])) for x in rec["entries"]]:
|
|
143
|
+
rec["entries"].append({
|
|
144
|
+
"_k": list(token),
|
|
145
|
+
"case_id": e["case_id"],
|
|
146
|
+
"kind": e["kind"],
|
|
147
|
+
"ref": e["ref"],
|
|
148
|
+
"detail": e.get("event_type") or e.get("state"),
|
|
149
|
+
})
|
|
150
|
+
for rec in sorted(pairs.values(), key=lambda r: (r["date"], r["case_a"], r["case_b"])):
|
|
151
|
+
conflicts.append({
|
|
152
|
+
"date": rec["date"],
|
|
153
|
+
"case_a": rec["case_a"],
|
|
154
|
+
"case_b": rec["case_b"],
|
|
155
|
+
"kinds": sorted({e["kind"] for e in rec["entries"]}),
|
|
156
|
+
"entries": [
|
|
157
|
+
{k: v for k, v in e.items() if k != "_k"} for e in rec["entries"]
|
|
158
|
+
],
|
|
159
|
+
})
|
|
160
|
+
return conflicts
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def same_case_hints(entries: List[dict]) -> List[dict]:
|
|
164
|
+
"""Подсказки: тот же день, то же дело, срок + слушание (не конфликт)."""
|
|
165
|
+
by_day_case: Dict[tuple, List[dict]] = {}
|
|
166
|
+
for e in entries:
|
|
167
|
+
by_day_case.setdefault((e["on_date"], str(e["case_id"])), []).append(e)
|
|
168
|
+
hints = []
|
|
169
|
+
for (day, case_id) in sorted(by_day_case):
|
|
170
|
+
items = by_day_case[(day, case_id)]
|
|
171
|
+
kinds = {i["kind"] for i in items}
|
|
172
|
+
if "hearing" in kinds and "deadline" in kinds:
|
|
173
|
+
hints.append({
|
|
174
|
+
"date": day,
|
|
175
|
+
"case_id": case_id,
|
|
176
|
+
"hearings": [i["ref"] for i in items if i["kind"] == "hearing"],
|
|
177
|
+
"deadlines": [i["ref"] for i in items if i["kind"] == "deadline"],
|
|
178
|
+
"note": "срок и слушание одного дела в один день — это "
|
|
179
|
+
"подсказка, а не конфликт разных дел",
|
|
180
|
+
})
|
|
181
|
+
return hints
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def conflicts_payload(state_dir: Any, days: int = 30,
|
|
185
|
+
today: Optional[_date] = None) -> Dict[str, Any]:
|
|
186
|
+
from .db import get_connection, init_db
|
|
187
|
+
|
|
188
|
+
init_db(state_dir)
|
|
189
|
+
conn = get_connection(state_dir)
|
|
190
|
+
try:
|
|
191
|
+
today = today or _date.today()
|
|
192
|
+
horizon = today + timedelta(days=days)
|
|
193
|
+
entries, skipped = collect_entries(conn, today, horizon)
|
|
194
|
+
finally:
|
|
195
|
+
conn.close()
|
|
196
|
+
return {
|
|
197
|
+
"days": days,
|
|
198
|
+
"today": today.isoformat(),
|
|
199
|
+
"window_to": horizon.isoformat(),
|
|
200
|
+
"hearing_event_types": sorted(HEARING_EVENT_TYPES),
|
|
201
|
+
"entries": entries,
|
|
202
|
+
"conflicts": pair_conflicts(entries),
|
|
203
|
+
"same_case_hints": same_case_hints(entries),
|
|
204
|
+
"skipped": skipped,
|
|
205
|
+
"empty": not entries,
|
|
206
|
+
"disclaimer": CONFLICTS_DISCLAIMER,
|
|
207
|
+
}
|