sud-cli 0.1.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. sud_cli/__init__.py +3 -0
  2. sud_cli/__main__.py +4 -0
  3. sud_cli/_wm.py +118 -0
  4. sud_cli/agenda.py +90 -0
  5. sud_cli/autofill.py +192 -0
  6. sud_cli/autofill_markers.json +56 -0
  7. sud_cli/backup.py +256 -0
  8. sud_cli/calendar_ics.py +191 -0
  9. sud_cli/case_diff.py +165 -0
  10. sud_cli/case_search_bridge.py +1322 -0
  11. sud_cli/chain.py +226 -0
  12. sud_cli/cli.py +3721 -0
  13. sud_cli/completeness.py +105 -0
  14. sud_cli/config.py +20 -0
  15. sud_cli/conflict.py +210 -0
  16. sud_cli/conflicts.py +207 -0
  17. sud_cli/court_alerts.py +156 -0
  18. sud_cli/court_source.py +108 -0
  19. sud_cli/data/completeness_rules.json +18 -0
  20. sud_cli/data/key_rates.json +59 -0
  21. sud_cli/data/npa_historical_editions.json +19 -0
  22. sud_cli/db.py +2072 -0
  23. sud_cli/deadline_matrix.py +99 -0
  24. sud_cli/deadline_state.py +25 -0
  25. sud_cli/deadlines.py +8 -0
  26. sud_cli/deadlines_state.py +1 -0
  27. sud_cli/deferral.py +5 -0
  28. sud_cli/efrsb_manual.py +85 -0
  29. sud_cli/efrsb_public.py +210 -0
  30. sud_cli/egrul_nalog.py +150 -0
  31. sud_cli/explain.py +190 -0
  32. sud_cli/fees_extra.py +104 -0
  33. sud_cli/forms.py +204 -0
  34. sud_cli/fssp_manual.py +91 -0
  35. sud_cli/fssp_semi.py +285 -0
  36. sud_cli/fssp_stats.py +105 -0
  37. sud_cli/government_transfers.json +8 -0
  38. sud_cli/holidays_rf.json +62 -0
  39. sud_cli/idempotency.py +78 -0
  40. sud_cli/importing.py +575 -0
  41. sud_cli/interest.py +144 -0
  42. sud_cli/jurisdiction.py +177 -0
  43. sud_cli/jurisdiction_rules.json +265 -0
  44. sud_cli/legal_deadlines.py +278 -0
  45. sud_cli/legal_engine.py +558 -0
  46. sud_cli/legal_rules.json +1058 -0
  47. sud_cli/limitation.py +70 -0
  48. sud_cli/mcp_server.py +1594 -0
  49. sud_cli/metrics.py +142 -0
  50. sud_cli/npa_as_of.py +21 -0
  51. sud_cli/output.py +89 -0
  52. sud_cli/payment.py +24 -0
  53. sud_cli/pdn.py +259 -0
  54. sud_cli/plenum_corpus.py +134 -0
  55. sud_cli/plenums_seed.json +45 -0
  56. sud_cli/practice_status.py +136 -0
  57. sud_cli/pravo_publication.py +361 -0
  58. sud_cli/pretrial.py +151 -0
  59. sud_cli/production_calendar.py +233 -0
  60. sud_cli/providers.py +95 -0
  61. sud_cli/readiness.py +234 -0
  62. sud_cli/regional_holidays.py +37 -0
  63. sud_cli/reminders.py +92 -0
  64. sud_cli/reports.py +349 -0
  65. sud_cli/sud_netguard.py +943 -0
  66. sud_cli/timeline_audit.py +80 -0
  67. sud_cli/verify.py +201 -0
  68. sud_cli-0.1.1.dist-info/METADATA +119 -0
  69. sud_cli-0.1.1.dist-info/RECORD +74 -0
  70. sud_cli-0.1.1.dist-info/WHEEL +4 -0
  71. sud_cli-0.1.1.dist-info/entry_points.txt +2 -0
  72. sud_cli-0.1.1.dist-info/licenses/AUTHORS +5 -0
  73. sud_cli-0.1.1.dist-info/licenses/LICENSE.md +661 -0
  74. sud_cli-0.1.1.dist-info/licenses/NOTICE +7 -0
sud_cli/fssp_semi.py ADDED
@@ -0,0 +1,285 @@
1
+ """S-61 полу-автомат ФССП (решение проекта 25.10, юридическая проверка запрошена отдельно).
2
+
3
+ Полу-автомат: скрипт делает ВСЁ, кроме капчи — открывает страницу БДИП через
4
+ общий loopback-браузер (Camofox runtime), заполняет форму, нажимает «Найти».
5
+ Если сайт выставил капчу — делает скриншот, отдаёт его человеку; человек
6
+ сообщает код, `fssp assist --resume` вводит его и забирает результат.
7
+ Капчу решает человек, не сервис-Recognizer и не брутфорс.
8
+
9
+ Ограничения честности:
10
+ - селекторы эвристические (страница недоступна для калибровки из-за лимита
11
+ подключений ФССП к нашему exit IP); при нераспознанной странице — статус
12
+ page_changed со снимком, ручной контур (fssp check) остаётся запасным;
13
+ - rate-limit: не более одного start на сессию; 503/429/'лимит подключений'
14
+ -> breaker_trip(fssp.gov.ru) и честная ошибка limited;
15
+ - runtime обязан быть loopback (SSRF-политика, как в мосте case-search).
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import os
21
+ import re
22
+ import secrets
23
+ import time
24
+ from pathlib import Path
25
+ from typing import Optional
26
+ from urllib.parse import urlsplit
27
+
28
+ FSSP_URL = "https://fssp.gov.ru/iss/ip/"
29
+ DEFAULT_RUNTIME = "http://127.0.0.1:9377"
30
+ _LOOPBACK = {"127.0.0.1", "localhost", "::1"}
31
+ _TTL_S = 30 * 60
32
+
33
+
34
+ class FsspAssistError(Exception):
35
+ pass
36
+
37
+
38
+ def _runtime_url() -> str:
39
+ raw = os.environ.get("CSE_FSSP_RUNTIME", DEFAULT_RUNTIME).strip()
40
+ parts = urlsplit(raw)
41
+ if parts.scheme not in ("http", "https") or parts.hostname not in _LOOPBACK:
42
+ raise FsspAssistError("CSE_FSSP_RUNTIME must be a loopback http(s) URL")
43
+ return raw.rstrip("/")
44
+
45
+
46
+ def _api(method: str, path: str, *, body: Optional[dict] = None,
47
+ query: Optional[dict] = None, timeout: int = 60, binary: bool = False):
48
+ import urllib.error
49
+ import urllib.request
50
+
51
+ base = _runtime_url()
52
+ qs = ("?" + "&".join(f"{k}={v}" for k, v in (query or {}).items())) if query else ""
53
+ url = f"{base}{path}{qs}"
54
+ data = json.dumps(body).encode("utf-8") if body is not None else None
55
+ req = urllib.request.Request(url, data=data, method=method,
56
+ headers={"Content-Type": "application/json"})
57
+ try:
58
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
59
+ raw = resp.read(12_000_000)
60
+ except urllib.error.HTTPError as exc:
61
+ detail = exc.read(300).decode("utf-8", "replace") if binary is False else ""
62
+ raise FsspAssistError(f"runtime http {exc.code}: {detail[:160]}") from exc
63
+ except urllib.error.URLError as exc:
64
+ raise FsspAssistError(f"runtime unreachable: {str(exc.reason)[:80]}") from exc
65
+ if binary:
66
+ return raw
67
+ try:
68
+ return json.loads(raw.decode("utf-8", "replace") or "{}")
69
+ except json.JSONDecodeError:
70
+ return {"raw": raw.decode("utf-8", "replace")[:400]}
71
+
72
+
73
+ def _state_dir(state_dir: Path) -> Path:
74
+ d = Path(state_dir) / "fssp_assist"
75
+ d.mkdir(parents=True, exist_ok=True)
76
+ return d
77
+
78
+
79
+ def _save(sess: dict) -> None:
80
+ d = _state_dir(Path(sess["state_dir"]))
81
+ (d / f"{sess['session_id']}.json").write_text(
82
+ json.dumps(sess, ensure_ascii=False), encoding="utf-8")
83
+
84
+
85
+ def _load(session_id: str, state_dir: Path) -> dict:
86
+ p = _state_dir(state_dir) / f"{session_id}.json"
87
+ if not p.is_file():
88
+ raise FsspAssistError(f"session not found: {session_id}")
89
+ sess = json.loads(p.read_text(encoding="utf-8"))
90
+ if time.time() - sess.get("created_at", 0) > _TTL_S:
91
+ raise FsspAssistError("session expired (30 min); start fresh or use manual contour")
92
+ return sess
93
+
94
+
95
+ def _breaker_trip(url: str) -> None:
96
+ try:
97
+ from sud_cli.sud_netguard import breaker_trip
98
+ breaker_trip(url, reason="fssp_assist_limited")
99
+ except Exception:
100
+ pass
101
+
102
+
103
+ _LIMIT_RE = re.compile(r"превысили лимит|попробуйте позже", re.I)
104
+ _CAPTCHA_RE = re.compile(r"captcha|капч|смарт-?каптча|не робот", re.I)
105
+ _RESULT_WORDS = re.compile(r"найдено|результат|исполнительное производство|отсутству", re.I)
106
+
107
+
108
+ _FORM_MARK = re.compile(r"Наименование предприятия-должника|Фамилия", re.I)
109
+ _RESULT_MARK = re.compile(
110
+ r"исполнительн\w* производств\w* (?:не )?(?:найдено|отсутству)"
111
+ r"|найдено (?:исполнительных )?производств"
112
+ r"|^.*\bдолжник\b", re.I | re.M)
113
+ _CAPTCHA_MARK = re.compile(r"captcha|каптч|не робот|robot check|challenge|подтвердите,? что", re.I)
114
+
115
+
116
+ def _detect_page(text: str, *, pre: bool = False) -> str:
117
+ body = text or ""
118
+ if _LIMIT_RE.search(body):
119
+ return "limited"
120
+ if _CAPTCHA_MARK.search(body):
121
+ return "captcha"
122
+ if not pre and _RESULT_MARK.search(body):
123
+ return "result"
124
+ if _FORM_MARK.search(body):
125
+ return "form"
126
+ return "unknown"
127
+
128
+
129
+ def _close_tab(user_id: str, tab_id: str) -> None:
130
+ """Закрыть вкладку (пул не должен копить мёртвые сессии)."""
131
+ try:
132
+ _api("DELETE", f"/tabs/{tab_id}", query={"userId": user_id})
133
+ except Exception:
134
+ pass
135
+
136
+
137
+ def start(state_dir: Path, *, kind: str, name: Optional[str] = None,
138
+ inn: Optional[str] = None, birth_date: Optional[str] = None) -> dict:
139
+ """Открыть БДИП и заполнить форму; капчу — не решает."""
140
+ kind = (kind or "").strip().lower()
141
+ if kind not in ("fiz", "ur"):
142
+ raise FsspAssistError("kind must be fiz|ur")
143
+ if not (name or inn):
144
+ raise FsspAssistError("provide --name or --inn")
145
+ sid = secrets.token_hex(6)
146
+ user_id = f"sud-fssp-{sid}"
147
+ tab = _api("POST", "/tabs", body={"userId": user_id, "sessionKey": sid, "url": FSSP_URL})
148
+ tab_id = tab.get("tabId") or (tab.get("tab") or {}).get("tabId")
149
+ if not tab_id:
150
+ raise FsspAssistError(f"runtime did not return tabId: {str(tab)[:120]}")
151
+ _api("POST", f"/tabs/{tab_id}/wait", body={"userId": user_id, "timeout": 15000})
152
+ snap = _api("GET", f"/tabs/{tab_id}/snapshot", query={"userId": user_id}, timeout=60)
153
+ text = snap.get("snapshot") or snap.get("text") or snap.get("raw") or json.dumps(snap, ensure_ascii=False)
154
+ state = _detect_page(text, pre=True)
155
+ sess = {"session_id": sid, "tab_id": tab_id, "user_id": user_id,
156
+ "state_dir": str(state_dir), "kind": kind, "name": name, "inn": inn,
157
+ "birth_date": birth_date, "created_at": time.time(), "page_state": state}
158
+ if state == "limited":
159
+ _breaker_trip(FSSP_URL)
160
+ sess["status"] = "limited"
161
+ sess["hint"] = ("ФССП ограничила подключения с exit IP; хост уведён в cooldown. "
162
+ "Повторите позже или используйте ручной контур: fssp check.")
163
+ _close_tab(user_id, tab_id)
164
+ _save(sess)
165
+ return sess
166
+ if state in ("form", "unknown"):
167
+ try:
168
+ filled = _fill_form(user_id, tab_id, kind=kind, name=name, inn=inn,
169
+ birth_date=birth_date)
170
+ sess["fill"] = filled
171
+ if filled.get("submitted"):
172
+ _api("POST", f"/tabs/{tab_id}/wait", body={"userId": user_id, "timeout": 15000})
173
+ snap2 = _api("GET", f"/tabs/{tab_id}/snapshot", query={"userId": user_id})
174
+ t2 = snap2.get("snapshot") or snap2.get("text") or ""
175
+ state = _detect_page(t2, pre=False)
176
+ sess["page_state"] = state
177
+ sess["page_text"] = t2[:4000]
178
+ except FsspAssistError as exc:
179
+ sess["page_state"] = "page_changed"
180
+ sess["hint"] = f"автозаполнение не удалось: {exc}; используйте fssp check"
181
+ _close_tab(user_id, tab_id)
182
+ if sess["page_state"] == "captcha":
183
+ png = _state_dir(Path(state_dir)) / f"{sid}.png"
184
+ raw = _api("GET", f"/tabs/{tab_id}/screenshot", query={"userId": user_id, "fullPage": "true"},
185
+ timeout=60, binary=True)
186
+ png.write_bytes(raw if isinstance(raw, (bytes, bytearray)) else b'')
187
+ sess["status"] = "waiting_human_captcha"
188
+ sess["captcha_image"] = str(png)
189
+ sess["instruction"] = ("Решите капчу на скриншоте, пришлите код: "
190
+ f"sud-cli fssp assist --resume {sid} --code <КОД>")
191
+ elif sess["page_state"] == "result":
192
+ sess["status"] = "done"
193
+ sess["results_text"] = sess.get("page_text", "")
194
+ else:
195
+ sess["status"] = sess.get("page_state", "unknown")
196
+ _save(sess)
197
+ return sess
198
+
199
+
200
+ def _fill_form(user_id: str, tab_id: str, *, kind: str, name: Optional[str],
201
+ inn: Optional[str], birth_date: Optional[str]) -> dict:
202
+ """Заполнение формы БДИП по калиброванным подписям (a11y-снапшот 25.10)."""
203
+ script = r"""((kind, name, inn, bd) => {
204
+ const vis = el => el && el.offsetParent !== null;
205
+ const labOf = el => {
206
+ let n = el.previousElementSibling;
207
+ let txt = '';
208
+ while (n && !txt) { txt = (n.innerText || n.textContent || '').trim(); n = n.previousElementSibling; }
209
+ return txt + ' ' + (el.placeholder || '') + ' ' + (el.name || '') + ' ' + (el.id || '');
210
+ };
211
+ const setVal = (el, v) => {
212
+ const desc = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value');
213
+ if (desc && desc.set) desc.set.call(el, v); else el.value = v;
214
+ el.dispatchEvent(new Event('input', {bubbles: true}));
215
+ el.dispatchEvent(new Event('change', {bubbles: true}));
216
+ };
217
+ const inputs = [...document.querySelectorAll('input')].filter(e => vis(e) && (e.type === 'text' || !e.type));
218
+ const byLabel = re => inputs.find(e => re.test(labOf(e)));
219
+ const steps = [];
220
+ let done = 0;
221
+ const put = (re, val, tag) => {
222
+ if (!val) return;
223
+ const el = byLabel(re);
224
+ if (el) { setVal(el, val); steps.push(tag); done++; }
225
+ };
226
+ if (kind === 'fiz') {
227
+ const parts = (name || '').split(/\s+/);
228
+ put(/Фамилия/i, parts[0], 'last');
229
+ put(/Имя(?! и отчество)/i, parts[1], 'first');
230
+ put(/Отчество/i, parts[2], 'middle');
231
+ if (bd) put(/Дата рождения/i, bd, 'birth');
232
+ } else {
233
+ put(/Наименование предприятия-должника/i, name, 'ur-name');
234
+ put(/ИНН юридического лица/i, inn, 'ur-inn');
235
+ }
236
+ const btns = [...document.querySelectorAll('button, input[type=submit]')].filter(vis);
237
+ const btn = btns.find(b => /найт/i.test((b.innerText || b.value || '').trim()));
238
+ let submitted = false;
239
+ if (btn && done) { btn.click(); submitted = true; steps.push('submit'); }
240
+ return {steps, filled: done, submitted};
241
+ })"""
242
+ body = {"userId": user_id,
243
+ "expression": f"{script}({json.dumps(kind)}, {json.dumps(name)}, {json.dumps(inn)}, {json.dumps(birth_date)})",
244
+ "timeout": 15000}
245
+ out = _api("POST", f"/tabs/{tab_id}/evaluate", body=body)
246
+ res = out.get("result") if isinstance(out, dict) else None
247
+ if not isinstance(res, dict):
248
+ raise FsspAssistError(f"evaluate returned {str(out)[:120]}")
249
+ if not res.get("submitted"):
250
+ raise FsspAssistError(f"форма не распознана (заполнено полей: {res.get('filled')}); используйте fssp check")
251
+ return res
252
+
253
+
254
+ def resume(state_dir: Path, session_id: str, code: str) -> dict:
255
+ """Ввести человеческий ответ капчи и забрать результат."""
256
+ sess = _load(session_id, state_dir)
257
+ if not code or not str(code).strip():
258
+ raise FsspAssistError("code required (what the human read from the captcha)")
259
+ user_id, tab_id = sess["user_id"], sess["tab_id"]
260
+ _api("POST", f"/tabs/{tab_id}/type", body={
261
+ "userId": user_id,
262
+ "selector": "input[name*='captch' i], input[id*='captch' i], input[name*='capcha' i], textarea[name*='captch' i]",
263
+ "text": str(code).strip(),
264
+ })
265
+ _api("POST", f"/tabs/{tab_id}/click", body={
266
+ "userId": user_id, "selector": "button, input[type=submit]"})
267
+ _api("POST", f"/tabs/{tab_id}/wait", body={"userId": user_id, "timeout": 20000})
268
+ snap = _api("GET", f"/tabs/{tab_id}/snapshot", query={"userId": user_id})
269
+ text = snap.get("text") or snap.get("snapshot") or json.dumps(snap, ensure_ascii=False)
270
+ state = _detect_page(text)
271
+ sess["page_state"] = state
272
+ sess["resumed_at"] = time.time()
273
+ if state == "result":
274
+ sess["status"] = "done"
275
+ sess["results_text"] = text[:4000]
276
+ sess["verdict"] = ("none" if re.search(r"не найдено|отсутству", text, re.I) else "found")
277
+ _close_tab(user_id, tab_id)
278
+ elif state == "limited":
279
+ _breaker_trip(FSSP_URL)
280
+ sess["status"] = "limited"
281
+ _close_tab(user_id, tab_id)
282
+ else:
283
+ sess["status"] = f"need_retry ({state})"
284
+ _save(sess)
285
+ return sess
sud_cli/fssp_stats.py ADDED
@@ -0,0 +1,105 @@
1
+ """S-61 (В) официальная статистика БДИП ФССП — агрегатный справочник.
2
+
3
+ Акт legal 2026.10.25: общедоступный CSV статистики исполнительных производств
4
+ (fssp.gov.ru/api/is/statistics/csv) годен как вспомогательный источник по
5
+ ст. 7 8-ФЗ (достоверность оперативной информации). Для индивидуальных досье
6
+ статистика проверку НЕ заменяет — только контекст/справки.
7
+
8
+ Сетевой слой: тот же netguard-путь, что и остальные live-адаптеры
9
+ (fail-closed egress, DNS-pin, no-redirect, size cap, per-host breaker на
10
+ 401/403/429/5xx-лимиты — ФССП ограничивает частые подключения, это штатно
11
+ уводится в cooldown, не брутфорсится).
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import csv
16
+ import io
17
+ from typing import Optional
18
+
19
+ STATS_URL = "https://fssp.gov.ru/api/is/statistics/csv"
20
+
21
+ _UA = ("Mozilla/5.0 (X11; Linux x86_64) Gecko/20100101 Firefox/126.0")
22
+
23
+
24
+ class FsspStatsError(Exception):
25
+ """Без PII: статистика не содержит персональные данные по построению."""
26
+
27
+
28
+ def _http_get(url: str, *, timeout: int = 30) -> bytes:
29
+ from sud_cli.sud_netguard import (
30
+ BreakerOpenError,
31
+ breaker_trip,
32
+ check_before_fetch,
33
+ )
34
+
35
+ try:
36
+ check_before_fetch(url)
37
+ except BreakerOpenError as exc:
38
+ raise FsspStatsError(f"host in cooldown (anti-ban breaker): {exc}") from exc
39
+
40
+ import urllib.error as _ue
41
+ import urllib.request as _ur
42
+
43
+ req = _ur.Request(url, headers={"User-Agent": _UA, "Accept": "text/csv,*/*"})
44
+
45
+ class _NoRedirect(_ur.HTTPRedirectHandler):
46
+ def redirect_request(self, *a, **k):
47
+ return None
48
+
49
+ try:
50
+ with _ur.build_opener(_NoRedirect()).open(req, timeout=timeout) as resp:
51
+ raw = resp.read(8_000_000)
52
+ except _ue.HTTPError as exc:
53
+ if exc.code in (301, 302, 303, 307, 308, 401, 403, 429) or exc.code >= 500:
54
+ breaker_trip(url, reason=f"fssp_stats_http_{exc.code}")
55
+ raise FsspStatsError(f"fssp stats http {exc.code}") from exc
56
+ except _ue.URLError as exc:
57
+ raise FsspStatsError("fssp stats unreachable: " + str(exc.reason)[:80]) from exc
58
+ return raw
59
+
60
+
61
+ def _parse(raw: bytes) -> list[dict]:
62
+ text = raw.decode("utf-8-sig", errors="replace")
63
+ rows: list[dict] = []
64
+ try:
65
+ reader = csv.DictReader(io.StringIO(text), delimiter=";", quotechar='"')
66
+ except Exception as exc: # noqa: BLE001
67
+ raise FsspStatsError("fssp stats: unparseable csv") from exc
68
+ for row in reader:
69
+ rid = (row.get("id") or "").strip()
70
+ title = (row.get("title") or "").strip()
71
+ count = (row.get("count") or "").strip()
72
+ if not rid or not title:
73
+ continue
74
+ try:
75
+ n = int(count)
76
+ except ValueError:
77
+ continue
78
+ rows.append({"region_id": rid, "region": title, "active_executions": n})
79
+ if not rows:
80
+ raise FsspStatsError("fssp stats: unexpected payload (no rows)")
81
+ return rows
82
+
83
+
84
+ def statistics(region: Optional[str] = None, *, timeout: int = 30) -> dict:
85
+ """Общедоступная статистика БДИП: число активных ИП по подразделениям/регионам.
86
+
87
+ region — опциональный фильтр по id региона (префикс) или названию (подстрока).
88
+ """
89
+ raw = _http_get(STATS_URL, timeout=timeout)
90
+ rows = _parse(raw)
91
+ total = sum(r["active_executions"] for r in rows if r["region_id"] == "00")
92
+ if region:
93
+ needle = region.strip().lower()
94
+ rows = [r for r in rows
95
+ if r["region_id"] == needle or needle in r["region"].lower()]
96
+ return {
97
+ "source": STATS_URL,
98
+ "provider_label": "fssp.gov.ru (официальная статистика БДИП, ст. 7 8-ФЗ — оперативные данные)",
99
+ "region_filter": region,
100
+ "count": len(rows),
101
+ "rows": rows,
102
+ "note": ("Агрегатная статистика; индивидуальные проверки — ручной контур "
103
+ "fssp check/record (юридическая проверка 25.10)."),
104
+ "center_total_hint": total,
105
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "source": "sud_cli/holidays_rf.json",
3
+ "verified_through": 2027,
4
+ "years": {
5
+ "2026": {"decree": "Постановление Правительства РФ от 24.09.2025 № 1466"},
6
+ "2027": {"decree": "Постановление Правительства РФ от 17.09.2026 № 1187"}
7
+ }
8
+ }
@@ -0,0 +1,62 @@
1
+ {
2
+ "_note": "Production calendar for the Russian Federation, federal level only.",
3
+ "_source": "ст. 112 ТК РФ (fixed federal non-working holidays) + Government transfer decrees",
4
+ "_scope": "MVP per colleague-legal addendum 1.1.0 п.Б2. Regional holidays are NOT included.",
5
+ "_transfer_provenance": "Decrees were verified against the official portal scan, not transcribed from memory. Each entry records its own decree; a year without an entry keeps the CALENDAR_WARNING.",
6
+ "federal_holidays": {
7
+ "2024": ["01-01", "01-02", "01-03", "01-04", "01-05", "01-06", "01-07", "01-08",
8
+ "02-23", "03-08", "05-01", "05-09", "06-12", "11-04"],
9
+ "2025": ["01-01", "01-02", "01-03", "01-04", "01-05", "01-06", "01-07", "01-08",
10
+ "02-23", "03-08", "05-01", "05-09", "06-12", "11-04"],
11
+ "2026": ["01-01", "01-02", "01-03", "01-04", "01-05", "01-06", "01-07", "01-08",
12
+ "02-23", "03-08", "05-01", "05-09", "06-12", "11-04"],
13
+ "2027": ["01-01", "01-02", "01-03", "01-04", "01-05", "01-06", "01-07", "01-08",
14
+ "02-23", "03-08", "05-01", "05-09", "06-12", "11-04"]
15
+ },
16
+ "_transfer_note": "Days moved in by a Government decree (a weekday declared a day off) or out of a holiday. Both directions matter: a day moved OUT becomes an ordinary working day, and one moved IN becomes non-working.",
17
+ "transfers": {
18
+ "2026": {
19
+ "decree": "Постановление Правительства РФ от 24.09.2025 № 1466 «О переносе выходных дней в 2026 году»",
20
+ "decree_number": 1466,
21
+ "decree_date": "2025-09-24",
22
+ "published": "официальный интернет-портал правовой информации, 24.09.2025, N 0001202509240023; СЗ РФ, 29.09.2025, N 39, ст. 5738",
23
+ "url": "http://publication.pravo.gov.ru/document/0001202509240023",
24
+ "amendments_found": false,
25
+ "extra_days_off": ["01-09", "12-31"],
26
+ "extra_days_off_reason": "с субботы 3 января на пятницу 9 января; с воскресенья 4 января на четверг 31 декабря (второй абзац ч. 1 ст. 112 ТК РФ — новогодние переносы делает только Правительство)",
27
+ "days_restored_to_working": []
28
+ },
29
+ "2027": {
30
+ "decree": "Постановление Правительства РФ от 17.09.2026 № 1187 «О переносе выходных дней в 2027 году»",
31
+ "decree_number": 1187,
32
+ "decree_date": "2026-09-17",
33
+ "published": "официальный интернет-портал правовой информации, 18.09.2026, N 0001202609180037",
34
+ "url": "http://publication.pravo.gov.ru/document/0001202609180037",
35
+ "amendments_found": false,
36
+ "extra_days_off": ["02-22", "11-05", "12-31"],
37
+ "extra_days_off_reason": "с субботы 20 февраля на понедельник 22 февраля (выходной переносится на рабочий понедельник, поскольку 23 февраля — праздник); с субботы 2 января на пятницу 5 ноября; с воскресенья 3 января на пятницу 31 декабря. Первые два новогодних переноса делает только Правительство (абз. 2 ч. 1 ст. 112 ТК РФ), первый — из общего правила ч. 2 ст. 112.",
38
+ "days_restored_to_working": ["02-20"],
39
+ "days_restored_to_working_reason": "с субботы 20 февраля на понедельник 22 февраля. Перенос двусторонний: 20 февраля перестаёт быть выходным (иначе появляется лишний нерабочий день), 22 февраля им становится. Без этой строки февраль 2027 даёт 18 рабочих дней вместо 19 по опубликованному календарю.",
40
+ "verification": "Прочитан со скана официального портала (publication.pravo.gov.ru/file/pdf?eoNumber=0001202609180037), 1 страница. Дни недели в тексте постановления сверены: 02.01.2027 = суббота, 03.01.2027 = воскресенье, 20.02.2027 = суббота, 22.02.2027 = понедельник. Контрольный счёт: 247 рабочих / 118 выходных и праздничных за 2027 год — совпадает с опубликованным производственным календарём."
41
+ }
42
+ },
43
+ "_auto_transfer_note": "ч. 2 ст. 112 ТК РФ: a holiday landing on a weekend moves to the next working day automatically, EXCEPT the holidays named in абз. 2 and 3 of the same article (1–8 января, 23 февраля) — for those only the Government may move the day off. The automatic moves below are therefore derived from ст. 112, not from the decree.",
44
+ "auto_transfers_st112": {
45
+ "2026": [
46
+ {"holiday": "03-08", "weekday": "воскресенье", "moved_to": "03-09"},
47
+ {"holiday": "05-09", "weekday": "суббота", "moved_to": "05-11"}
48
+ ],
49
+ "2027": [
50
+ {"holiday": "05-01", "weekday": "суббота", "moved_to": "05-03"},
51
+ {"holiday": "05-09", "weekday": "воскресенье", "moved_to": "05-10"},
52
+ {"holiday": "06-12", "weekday": "суббота", "moved_to": "06-14"}
53
+ ]
54
+ },
55
+ "transfer_decrees_encoded": true,
56
+ "years_with_verified_transfers": [2026, 2027],
57
+ "verified_through": 2027,
58
+ "known_years": [2024, 2025, 2026, 2027],
59
+ "warning_template": "CALENDAR_WARNING: расчёт произведён по базовым выходным (сб/вс), ст. 112 ТК РФ и автопереносам, но перенос Правительства РФ за {year} год НЕ зашит (постановление не подтверждено). Фактические нерабочие дни могут отличаться.",
60
+ "no_warning_template": "Переносы Правительства РФ за {year} год учтены ({decree}). Переносы, введённые позже этой даты, не учтены.",
61
+ "regional_note": "Региональные нерабочие праздничные дни субъектов РФ не учитываются."
62
+ }
sud_cli/idempotency.py ADDED
@@ -0,0 +1,78 @@
1
+ """W6a §1 (S-80): реестр идемпотентности write-инструментов MCP.
2
+
3
+ Повтор write-вызова с тем же ``idempotency_key`` в окне 10 минут обязан
4
+ вернуть ПЕРВЫЙ результат, а не устроить вторую запись. Реестр живёт в
5
+ state-dir (``idempotency.json``, права 0600 — в значении могут быть реквизиты
6
+ дела) и чистится по тому же окну: запись старше 600 с больше ничего не
7
+ подавляет.
8
+
9
+ Границы: модуль не знает про сеть и про MCP-транспорт — только чтение/запись
10
+ одного файла. Повреждённый реестр трактуется как пустой (fail-open для
11
+ легальных записей): потерянное подавление повтора видимо по audit-журналу, а
12
+ заблокировать законный вызов молча — худший из исходов.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import os
18
+ import time
19
+ from pathlib import Path
20
+
21
+ WINDOW_SECONDS = 600 # окно подавления повторов: 10 минут = первый результат
22
+ REGISTRY_NAME = "idempotency.json"
23
+
24
+
25
+ def registry_path(state_dir) -> Path:
26
+ return Path(state_dir) / REGISTRY_NAME
27
+
28
+
29
+ def _load(state_dir) -> dict:
30
+ path = registry_path(state_dir)
31
+ try:
32
+ data = json.loads(path.read_text(encoding="utf-8"))
33
+ except (OSError, ValueError):
34
+ return {}
35
+ return data if isinstance(data, dict) else {}
36
+
37
+
38
+ def _entry_id(tool: str, key: str) -> str:
39
+ return f"{tool}|{key}"
40
+
41
+
42
+ def lookup(state_dir, tool: str, key: str, now: float | None = None):
43
+ """Первый результат по (tool, key), если он ещё в окне; иначе None."""
44
+ now = time.time() if now is None else now
45
+ entry = _load(state_dir).get(_entry_id(tool, key))
46
+ if not isinstance(entry, dict):
47
+ return None
48
+ try:
49
+ ts = float(entry.get("ts", 0))
50
+ except (TypeError, ValueError):
51
+ return None
52
+ if now - ts > WINDOW_SECONDS:
53
+ return None
54
+ result = entry.get("result")
55
+ return result if isinstance(result, dict) else None
56
+
57
+
58
+ def record(state_dir, tool: str, key: str, result: dict, now: float | None = None) -> None:
59
+ """Сохранить первый результат; записи вне окна вычищаются заодно."""
60
+ now = time.time() if now is None else now
61
+ state_dir = Path(state_dir)
62
+ state_dir.mkdir(parents=True, exist_ok=True)
63
+ data = _load(state_dir)
64
+ data = {
65
+ k: v for k, v in data.items()
66
+ if isinstance(v, dict) and (now - float(v.get("ts") or 0)) <= WINDOW_SECONDS
67
+ }
68
+ data[_entry_id(tool, key)] = {"ts": now, "result": result}
69
+ path = registry_path(state_dir)
70
+ tmp = path.with_name(path.name + ".tmp")
71
+ fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
72
+ try:
73
+ with os.fdopen(fd, "w", encoding="utf-8") as fh:
74
+ json.dump(data, fh, ensure_ascii=False, sort_keys=True)
75
+ os.replace(tmp, path)
76
+ finally:
77
+ tmp.unlink(missing_ok=True)
78
+ os.chmod(path, 0o600)