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/cli.py ADDED
@@ -0,0 +1,3721 @@
1
+ """Command-line interface for sud-cli."""
2
+
3
+ import argparse
4
+ import contextlib
5
+ import io
6
+ import sys
7
+ from decimal import Decimal, InvalidOperation
8
+ from pathlib import Path
9
+
10
+ from sud_cli import __version__
11
+ from sud_cli import _wm
12
+ from sud_cli.config import get_state_dir
13
+ from sud_cli.case_search_bridge import run_bridge_command
14
+ from sud_cli.forms import FORM_KINDS
15
+ from sud_cli.output import blocked, error, result
16
+ from sud_cli.providers import list_providers
17
+
18
+
19
+ def claim_amount_arg(raw: str) -> Decimal:
20
+ """argparse type for ``--claim-amount``: рубли, копейки допустимы (v2.3.1).
21
+
22
+ Цена иска — денежная величина, а не счётный объект: п. 1 ст. 333.19,
23
+ ст. 333.21 НК РФ, ст. 91 ГПК РФ, ст. 103 АПК РФ определяют её точной
24
+ суммой с копейками, и округление по п. 6 ст. 52 НК РФ применяется только
25
+ к итоговой пошлине. ``int`` здесь терял копейки на входе, а ``float``
26
+ вносил двоичную погрешность в деньги — поэтому ``Decimal(str)``.
27
+
28
+ Zero and negative values are deliberately not rejected here: the rules
29
+ engine already answers them with a typed status, and two different refusal
30
+ paths for one parameter would only invite them to drift apart.
31
+ """
32
+ text = raw.strip()
33
+ try:
34
+ value = Decimal(text)
35
+ except InvalidOperation:
36
+ raise argparse.ArgumentTypeError(
37
+ f"invalid claim amount {raw!r}: ожидаются рубли, опционально с копейками "
38
+ "(например 3697453 или 3697453.16)"
39
+ ) from None
40
+ if not value.is_finite():
41
+ # Decimal('NaN')/Decimal('Infinity') are valid Decimals and would reach
42
+ # the tariff comparisons as nonsense.
43
+ raise argparse.ArgumentTypeError(
44
+ f"invalid claim amount {raw!r}: цена иска должна быть конечным числом"
45
+ )
46
+ return value
47
+
48
+
49
+ def build_parser() -> argparse.ArgumentParser:
50
+ parser = argparse.ArgumentParser(
51
+ prog="sud-cli",
52
+ description="Command-line legal workbench with an integrated case-research engine.",
53
+ )
54
+ parser.add_argument(
55
+ "--version", action="version", version=f"%(prog)s {__version__}"
56
+ )
57
+ parser.add_argument(
58
+ "--state-dir",
59
+ dest="state_dir",
60
+ type=Path,
61
+ default=None,
62
+ help="Application state directory (default: ~/.local/share/sud-cli).",
63
+ )
64
+ parser.add_argument(
65
+ "--json",
66
+ dest="json_mode",
67
+ action="store_true",
68
+ help="Emit machine-readable JSON output on stdout.",
69
+ )
70
+ parser.add_argument(
71
+ "--explain",
72
+ dest="explain",
73
+ action="store_true",
74
+ help=("Добавить в конверт расчётных команд блок explain: нормы, данные, "
75
+ "формулы, ручные причины (fees, deadlines, limitation, interest, "
76
+ "pretrial, jurisdiction, verify)."),
77
+ )
78
+
79
+ subparsers = parser.add_subparsers(dest="command", help="Available commands")
80
+
81
+ # providers
82
+ p_providers = subparsers.add_parser(
83
+ "providers", help="List available/offline/live source providers."
84
+ )
85
+ p_providers.set_defaults(func=cmd_providers)
86
+
87
+ # doctor
88
+ p_doctor = subparsers.add_parser(
89
+ "doctor", help="Check sud-cli's own local state and database."
90
+ )
91
+ p_doctor.set_defaults(func=cmd_doctor)
92
+
93
+ p_calendar = subparsers.add_parser("calendar", help="Проверка производственного календаря.")
94
+ calendar_sub = p_calendar.add_subparsers(dest="calendar_command", required=True)
95
+ p_calendar_check = calendar_sub.add_parser("check", help="Проверить манифест переносов.")
96
+ p_calendar_check.set_defaults(func=cmd_calendar_check)
97
+ p_calendar_agenda = calendar_sub.add_parser("agenda", help="Единый календарь сроков и слушаний.")
98
+ p_calendar_agenda.add_argument("--days", type=int, default=14)
99
+ p_calendar_agenda.set_defaults(func=cmd_calendar_agenda)
100
+ # W6a §3 (S-28): RFC 5545 экспорт computed/confirmed сроков.
101
+ p_calendar_ics = calendar_sub.add_parser("ics", help="Экспорт сроков в ICS (RFC 5545, VCALENDAR).")
102
+ p_calendar_ics.add_argument("--days", type=int, default=60, help="Горизонт окна, дней (1..3650).")
103
+ p_calendar_ics.add_argument("--out", type=Path, default=None, dest="out",
104
+ help="Записать календарь в файл (UTF-8, CRLF).")
105
+ p_calendar_ics.set_defaults(func=cmd_calendar_ics)
106
+
107
+ # the wave §3 (S-27): конфликты расписания (слушания фикс. набора × сроки).
108
+ p_calendar_conf = calendar_sub.add_parser(
109
+ "conflicts", help="Пересечения слушаний и сроков по разным делам (справочно).")
110
+ p_calendar_conf.add_argument("--days", type=int, default=30,
111
+ help="Окно вперёд от сегодня, дней (1..3650).")
112
+ p_calendar_conf.set_defaults(func=cmd_calendar_conflicts)
113
+
114
+ # the wave §4 (S-84): отчёты портфеля и дела — только счётчики из таблиц.
115
+ p_report = subparsers.add_parser("report", help="Отчёты портфеля и дела (the wave).")
116
+ report_sub = p_report.add_subparsers(dest="report_command", required=True)
117
+ p_report_portfolio = report_sub.add_parser("portfolio", help="Сводка по всем делам.")
118
+ p_report_portfolio.add_argument("--as-of", "--as-of-date", dest="as_of", default=None,
119
+ help="Дата отсчёта YYYY-MM-DD (по умолчанию — сегодня).")
120
+ p_report_portfolio.add_argument("--days", type=int, default=30,
121
+ help="Горизонт near, дней (1..3650).")
122
+ p_report_portfolio.set_defaults(func=cmd_report_portfolio)
123
+ p_report_case = report_sub.add_parser("case", help="Печатная развёртка дела.")
124
+ p_report_case.add_argument("case_id")
125
+ p_report_case.set_defaults(func=cmd_report_case)
126
+
127
+ # история §5 (S-85): backup/verify/restore snapshot'а store.db (sqlite stdlib).
128
+ p_backup = subparsers.add_parser("backup", help="Снимки базы (S-85).")
129
+ backup_sub = p_backup.add_subparsers(dest="backup_command", required=True)
130
+ p_backup_create = backup_sub.add_parser("create", help="Снять snapshot store.db.")
131
+ p_backup_create.add_argument("--out", type=Path, default=None,
132
+ help="Куда положить снимок (по умолчанию — state-dir/backups).")
133
+ p_backup_create.set_defaults(func=cmd_backup_create)
134
+ p_backup_verify = backup_sub.add_parser("verify", help="integrity_check + quick_check + таблицы.")
135
+ p_backup_verify.add_argument("file", type=Path)
136
+ p_backup_verify.set_defaults(func=cmd_backup_verify)
137
+ p_backup_restore = backup_sub.add_parser("restore", help="Перезаписать store.db из снимка.")
138
+ p_backup_restore.add_argument("file", type=Path)
139
+ p_backup_restore.add_argument("--yes", action="store_true", default=False,
140
+ help="Подтверждение деструктивного действия.")
141
+ p_backup_restore.set_defaults(func=cmd_backup_restore)
142
+
143
+ # история §5 (S-86): метрики state-dir (секция есть и в doctor).
144
+ p_metrics = subparsers.add_parser("metrics", help="Счётчики state-dir без выдуманных KPI.")
145
+ p_metrics.set_defaults(func=cmd_metrics)
146
+
147
+ # ready — agent-facing gate for everything that touches a court host
148
+ p_ready = subparsers.add_parser(
149
+ "ready",
150
+ help=(
151
+ "Report whether live court commands can run (env, binary, license, "
152
+ "Camofox runtime, rules version) and print the calling conventions. "
153
+ "Exits non-zero when something is missing. Contacts no court host."
154
+ ),
155
+ )
156
+ p_ready.set_defaults(func=cmd_ready)
157
+
158
+ # import
159
+ p_import = subparsers.add_parser(
160
+ "import", help="Import a local JSON fixture into the offline store."
161
+ )
162
+ p_import.add_argument("kind", choices=["court", "npa"], help="Record kind to import.")
163
+ p_import.add_argument("file", type=Path, help="Path to a local JSON file.")
164
+ p_import.set_defaults(func=cmd_import)
165
+
166
+ # search
167
+ p_search = subparsers.add_parser(
168
+ "search", help="Search imported offline records."
169
+ )
170
+ p_search.add_argument("kind", choices=["court", "npa"], help="Record kind to search.")
171
+ p_search.add_argument("query", nargs="?", default=None, help="Free-text query.")
172
+ p_search.add_argument("--limit", type=int, default=20, help="Maximum results (default: 20).")
173
+ p_search.add_argument("--since", type=str, default=None, help="Start date (YYYY-MM-DD).")
174
+ p_search.add_argument("--until", type=str, default=None, help="End date (YYYY-MM-DD).")
175
+ p_search.set_defaults(func=cmd_search)
176
+
177
+ # npa
178
+ p_npa = subparsers.add_parser(
179
+ "npa", help="NPA fixture search and edition comparison."
180
+ )
181
+ npa_sub = p_npa.add_subparsers(dest="npa_command", required=True)
182
+ npa_search = npa_sub.add_parser("search", help="Search NPA fixtures.")
183
+ npa_search.add_argument("query", nargs="?", default=None)
184
+ npa_search.add_argument("--limit", type=int, default=20)
185
+ npa_search.set_defaults(func=cmd_npa_search)
186
+ npa_compare = npa_sub.add_parser("compare", help="Compare two NPA fixture editions.")
187
+ npa_compare.add_argument("--id", dest="doc_id", required=True)
188
+ npa_compare.add_argument("--edition1", required=True)
189
+ npa_compare.add_argument("--edition2", required=True)
190
+ npa_compare.set_defaults(func=cmd_npa_compare)
191
+
192
+ # law — official publication metadata search
193
+ p_law = subparsers.add_parser(
194
+ "law", help="Official legal publication metadata search."
195
+ )
196
+ law_sub = p_law.add_subparsers(dest="law_command", required=True)
197
+ p_law_search = law_sub.add_parser(
198
+ "search", help="Search official publication metadata (one HTTPS GET)."
199
+ )
200
+ p_law_search.add_argument(
201
+ "query", nargs="?", default=None, help="Free-text metadata query."
202
+ )
203
+ p_law_search.add_argument(
204
+ "--number", type=str, default=None, help="Exact document number."
205
+ )
206
+ p_law_search.set_defaults(func=cmd_law_search)
207
+
208
+ # court — native offline case/event timeline
209
+ p_court = subparsers.add_parser(
210
+ "court", help="Native offline court case import and timeline."
211
+ )
212
+ court_sub = p_court.add_subparsers(dest="court_command", required=True)
213
+
214
+ p_court_import = court_sub.add_parser(
215
+ "import", help="Import a local synthetic court case/event fixture."
216
+ )
217
+ p_court_import.add_argument("file", type=Path, help="Path to a local JSON file.")
218
+ p_court_import.set_defaults(func=cmd_court_import)
219
+
220
+ p_court_search = court_sub.add_parser(
221
+ "search", help="Search imported native court cases and events."
222
+ )
223
+ p_court_search.add_argument("query", nargs="?", default=None, help="Free-text query.")
224
+ p_court_search.add_argument(
225
+ "--limit", type=int, default=20, help="Maximum results (default: 20)."
226
+ )
227
+ p_court_search.add_argument("--since", type=str, default=None, help="Start date (YYYY-MM-DD).")
228
+ p_court_search.add_argument("--until", type=str, default=None, help="End date (YYYY-MM-DD).")
229
+ p_court_search.set_defaults(func=cmd_court_search)
230
+
231
+ p_court_case = court_sub.add_parser(
232
+ "case", help="Show a normalized case summary and event timeline."
233
+ )
234
+ p_court_case.add_argument("case_id", help="Synthetic case identifier.")
235
+ p_court_case.set_defaults(func=cmd_court_case)
236
+
237
+ # case-search — safe transitional compatibility bridge
238
+ p_cw = subparsers.add_parser(
239
+ "case-search",
240
+ help="Integrated case research engine: import, lookup, case history, live sources.",
241
+ )
242
+ cw_sub = p_cw.add_subparsers(dest="cw_command", required=True)
243
+
244
+ p_cw_providers = cw_sub.add_parser(
245
+ "providers", help="Bridge to case-search provider status."
246
+ )
247
+ p_cw_providers.set_defaults(func=cmd_case_search_providers)
248
+
249
+ for cw_cmd in ("import", "sudrf-import", "kad-import", "vsrf-import", "auto-import"):
250
+ p_cw_import = cw_sub.add_parser(
251
+ cw_cmd,
252
+ help=f"Bridge to case-search {cw_cmd} (saved file -> JSON).",
253
+ )
254
+ p_cw_import.add_argument("file", type=Path, help="Path to a saved file under the sud-cli state directory.")
255
+ p_cw_import.add_argument("--case", required=True, dest="case_id", help="Synthetic case identifier.")
256
+ p_cw_import.set_defaults(func=cmd_case_search_import)
257
+
258
+ p_cw_extract = cw_sub.add_parser(
259
+ "extract-text", help="Bridge to case-search extract-text (saved file -> text)."
260
+ )
261
+ p_cw_extract.add_argument("file", type=Path, help="Path to a saved file under the sud-cli state directory.")
262
+ p_cw_extract.set_defaults(func=cmd_case_search_extract)
263
+
264
+ p_cw_parity = cw_sub.add_parser(
265
+ "parity", help="Show sud-cli vs. case-search capability parity matrix."
266
+ )
267
+ p_cw_parity.set_defaults(func=cmd_case_search_parity)
268
+
269
+ p_cw_diff = cw_sub.add_parser(
270
+ "case-diff",
271
+ help="Compare two saved case snapshots (offline, no court contact).",
272
+ )
273
+ p_cw_diff.add_argument("old", type=Path, help="Earlier snapshot JSON.")
274
+ p_cw_diff.add_argument("new", type=Path, help="Later snapshot JSON.")
275
+ p_cw_diff.set_defaults(func=cmd_case_diff)
276
+
277
+ # W6a §4 (S-60): локальные алерты -> bus-free a2a-строки (шина не вызывается).
278
+ p_cw_alerts = cw_sub.add_parser(
279
+ "alerts",
280
+ help="Локальные алерты case-search: готовые a2a-строки (bus-free, сеть не трогается).",
281
+ )
282
+ p_cw_alerts.add_argument("--days", type=int, default=7, help="Окно по created_at, дней (1..90)")
283
+ p_cw_alerts.add_argument("--alerts-dir", dest="alerts_dir", type=Path, default=None,
284
+ help="Каталог alert-артефактов (по умолчанию — локальные пути).")
285
+ p_cw_alerts.set_defaults(func=cmd_case_search_alerts)
286
+
287
+ # Live flag-driven commands (Phase 8). These reach external court sources
288
+ # and stay gated behind SUD_CLI_BRIDGE_LIVE inside the bridge.
289
+ p_cw_search = cw_sub.add_parser(
290
+ "search-practice",
291
+ help="Live sudact practice search (needs SUD_CLI_BRIDGE_LIVE=1).",
292
+ )
293
+ p_cw_search.add_argument("query", help="Search phrase.")
294
+ p_cw_search.add_argument("--section", choices=["arbitral", "regular", "magistrate", "vsrf"], default=None)
295
+ p_cw_search.add_argument("--page", default=None, help="Result page (1..1000).")
296
+ p_cw_search.add_argument("--limit", default=None, help="Max results (1..30).")
297
+ p_cw_search.add_argument("--via-camofox", action="store_true", dest="via_camofox",
298
+ help="Use the external Camofox runtime instead of the embedded browser.")
299
+ p_cw_search.add_argument("--no-camofox", action="store_true", dest="no_camofox",
300
+ help="Force the embedded browser even when Camofox is running.")
301
+ p_cw_search.set_defaults(func=cmd_case_search_search_practice, via_camofox=True,
302
+ no_camofox=False)
303
+
304
+ p_cw_kad = cw_sub.add_parser(
305
+ "kad-search",
306
+ help="Live KAD participant search (needs SUD_CLI_BRIDGE_LIVE=1).",
307
+ )
308
+ p_cw_kad.add_argument("--inn", default=None, help="Participant INN (10 or 12 digits).")
309
+ p_cw_kad.add_argument("--ogrn", default=None, help="Participant OGRN/OGRNIP (13 or 15 digits).")
310
+ p_cw_kad.add_argument("--name", default=None, help="Participant name.")
311
+ p_cw_kad.add_argument("--page", default=None, help="Result page (1..1000).")
312
+ p_cw_kad.set_defaults(func=cmd_case_search_kad_search)
313
+
314
+ p_cw_gettext = cw_sub.add_parser(
315
+ "get-text",
316
+ help="Live sudact.ru document text (needs SUD_CLI_BRIDGE_LIVE=1).",
317
+ )
318
+ p_cw_gettext.add_argument("url", help="sudact.ru document URL.")
319
+ p_cw_gettext.add_argument("--no-cache", action="store_true", dest="no_cache",
320
+ help="Force refetch, ignore the upstream SQLite cache.")
321
+ p_cw_gettext.add_argument("--blocks", action="store_true",
322
+ help="Return УСТАНОВИЛ/РЕШИЛ blocks separately.")
323
+ p_cw_gettext.set_defaults(func=cmd_case_search_get_text)
324
+
325
+ # Positional live commands (Phase 11). These four were already vetted in the
326
+ # bridge allowlist but had no CLI entry point, so the bridge could reach
327
+ # them while the user could not.
328
+ p_cw_lookup = cw_sub.add_parser(
329
+ "lookup",
330
+ help="Live case lookup by case id (needs SUD_CLI_BRIDGE_LIVE=1).",
331
+ )
332
+ p_cw_lookup.add_argument("case_id", help="Case id, e.g. А40-12345/2026 or А40-12345/25.")
333
+ p_cw_lookup.add_argument(
334
+ "--source",
335
+ choices=["kad", "mos-gorsud", "mos-gorsud-dom", "sudrf"],
336
+ default=None,
337
+ help=(
338
+ "Upstream data source. Without it case-search defaults to its "
339
+ "'synthetic' provider, which only knows fixture ids, so a real "
340
+ "case reference always fails — pass a real source."
341
+ ),
342
+ )
343
+ p_cw_lookup.set_defaults(func=cmd_case_search_lookup)
344
+
345
+ p_cw_hist = cw_sub.add_parser(
346
+ "case-history",
347
+ help="Live case hearing history (needs SUD_CLI_BRIDGE_LIVE=1).",
348
+ )
349
+ p_cw_hist.add_argument("case_id", help="Case id, e.g. А40-12345/2026.")
350
+ p_cw_hist.set_defaults(func=cmd_case_search_case_history)
351
+
352
+ p_cw_sudrf = cw_sub.add_parser(
353
+ "sudrf-live",
354
+ help="Live ГАС «Правосудие» lookup (needs SUD_CLI_BRIDGE_LIVE=1).",
355
+ )
356
+ p_cw_sudrf.add_argument("case_id", help="Case id, e.g. А40-12345/2026.")
357
+ p_cw_sudrf.set_defaults(func=cmd_case_search_sudrf_live)
358
+
359
+ p_cw_mos = cw_sub.add_parser(
360
+ "mos-live",
361
+ help="Live mos.ru case lookup (needs SUD_CLI_BRIDGE_LIVE=1).",
362
+ )
363
+ p_cw_mos.add_argument("court_slug", help="mos.ru court slug.")
364
+ p_cw_mos.add_argument("case_id", help="Case id, e.g. А40-12345/2026.")
365
+ p_cw_mos.set_defaults(func=cmd_case_search_mos_live)
366
+
367
+ # First-class superapp aliases. These call the embedded Python API directly;
368
+ # the case-search namespace above remains the compatibility bridge.
369
+ p_practice = subparsers.add_parser("practice", help="Search judicial practice.")
370
+ p_practice.add_argument("query")
371
+ p_practice.add_argument("--section", choices=["arbitral", "regular", "magistrate", "vsrf"], default="arbitral")
372
+ p_practice.add_argument("--page", type=int, default=1)
373
+ p_practice.add_argument("--limit", type=int, default=10)
374
+ p_practice.set_defaults(func=cmd_native_practice)
375
+
376
+ p_kad = subparsers.add_parser("kad", help="Search KAD by participant.")
377
+ p_kad.add_argument("--inn")
378
+ p_kad.add_argument("--ogrn")
379
+ p_kad.add_argument("--name")
380
+ p_kad.add_argument("--page", type=int, default=1)
381
+ p_kad.set_defaults(func=cmd_native_kad)
382
+
383
+ p_history = subparsers.add_parser("case-history", help="Show case history.")
384
+ p_history.add_argument("case_id")
385
+ p_history.add_argument("--section", choices=["arbitral", "regular", "magistrate", "vsrf"], default="arbitral")
386
+ p_history.add_argument("--max-related", type=int, default=5)
387
+ p_history.add_argument("--limit", type=int, default=10)
388
+ p_history.set_defaults(func=cmd_native_case_history)
389
+
390
+ p_sudrf = subparsers.add_parser("sudrf-live", help="Look up a ГАС Правосудие case.")
391
+ p_sudrf.add_argument("case_id")
392
+ p_sudrf.add_argument("--host", default="oblsud.mo.sudrf.ru")
393
+ p_sudrf.set_defaults(func=cmd_native_sudrf)
394
+
395
+ p_mos = subparsers.add_parser("mos-live", help="Look up a Moscow court case.")
396
+ p_mos.add_argument("court_slug")
397
+ p_mos.add_argument("case_id")
398
+ p_mos.set_defaults(func=cmd_native_mos)
399
+
400
+ # v0.1.0 ЕФРСБ (bankrot.fedresurs.ru) — банкротные проверки (мандат владельца 20.10)
401
+ p_efrsb = subparsers.add_parser(
402
+ "efrsb", help="ЕФРСБ: банкротные проверки ЮЛ/ИП/физлиц, публикации дел, управляющие.")
403
+ efrsb_sub = p_efrsb.add_subparsers(dest="efrsb_cmd", required=True)
404
+ e_companies = efrsb_sub.add_parser("companies", help="Поиск ЮЛ/ИП в банкротстве.")
405
+ e_companies.add_argument("--inn")
406
+ e_companies.add_argument("--ogrn")
407
+ e_companies.add_argument("--name")
408
+ e_persons = efrsb_sub.add_parser("persons", help="Поиск физлиц в банкротстве.")
409
+ e_persons.add_argument("--name", required=True)
410
+ e_persons.add_argument("--birth-date", default=None)
411
+ e_pubs = efrsb_sub.add_parser("publications", help="Публикации по делу.")
412
+ e_pubs.add_argument("--case-id", required=True)
413
+ e_link = efrsb_sub.add_parser("link", help="План ручной проверки ЕФРСБ (человек открывает ссылку; акт 25.10).")
414
+ e_link.add_argument("--kind", required=True, choices=["ul", "ip", "fl"])
415
+ e_link.add_argument("--name")
416
+ e_link.add_argument("--inn")
417
+ e_link.add_argument("--ogrn")
418
+ e_link.add_argument("--birth-date", dest="birth_date")
419
+ e_link.set_defaults(func=cmd_efrsb)
420
+ e_rec = efrsb_sub.add_parser("record", help="Фиксация результата ручной проверки в досье дела (provenance).")
421
+ e_rec.add_argument("--case", required=True)
422
+ e_rec.add_argument("--subject", required=True)
423
+ e_rec.add_argument("--result", required=True, choices=["found", "none", "inconclusive"])
424
+ e_rec.add_argument("--checked-by", dest="checked_by", required=True)
425
+ e_rec.add_argument("--source-ref", dest="source_ref")
426
+ e_rec.add_argument("--notes")
427
+ e_rec.set_defaults(func=cmd_efrsb)
428
+ p_pl = subparsers.add_parser("plenum", help="Корпус постановлений Пленума/обзоров ВС и ВАС РФ (S-55).")
429
+ pl_sub = p_pl.add_subparsers(dest="plenum_cmd", required=True)
430
+ pl_list = pl_sub.add_parser("list", help="Список карточек корпуса.")
431
+ pl_list.set_defaults(func=cmd_plenum)
432
+ pl_show = pl_sub.add_parser("show", help="Карточка полностью.")
433
+ pl_show.add_argument("doc_id")
434
+ pl_show.set_defaults(func=cmd_plenum)
435
+ pl_search = pl_sub.add_parser("search", help="Поиск по тексту параграфов корпуса.")
436
+ pl_search.add_argument("text")
437
+ pl_search.set_defaults(func=cmd_plenum)
438
+ pl_ver = pl_sub.add_parser("verify", help="Пересчёт и проверка sha256 цитат.")
439
+ pl_ver.set_defaults(func=cmd_plenum)
440
+ pl_imp = pl_sub.add_parser("import", help="Импорт карточки из JSON-файла.")
441
+ pl_imp.add_argument("--file", required=True)
442
+ pl_imp.set_defaults(func=cmd_plenum)
443
+ e_managers = efrsb_sub.add_parser("managers", help="Арбитражные управляющие.")
444
+ e_managers.add_argument("--search")
445
+ e_managers.add_argument("--inn")
446
+ e_managers.add_argument("--sro")
447
+ for e_sub in (e_companies, e_persons, e_pubs, e_managers):
448
+ e_sub.add_argument("--limit", type=int, default=5)
449
+ e_sub.set_defaults(func=cmd_efrsb)
450
+
451
+ # v2.14.0 ЕГРЮЛ/ЕГРИП открытая проверка контрагента (мандат владельца 24-25.10)
452
+ p_egrul = subparsers.add_parser(
453
+ "egrul", help="ЕГРЮЛ/ЕГРИП: открытая проверка контрагента (egrul.nalog.ru, ФНС).")
454
+ p_egrul.add_argument("--query", required=True,
455
+ help="ИНН, ОГРН или наименование.")
456
+ p_egrul.add_argument("--name-eq", action="store_true",
457
+ help="точное соответствие наименования.")
458
+ p_egrul.set_defaults(func=cmd_egrul)
459
+
460
+ # v2.15.0 S-61 ФССП по юридической проверке 25.10: (Б) ручной контур + (В) официальная статистика
461
+ p_fssp = subparsers.add_parser(
462
+ "fssp", help="ФССП БДИП: ручная проверка (human-in-the-loop) + официальная статистика.")
463
+ fssp_sub = p_fssp.add_subparsers(dest="fssp_cmd", required=True)
464
+ p_f_check = fssp_sub.add_parser(
465
+ "check", help="План ручной проверки: ссылка+параметры+чек-лист (капчу проходит человек).")
466
+ p_f_check.add_argument("--kind", required=True, choices=["fiz", "ur"])
467
+ p_f_check.add_argument("--name")
468
+ p_f_check.add_argument("--inn")
469
+ p_f_check.add_argument("--ogrn")
470
+ p_f_check.add_argument("--birth-date", dest="birth_date")
471
+ p_f_check.add_argument("--region")
472
+ p_f_check.set_defaults(func=cmd_fssp_check)
473
+ p_f_rec = fssp_sub.add_parser(
474
+ "record", help="Внести результат ручной проверки в реестр доказательств дела.")
475
+ p_f_rec.add_argument("--case", required=True)
476
+ p_f_rec.add_argument("--subject", required=True)
477
+ p_f_rec.add_argument("--result", required=True, choices=["none", "found", "inconclusive"])
478
+ p_f_rec.add_argument("--checked-by", dest="checked_by", required=True)
479
+ p_f_rec.add_argument("--notes")
480
+ p_f_rec.add_argument("--file", help="скриншот/PDF результата (путь к файлу-доказательству)")
481
+ p_f_rec.set_defaults(func=cmd_fssp_record)
482
+ p_f_st = fssp_sub.add_parser("stats", help="Официальная статистика БДИП (агрегатная, по регионам).")
483
+ p_f_st.add_argument("--region")
484
+ p_f_st.set_defaults(func=cmd_fssp_stats)
485
+ p_f_as = fssp_sub.add_parser(
486
+ "assist", help="Полу-автомат: форма заполняется скриптом через локальный браузер, капчу решает человек (скриншот->код).")
487
+ p_f_as.add_argument("--kind", choices=["fiz", "ur"])
488
+ p_f_as.add_argument("--name")
489
+ p_f_as.add_argument("--inn")
490
+ p_f_as.add_argument("--birth-date", dest="birth_date")
491
+ p_f_as.add_argument("--resume", help="код сессии из предыдущего вызова")
492
+ p_f_as.add_argument("--code", help="ответ капчи, который прочитал человек")
493
+ p_f_as.set_defaults(func=cmd_fssp_assist)
494
+ p_lic = subparsers.add_parser("license", help="Лицензия AGPL, источник сборки и флаги сборки (§13).")
495
+ p_lic.add_argument("--with-build-flags", dest="with_build_flags", action="store_true", default=True)
496
+ p_lic.set_defaults(func=cmd_license)
497
+
498
+ p_text = subparsers.add_parser("extract-text", help="Extract act text.")
499
+ p_text.add_argument("file", type=Path)
500
+ p_text.set_defaults(func=cmd_native_text)
501
+
502
+ p_card = subparsers.add_parser("import-card", help="Import a saved case card.")
503
+ p_card.add_argument("file", type=Path)
504
+ p_card.add_argument("--case", required=True, dest="case_id")
505
+ p_card.set_defaults(func=cmd_native_import_card)
506
+
507
+ # fees — state fee calculation (approved spec v1.0.0, ФЗ-259 от 08.08.2024)
508
+ p_fees = subparsers.add_parser(
509
+ "fees",
510
+ help="Расчёт госпошлины (справка, ст. 333.19/333.21 НК РФ, ред. 259-ФЗ).",
511
+ )
512
+ p_fees.add_argument(
513
+ "--jurisdiction",
514
+ required=True,
515
+ choices=["arbitration", "general_jurisdiction", "kas"],
516
+ help=(
517
+ "AS — арбитражные суды (АПК РФ); SOYU — суды общей юрисдикции (ГПК РФ); "
518
+ "KAS — административное судопроизводство (КАС РФ, тарифы ст. 333.19 НК РФ)"
519
+ ),
520
+ )
521
+ p_fees.add_argument(
522
+ "--kind",
523
+ required=True,
524
+ choices=[
525
+ "property_claim",
526
+ "court_order",
527
+ "non_property_claim",
528
+ "appeal",
529
+ "cassation_first",
530
+ "cassation_supreme",
531
+ "bankruptcy_petition",
532
+ "bankruptcy_creditor_claim",
533
+ ],
534
+ help="Вид требования/обжалования",
535
+ )
536
+ p_fees.add_argument(
537
+ "--claim-amount",
538
+ type=claim_amount_arg,
539
+ default=None,
540
+ help=(
541
+ "Цена иска в рублях, копейки допускаются и не отбрасываются "
542
+ "(3697453.16); нужна для имущественных исков и судебного приказа"
543
+ ),
544
+ )
545
+ p_fees.add_argument(
546
+ "--applicant",
547
+ choices=["individual", "entity"],
548
+ default=None,
549
+ help="Заявитель: физлицо или организация (нужен для фиксированных ставок)",
550
+ )
551
+ p_fees.add_argument(
552
+ "--claim-class",
553
+ default=None,
554
+ help="Класс дела; bankruptcy/criminal/administrative_offence отклоняются",
555
+ )
556
+ p_fees.add_argument(
557
+ "--as-of",
558
+ dest="as_of",
559
+ default=None,
560
+ metavar="YYYY-MM-DD",
561
+ help=(
562
+ "Тариф на дату (ст. 333.19/333.21 НК РФ): 2015-01-01..2024-09-08 — "
563
+ "редакция 221-ФЗ, с 2024-09-09 — действующая редакция 259-ФЗ. Без флага "
564
+ "считается по действующему тарифу; дата раньше 2015-01-01 считается по "
565
+ "действующему тарифу с предупреждением (устаревший тариф не додумывается)"
566
+ ),
567
+ )
568
+ p_fees.set_defaults(func=cmd_fees)
569
+
570
+ # deadlines — procedural deadline calculation (approved spec v1.0.0)
571
+ p_deadlines = subparsers.add_parser(
572
+ "deadlines",
573
+ help="Расчёт процессуального срока (АПК РФ / ГПК РФ).",
574
+ )
575
+ deadline_sub = p_deadlines.add_subparsers(dest="deadlines_command")
576
+ p_deadlines.add_argument(
577
+ "deadline_key",
578
+ nargs="?",
579
+ default=None,
580
+ help=(
581
+ "Ключ срока (legacy). АПК: as_appeal_decision, as_appeal_simplified, as_appeal_ruling, "
582
+ "as_cassation_district, as_court_order_cancel, as_court_order_cassation. "
583
+ "ГПК: soyu_appeal_decision, soyu_appeal_ruling, soyu_cassation, "
584
+ "soyu_court_order_cancel, soyu_court_order_cassation. "
585
+ "КАС: kas_appeal_decision, kas_cassation, kas_court_order_cancel"
586
+ ),
587
+ )
588
+ p_deadlines.add_argument(
589
+ "--event-date",
590
+ default=None,
591
+ help="Дата события (Day 0) в формате ГГГГ-ММ-ДД",
592
+ )
593
+ p_deadlines.add_argument("--region", default=None, help="Код субъекта РФ, например 16.")
594
+ p_deadlines.add_argument("--tz", "--timezone", dest="tz", default="Europe/Moscow", help="IANA timezone суда.")
595
+ p_deadlines.add_argument("--case", "--case-id", dest="case_id", default=None, help="Привязать расчёт к делу.")
596
+ p_deadlines.add_argument("--confirm", action="store_true", help="Сразу подтвердить срок (anti-laches).")
597
+ p_deadlines.add_argument("--by", default=None, help="Актор подтверждения/anchor.")
598
+ p_deadlines.add_argument("--evidence", default=None, help="Доказательство (вручение и т.п.).")
599
+ p_deadlines.add_argument("--norm", default=None, help="Норма-ссылка для waiver.")
600
+ p_deadlines.add_argument("--waive", action="store_true", help="Зафиксировать waiver (требует --norm).")
601
+ p_deadlines.set_defaults(func=cmd_deadlines)
602
+
603
+ p_dc = deadline_sub.add_parser("calculate", aliases=["calc"], help="Рассчитать срок и (опц.) привязать к делу.")
604
+ p_dc.add_argument("calc_case_id", nargs="?", default=None, help="ID дела (опционально).")
605
+ p_dc.add_argument("calc_deadline_key", help="Ключ срока.")
606
+ p_dc.add_argument("--event-date", dest="event_date", default=None)
607
+ p_dc.add_argument("--region", default=None)
608
+ p_dc.add_argument("--tz", "--timezone", dest="tz", default="Europe/Moscow")
609
+ p_dc.add_argument("--case", "--case-id", dest="case_id", default=None)
610
+ p_dc.add_argument("--confirm", action="store_true")
611
+ p_dc.add_argument("--by", default=None)
612
+ p_dc.add_argument("--evidence", default=None)
613
+ p_dc.set_defaults(func=cmd_deadlines_calculate)
614
+
615
+ p_dconf = deadline_sub.add_parser("confirm", help="Подтвердить срок.")
616
+ p_dconf.add_argument("case_id")
617
+ p_dconf.add_argument("--key", required=True, dest="key")
618
+ p_dconf.add_argument("--by", required=True)
619
+ p_dconf.add_argument("--evidence", default=None)
620
+ p_dconf.add_argument("--waive", action="store_true")
621
+ p_dconf.add_argument("--norm", default=None)
622
+ p_dconf.set_defaults(func=cmd_deadlines_confirm)
623
+
624
+ p_danchor = deadline_sub.add_parser("anchor", help="Зафиксировать начало срока с доказательством.")
625
+ p_danchor.add_argument("case_id")
626
+ p_danchor.add_argument("--key", required=True, dest="key")
627
+ p_danchor.add_argument("--event-date", dest="event_date", required=True)
628
+ p_danchor.add_argument("--method", required=True, choices=["вручение", "публикация", "почта", "иное"])
629
+ p_danchor.add_argument("--evidence", required=True)
630
+ p_danchor.add_argument("--by", default=None)
631
+ p_danchor.set_defaults(func=cmd_deadlines_anchor)
632
+
633
+ p_dwaive = deadline_sub.add_parser("waive", help="Waiver срока с нормой-ссылкой.")
634
+ p_dwaive.add_argument("case_id")
635
+ p_dwaive.add_argument("--key", required=True, dest="key")
636
+ p_dwaive.add_argument("--by", required=True)
637
+ p_dwaive.add_argument("--norm", required=True)
638
+ p_dwaive.add_argument("--evidence", default=None)
639
+ p_dwaive.set_defaults(func=cmd_deadlines_waive)
640
+
641
+ p_daudit = deadline_sub.add_parser("audit", help="Журнал правок сроков.")
642
+ p_daudit.add_argument("case_id")
643
+ p_daudit.set_defaults(func=cmd_deadlines_audit)
644
+
645
+ p_dlist = deadline_sub.add_parser("list", help="Список состояний сроков.")
646
+ p_dlist.add_argument("case_id", nargs="?", default=None)
647
+ p_dlist.add_argument("--case", "--case-id", dest="case_id", default=None)
648
+ p_dlist.set_defaults(func=cmd_deadlines_list)
649
+
650
+ p_dmatrix = deadline_sub.add_parser("matrix", help="Матрица контрольных точек дела.")
651
+ p_dmatrix.add_argument("case_id", nargs="?", default=None)
652
+ p_dmatrix.add_argument("--case", "--case-id", dest="case_id", default=None)
653
+ p_dmatrix.add_argument("--as-of", dest="as_of", default=None, help="Дата as-of (не влияет на расчёт).")
654
+ p_dmatrix.add_argument("--instance", choices=["apk", "gpk", "kas"], default=None, help="Override инстанции дела.")
655
+ p_dmatrix.add_argument("--missing", action="store_true", help="Только точки с needs_anchor.")
656
+ p_dmatrix.set_defaults(func=cmd_deadlines_matrix)
657
+
658
+ # W6a §5 (S-23): автозаполнение точек из текстов актов — computed+needs_confirm, никогда confirmed.
659
+ p_dauto = deadline_sub.add_parser("autofill", help="Автозаполнение точек срока из текстов актов (маркеры).")
660
+ p_dauto.add_argument("case_id")
661
+ p_dauto.add_argument("--event", dest="event_id", default=None, help="Обработать только этот event_id.")
662
+ p_dauto.set_defaults(func=cmd_deadlines_autofill)
663
+
664
+ # the wave §1 (S-12): цепочка инстанций поверх тех же матричных ключей.
665
+ p_dchain = deadline_sub.add_parser(
666
+ "chain", help="Цепочка инстанций дела по ключам VERIFIED матрицы (справочно).")
667
+ p_dchain.add_argument("case_id", nargs="?", default=None)
668
+ p_dchain.add_argument("--case", "--case-id", dest="case_id", default=None)
669
+ p_dchain.add_argument("--instance", choices=["apk", "gpk", "kas"], default=None,
670
+ help="Override инстанции дела (без него — поле instance, "
671
+ "без поля — все юрисдикции матрицы).")
672
+ p_dchain.set_defaults(func=cmd_deadlines_chain)
673
+
674
+ p_interest = subparsers.add_parser("interest", help="Проценты по ст. 395 или 317.1 ГК РФ.")
675
+ p_interest.add_argument("--amount", "--principal", dest="amount", type=int, required=True)
676
+ p_interest.add_argument("--from", "--start", dest="start_date", required=True)
677
+ p_interest.add_argument("--to", "--end", dest="end_date", required=True)
678
+ p_interest.add_argument("--article", choices=["395", "317.1"], default="395")
679
+ p_interest.add_argument("--calc-fee", action="store_true")
680
+ p_interest.add_argument("--jurisdiction", choices=["arbitration", "general_jurisdiction", "kas"], default="arbitration")
681
+ p_interest.add_argument("--kind", default="property_claim")
682
+ p_interest.add_argument("--applicant", choices=["individual", "entity"], default="entity")
683
+ p_interest.add_argument("--payments", default=None, help="Частичные оплаты: YYYY-MM-DD:сумма,... или JSON-список")
684
+ p_interest.set_defaults(func=cmd_interest)
685
+
686
+ p_payment = subparsers.add_parser("payment", help="Реквизиты и назначение платежа.")
687
+ p_payment.add_argument("--jurisdiction", choices=["arbitration", "general_jurisdiction", "kas", "soyu", "eks"], required=True)
688
+ p_payment.add_argument("--amount", type=int, required=True)
689
+ p_payment.add_argument("--court", required=True)
690
+ p_payment.add_argument("--defendant", required=True)
691
+ p_payment.set_defaults(func=cmd_payment)
692
+
693
+ p_deferral = subparsers.add_parser("deferral", help="Ходатайство об отсрочке госпошлины.")
694
+ p_deferral.add_argument("--applicant", "--claimant", dest="applicant", required=True)
695
+ p_deferral.add_argument("--court", required=True)
696
+ p_deferral.add_argument("--amount", type=int, required=True)
697
+ p_deferral.add_argument("--reason", "--grounds", dest="reason", required=True)
698
+ p_deferral.set_defaults(func=cmd_deferral)
699
+
700
+ p_feeex = subparsers.add_parser("fee-exemption", help="Льгота по госпошлине (VERIFIED-выборка категорий ст.333.35-37).")
701
+ p_feeex.add_argument("--jurisdiction", required=True, choices=["apk", "gpk", "kas", "arbitration", "general_jurisdiction"])
702
+ p_feeex.add_argument("--category", required=True)
703
+ p_feeex.set_defaults(func=cmd_fee_exemption)
704
+
705
+ p_feeref = subparsers.add_parser("fee-refund", help="Возврат госпошлины по основаниям ст.333.40 НК РФ (VERIFIED).")
706
+ p_feeref.add_argument("--paid", required=True, help="Уплаченная сумма, Decimal")
707
+ p_feeref.add_argument("--ground", required=True)
708
+ p_feeref.add_argument("--claim-admitted", action="store_true")
709
+ p_feeref.add_argument("--plaintiff-default", action="store_true")
710
+ p_feeref.add_argument("--transferred-to-arbitration", action="store_true")
711
+ p_feeref.set_defaults(func=cmd_fee_refund)
712
+
713
+ p_feeidx = subparsers.add_parser("fee-indexation", help="Рамка/расчёт индексации 183 АПК / 208 ГПК / 189.1 КАС.")
714
+ p_feeidx.add_argument("--code", required=True, choices=["apk", "gpk", "kas"])
715
+ p_feeidx.add_argument("--amount")
716
+ p_feeidx.add_argument("--cpi-start")
717
+ p_feeidx.add_argument("--cpi-end")
718
+ p_feeidx.set_defaults(func=cmd_fee_indexation)
719
+
720
+ p_limitation = subparsers.add_parser("limitation", aliases=["laches"], help="Расчёт срока исковой давности.")
721
+ p_limitation.add_argument("--start", required=True)
722
+ p_limitation.add_argument("--type", dest="period_type", default="general",
723
+ choices=["general", "contract_work", "carriage", "voidable_deal", "custom_years", "custom_months"])
724
+ p_limitation.add_argument("--years", type=int, default=0)
725
+ p_limitation.add_argument("--months", type=int, default=0)
726
+ p_limitation.add_argument("--claim-notice-days", type=int, default=0)
727
+ p_limitation.add_argument("--check-date", default=None)
728
+ p_limitation.set_defaults(func=cmd_limitation)
729
+
730
+ p_audit = subparsers.add_parser("timeline-audit", help="Проверить процессуальную хронологию дела.")
731
+ p_audit.add_argument("--events", required=True, help="JSON-файл или сырой JSON-массив событий.")
732
+ p_audit.set_defaults(func=cmd_timeline_audit)
733
+
734
+ p_asof = subparsers.add_parser("npa-as-of", help="Получить редакцию нормы на дату события.")
735
+ p_asof.add_argument("--act", required=True)
736
+ p_asof.add_argument("--article", required=True)
737
+ p_asof.add_argument("--date", required=True)
738
+ p_asof.set_defaults(func=cmd_npa_as_of)
739
+
740
+ # verify — правило «тексты только из реестра»: валидация цитат (v2.9.0, S-52)
741
+ p_verify = subparsers.add_parser(
742
+ "verify", help="Verify a quoted fragment against an explicit local source."
743
+ )
744
+ verify_sub = p_verify.add_subparsers(dest="verify_command", required=True)
745
+ p_vq = verify_sub.add_parser("quote", help="Check a quotation (npa bundle | case events | text file).")
746
+ p_vq.add_argument("--text", required=True)
747
+ p_vq.add_argument("--source", required=True, choices=["npa", "case", "file"])
748
+ p_vq.add_argument("--act")
749
+ p_vq.add_argument("--article")
750
+ p_vq.add_argument("--date")
751
+ p_vq.add_argument("--case-id", dest="case_id")
752
+ p_vq.add_argument("--path")
753
+ p_vq.add_argument("--max-fuzzy", dest="max_fuzzy", type=float, default=0.0)
754
+ p_vq.set_defaults(func=cmd_verify_quote)
755
+
756
+ # practice-status — offline fate probe (v2.9.0, S-54). The user-facing form
757
+ # is `practice status <n>`: main() rewrites it here because the live
758
+ # `practice <query>` parser must stay flat for the MCP drift contract.
759
+ p_ps = subparsers.add_parser(
760
+ "practice-status", help="Fate probe: possibly_overturned|unknown из ЛОКАЛЬНЫХ артефактов."
761
+ )
762
+ p_ps.add_argument("case_number")
763
+ p_ps.add_argument("--related", default=None, help="Fixture-режим: заголовки related-инстансов через ';'.")
764
+ p_ps.add_argument("--store", type=Path, default=None, help="Каталог snapshot/diff артефактов case-search.")
765
+ p_ps.set_defaults(func=cmd_practice_status)
766
+
767
+
768
+ p_mcp = subparsers.add_parser("serve-mcp", help="Запустить локальный MCP JSON-RPC stdio сервер.")
769
+ p_mcp.set_defaults(func=cmd_serve_mcp)
770
+
771
+ # case — central case registry (v2.4.0)
772
+ p_case = subparsers.add_parser("case", help="Реестр дел.")
773
+ case_sub = p_case.add_subparsers(dest="case_command", required=True)
774
+
775
+ p_case_new = case_sub.add_parser("new", help="Создать дело.")
776
+ p_case_new.add_argument("--number", required=True, dest="number")
777
+ p_case_new.add_argument("--court", required=True)
778
+ p_case_new.add_argument("--plaintiff", required=True)
779
+ p_case_new.add_argument("--defendant", required=True)
780
+ p_case_new.add_argument("--owner", default=None)
781
+ p_case_new.add_argument("--status", default=None)
782
+ # the wave §1: instance дела (apk|gpk|kas) — поле, из которого матрица и
783
+ # цепочка берут юрисдикцию; без него — unknown.
784
+ p_case_new.add_argument("--instance", default=None, choices=["apk", "gpk", "kas"],
785
+ help="Инстанция дела: apk (арбитраж), gpk (ОЮ), kas.")
786
+ p_case_new.set_defaults(func=cmd_case_new)
787
+
788
+ p_case_set = case_sub.add_parser("set", help="Обновить дело.")
789
+ p_case_set.add_argument("case_id")
790
+ p_case_set.add_argument("--status", default=None)
791
+ p_case_set.add_argument("--owner", default=None)
792
+ p_case_set.add_argument("--closed-at", dest="closed_at", default=None)
793
+ p_case_set.add_argument("--instance", default=None, choices=["apk", "gpk", "kas"],
794
+ help="Инстанция дела (the wave §1).")
795
+ p_case_set.set_defaults(func=cmd_case_set)
796
+
797
+ p_case_amount = case_sub.add_parser("amount", help="Суммы по делу.")
798
+ amount_sub = p_case_amount.add_subparsers(dest="case_amount_command", required=True)
799
+ p_amount_add = amount_sub.add_parser("add", help="Добавить сумму.")
800
+ p_amount_add.add_argument("case_id")
801
+ p_amount_add.add_argument("--kind", required=True)
802
+ p_amount_add.add_argument("--amount", required=True)
803
+ p_amount_add.add_argument("--note", default=None)
804
+ p_amount_add.set_defaults(func=cmd_case_amount_add)
805
+ p_amount_list = amount_sub.add_parser("list", help="Список сумм.")
806
+ p_amount_list.add_argument("case_id")
807
+ p_amount_list.set_defaults(func=cmd_case_amount_list)
808
+ p_amount_set = amount_sub.add_parser("set", help="Статус/дата платежа по сумме (the wave).")
809
+ p_amount_set.add_argument("case_id")
810
+ p_amount_set.add_argument("amount_id", type=int)
811
+ p_amount_set.add_argument("--status", required=True,
812
+ choices=["planned", "paid", "refunded"])
813
+ p_amount_set.add_argument("--paid-at", dest="paid_at", default=None,
814
+ help="Дата платежа YYYY-MM-DD")
815
+ p_amount_set.set_defaults(func=cmd_case_amount_set)
816
+ p_amount_summary = amount_sub.add_parser("summary", help="Сводка сумм по kind x status (the wave).")
817
+ p_amount_summary.add_argument("case_id")
818
+ p_amount_summary.set_defaults(func=cmd_case_amount_summary)
819
+
820
+ p_case_doc = case_sub.add_parser("doc", help="Документы по делу.")
821
+ doc_sub = p_case_doc.add_subparsers(dest="case_doc_command", required=True)
822
+ p_doc_add = doc_sub.add_parser("add", help="Добавить документ.")
823
+ p_doc_add.add_argument("case_id")
824
+ p_doc_add.add_argument("--path", required=True)
825
+ p_doc_add.add_argument("--title", default=None)
826
+ p_doc_add.add_argument("--doc-id", dest="doc_id", type=int, default=None,
827
+ help="Явная ревизия: supersedes=<id> существующего документа этого дела (the wave §2)")
828
+ p_doc_add.set_defaults(func=cmd_case_doc_add)
829
+ p_doc_list = doc_sub.add_parser("list", help="Список документов.")
830
+ p_doc_list.add_argument("case_id")
831
+ p_doc_list.set_defaults(func=cmd_case_doc_list)
832
+ p_doc_hist = doc_sub.add_parser("history", help="Цепочка ревизий документа (newest-first, the wave §2).")
833
+ p_doc_hist.add_argument("case_id")
834
+ p_doc_hist.add_argument("--path", default=None, help="Только цепочка этого файла.")
835
+ p_doc_hist.set_defaults(func=cmd_case_doc_history)
836
+ p_doc_chk = doc_sub.add_parser(
837
+ "checklist", help="Структурный чек-лист комплектности (the wave §3; справка, не валиация).")
838
+ p_doc_chk.add_argument("case_id")
839
+ p_doc_chk.add_argument("doc_id", type=int)
840
+ p_doc_chk.add_argument("--kind", default=None,
841
+ help="kind из completeness_rules.json (isk_apk|claim_apk|appeal_apk|hodat)")
842
+ p_doc_chk.set_defaults(func=cmd_case_doc_checklist)
843
+
844
+ # the wave §4 (S-70): процессуальные формы — структурные скелеты + bus-free docx
845
+ p_form = subparsers.add_parser("form", help="Процессуальные формы (the wave): скелеты и docx-команда.")
846
+ form_sub = p_form.add_subparsers(dest="form_command", required=True)
847
+ p_form_new = form_sub.add_parser("new", help="Напечатать структурный скелет формы по делу.")
848
+ p_form_new.add_argument("kind", choices=list(FORM_KINDS))
849
+ p_form_new.add_argument("--case", required=True, dest="case_id")
850
+ p_form_new.add_argument("--format", default="md", choices=["md", "json"])
851
+ p_form_new.set_defaults(func=cmd_form_new)
852
+ p_form_docx = form_sub.add_parser(
853
+ "docx-command", help="Bus-free: готовая shlex-строка docx-cli build (sud её не исполняет).")
854
+ p_form_docx.add_argument("kind", choices=list(FORM_KINDS))
855
+ p_form_docx.add_argument("--case", required=True, dest="case_id")
856
+ p_form_docx.add_argument("--spec-dir", dest="spec_dir", default=None)
857
+ p_form_docx.set_defaults(func=cmd_form_docx_command)
858
+
859
+ # the wave §1 (S-73): реестр доказательств — том/лист/hash-целостность
860
+ p_case_ev = case_sub.add_parser("evidence", help="Реестр доказательств (the wave).")
861
+ ev_sub = p_case_ev.add_subparsers(dest="case_evidence_command", required=True)
862
+ p_ev_add = ev_sub.add_parser("add", help="Добавить доказательство.")
863
+ p_ev_add.add_argument("case_id")
864
+ p_ev_add.add_argument("--title", required=True)
865
+ p_ev_add.add_argument("--path", default=None,
866
+ help="Файл: тогда sha256 + doc_ref на строку case_documents")
867
+ p_ev_add.add_argument("--volume", default=None, help="Том")
868
+ p_ev_add.add_argument("--sheet", default=None, help="Лист")
869
+ p_ev_add.add_argument("--media", choices=["paper", "electronic"], default=None)
870
+ p_ev_add.add_argument("--note", default=None)
871
+ p_ev_add.set_defaults(func=cmd_case_evidence_add)
872
+ p_ev_list = ev_sub.add_parser("list", help="Список доказательств (том->лист, numerical-aware).")
873
+ p_ev_list.add_argument("case_id")
874
+ p_ev_list.set_defaults(func=cmd_case_evidence_list)
875
+ p_ev_verify = ev_sub.add_parser("verify", help="Пересчёт sha256: intact|CHANGED|MISSING_FILE|unchecked.")
876
+ p_ev_verify.add_argument("case_id")
877
+ p_ev_verify.set_defaults(func=cmd_case_evidence_verify)
878
+
879
+ # the wave §2 (S-15): представители. Проверка полномочий НЕ автоматическая.
880
+ p_case_rep = case_sub.add_parser("rep", help="Представители по делу (the wave).")
881
+ rep_sub = p_case_rep.add_subparsers(dest="case_rep_command", required=True)
882
+ p_rep_add = rep_sub.add_parser("add", help="Добавить представителя.")
883
+ p_rep_add.add_argument("case_id")
884
+ p_rep_add.add_argument("--side", required=True, choices=["plaintiff", "defendant"])
885
+ p_rep_add.add_argument("--name", required=True)
886
+ p_rep_add.add_argument("--authority-note", dest="authority_note", default=None)
887
+ p_rep_add.add_argument("--power-docs", dest="power_docs", default=None,
888
+ help="Свободное описание доверенности (sud её не сверяет).")
889
+ p_rep_add.set_defaults(func=cmd_case_rep_add)
890
+ p_rep_list = rep_sub.add_parser("list", help="Список представителей дела.")
891
+ p_rep_list.add_argument("case_id")
892
+ p_rep_list.set_defaults(func=cmd_case_rep_list)
893
+
894
+ # история §5 (S-07): права субъекта — экспорт дела и полное уничтожение.
895
+ p_case_export = case_sub.add_parser(
896
+ "export", help="Маш-читаемый дамп дела (json|md) со ссылкой на pdn_policy.")
897
+ p_case_export.add_argument("case_id")
898
+ p_case_export.add_argument("--format", default="json", choices=["json", "md"])
899
+ p_case_export.add_argument("--out", type=Path, default=None, dest="out",
900
+ help="Записать дамп в файл (UTF-8).")
901
+ p_case_export.set_defaults(func=cmd_case_export)
902
+ p_case_purge = case_sub.add_parser(
903
+ "purge", help="Уничтожить все данные дела (деструктив: требует --yes).")
904
+ p_case_purge.add_argument("case_id")
905
+ p_case_purge.add_argument("--yes", action="store_true", default=False,
906
+ help="Подтверждение уничтожения.")
907
+ p_case_purge.add_argument("--keep-files", dest="keep_files", action="store_true",
908
+ default=False, help="Не удалять физические файлы.")
909
+ p_case_purge.set_defaults(func=cmd_case_purge)
910
+
911
+ p_case_note = case_sub.add_parser("note", help="Заметки по делу.")
912
+ note_sub = p_case_note.add_subparsers(dest="case_note_command", required=True)
913
+ p_note_add = note_sub.add_parser("add", help="Добавить заметку.")
914
+ p_note_add.add_argument("case_id")
915
+ p_note_add.add_argument("--text", required=True)
916
+ p_note_add.add_argument("--author", default=None)
917
+ p_note_add.set_defaults(func=cmd_case_note_add)
918
+ p_note_list = note_sub.add_parser("list", help="Список заметок.")
919
+ p_note_list.add_argument("case_id")
920
+ p_note_list.set_defaults(func=cmd_case_note_list)
921
+ p_note_sup = note_sub.add_parser("supersede", help="Заменить заметку.")
922
+ p_note_sup.add_argument("note_id")
923
+ p_note_sup.add_argument("--text", required=True)
924
+ p_note_sup.add_argument("--author", default=None)
925
+ p_note_sup.set_defaults(func=cmd_case_note_supersede)
926
+
927
+ p_case_list = case_sub.add_parser("list", help="Список дел.")
928
+ p_case_list.add_argument("--status", default=None)
929
+ p_case_list.add_argument("--owner", default=None)
930
+ p_case_list.add_argument("--court", default=None)
931
+ p_case_list.add_argument("--party", default=None)
932
+ p_case_list.set_defaults(func=cmd_case_list)
933
+
934
+ p_case_card = case_sub.add_parser("card", help="Карточка дела.")
935
+ p_case_card.add_argument("case_id")
936
+ p_case_card.set_defaults(func=cmd_case_card)
937
+
938
+ # reminders — bus-free генератор напоминаний (the wave §2, S-26)
939
+ p_reminders = subparsers.add_parser("reminders", help="Напоминания по срокам (bus-free).")
940
+ reminders_sub = p_reminders.add_subparsers(dest="reminders_command", required=True)
941
+ p_rem_due = reminders_sub.add_parser(
942
+ "due", help="Сроки в окне дней + готовые a2a-строки; sud шину не вызывает.")
943
+ p_rem_due.add_argument("--days", type=int, default=7, help="Окно выборки, 1..90")
944
+ p_rem_due.set_defaults(func=cmd_reminders_due)
945
+
946
+ # conflict — conflict-of-interest heuristic (v2.4.1)
947
+ p_conflict = subparsers.add_parser("conflict", help="Детект конфликта интересов (name-exact).")
948
+ conflict_sub = p_conflict.add_subparsers(dest="conflict_command", required=True)
949
+
950
+ p_conflict_check = conflict_sub.add_parser("check", help="Проверка кандидата по локальному реестру.")
951
+ p_conflict_check.add_argument("--plaintiff", default=None)
952
+ p_conflict_check.add_argument("--defendant", default=None)
953
+ p_conflict_check.add_argument("--court", default=None)
954
+ p_conflict_check.add_argument("--case-number", dest="case_number", default=None)
955
+ p_conflict_check.set_defaults(func=cmd_conflict_check)
956
+
957
+ p_conflict_case = conflict_sub.add_parser("case", help="Проверка существующего дела.")
958
+ p_conflict_case.add_argument("case_id")
959
+ p_conflict_case.set_defaults(func=cmd_conflict_case)
960
+
961
+ # jurisdiction — подсудность на VERIFIED rules (v2.6.0)
962
+ p_jur = subparsers.add_parser("jurisdiction", help="Гейдер подсудности по VERIFIED правилам.")
963
+ jur_sub = p_jur.add_subparsers(dest="jurisdiction_command", required=True)
964
+ p_jur_check = jur_sub.add_parser("check", help="Проверка подсудности.")
965
+ p_jur_check.add_argument("--code", required=True, choices=["apk", "gpk", "kas"])
966
+ p_jur_check.add_argument("--claim", required=True)
967
+ p_jur_check.add_argument("--party-org", dest="party_org", action="store_true")
968
+ p_jur_check.add_argument("--party-phys", dest="party_phys", action="store_true")
969
+ p_jur_check.add_argument("--unknown-rf-location", dest="unknown_rf_location", action="store_true")
970
+ p_jur_check.add_argument("--immovable", action="store_true")
971
+ p_jur_check.add_argument("--corporate", action="store_true")
972
+ p_jur_check.add_argument("--bankruptcy", action="store_true")
973
+ p_jur_check.add_argument("--ip-rights", dest="ip_rights", action="store_true")
974
+ p_jur_check.add_argument("--contract-forum", dest="contract_forum", action="store_true")
975
+ p_jur_check.add_argument("--arbitration-clause", dest="arbitration_clause", action="store_true")
976
+ p_jur_check.add_argument("--multiple-defendants", dest="multiple_defendants", action="store_true")
977
+ p_jur_check.add_argument("--foreign-element", dest="foreign_element", action="store_true")
978
+ p_jur_check.add_argument("--consumer", action="store_true")
979
+ p_jur_check.add_argument("--alimony", action="store_true")
980
+ p_jur_check.set_defaults(func=cmd_jurisdiction_check)
981
+
982
+ # pretrial — претензионный порядок (v2.6.0)
983
+ p_pre = subparsers.add_parser("pretrial", help="Претензионный порядок по VERIFIED правилам.")
984
+ pre_sub = p_pre.add_subparsers(dest="pretrial_command", required=True)
985
+ p_pre_calc = pre_sub.add_parser("calc", help="Срок ответа на претензию.")
986
+ p_pre_calc.add_argument("--code", required=True, choices=["apk", "zozpp", "osago",
987
+ "railway", "road", "maritime",
988
+ "inland", "air", "freight", "postal"])
989
+ p_pre_calc.add_argument("--sent", required=True)
990
+ p_pre_calc.set_defaults(func=cmd_pretrial_calc)
991
+ p_pre_cons = pre_sub.add_parser("consequences", help="Последствия несоблюдения претензионного порядка.")
992
+ p_pre_cons.add_argument("--code", required=True, choices=["apk", "gpk", "kas"])
993
+ p_pre_cons.add_argument("--stage", required=True, choices=["filing", "trial", "movement"])
994
+ p_pre_cons.set_defaults(func=cmd_pretrial_consequences)
995
+
996
+ return parser
997
+
998
+
999
+ def _strip_global_options(argv):
1000
+ """Return argv with global flags and their values removed.
1001
+
1002
+ This must stay in sync with the global options accepted by build_parser:
1003
+ --json, --state-dir PATH, --state-dir=PATH.
1004
+ """
1005
+ result = []
1006
+ it = iter(argv)
1007
+ for token in it:
1008
+ if token == "--json":
1009
+ continue
1010
+ elif token == "--state-dir":
1011
+ try:
1012
+ next(it)
1013
+ except StopIteration:
1014
+ pass
1015
+ continue
1016
+ elif token.startswith("--state-dir="):
1017
+ continue
1018
+ result.append(token)
1019
+ return result
1020
+
1021
+
1022
+ def _first_command_token(argv):
1023
+ """Return the first positional command token in argv, or None."""
1024
+ for token in _strip_global_options(argv):
1025
+ if token.startswith("-"):
1026
+ continue
1027
+ return token
1028
+ return None
1029
+
1030
+
1031
+ def _extract_global_options(argv):
1032
+ """Extract --json and --state-dir from argv without enforcing full parsing."""
1033
+ json_mode = False
1034
+ state_dir = None
1035
+ it = iter(argv)
1036
+ for token in it:
1037
+ if token == "--json":
1038
+ json_mode = True
1039
+ elif token == "--state-dir":
1040
+ try:
1041
+ state_dir = Path(next(it))
1042
+ except StopIteration:
1043
+ pass
1044
+ elif token.startswith("--state-dir="):
1045
+ state_dir = Path(token.split("=", 1)[1])
1046
+ return json_mode, state_dir
1047
+
1048
+
1049
+ def _rejected_tokens(argv, limit=8):
1050
+ """List the offending tokens so the caller can see what was rejected.
1051
+
1052
+ Path-like tokens are dropped: the suite asserts that no output ever echoes
1053
+ $HOME, and an echo of an arbitrary argument is exactly how that leaks. The
1054
+ caller still gets the subcommand name, which is what identifies the call.
1055
+ """
1056
+ kept = []
1057
+ for token in argv:
1058
+ if len(kept) >= limit:
1059
+ break
1060
+ if token.startswith("/") or token.startswith("~") or "://" in token:
1061
+ continue
1062
+ kept.append(token)
1063
+ return " ".join(kept) if kept else "(аргументы не распознаны)"
1064
+
1065
+
1066
+ def main(argv=None):
1067
+ argv = list(argv) if argv is not None else sys.argv[1:]
1068
+ # Backward-compat: legacy "deadlines <key> ..." (v2.4) -> "deadlines calculate <key> ..."
1069
+ _DEADLINE_SUBS = {"calculate", "calc", "confirm", "anchor", "waive", "audit", "list", "matrix", "autofill", "chain"}
1070
+ try:
1071
+ i = argv.index("deadlines")
1072
+ except ValueError:
1073
+ i = -1
1074
+ if i >= 0 and i + 1 < len(argv):
1075
+ nxt = argv[i + 1]
1076
+ if not nxt.startswith("-") and nxt not in _DEADLINE_SUBS:
1077
+ argv = argv[: i + 1] + ["calculate"] + argv[i + 1:]
1078
+ # v2.9.0 (S-54): `practice status <n>` routes to the offline fate probe.
1079
+ # The live `practice <query>` parser stays flat (MCP drift contract), so
1080
+ # the subcommand shape is dispatched here, same precedent as deadlines.
1081
+ try:
1082
+ _ps_i = argv.index("practice")
1083
+ except ValueError:
1084
+ _ps_i = -1
1085
+ if _ps_i >= 0 and _ps_i + 1 < len(argv) and argv[_ps_i + 1] == "status":
1086
+ argv = argv[:_ps_i] + ["practice-status"] + argv[_ps_i + 2:]
1087
+
1088
+ json_requested = "--json" in argv
1089
+ argv = [token for token in argv if token != "--json"]
1090
+ # W6a §2: --explain принимается в любой позиции (как --json) — флаг
1091
+ # глобальный, субпарсеры его не объявляют.
1092
+ explain_requested = "--explain" in argv
1093
+ argv = [token for token in argv if token != "--explain"]
1094
+
1095
+ parser = build_parser()
1096
+ # A consumer parsing stdout with --json must get parseable output even when
1097
+ # the arguments are malformed. argparse exits 2 with human-readable usage
1098
+ # on stderr and nothing on stdout, which breaks the machine contract, so in
1099
+ # JSON mode we emit a typed error instead. Text mode keeps argparse's own
1100
+ # behaviour untouched.
1101
+ parse_failed = False
1102
+ if json_requested:
1103
+ # argparse writes its usage text to stderr the moment it rejects the
1104
+ # arguments, which would corrupt the JSON error we are about to emit on
1105
+ # the same stream. Swallow it during parsing only — the JSON error is
1106
+ # emitted afterwards, outside this block, so it still reaches stderr.
1107
+ # In text mode argparse keeps its own behaviour untouched.
1108
+ with contextlib.redirect_stderr(io.StringIO()):
1109
+ try:
1110
+ args = parser.parse_args(argv)
1111
+ except SystemExit:
1112
+ parse_failed = True
1113
+ args = None
1114
+ else:
1115
+ args = parser.parse_args(argv)
1116
+
1117
+ if parse_failed:
1118
+ bad_command = _first_command_token(argv)
1119
+ return error(
1120
+ {
1121
+ "status": "INVALID_ARGUMENTS",
1122
+ "detail": (
1123
+ f"неверные или недостающие аргументы для команды "
1124
+ f"'{bad_command or 'unknown'}': {_rejected_tokens(argv)}"
1125
+ ),
1126
+ },
1127
+ meta={"command": bad_command},
1128
+ json_mode=True,
1129
+ )
1130
+
1131
+ if args.command is None:
1132
+ parser.print_help()
1133
+ return 0
1134
+
1135
+ state_dir = args.state_dir or get_state_dir()
1136
+ args.state_dir = state_dir
1137
+ args.json_mode = json_requested
1138
+ args.explain = explain_requested or getattr(args, "explain", False)
1139
+
1140
+ try:
1141
+ return args.func(args)
1142
+ except Exception as exc:
1143
+ return error(
1144
+ f"unexpected internal error: {type(exc).__name__}",
1145
+ meta={"command": args.command, "state_dir": str(state_dir)},
1146
+ json_mode=args.json_mode,
1147
+ )
1148
+
1149
+
1150
+ def cmd_providers(args):
1151
+ providers = list_providers()
1152
+ payload = [
1153
+ {
1154
+ "name": p.name,
1155
+ "kind": p.kind,
1156
+ "status": p.status,
1157
+ "description": p.description,
1158
+ }
1159
+ for p in providers
1160
+ ]
1161
+ result(
1162
+ payload,
1163
+ meta={"command": "providers", "state_dir": str(args.state_dir)},
1164
+ json_mode=args.json_mode,
1165
+ )
1166
+ return 0
1167
+
1168
+
1169
+ def cmd_ready(args):
1170
+ """Report whether live court commands can run, and how to call them.
1171
+
1172
+ Exits non-zero when something is missing so an agent can gate on it:
1173
+ ``sud-cli --json ready || echo "court access not configured"``.
1174
+ No court host is contacted — the answer is about configuration only.
1175
+ """
1176
+ from sud_cli.readiness import check_live_readiness
1177
+
1178
+ report = check_live_readiness()
1179
+ ready = bool(report.get("ready"))
1180
+ result(
1181
+ report,
1182
+ meta={
1183
+ "command": "ready",
1184
+ "ready": ready,
1185
+ "state_dir": str(args.state_dir),
1186
+ },
1187
+ json_mode=args.json_mode,
1188
+ )
1189
+ return 0 if ready else 1
1190
+
1191
+
1192
+ def cmd_doctor(args):
1193
+ from sud_cli.db import doctor_check, init_db
1194
+
1195
+ init_db(args.state_dir)
1196
+ report = doctor_check(args.state_dir)
1197
+ status = 0 if report.get("healthy") else 1
1198
+ result(
1199
+ report,
1200
+ meta={"command": "doctor", "state_dir": str(args.state_dir)},
1201
+ json_mode=args.json_mode,
1202
+ )
1203
+ return status
1204
+
1205
+
1206
+ def cmd_calendar_check(args):
1207
+ from .production_calendar import calendar_check
1208
+
1209
+ report = calendar_check()
1210
+ result(report, meta={"command": "calendar check"}, json_mode=args.json_mode)
1211
+ return 0 if report.get("valid") else 1
1212
+
1213
+
1214
+ def cmd_import(args):
1215
+ from sud_cli.db import get_connection, init_db
1216
+ from sud_cli.importing import import_fixture
1217
+
1218
+ init_db(args.state_dir)
1219
+ conn = get_connection(args.state_dir)
1220
+ try:
1221
+ report = import_fixture(conn, args.kind, args.file)
1222
+ except Exception as exc:
1223
+ return error(
1224
+ str(exc),
1225
+ meta={"command": "import"},
1226
+ json_mode=args.json_mode,
1227
+ )
1228
+ result(
1229
+ report,
1230
+ meta={"command": "import", "state_dir": str(args.state_dir)},
1231
+ json_mode=args.json_mode,
1232
+ )
1233
+ return 0
1234
+
1235
+
1236
+ def cmd_search(args):
1237
+ from sud_cli.db import get_connection, init_db, search_records
1238
+
1239
+ init_db(args.state_dir)
1240
+ conn = get_connection(args.state_dir)
1241
+ try:
1242
+ payload, meta = search_records(
1243
+ conn,
1244
+ kind=args.kind,
1245
+ query=args.query,
1246
+ limit=args.limit,
1247
+ since=args.since,
1248
+ until=args.until,
1249
+ )
1250
+ except Exception as exc:
1251
+ return error(
1252
+ str(exc),
1253
+ meta={"command": "search", "state_dir": str(args.state_dir)},
1254
+ json_mode=args.json_mode,
1255
+ )
1256
+ meta = meta or {}
1257
+ meta.update({"command": "search", "state_dir": str(args.state_dir)})
1258
+ result(payload, meta=meta, json_mode=args.json_mode)
1259
+ return 0
1260
+
1261
+
1262
+ def cmd_npa_search(args):
1263
+ from sud_cli.db import get_connection, init_db, search_npa
1264
+
1265
+ init_db(args.state_dir)
1266
+ conn = get_connection(args.state_dir)
1267
+ try:
1268
+ payload, meta = search_npa(conn, query=args.query, limit=args.limit)
1269
+ except Exception as exc:
1270
+ return error(
1271
+ str(exc),
1272
+ meta={"command": "npa search", "state_dir": str(args.state_dir)},
1273
+ json_mode=args.json_mode,
1274
+ )
1275
+ meta = meta or {}
1276
+ meta.update({"command": "npa search", "state_dir": str(args.state_dir),
1277
+ "source_basis": _wm.render_line("npa_source", __version__)})
1278
+ result(payload, meta=meta, json_mode=args.json_mode)
1279
+ return 0
1280
+
1281
+
1282
+ def cmd_npa_compare(args):
1283
+ from sud_cli.db import get_connection, init_db, compare_npa_editions
1284
+
1285
+ init_db(args.state_dir)
1286
+ conn = get_connection(args.state_dir)
1287
+ try:
1288
+ payload = compare_npa_editions(
1289
+ conn, doc_id=args.doc_id, edition1=args.edition1, edition2=args.edition2
1290
+ )
1291
+ except Exception as exc:
1292
+ return error(
1293
+ str(exc),
1294
+ meta={"command": "npa compare", "state_dir": str(args.state_dir)},
1295
+ json_mode=args.json_mode,
1296
+ )
1297
+ result(
1298
+ payload,
1299
+ meta={"command": "npa compare", "state_dir": str(args.state_dir)},
1300
+ json_mode=args.json_mode,
1301
+ )
1302
+ return 0
1303
+
1304
+
1305
+ def cmd_law_search(args):
1306
+ from sud_cli.pravo_publication import PublicationError, query_publication_metadata
1307
+
1308
+ try:
1309
+ payload = query_publication_metadata(query=args.query, number=args.number)
1310
+ except PublicationError as exc:
1311
+ return error(
1312
+ str(exc),
1313
+ meta={"command": "law search"},
1314
+ json_mode=args.json_mode,
1315
+ )
1316
+ meta = {
1317
+ "command": "law search",
1318
+ "state_dir": str(args.state_dir),
1319
+ "source": payload["source"],
1320
+ "provider_label": payload["provider_label"],
1321
+ "page": payload["pagination"]["page"],
1322
+ "page_size": payload["pagination"]["page_size"],
1323
+ "total": payload["pagination"]["total"],
1324
+ "pages_total": payload["pagination"]["pages_total"],
1325
+ }
1326
+ result(payload["items"], meta=meta, json_mode=args.json_mode)
1327
+ return 0
1328
+
1329
+
1330
+ def cmd_court_import(args):
1331
+ from sud_cli.db import get_connection, init_db
1332
+ from sud_cli.importing import CourtImportError, ImportError as FixtureImportError
1333
+ from sud_cli.importing import import_court_cases
1334
+
1335
+ init_db(args.state_dir)
1336
+ conn = get_connection(args.state_dir)
1337
+ try:
1338
+ report = import_court_cases(conn, args.file)
1339
+ except (CourtImportError, FixtureImportError) as exc:
1340
+ return error(
1341
+ str(exc),
1342
+ meta={"command": "court import"},
1343
+ json_mode=args.json_mode,
1344
+ )
1345
+ except Exception as exc:
1346
+ return error(
1347
+ f"unexpected internal error: {type(exc).__name__}",
1348
+ meta={"command": "court import"},
1349
+ json_mode=args.json_mode,
1350
+ )
1351
+ result(
1352
+ report,
1353
+ meta={"command": "court import", "state_dir": str(args.state_dir)},
1354
+ json_mode=args.json_mode,
1355
+ )
1356
+ return 0
1357
+
1358
+
1359
+ def cmd_court_search(args):
1360
+ from sud_cli.db import SearchError, get_connection, init_db, search_court_cases
1361
+
1362
+ init_db(args.state_dir)
1363
+ conn = get_connection(args.state_dir)
1364
+ try:
1365
+ payload, meta = search_court_cases(
1366
+ conn,
1367
+ query=args.query,
1368
+ limit=args.limit,
1369
+ since=args.since,
1370
+ until=args.until,
1371
+ )
1372
+ except SearchError as exc:
1373
+ return error(
1374
+ str(exc),
1375
+ meta={"command": "court search"},
1376
+ json_mode=args.json_mode,
1377
+ )
1378
+ except Exception as exc:
1379
+ return error(
1380
+ f"unexpected internal error: {type(exc).__name__}",
1381
+ meta={"command": "court search"},
1382
+ json_mode=args.json_mode,
1383
+ )
1384
+ meta = meta or {}
1385
+ meta.update({"command": "court search", "state_dir": str(args.state_dir)})
1386
+ result(payload, meta=meta, json_mode=args.json_mode)
1387
+ return 0
1388
+
1389
+
1390
+ def cmd_court_case(args):
1391
+ from sud_cli.db import get_case_events, get_case_summary, get_connection, init_db
1392
+
1393
+ init_db(args.state_dir)
1394
+ conn = get_connection(args.state_dir)
1395
+ try:
1396
+ summary = get_case_summary(conn, args.case_id)
1397
+ if summary is None:
1398
+ return error(
1399
+ f"case not found: {args.case_id}",
1400
+ meta={"command": "court case"},
1401
+ json_mode=args.json_mode,
1402
+ )
1403
+ events = get_case_events(conn, args.case_id)
1404
+ payload = {
1405
+ **summary,
1406
+ "events": events,
1407
+ "event_count": len(events),
1408
+ }
1409
+ except Exception as exc:
1410
+ return error(
1411
+ f"unexpected internal error: {type(exc).__name__}",
1412
+ meta={"command": "court case", "state_dir": str(args.state_dir)},
1413
+ json_mode=args.json_mode,
1414
+ )
1415
+ result(
1416
+ payload,
1417
+ meta={"command": "court case", "state_dir": str(args.state_dir)},
1418
+ json_mode=args.json_mode,
1419
+ )
1420
+ return 0
1421
+
1422
+
1423
+ def _emit_bridge_result(bridge_result, label, state_dir, json_mode):
1424
+ """Emit a bridge result under its own status and return the exit code.
1425
+
1426
+ The bridge distinguishes ``blocked`` ("live access is not enabled", nothing
1427
+ was attempted) from ``error`` (something was attempted and failed). Routing
1428
+ both through ``error`` told an agent that a missing env var was a crash, so
1429
+ it retried a call that could never succeed.
1430
+ """
1431
+ status = bridge_result.get("status")
1432
+ if status == "ok":
1433
+ result(
1434
+ bridge_result["data"],
1435
+ meta={"command": label, "state_dir": str(state_dir)},
1436
+ json_mode=json_mode,
1437
+ )
1438
+ return 0
1439
+ emitter = blocked if status == "blocked" else error
1440
+ emitter(
1441
+ bridge_result["data"],
1442
+ meta={"command": label},
1443
+ json_mode=json_mode,
1444
+ )
1445
+ return 1
1446
+
1447
+
1448
+ def cmd_case_search_providers(args):
1449
+ bridge_result = run_bridge_command(
1450
+ "providers",
1451
+ state_dir=args.state_dir,
1452
+ )
1453
+ return _emit_bridge_result(
1454
+ bridge_result, "case-search providers", args.state_dir, args.json_mode
1455
+ )
1456
+
1457
+
1458
+ def cmd_case_search_import(args):
1459
+ bridge_result = run_bridge_command(
1460
+ args.cw_command,
1461
+ state_dir=args.state_dir,
1462
+ input_path=args.file,
1463
+ case_id=args.case_id,
1464
+ )
1465
+ return _emit_bridge_result(
1466
+ bridge_result,
1467
+ f"case-search {args.cw_command}",
1468
+ args.state_dir,
1469
+ args.json_mode,
1470
+ )
1471
+
1472
+
1473
+ def cmd_case_search_extract(args):
1474
+ bridge_result = run_bridge_command(
1475
+ "extract-text",
1476
+ state_dir=args.state_dir,
1477
+ input_path=args.file,
1478
+ )
1479
+ return _emit_bridge_result(
1480
+ bridge_result, "case-search extract-text", args.state_dir, args.json_mode
1481
+ )
1482
+
1483
+
1484
+ def cmd_case_diff(args):
1485
+ """Compare two saved snapshots of the same case.
1486
+
1487
+ Offline by construction: it reads two JSON files and touches no court
1488
+ source, so it needs no live gate and no license. The compared field set
1489
+ mirrors ``case_search.diff._SCALARS``, which case-search computes
1490
+ internally but never exposes.
1491
+ """
1492
+ from sud_cli.case_diff import DiffError, diff_case_snapshots
1493
+
1494
+ try:
1495
+ payload = diff_case_snapshots(args.old, args.new)
1496
+ except DiffError as exc:
1497
+ return error(
1498
+ {"status": "DIFF_ERROR", "detail": str(exc)},
1499
+ meta={"command": "case-search case-diff"},
1500
+ json_mode=args.json_mode,
1501
+ )
1502
+
1503
+ result(
1504
+ payload,
1505
+ meta={
1506
+ "command": "case-search case-diff",
1507
+ "changed": payload["changed"],
1508
+ },
1509
+ json_mode=args.json_mode,
1510
+ )
1511
+ return 0
1512
+
1513
+
1514
+ def cmd_case_search_parity(args):
1515
+ from sud_cli.case_search_bridge import get_parity_matrix
1516
+
1517
+ matrix = get_parity_matrix()
1518
+ result(
1519
+ matrix,
1520
+ meta={"command": "case-search parity", "state_dir": str(args.state_dir)},
1521
+ json_mode=args.json_mode,
1522
+ )
1523
+ return 0
1524
+
1525
+
1526
+ def _run_flag_command_cli(args, command, options, label):
1527
+ """Shared handler for the Phase 8 flag-driven live bridge commands."""
1528
+ bridge_result = run_bridge_command(
1529
+ command,
1530
+ state_dir=args.state_dir,
1531
+ options=options,
1532
+ )
1533
+ return _emit_bridge_result(bridge_result, label, args.state_dir, args.json_mode)
1534
+
1535
+
1536
+ def cmd_case_search_search_practice(args):
1537
+ # sudact.ru serves a "Поиск не доступен" stub to plain HTTP clients, so the
1538
+ # search only returns results when it goes through the Camofox browser. The
1539
+ # flag therefore defaults on; --no-camofox is the explicit opt-out.
1540
+ via_camofox = not args.no_camofox
1541
+ return _run_flag_command_cli(
1542
+ args,
1543
+ "search-practice",
1544
+ {
1545
+ "query": args.query,
1546
+ "section": args.section,
1547
+ "page": args.page,
1548
+ "limit": args.limit,
1549
+ "via_camofox": via_camofox,
1550
+ },
1551
+ "case-search search-practice",
1552
+ )
1553
+
1554
+
1555
+ def _run_live_command_cli(args, command, label, **kwargs):
1556
+ """Shared handler for positional live commands reached through the bridge."""
1557
+ bridge_result = run_bridge_command(
1558
+ command,
1559
+ state_dir=args.state_dir,
1560
+ **kwargs,
1561
+ )
1562
+ return _emit_bridge_result(bridge_result, label, args.state_dir, args.json_mode)
1563
+
1564
+
1565
+ def cmd_case_search_lookup(args):
1566
+ return _run_live_command_cli(
1567
+ args,
1568
+ "lookup",
1569
+ "case-search lookup",
1570
+ case_id=args.case_id,
1571
+ options={"--source": args.source} if args.source else None,
1572
+ )
1573
+
1574
+
1575
+ def cmd_case_search_case_history(args):
1576
+ return _run_live_command_cli(
1577
+ args, "case-history", "case-search case-history", case_id=args.case_id
1578
+ )
1579
+
1580
+
1581
+ def cmd_case_search_sudrf_live(args):
1582
+ return _run_live_command_cli(
1583
+ args, "sudrf-live", "case-search sudrf-live", case_id=args.case_id
1584
+ )
1585
+
1586
+
1587
+ def cmd_case_search_mos_live(args):
1588
+ return _run_live_command_cli(
1589
+ args,
1590
+ "mos-live",
1591
+ "case-search mos-live",
1592
+ case_id=args.case_id,
1593
+ court_slug=args.court_slug,
1594
+ )
1595
+
1596
+
1597
+ def cmd_case_search_kad_search(args):
1598
+ return _run_flag_command_cli(
1599
+ args,
1600
+ "kad-search",
1601
+ {
1602
+ "inn": args.inn,
1603
+ "ogrn": args.ogrn,
1604
+ "name": args.name,
1605
+ "page": args.page,
1606
+ },
1607
+ "case-search kad-search",
1608
+ )
1609
+
1610
+
1611
+ def cmd_case_search_get_text(args):
1612
+ return _run_flag_command_cli(
1613
+ args,
1614
+ "get-text",
1615
+ {
1616
+ "url_or_file": args.url,
1617
+ "no_cache": args.no_cache,
1618
+ "blocks": args.blocks,
1619
+ },
1620
+ "case-search get-text",
1621
+ )
1622
+
1623
+
1624
+ def _emit_native(outcome, label, args):
1625
+ payload = outcome.to_dict() if hasattr(outcome, "to_dict") else outcome
1626
+ status = payload.get("status", "error")
1627
+ emitter = result if status == "ok" else (blocked if status == "degraded" else error)
1628
+ emitter(payload, meta={"command": label, "state_dir": str(args.state_dir)}, json_mode=args.json_mode)
1629
+ return 0 if status == "ok" else 1
1630
+
1631
+
1632
+ def cmd_native_practice(args):
1633
+ from sud_cli.case_search import search_practice
1634
+ return _emit_native(search_practice(args.query, section=args.section, page=args.page, limit=args.limit), "practice", args)
1635
+
1636
+
1637
+ def cmd_native_kad(args):
1638
+ from sud_cli.case_search import kad_search
1639
+ return _emit_native(kad_search(args.inn, args.ogrn, args.name, page=args.page), "kad", args)
1640
+
1641
+
1642
+ def cmd_native_case_history(args):
1643
+ from sud_cli.case_search import case_history
1644
+ return _emit_native(case_history(args.case_id, section=args.section, max_related=args.max_related, limit=args.limit), "case-history", args)
1645
+
1646
+
1647
+ def cmd_native_sudrf(args):
1648
+ from sud_cli.case_search import get_sudrf_case
1649
+ return _emit_native(get_sudrf_case(args.case_id, host=args.host), "sudrf-live", args)
1650
+
1651
+
1652
+ def cmd_native_mos(args):
1653
+ from sud_cli.case_search import get_mos_case
1654
+ return _emit_native(get_mos_case(args.court_slug, args.case_id), "mos-live", args)
1655
+
1656
+
1657
+ def cmd_efrsb(args):
1658
+ """ЕФРСБ: link/record — ручной human-in-the-loop контур (акт 25.10);
1659
+ сетевые companies/persons/... — legacy-эндпоинты (fail-closed)."""
1660
+ from sud_cli import efrsb_public as ep
1661
+
1662
+ if args.efrsb_cmd == "link":
1663
+ from sud_cli import efrsb_manual
1664
+ try:
1665
+ payload = efrsb_manual.link_plan(args.kind, name=args.name, inn=args.inn,
1666
+ ogrn=args.ogrn, birth_date=args.birth_date)
1667
+ except ValueError as exc:
1668
+ error(str(exc), meta={"command": "efrsb link"}, json_mode=args.json_mode)
1669
+ return 1
1670
+ result(payload, meta={"command": "efrsb link",
1671
+ "policy": "browser-адаптер запрещён актом 25.10; ссылку открывает человек"},
1672
+ json_mode=args.json_mode)
1673
+ return 0
1674
+ if args.efrsb_cmd == "record":
1675
+ from sud_cli import efrsb_manual
1676
+ from sud_cli.db import (DocNotFoundError, EvidenceError, add_case_evidence,
1677
+ get_connection, init_db)
1678
+ try:
1679
+ payload = efrsb_manual.record_payload(subject=args.subject, result=args.result,
1680
+ checked_by=args.checked_by,
1681
+ source_ref=args.source_ref, notes=args.notes)
1682
+ except ValueError as exc:
1683
+ error(str(exc), meta={"command": "efrsb record"}, json_mode=args.json_mode)
1684
+ return 1
1685
+ init_db(args.state_dir)
1686
+ conn = get_connection(args.state_dir)
1687
+ try:
1688
+ try:
1689
+ res = add_case_evidence(conn, args.case, title=payload["title"],
1690
+ path=None, note=payload["note"])
1691
+ except (DocNotFoundError, EvidenceError) as exc:
1692
+ return error({"status": "INVALID_ARGUMENTS", "detail": str(exc)},
1693
+ meta={"command": "efrsb record", "state_dir": str(args.state_dir)},
1694
+ json_mode=args.json_mode)
1695
+ finally:
1696
+ conn.close()
1697
+ if res is None:
1698
+ return error(f"case not found: {args.case}",
1699
+ meta={"command": "efrsb record"}, json_mode=args.json_mode)
1700
+ result(res, meta={"command": "efrsb record", "state_dir": str(args.state_dir)},
1701
+ json_mode=args.json_mode)
1702
+ return 0
1703
+
1704
+ try:
1705
+ if args.efrsb_cmd == "companies":
1706
+ payload = ep.search_companies(inn=args.inn, ogrn=args.ogrn,
1707
+ name=args.name, limit=args.limit)
1708
+ elif args.efrsb_cmd == "persons":
1709
+ payload = ep.search_persons(args.name, birth_date=args.birth_date,
1710
+ limit=args.limit)
1711
+ elif args.efrsb_cmd == "publications":
1712
+ payload = ep.case_publications(args.case_id, limit=args.limit)
1713
+ else:
1714
+ payload = ep.search_managers(search=args.search, inn=args.inn,
1715
+ sro=args.sro, limit=args.limit)
1716
+ except ep.EfrsbPublicError as exc:
1717
+ error(str(exc), meta={"command": "efrsb"}, json_mode=args.json_mode)
1718
+ return 1
1719
+ result(payload,
1720
+ meta={"command": "efrsb", "source": "bankrot.fedresurs.ru"},
1721
+ json_mode=args.json_mode)
1722
+ return 0
1723
+
1724
+
1725
+ def cmd_egrul(args):
1726
+ """ЕГРЮЛ/ЕГРИП (egrul.nalog.ru): карточка контрагента из открытых данных ФНС."""
1727
+ from sud_cli import egrul_nalog
1728
+
1729
+ try:
1730
+ payload = egrul_nalog.search(args.query, exact_name=args.name_eq)
1731
+ except egrul_nalog.EgrulError as exc:
1732
+ error(str(exc), meta={"command": "egrul"}, json_mode=args.json_mode)
1733
+ return 1
1734
+ result(payload,
1735
+ meta={"command": "egrul", "source": "egrul.nalog.ru",
1736
+ "basis": _wm.render_line("egrul_basis", __version__),
1737
+ "rows": len(payload.get("rows") or [])},
1738
+ json_mode=args.json_mode)
1739
+ return 0
1740
+
1741
+
1742
+ def cmd_license(args):
1743
+ """§13 AGPL: лицензия, источник сборки, флаги сборки (v2.17.0 §13-интерфейс)."""
1744
+ from sud_cli import _wm
1745
+ from sud_cli import __version__ as _v
1746
+ payload = _wm.source_info(_v)
1747
+ payload["build_flags"] = _wm.build_flags(_v) if getattr(args, "with_build_flags", True) else []
1748
+ result(payload, meta={"command": "license", "policy": "AGPL-3.0-only; сетевой сервис = обязанность отдать исходники"},
1749
+ json_mode=args.json_mode)
1750
+ return 0
1751
+
1752
+
1753
+ def cmd_fssp_check(args):
1754
+ """План ручной проверки БДИП — офлайн, сетевых запросов нет (юридическая проверка 25.10)."""
1755
+ from sud_cli import fssp_manual
1756
+
1757
+ try:
1758
+ payload = fssp_manual.check_plan(args.kind, name=args.name, inn=args.inn,
1759
+ ogrn=args.ogrn, birth_date=args.birth_date,
1760
+ region=args.region)
1761
+ except ValueError as exc:
1762
+ error(str(exc), meta={"command": "fssp check"}, json_mode=args.json_mode)
1763
+ return 1
1764
+ payload["policy_line"] = _wm.render_line("fssp_captcha", __version__)
1765
+ result(payload, meta={"command": "fssp check"}, json_mode=args.json_mode)
1766
+ return 0
1767
+
1768
+
1769
+ def cmd_plenum(args):
1770
+ """Корпус разъяснений ВС/ВАС (S-55): список/карточка/поиск/проверка хэшей/импорт."""
1771
+ from sud_cli import plenum_corpus as pc
1772
+
1773
+ try:
1774
+ if args.plenum_cmd == "list":
1775
+ payload = pc.list_cards(args.state_dir)
1776
+ elif args.plenum_cmd == "show":
1777
+ payload = pc.get_card(args.state_dir, args.doc_id)
1778
+ elif args.plenum_cmd == "search":
1779
+ payload = pc.search(args.state_dir, args.text)
1780
+ elif args.plenum_cmd == "verify":
1781
+ payload = pc.verify(args.state_dir)
1782
+ else: # import
1783
+ card = json.loads(Path(args.file).read_text(encoding="utf-8"))
1784
+ if "court_ruling_card" in card:
1785
+ card = card["court_ruling_card"]
1786
+ payload = pc.add_card(args.state_dir, card, rehash=True)
1787
+ except pc.PlenumError as exc:
1788
+ error(str(exc), meta={"command": "plenum " + args.plenum_cmd}, json_mode=args.json_mode)
1789
+ return 1
1790
+ except FileNotFoundError as exc:
1791
+ error(f"file not found: {exc}", meta={"command": "plenum import"}, json_mode=args.json_mode)
1792
+ return 1
1793
+ result(payload, meta={"command": "plenum " + args.plenum_cmd,
1794
+ "sources_policy": "только официальные публикации (vsrf/pravo/Бюллетень; ФКЗ 8-ФКЗ)"},
1795
+ json_mode=args.json_mode)
1796
+ return 0
1797
+
1798
+
1799
+ def cmd_fssp_record(args):
1800
+ """Результат ручной проверки → реестр доказательств дела (provenance обязателен)."""
1801
+ from sud_cli import fssp_manual
1802
+ from sud_cli.db import (DocNotFoundError, EvidenceError, add_case_evidence,
1803
+ get_connection, init_db)
1804
+
1805
+ try:
1806
+ payload = fssp_manual.record_payload(subject=args.subject, result=args.result,
1807
+ checked_by=args.checked_by, notes=args.notes,
1808
+ evidence_file=args.file)
1809
+ except ValueError as exc:
1810
+ error(str(exc), meta={"command": "fssp record"}, json_mode=args.json_mode)
1811
+ return 1
1812
+ init_db(args.state_dir)
1813
+ conn = get_connection(args.state_dir)
1814
+ try:
1815
+ try:
1816
+ res = add_case_evidence(conn, args.case, title=payload["title"],
1817
+ path=payload["evidence_file"], note=payload["note"])
1818
+ except (DocNotFoundError, EvidenceError) as exc:
1819
+ return error({"status": "INVALID_ARGUMENTS", "detail": str(exc)},
1820
+ meta={"command": "fssp record", "state_dir": str(args.state_dir)},
1821
+ json_mode=args.json_mode)
1822
+ finally:
1823
+ conn.close()
1824
+ if res is None:
1825
+ return error(f"case not found: {args.case}",
1826
+ meta={"command": "fssp record"}, json_mode=args.json_mode)
1827
+ result(res, meta={"command": "fssp record", "state_dir": str(args.state_dir)},
1828
+ json_mode=args.json_mode)
1829
+ return 0
1830
+
1831
+
1832
+ def cmd_fssp_assist(args):
1833
+ """Полу-автомат ФССП: старт или возврат с кодом капчи от человека."""
1834
+ from sud_cli import fssp_semi
1835
+
1836
+ try:
1837
+ if args.resume:
1838
+ if not args.code:
1839
+ return error("assist --resume requires --code (human-read captcha)",
1840
+ meta={"command": "fssp assist"}, json_mode=args.json_mode)
1841
+ payload = fssp_semi.resume(args.state_dir, args.resume, args.code)
1842
+ else:
1843
+ if not args.kind:
1844
+ return error("assist requires --kind fiz|ur (or --resume/--code)",
1845
+ meta={"command": "fssp assist"}, json_mode=args.json_mode)
1846
+ payload = fssp_semi.start(args.state_dir, kind=args.kind, name=args.name,
1847
+ inn=args.inn, birth_date=args.birth_date)
1848
+ except fssp_semi.FsspAssistError as exc:
1849
+ return error(str(exc), meta={"command": "fssp assist"}, json_mode=args.json_mode)
1850
+ # из состояния убираем служебный путь state_dir для чистоты вывода
1851
+ public = {k: v for k, v in payload.items() if k not in ("state_dir", "user_id", "tab_id")}
1852
+ result(public, meta={"command": "fssp assist",
1853
+ "policy": "капчу решает человек; авто.solve запрещён актом legal"},
1854
+ json_mode=args.json_mode)
1855
+ return 0
1856
+
1857
+
1858
+ def cmd_fssp_stats(args):
1859
+ """Официальная статистика БДИП (общедоступный CSV ФССП)."""
1860
+ from sud_cli import fssp_stats
1861
+
1862
+ try:
1863
+ payload = fssp_stats.statistics(args.region)
1864
+ except fssp_stats.FsspStatsError as exc:
1865
+ error(str(exc), meta={"command": "fssp stats"}, json_mode=args.json_mode)
1866
+ return 1
1867
+ result(payload, meta={"command": "fssp stats", "source": fssp_stats.STATS_URL},
1868
+ json_mode=args.json_mode)
1869
+ return 0
1870
+
1871
+
1872
+ def cmd_native_text(args):
1873
+ from sud_cli.case_search import get_decision_text
1874
+ return _emit_native(get_decision_text(str(args.file)), "extract-text", args)
1875
+
1876
+
1877
+ def cmd_native_import_card(args):
1878
+ from sud_cli.case_search import import_document
1879
+ return _emit_native(import_document(args.file, args.case_id, store_dir=args.state_dir / "snapshots"), "import-card", args)
1880
+
1881
+
1882
+ def _render_disclaimer(rules, calc_date):
1883
+ """The specification makes the reference-only disclaimer mandatory."""
1884
+ return rules["mandatory_disclaimer"].format(calc_date=calc_date)
1885
+
1886
+
1887
+ def _print_disclaimer(disclaimer, json_mode=False):
1888
+ # Printed to stderr so it survives JSON mode: a consumer parsing stdout
1889
+ # must still be able to show the user that this is advisory, not advice.
1890
+ #
1891
+ # In --json mode it must NOT be printed as bare text: a caller that parses
1892
+ # stderr as JSON would choke on the Russian notice, and the project's
1893
+ # convention is typed output on both streams. The disclaimer is still
1894
+ # carried — in the successful payload as ``disclaimer``, and for refusals
1895
+ # the caller gets a JSON envelope with the same rule metadata. Skipping it
1896
+ # here keeps the stream machine-readable; the notice is not lost, it moves
1897
+ # into the JSON.
1898
+ if json_mode:
1899
+ return
1900
+ print(disclaimer, file=sys.stderr)
1901
+
1902
+
1903
+ def cmd_fees(args):
1904
+ from datetime import date as _date
1905
+
1906
+ from .legal_engine import (
1907
+ STATUS_OK,
1908
+ calculate_fee,
1909
+ load_rules,
1910
+ parse_as_of,
1911
+ validate_claim_class,
1912
+ )
1913
+
1914
+ try:
1915
+ rules = load_rules()
1916
+ except Exception as exc: # rules file missing/corrupt must fail closed
1917
+ error(
1918
+ {"status": "RULES_UNAVAILABLE", "detail": str(exc)},
1919
+ meta={"command": "fees"},
1920
+ json_mode=args.json_mode,
1921
+ )
1922
+ return 1
1923
+
1924
+ # ``--as-of`` is parsed by the same strict validator the engine uses, so CLI
1925
+ # and MCP cannot disagree about which date strings are legal. A bad date is
1926
+ # an argument error, not a calculation result: nothing is computed and the
1927
+ # exit code says so.
1928
+ as_of_value = None
1929
+ if getattr(args, "as_of", None) is not None:
1930
+ try:
1931
+ as_of_value = parse_as_of(args.as_of)
1932
+ except ValueError as exc:
1933
+ error(
1934
+ {"status": "INVALID_ARGUMENTS", "detail": f"--as-of: {exc}"},
1935
+ meta={"command": "fees"},
1936
+ json_mode=args.json_mode,
1937
+ )
1938
+ return 1
1939
+
1940
+ if args.claim_class is not None:
1941
+ class_result = validate_claim_class(args.claim_class, rules, args.kind)
1942
+ if class_result.status != STATUS_OK:
1943
+ error(
1944
+ class_result.to_dict(),
1945
+ meta={"command": "fees", "rules_version": rules["version"],
1946
+ "basis": _wm.render_line("fees_basis", __version__)},
1947
+ json_mode=args.json_mode,
1948
+ )
1949
+ return 1
1950
+
1951
+ fee = calculate_fee(
1952
+ claim_amount=args.claim_amount,
1953
+ jurisdiction=args.jurisdiction,
1954
+ fee_kind=args.kind,
1955
+ applicant_type=args.applicant,
1956
+ rules=rules,
1957
+ as_of=as_of_value,
1958
+ )
1959
+
1960
+ calc_date = _date.today().isoformat()
1961
+ _print_disclaimer(_render_disclaimer(rules, calc_date), args.json_mode)
1962
+
1963
+ meta = {
1964
+ "command": "fees",
1965
+ "rules_version": rules["version"],
1966
+ "tariff_version": rules["tariff_version"],
1967
+ "calc_date": calc_date,
1968
+ }
1969
+ if as_of_value is not None:
1970
+ # The number's provenance belongs in the machine-readable envelope: which
1971
+ # edition produced it, its window, and any fallback warning.
1972
+ meta["as_of"] = as_of_value.isoformat()
1973
+ for key, meta_key in (
1974
+ ("edition_id", "tariff_edition_id"),
1975
+ ("tariff_effective_window", "tariff_effective_window"),
1976
+ ("basis", "tariff_basis"),
1977
+ ("tariff_scale", "tariff_scale"),
1978
+ ):
1979
+ if fee.meta.get(key):
1980
+ meta[meta_key] = fee.meta[key]
1981
+ if fee.meta.get("warnings"):
1982
+ meta["tariff_warnings"] = list(fee.meta["warnings"])
1983
+ if not args.json_mode:
1984
+ for line in fee.meta["warnings"]:
1985
+ print(f"ПРЕДУПРЕЖДЕНИЕ: {line}", file=sys.stderr)
1986
+
1987
+ if fee.status != STATUS_OK:
1988
+ payload_err = fee.to_dict()
1989
+ if getattr(args, "explain", False):
1990
+ from .explain import fees_explain
1991
+ payload_err["explain"] = fees_explain(
1992
+ fee_doc=fee.to_dict(), rules=rules,
1993
+ as_of_iso=as_of_value.isoformat() if as_of_value else None)
1994
+ error(payload_err, meta=meta, json_mode=args.json_mode)
1995
+ return 1
1996
+
1997
+ payload = fee.to_dict()
1998
+ if getattr(args, "explain", False):
1999
+ from .explain import fees_explain
2000
+ payload["explain"] = fees_explain(
2001
+ fee_doc=payload, rules=rules,
2002
+ as_of_iso=as_of_value.isoformat() if as_of_value else None)
2003
+ payload["disclaimer"] = _render_disclaimer(rules, calc_date)
2004
+ payload["exemptions_note"] = rules["exemptions"]["mandatory_note"]
2005
+ payload["clean_room"] = rules["clean_room"]
2006
+ result(payload, meta=meta, json_mode=args.json_mode)
2007
+ return 0
2008
+
2009
+
2010
+ def _run_deadline_calculation(args):
2011
+ from datetime import date as _date, datetime, timezone
2012
+
2013
+ from .legal_deadlines import calculate_deadline
2014
+ from .legal_engine import STATUS_OK, load_rules
2015
+ from sud_cli.db import (
2016
+ get_latest_anchored_state,
2017
+ insert_deadline_state,
2018
+ )
2019
+
2020
+ try:
2021
+ rules = load_rules()
2022
+ except Exception as exc:
2023
+ error(
2024
+ {"status": "RULES_UNAVAILABLE", "detail": str(exc)},
2025
+ meta={"command": "deadlines"},
2026
+ json_mode=args.json_mode,
2027
+ )
2028
+ return 1
2029
+
2030
+ if getattr(args, "calc_case_id", None) and not getattr(args, "case_id", None):
2031
+ args.case_id = args.calc_case_id
2032
+ if not getattr(args, "deadline_key", None):
2033
+ args.deadline_key = getattr(args, "calc_deadline_key", None)
2034
+
2035
+ event_date = None
2036
+ if getattr(args, "event_date", None):
2037
+ try:
2038
+ event_date = datetime.strptime(args.event_date, "%Y-%m-%d").date()
2039
+ except ValueError:
2040
+ error(
2041
+ {
2042
+ "status": "MISSING_REQUIRED_PARAM",
2043
+ "detail": f"event_date '{args.event_date}' не в формате ГГГГ-ММ-ДД",
2044
+ },
2045
+ meta={"command": "deadlines"},
2046
+ json_mode=args.json_mode,
2047
+ )
2048
+ return 1
2049
+
2050
+ case_id_for_anchor = getattr(args, "case_id", None)
2051
+ if event_date is None and case_id_for_anchor:
2052
+ try:
2053
+ from .db import get_connection, get_latest_anchored_state, init_db
2054
+ init_db(args.state_dir)
2055
+ conn = get_connection(args.state_dir)
2056
+ try:
2057
+ anchored = get_latest_anchored_state(conn, case_id_for_anchor, args.deadline_key)
2058
+ finally:
2059
+ conn.close()
2060
+ if anchored:
2061
+ ab = anchored.get("basis") or {}
2062
+ if isinstance(ab, dict) and ab.get("event_date"):
2063
+ try:
2064
+ event_date = datetime.strptime(str(ab["event_date"]), "%Y-%m-%d").date()
2065
+ args.event_date = str(ab["event_date"])
2066
+ except ValueError:
2067
+ pass
2068
+ except Exception:
2069
+ pass
2070
+
2071
+ deadline = calculate_deadline(
2072
+ deadline_key=args.deadline_key,
2073
+ event_date=event_date,
2074
+ rules=rules,
2075
+ region=getattr(args, "region", None),
2076
+ tz=getattr(args, "tz", None) or "Europe/Moscow",
2077
+ )
2078
+
2079
+ calc_date = _date.today().isoformat()
2080
+ _print_disclaimer(_render_disclaimer(rules, calc_date), args.json_mode)
2081
+
2082
+ meta = {
2083
+ "command": "deadlines",
2084
+ "rules_version": rules["version"],
2085
+ "calc_date": calc_date,
2086
+ "open_questions": rules["spec_open_questions"],
2087
+ }
2088
+
2089
+ if deadline.status != STATUS_OK:
2090
+ payload_err = deadline.to_dict()
2091
+ if getattr(args, "explain", False):
2092
+ from .explain import deadlines_explain
2093
+ payload_err["explain"] = deadlines_explain(
2094
+ payload=deadline.to_dict(), rules=rules,
2095
+ event_date=getattr(args, "event_date", None),
2096
+ region=getattr(args, "region", None),
2097
+ tz=getattr(args, "tz", None))
2098
+ error(payload_err, meta=meta, json_mode=args.json_mode)
2099
+ return 1
2100
+
2101
+ case_id = getattr(args, "case_id", None)
2102
+ warning_basis = None
2103
+ state = "computed"
2104
+ confirmed_by = None
2105
+
2106
+ if case_id:
2107
+ from sud_cli.db import get_connection, init_db
2108
+ init_db(args.state_dir)
2109
+ conn = get_connection(args.state_dir)
2110
+ try:
2111
+ anchored = get_latest_anchored_state(conn, case_id, args.deadline_key)
2112
+ if anchored is None:
2113
+ warning_basis = "event_date_assumed"
2114
+ basis = anchored.get("basis") if anchored else {
2115
+ "event_date": args.event_date,
2116
+ "assumed": True,
2117
+ "actor": "calculator",
2118
+ "ts": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
2119
+ }
2120
+ # calculate due_at already
2121
+ due_at = deadline.deadline.isoformat() if deadline.deadline else None
2122
+ row = insert_deadline_state(
2123
+ conn, case_id, args.deadline_key, due_at=due_at, basis=basis,
2124
+ state="computed", state_dir=None,
2125
+ )
2126
+ if getattr(args, "confirm", False):
2127
+ actor = getattr(args, "by", None) or "calculator"
2128
+ from sud_cli.db import confirm_deadline_state
2129
+ row = confirm_deadline_state(conn, args.state_dir, case_id, args.deadline_key, actor,
2130
+ evidence=getattr(args, "evidence", None))
2131
+ state = "confirmed"
2132
+ confirmed_by = actor
2133
+ # if waive requested
2134
+ if getattr(args, "waive", False) and getattr(args, "norm", None):
2135
+ actor = getattr(args, "by", None) or "calculator"
2136
+ from sud_cli.db import waive_deadline_state
2137
+ row = waive_deadline_state(conn, args.state_dir, case_id, args.deadline_key, actor,
2138
+ getattr(args, "norm"), evidence=getattr(args, "evidence", None))
2139
+ state = "waived"
2140
+ confirmed_by = actor
2141
+ finally:
2142
+ conn.close()
2143
+
2144
+ payload = deadline.to_dict()
2145
+ if getattr(args, "explain", False):
2146
+ from .explain import deadlines_explain
2147
+ payload["explain"] = deadlines_explain(
2148
+ payload=deadline.to_dict(), rules=rules,
2149
+ event_date=getattr(args, "event_date", None),
2150
+ region=getattr(args, "region", None),
2151
+ tz=getattr(args, "tz", None))
2152
+ payload["disclaimer"] = _render_disclaimer(rules, calc_date)
2153
+ payload["clean_room"] = rules["clean_room"]
2154
+ if case_id:
2155
+ payload["state"] = state
2156
+ payload["confirmed_by"] = confirmed_by
2157
+ if warning_basis:
2158
+ payload["warning_basis"] = warning_basis
2159
+ result(payload, meta=meta, json_mode=args.json_mode)
2160
+ return 0
2161
+
2162
+
2163
+ def cmd_deadlines(args):
2164
+ sub = getattr(args, "deadlines_command", None)
2165
+ if sub in ("calculate", "calc"):
2166
+ # normalize positional
2167
+ if getattr(args, "calc_case_id", None) and not getattr(args, "case_id", None):
2168
+ args.case_id = args.calc_case_id
2169
+ if not getattr(args, "deadline_key", None):
2170
+ args.deadline_key = getattr(args, "calc_deadline_key", None)
2171
+ # map event_date already
2172
+ return _run_deadline_calculation(args)
2173
+ if sub == "confirm":
2174
+ return cmd_deadlines_confirm(args)
2175
+ if sub == "anchor":
2176
+ return cmd_deadlines_anchor(args)
2177
+ if sub == "waive":
2178
+ return cmd_deadlines_waive(args)
2179
+ if sub == "audit":
2180
+ return cmd_deadlines_audit(args)
2181
+ if sub == "list":
2182
+ return cmd_deadlines_list(args)
2183
+ if sub == "matrix":
2184
+ return cmd_deadlines_matrix(args)
2185
+ if sub == "chain":
2186
+ return cmd_deadlines_chain(args)
2187
+
2188
+ # legacy: if no deadline_key but a case_id-like first? handle
2189
+ if getattr(args, "deadline_key", None) is None:
2190
+ # maybe called as: deadlines <case_id> <key>? not supported; error
2191
+ error({"status": "MISSING_REQUIRED_PARAM", "detail": "deadline_key required"}, meta={"command": "deadlines"}, json_mode=args.json_mode)
2192
+ return 1
2193
+
2194
+ # support calculate with case_id in legacy positional? If deadline_key looks like case? no.
2195
+ return _run_deadline_calculation(args)
2196
+
2197
+
2198
+ def cmd_deadlines_calculate(args):
2199
+ if getattr(args, "calc_case_id", None) and not getattr(args, "case_id", None):
2200
+ args.case_id = args.calc_case_id
2201
+ if not getattr(args, "deadline_key", None):
2202
+ args.deadline_key = getattr(args, "calc_deadline_key", None)
2203
+ return _run_deadline_calculation(args)
2204
+
2205
+
2206
+ def cmd_deadlines_confirm(args):
2207
+ from sud_cli.db import confirm_deadline_state, get_connection, init_db
2208
+ init_db(args.state_dir)
2209
+ conn = get_connection(args.state_dir)
2210
+ try:
2211
+ if getattr(args, "waive", False) or getattr(args, "norm", None):
2212
+ if not getattr(args, "norm", None):
2213
+ return error({"status": "INVALID_ARGUMENTS", "detail": "norm required for waive"}, meta={"command": "deadlines confirm"}, json_mode=args.json_mode)
2214
+ row = confirm_deadline_state(conn, args.state_dir, args.case_id, args.key, args.by,
2215
+ evidence=getattr(args, "evidence", None), waive=True, norm=args.norm)
2216
+ else:
2217
+ row = confirm_deadline_state(conn, args.state_dir, args.case_id, args.key, args.by,
2218
+ evidence=getattr(args, "evidence", None))
2219
+ finally:
2220
+ conn.close()
2221
+ result(row, meta={"command": "deadlines confirm", "state_dir": str(args.state_dir)}, json_mode=args.json_mode)
2222
+ return 0
2223
+
2224
+
2225
+ def cmd_deadlines_anchor(args):
2226
+ from sud_cli.db import anchor_deadline_state, get_connection, init_db
2227
+ init_db(args.state_dir)
2228
+ conn = get_connection(args.state_dir)
2229
+ try:
2230
+ actor = getattr(args, "by", None) or "cli"
2231
+ try:
2232
+ row = anchor_deadline_state(conn, args.state_dir, args.case_id, args.key,
2233
+ args.event_date, args.method, args.evidence, actor=actor)
2234
+ except ValueError as exc:
2235
+ return error(
2236
+ {"status": "invalid_deadline_key", "detail": str(exc)},
2237
+ meta={"command": "deadlines anchor", "state_dir": str(args.state_dir)},
2238
+ json_mode=args.json_mode,
2239
+ )
2240
+ finally:
2241
+ conn.close()
2242
+ result(row, meta={"command": "deadlines anchor", "state_dir": str(args.state_dir)}, json_mode=args.json_mode)
2243
+ return 0
2244
+
2245
+
2246
+ def cmd_deadlines_waive(args):
2247
+ from sud_cli.db import get_connection, init_db, waive_deadline_state
2248
+ init_db(args.state_dir)
2249
+ conn = get_connection(args.state_dir)
2250
+ try:
2251
+ row = waive_deadline_state(conn, args.state_dir, args.case_id, args.key, args.by, args.norm,
2252
+ evidence=getattr(args, "evidence", None))
2253
+ finally:
2254
+ conn.close()
2255
+ result(row, meta={"command": "deadlines waive", "state_dir": str(args.state_dir)}, json_mode=args.json_mode)
2256
+ return 0
2257
+
2258
+
2259
+ def cmd_deadlines_audit(args):
2260
+ from sud_cli.db import get_connection, init_db, read_deadline_audit
2261
+ init_db(args.state_dir)
2262
+ conn = get_connection(args.state_dir)
2263
+ try:
2264
+ entries = read_deadline_audit(args.state_dir, args.case_id)
2265
+ finally:
2266
+ conn.close()
2267
+ result(entries, meta={"command": "deadlines audit", "state_dir": str(args.state_dir)}, json_mode=args.json_mode)
2268
+ return 0
2269
+
2270
+
2271
+ def cmd_deadlines_list(args):
2272
+ from sud_cli.db import get_connection, init_db, list_all_deadline_states, list_deadline_states
2273
+ init_db(args.state_dir)
2274
+ conn = get_connection(args.state_dir)
2275
+ try:
2276
+ if getattr(args, "case_id", None):
2277
+ rows = list_deadline_states(conn, args.case_id)
2278
+ else:
2279
+ rows = list_all_deadline_states(conn)
2280
+ finally:
2281
+ conn.close()
2282
+ result(rows, meta={"command": "deadlines list", "state_dir": str(args.state_dir)}, json_mode=args.json_mode)
2283
+ return 0
2284
+
2285
+
2286
+ def cmd_deadlines_matrix(args):
2287
+ from datetime import date as _date
2288
+
2289
+ from .deadline_matrix import build_matrix
2290
+ from .legal_engine import load_rules
2291
+
2292
+ case_id = getattr(args, "case_id", None)
2293
+ if not case_id:
2294
+ return error({"status": "MISSING_REQUIRED_PARAM", "detail": "case_id required"},
2295
+ meta={"command": "deadlines matrix"}, json_mode=args.json_mode)
2296
+
2297
+ try:
2298
+ rules = load_rules()
2299
+ except Exception as exc:
2300
+ return error({"status": "RULES_UNAVAILABLE", "detail": str(exc)},
2301
+ meta={"command": "deadlines matrix"}, json_mode=args.json_mode)
2302
+
2303
+ try:
2304
+ instance, points = build_matrix(args.state_dir, case_id,
2305
+ instance_override=getattr(args, "instance", None),
2306
+ rules=rules, as_of=getattr(args, "as_of", None))
2307
+ except ValueError as exc:
2308
+ return error({"status": "INVALID_ARGUMENTS", "detail": str(exc)},
2309
+ meta={"command": "deadlines matrix", "state_dir": str(args.state_dir)},
2310
+ json_mode=args.json_mode)
2311
+
2312
+ if getattr(args, "missing", False):
2313
+ points = [p for p in points if p.get("needs_anchor")]
2314
+
2315
+ calc_date = _date.today().isoformat()
2316
+ disclaimer = _render_disclaimer(rules, calc_date)
2317
+ _print_disclaimer(disclaimer, args.json_mode)
2318
+
2319
+ payload = {
2320
+ "case_id": case_id,
2321
+ "instance": instance,
2322
+ "as_of": getattr(args, "as_of", None),
2323
+ "points": points,
2324
+ "matrix": points,
2325
+ "items": points,
2326
+ "deadlines": points,
2327
+ "entries": points,
2328
+ "disclaimer": disclaimer,
2329
+ "clean_room": rules.get("clean_room"),
2330
+ }
2331
+ meta = {
2332
+ "command": "deadlines matrix",
2333
+ "rules_version": rules["version"],
2334
+ "calc_date": calc_date,
2335
+ "state_dir": str(args.state_dir),
2336
+ }
2337
+ result(payload, meta=meta, json_mode=args.json_mode)
2338
+ return 0
2339
+
2340
+
2341
+ def cmd_deadlines_autofill(args):
2342
+ """W6a §5 (S-23): автозаполнение точек из текстов актов.
2343
+
2344
+ created — только state=computed с needs_confirm; confirmed autofill не
2345
+ делает никогда; cure-маркеры без VERIFIED строки матрицы отдаются
2346
+ manual_review-hint, точка не выдумывается.
2347
+ """
2348
+ from .autofill import autofill_case
2349
+ from sud_cli.db import get_connection, init_db
2350
+
2351
+ init_db(args.state_dir)
2352
+ conn = get_connection(args.state_dir)
2353
+ try:
2354
+ try:
2355
+ payload = autofill_case(conn, args.state_dir, args.case_id,
2356
+ event_id=getattr(args, "event_id", None))
2357
+ except ValueError as exc:
2358
+ return error({"status": "INVALID_ARGUMENTS", "detail": str(exc)},
2359
+ meta={"command": "deadlines autofill",
2360
+ "state_dir": str(args.state_dir)},
2361
+ json_mode=args.json_mode)
2362
+ finally:
2363
+ conn.close()
2364
+ result(payload, meta={"command": "deadlines autofill",
2365
+ "state_dir": str(args.state_dir)},
2366
+ json_mode=args.json_mode)
2367
+ return 0
2368
+
2369
+
2370
+ def cmd_calendar_agenda(args):
2371
+ from .agenda import agenda_payload
2372
+
2373
+ # W6a §1: тело вынесено в sud_cli.agenda без изменения формы конверта —
2374
+ # CLI и MCP вызывают один и тот же билдер (паритет S-80).
2375
+ payload = agenda_payload(args.state_dir, days=getattr(args, "days", 14))
2376
+ result(payload, meta={"command": "calendar agenda", "state_dir": str(args.state_dir)}, json_mode=args.json_mode)
2377
+ return 0
2378
+
2379
+
2380
+ def cmd_calendar_ics(args):
2381
+ """W6a §3 (S-28): RFC 5545 экспорт computed/confirmed сроков."""
2382
+ from .calendar_ics import ics_for_state_dir
2383
+
2384
+ days = args.days
2385
+ if not isinstance(days, int) or days < 1 or days > 3650:
2386
+ return error(
2387
+ {"status": "INVALID_ARGUMENTS",
2388
+ "detail": f"--days должен быть целым 1..3650, получен {days!r}"},
2389
+ meta={"command": "calendar ics", "state_dir": str(args.state_dir)},
2390
+ json_mode=args.json_mode,
2391
+ )
2392
+ text, events = ics_for_state_dir(args.state_dir, days=days)
2393
+ payload = {
2394
+ "days": days,
2395
+ "count": len(events),
2396
+ "events": events,
2397
+ "ics": text,
2398
+ }
2399
+ if args.out is not None:
2400
+ try:
2401
+ args.out.parent.mkdir(parents=True, exist_ok=True)
2402
+ args.out.write_bytes(text.encode("utf-8"))
2403
+ except OSError as exc:
2404
+ return error({"status": "INVALID_ARGUMENTS",
2405
+ "detail": f"--out: {exc}"},
2406
+ meta={"command": "calendar ics"},
2407
+ json_mode=args.json_mode)
2408
+ payload["path"] = str(args.out)
2409
+ result(payload, meta={"command": "calendar ics", "state_dir": str(args.state_dir)},
2410
+ json_mode=args.json_mode)
2411
+ return 0
2412
+
2413
+
2414
+ def cmd_interest(args):
2415
+ from datetime import datetime
2416
+
2417
+ from .interest import calculate_interest
2418
+
2419
+ try:
2420
+ start = datetime.strptime(args.start_date, "%Y-%m-%d").date()
2421
+ end = datetime.strptime(args.end_date, "%Y-%m-%d").date()
2422
+ except ValueError:
2423
+ return error({"status": "MISSING_REQUIRED_PARAM", "detail": "dates must use YYYY-MM-DD"}, meta={"command": "interest"}, json_mode=args.json_mode)
2424
+ calc = calculate_interest(args.amount, start, end, args.article, args.payments)
2425
+ payload = calc.to_dict()
2426
+ if getattr(args, "explain", False):
2427
+ from .explain import interest_explain
2428
+ payload["explain"] = interest_explain(
2429
+ payload=payload, article=args.article, amount=args.amount,
2430
+ start=args.start_date, end=args.end_date)
2431
+ if args.calc_fee and calc.status == "ok":
2432
+ from .legal_engine import calculate_fee, load_rules
2433
+ claim_amount = int(calc.remaining_principal) + int(calc.amount_rub)
2434
+ fee = calculate_fee(claim_amount=claim_amount, jurisdiction=args.jurisdiction, fee_kind=args.kind, applicant_type=args.applicant, rules=load_rules())
2435
+ payload.update({"principal_rub": args.amount, "claim_amount_rub": claim_amount, "fee": fee.to_dict()})
2436
+ emitter = result if calc.status == "ok" else error
2437
+ if emitter is error:
2438
+ return emitter(payload, meta={"command": "interest"}, json_mode=args.json_mode)
2439
+ emitter(payload, meta={"command": "interest"}, json_mode=args.json_mode)
2440
+ return 0
2441
+
2442
+
2443
+ def cmd_payment(args):
2444
+ from .payment import payment_details
2445
+ try:
2446
+ payload = payment_details(args.jurisdiction, args.amount, args.court, args.defendant)
2447
+ except ValueError as exc:
2448
+ return error(str(exc), meta={"command": "payment"}, json_mode=args.json_mode)
2449
+ result(payload, meta={"command": "payment"}, json_mode=args.json_mode)
2450
+ return 0
2451
+
2452
+
2453
+ def cmd_fee_exemption(args):
2454
+ from .fees_extra import fee_exemption_check
2455
+ from .legal_engine import load_rules
2456
+ try:
2457
+ rules = load_rules()
2458
+ except Exception as exc:
2459
+ return error({"status": "RULES_UNAVAILABLE", "detail": str(exc)},
2460
+ meta={"command": "fee-exemption"}, json_mode=args.json_mode)
2461
+ payload = fee_exemption_check(rules, jurisdiction=args.jurisdiction, category_code=args.category)
2462
+ emitter = result if payload.get("status") == "exempt" else (error if payload.get("status") == "manual_review" else result)
2463
+ return emitter(payload, meta={"command": "fee-exemption", "rules_version": rules["version"]},
2464
+ json_mode=args.json_mode) or 0
2465
+
2466
+
2467
+ def cmd_fee_refund(args):
2468
+ from .fees_extra import fee_refund
2469
+ from .legal_engine import load_rules
2470
+ from decimal import Decimal
2471
+ try:
2472
+ rules = load_rules()
2473
+ paid = Decimal(args.paid)
2474
+ assert paid >= 0
2475
+ except Exception as exc:
2476
+ return error({"status": "INVALID_ARGUMENTS", "detail": str(exc)},
2477
+ meta={"command": "fee-refund"}, json_mode=args.json_mode)
2478
+ payload = fee_refund(rules, paid=paid, ground=args.ground,
2479
+ claim_admitted=args.claim_admitted,
2480
+ plaintiff_default=args.plaintiff_default,
2481
+ transferred_to_arbitration=args.transferred_to_arbitration)
2482
+ emitter = result if payload.get("status") == "ok" else error
2483
+ return emitter(payload, meta={"command": "fee-refund", "rules_version": rules["version"]},
2484
+ json_mode=args.json_mode) or 0
2485
+
2486
+
2487
+ def cmd_fee_indexation(args):
2488
+ from .fees_extra import indexation_compute, indexation_frame
2489
+ from .legal_engine import load_rules
2490
+ from decimal import Decimal
2491
+ try:
2492
+ rules = load_rules()
2493
+ amount = Decimal(args.amount) if args.amount else None
2494
+ cs = Decimal(args.cpi_start) if args.cpi_start else None
2495
+ ce = Decimal(args.cpi_end) if args.cpi_end else None
2496
+ except Exception as exc:
2497
+ return error({"status": "INVALID_ARGUMENTS", "detail": str(exc)},
2498
+ meta={"command": "fee-indexation"}, json_mode=args.json_mode)
2499
+ payload = (indexation_compute(rules, code=args.code, amount=amount, cpi_start=cs, cpi_end=ce)
2500
+ if args.amount else indexation_frame(rules, code=args.code))
2501
+ emitter = result if payload.get("status") in ("ok", "frame_only") else error
2502
+ return emitter(payload, meta={"command": "fee-indexation", "rules_version": rules["version"]},
2503
+ json_mode=args.json_mode) or 0
2504
+
2505
+
2506
+ def cmd_deferral(args):
2507
+ from .deferral import generate_deferral
2508
+ result(generate_deferral(args.applicant, args.court, args.amount, args.reason), meta={"command": "deferral"}, json_mode=args.json_mode)
2509
+ return 0
2510
+
2511
+
2512
+ def cmd_limitation(args):
2513
+ from datetime import date
2514
+ from .limitation import calculate_limitation
2515
+ try:
2516
+ start = date.fromisoformat(args.start)
2517
+ check_date = date.fromisoformat(args.check_date) if args.check_date else date.today()
2518
+ payload = calculate_limitation(start=start, period_type=args.period_type,
2519
+ claim_notice_days=args.claim_notice_days, check_date=check_date,
2520
+ custom_years=args.years, custom_months=args.months).to_dict()
2521
+ except ValueError as exc:
2522
+ return error({"status": "INVALID_ARGUMENTS", "detail": str(exc)}, meta={"command": "limitation"}, json_mode=args.json_mode)
2523
+ if getattr(args, "explain", False):
2524
+ from .explain import limitation_explain
2525
+ payload["explain"] = limitation_explain(
2526
+ payload=dict(payload), period_type=args.period_type,
2527
+ start=args.start, check_date=args.check_date)
2528
+ emitter = result if payload["status"] == "ok" else error
2529
+ return emitter(payload, meta={"command": "limitation"}, json_mode=args.json_mode) or 0
2530
+
2531
+
2532
+ def cmd_timeline_audit(args):
2533
+ import json
2534
+ from .timeline_audit import audit_timeline
2535
+ try:
2536
+ source = Path(args.events)
2537
+ raw = json.loads(source.read_text(encoding="utf-8")) if source.exists() else json.loads(args.events)
2538
+ events = raw.get("events", raw) if isinstance(raw, dict) else raw
2539
+ payload = audit_timeline(events)
2540
+ except (OSError, json.JSONDecodeError, TypeError, ValueError) as exc:
2541
+ return error({"status": "INVALID_ARGUMENTS", "detail": str(exc)}, meta={"command": "timeline-audit"}, json_mode=args.json_mode)
2542
+ result(payload, meta={"command": "timeline-audit"}, json_mode=args.json_mode)
2543
+ return 0
2544
+
2545
+
2546
+ def cmd_npa_as_of(args):
2547
+ from .npa_as_of import lookup_as_of
2548
+ payload = lookup_as_of(args.act, args.article, args.date)
2549
+ if payload.get("status") != "ok":
2550
+ return error(payload, meta={"command": "npa-as-of"}, json_mode=args.json_mode)
2551
+ result(payload, meta={"command": "npa-as-of"}, json_mode=args.json_mode)
2552
+ return 0
2553
+
2554
+
2555
+ def cmd_verify_quote(args):
2556
+ """правило «тексты только из реестра» (S-52): цитата подтверждается только против явного источника.
2557
+
2558
+ exact/fuzzy/not_found/edition_unavailable — это данные (exit 0); кривые
2559
+ аргументы — INVALID_ARGUMENTS (exit != 0). Сети нет ни в одном источнике.
2560
+ """
2561
+ from .verify import VerifyInputError, verify_quote
2562
+ try:
2563
+ payload = verify_quote(
2564
+ args.text, args.source,
2565
+ act=args.act, article=args.article, date=args.date,
2566
+ case_id=args.case_id, path=args.path,
2567
+ state_dir=args.state_dir, max_fuzzy=args.max_fuzzy,
2568
+ )
2569
+ except VerifyInputError as exc:
2570
+ return error({"status": "INVALID_ARGUMENTS", "detail": str(exc)},
2571
+ meta={"command": "verify quote"}, json_mode=args.json_mode)
2572
+ except UnicodeDecodeError as exc:
2573
+ return error({"status": "INVALID_ARGUMENTS",
2574
+ "detail": f"source file is not valid UTF-8: {exc}"},
2575
+ meta={"command": "verify quote"}, json_mode=args.json_mode)
2576
+ except OSError as exc:
2577
+ return error({"status": "INVALID_ARGUMENTS",
2578
+ "detail": f"source file unreadable: {exc}"},
2579
+ meta={"command": "verify quote"}, json_mode=args.json_mode)
2580
+ if getattr(args, "explain", False):
2581
+ from .explain import verify_explain
2582
+ payload["explain"] = verify_explain(payload=dict(payload), text_len=len(args.text or ""))
2583
+ result(payload, meta={"command": "verify quote"}, json_mode=args.json_mode)
2584
+ return 0
2585
+
2586
+
2587
+ def cmd_practice_status(args):
2588
+ """S-54: verdict только из локальных артефактов; «действует» не выдается."""
2589
+ from .practice_status import assess_status
2590
+ store = args.store
2591
+ if store is None:
2592
+ candidate = args.state_dir / "case-search-snapshots"
2593
+ store = candidate if candidate.is_dir() else None
2594
+ try:
2595
+ payload = assess_status(args.case_number, related=args.related, store_dir=store)
2596
+ except ValueError as exc:
2597
+ return error({"status": "INVALID_ARGUMENTS", "detail": str(exc)},
2598
+ meta={"command": "practice status"}, json_mode=args.json_mode)
2599
+ result(payload, meta={"command": "practice status"}, json_mode=args.json_mode)
2600
+ return 0
2601
+
2602
+
2603
+ def cmd_serve_mcp(args):
2604
+ from .mcp_server import serve_stdio
2605
+ serve_stdio()
2606
+ return 0
2607
+
2608
+
2609
+ LEGAL_CASE_STATUSES = ["new", "review", "filed", "pending", "decided", "appealed", "closed", "unknown"]
2610
+
2611
+
2612
+ def cmd_case_new(args):
2613
+ from sud_cli.db import create_case, get_connection, init_db
2614
+ if args.status is not None and args.status not in LEGAL_CASE_STATUSES:
2615
+ return error(
2616
+ {"status": "invalid_status", "detail": f"status {args.status!r} not allowed"},
2617
+ meta={"command": "case new", "state_dir": str(args.state_dir)},
2618
+ json_mode=args.json_mode,
2619
+ )
2620
+ init_db(args.state_dir)
2621
+ conn = get_connection(args.state_dir)
2622
+ try:
2623
+ res = create_case(
2624
+ conn, args.number, args.court, args.plaintiff, args.defendant,
2625
+ owner=args.owner, status=args.status,
2626
+ # the wave §1: поле instance дела (None = не указана -> unknown).
2627
+ instance=getattr(args, "instance", None),
2628
+ )
2629
+ except Exception as exc:
2630
+ return error(
2631
+ str(exc),
2632
+ meta={"command": "case new", "state_dir": str(args.state_dir)},
2633
+ json_mode=args.json_mode,
2634
+ )
2635
+ finally:
2636
+ conn.close()
2637
+ if res.get("duplicate"):
2638
+ result(
2639
+ {"status": "duplicate_window", "case_id": res["case_id"]},
2640
+ meta={"command": "case new", "state_dir": str(args.state_dir)},
2641
+ json_mode=args.json_mode,
2642
+ )
2643
+ return 0
2644
+ result(
2645
+ {"case_id": res["case_id"], "case_number": res.get("case_number"),
2646
+ "court": res.get("court"), "status": res.get("status"),
2647
+ "owner": res.get("owner"), "instance": getattr(args, "instance", None)},
2648
+ meta={"command": "case new", "state_dir": str(args.state_dir)},
2649
+ json_mode=args.json_mode,
2650
+ )
2651
+ return 0
2652
+
2653
+
2654
+ def cmd_case_set(args):
2655
+ from sud_cli.db import get_connection, init_db, set_case
2656
+ if args.status is not None and args.status not in LEGAL_CASE_STATUSES:
2657
+ return error(
2658
+ {"status": "invalid_status", "detail": f"status {args.status!r} not allowed"},
2659
+ meta={"command": "case set", "state_dir": str(args.state_dir)},
2660
+ json_mode=args.json_mode,
2661
+ )
2662
+ init_db(args.state_dir)
2663
+ conn = get_connection(args.state_dir)
2664
+ try:
2665
+ fields = {}
2666
+ if args.status is not None:
2667
+ fields["status"] = args.status
2668
+ if args.owner is not None:
2669
+ fields["owner"] = args.owner
2670
+ if args.closed_at is not None:
2671
+ fields["closed_at"] = args.closed_at
2672
+ if getattr(args, "instance", None) is not None:
2673
+ fields["instance"] = args.instance
2674
+ updated = set_case(conn, args.case_id, **fields)
2675
+ except Exception as exc:
2676
+ return error(
2677
+ {"status": "invalid_status" if "invalid_status" in str(exc) else str(exc),
2678
+ "detail": str(exc)},
2679
+ meta={"command": "case set", "state_dir": str(args.state_dir)},
2680
+ json_mode=args.json_mode,
2681
+ )
2682
+ finally:
2683
+ conn.close()
2684
+ if updated is None:
2685
+ return error(
2686
+ f"case not found: {args.case_id}",
2687
+ meta={"command": "case set", "state_dir": str(args.state_dir)},
2688
+ json_mode=args.json_mode,
2689
+ )
2690
+ result(
2691
+ updated,
2692
+ meta={"command": "case set", "state_dir": str(args.state_dir)},
2693
+ json_mode=args.json_mode,
2694
+ )
2695
+ return 0
2696
+
2697
+
2698
+ def cmd_case_amount_add(args):
2699
+ from sud_cli.db import InvalidAmountError, add_case_amount, get_connection, init_db
2700
+ init_db(args.state_dir)
2701
+ conn = get_connection(args.state_dir)
2702
+ try:
2703
+ try:
2704
+ res = add_case_amount(conn, args.case_id, args.kind, args.amount, note=args.note)
2705
+ except InvalidAmountError as exc:
2706
+ return error(
2707
+ {"status": "invalid_amount", "detail": str(exc)},
2708
+ meta={"command": "case amount add", "state_dir": str(args.state_dir)},
2709
+ json_mode=args.json_mode,
2710
+ )
2711
+ finally:
2712
+ conn.close()
2713
+ if res is None:
2714
+ return error(
2715
+ f"case not found: {args.case_id}",
2716
+ meta={"command": "case amount add", "state_dir": str(args.state_dir)},
2717
+ json_mode=args.json_mode,
2718
+ )
2719
+ result(
2720
+ res,
2721
+ meta={"command": "case amount add", "state_dir": str(args.state_dir)},
2722
+ json_mode=args.json_mode,
2723
+ )
2724
+ return 0
2725
+
2726
+
2727
+ def cmd_case_amount_list(args):
2728
+ from sud_cli.db import get_connection, init_db, list_case_amounts
2729
+ init_db(args.state_dir)
2730
+ conn = get_connection(args.state_dir)
2731
+ try:
2732
+ amounts = list_case_amounts(conn, args.case_id)
2733
+ finally:
2734
+ conn.close()
2735
+ result(
2736
+ amounts,
2737
+ meta={"command": "case amount list", "state_dir": str(args.state_dir)},
2738
+ json_mode=args.json_mode,
2739
+ )
2740
+ return 0
2741
+
2742
+
2743
+ # refund 333.40 — норма уже VERIFIED (pack W4c), но применение возврата —
2744
+ # решение по основанию через fee-refund движок; ручная маркировка обязательна.
2745
+ _AMOUNT_REFUND_MR = (
2746
+ "manual_review: норма возврата 333.40 применена через fee-refund движок "
2747
+ "— сверь основание"
2748
+ )
2749
+
2750
+ _AMOUNTS_DISCLAIMER = (
2751
+ "Расчёт справочный: реестр расходов — учётные записи оператора по делу, "
2752
+ "суммы сверяются с платёжными документами; sud не даёт фискальной гарантии."
2753
+ )
2754
+
2755
+
2756
+ def cmd_case_amount_set(args):
2757
+ from datetime import date as _date
2758
+ from sud_cli.db import get_connection, init_db, set_case_amount
2759
+ paid_at = args.paid_at
2760
+ if paid_at is not None:
2761
+ try:
2762
+ paid_at = _date.fromisoformat(paid_at).isoformat()
2763
+ except ValueError:
2764
+ return error(
2765
+ {"status": "INVALID_ARGUMENTS",
2766
+ "detail": f"invalid paid_at {args.paid_at!r}: ожидается YYYY-MM-DD"},
2767
+ meta={"command": "case amount set", "state_dir": str(args.state_dir)},
2768
+ json_mode=args.json_mode,
2769
+ )
2770
+ init_db(args.state_dir)
2771
+ conn = get_connection(args.state_dir)
2772
+ try:
2773
+ row = set_case_amount(conn, args.case_id, args.amount_id,
2774
+ status=args.status, paid_at=paid_at)
2775
+ finally:
2776
+ conn.close()
2777
+ if row is None:
2778
+ return error(
2779
+ f"amount not found: {args.amount_id} for case {args.case_id}",
2780
+ meta={"command": "case amount set", "state_dir": str(args.state_dir)},
2781
+ json_mode=args.json_mode,
2782
+ )
2783
+ payload = dict(row)
2784
+ if args.status == "refunded":
2785
+ # принимается, но маркируется: возврат по 333.40 требует сверки основания
2786
+ payload["warnings"] = [_AMOUNT_REFUND_MR]
2787
+ result(
2788
+ payload,
2789
+ meta={"command": "case amount set", "state_dir": str(args.state_dir)},
2790
+ json_mode=args.json_mode,
2791
+ )
2792
+ return 0
2793
+
2794
+
2795
+ def cmd_case_amount_summary(args):
2796
+ from sud_cli.db import get_connection, init_db, summarize_case_amounts
2797
+ init_db(args.state_dir)
2798
+ conn = get_connection(args.state_dir)
2799
+ try:
2800
+ summary = summarize_case_amounts(conn, args.case_id)
2801
+ finally:
2802
+ conn.close()
2803
+ summary["disclaimer"] = _AMOUNTS_DISCLAIMER
2804
+ result(
2805
+ summary,
2806
+ meta={"command": "case amount summary", "state_dir": str(args.state_dir)},
2807
+ json_mode=args.json_mode,
2808
+ )
2809
+ return 0
2810
+
2811
+
2812
+ def cmd_reminders_due(args):
2813
+ """the wave §2 (S-26): окна сроков + bus-free a2a-строки.
2814
+
2815
+ Здесь нет и не может быть запуска внешних процессов: модуль
2816
+ sud_cli.reminders чистый, строки только печатаются (as docx).
2817
+ """
2818
+ from datetime import date as _date, timedelta
2819
+ from sud_cli.db import get_connection, init_db, list_all_deadline_states
2820
+ from sud_cli.reminders import BUS_FREE_NOTE, due_items, generate_mail_commands
2821
+
2822
+ days = args.days
2823
+ if not isinstance(days, int) or days < 1 or days > 90:
2824
+ return error(
2825
+ {"status": "INVALID_ARGUMENTS",
2826
+ "detail": f"--days должен быть целым 1..90, получен {days!r}"},
2827
+ meta={"command": "reminders due", "state_dir": str(args.state_dir)},
2828
+ json_mode=args.json_mode,
2829
+ )
2830
+ init_db(args.state_dir)
2831
+ conn = get_connection(args.state_dir)
2832
+ try:
2833
+ states = list_all_deadline_states(conn)
2834
+ finally:
2835
+ conn.close()
2836
+ today = _date.today()
2837
+ items = due_items(states, today, days)
2838
+ payload = {
2839
+ "days": days,
2840
+ "today": today.isoformat(),
2841
+ "window_to": (today + timedelta(days=days)).isoformat(),
2842
+ "items": items,
2843
+ "commands": generate_mail_commands(items, days),
2844
+ "bus_free": BUS_FREE_NOTE,
2845
+ }
2846
+ result(
2847
+ payload,
2848
+ meta={"command": "reminders due", "state_dir": str(args.state_dir)},
2849
+ json_mode=args.json_mode,
2850
+ )
2851
+ return 0
2852
+
2853
+
2854
+ def cmd_case_search_alerts(args):
2855
+ """W6a §4 (S-60): алерты case-search -> bus-free a2a-строки.
2856
+
2857
+ Только чтение локальных артефактов (alert-файлы + dedup-store). sud-cli
2858
+ шину не вызывает и внешних процессов не запускает — паттерн reminders.py.
2859
+ """
2860
+ from datetime import date as _date
2861
+
2862
+ from . import court_alerts
2863
+
2864
+ days = args.days
2865
+ if not isinstance(days, int) or days < 1 or days > 90:
2866
+ return error(
2867
+ {"status": "INVALID_ARGUMENTS",
2868
+ "detail": f"--days должен быть целым 1..90, получен {days!r}"},
2869
+ meta={"command": "case-search alerts", "state_dir": str(args.state_dir)},
2870
+ json_mode=args.json_mode,
2871
+ )
2872
+ if args.alerts_dir is not None:
2873
+ alerts_dir = args.alerts_dir
2874
+ else:
2875
+ candidates = court_alerts.alerts_search_dirs(args.state_dir)
2876
+ alerts_dir = next((d for d in candidates if d.is_dir()), candidates[0])
2877
+ payload = court_alerts.alerts_payload(alerts_dir, _date.today(), days)
2878
+ result(
2879
+ payload,
2880
+ meta={"command": "case-search alerts", "state_dir": str(args.state_dir)},
2881
+ json_mode=args.json_mode,
2882
+ )
2883
+ return 0
2884
+
2885
+
2886
+ def cmd_case_doc_add(args):
2887
+ from sud_cli.db import DocNotFoundError, add_case_document, get_connection, init_db
2888
+ init_db(args.state_dir)
2889
+ conn = get_connection(args.state_dir)
2890
+ try:
2891
+ try:
2892
+ res = add_case_document(conn, args.case_id, args.path, title=args.title,
2893
+ supersedes=args.doc_id)
2894
+ except DocNotFoundError as exc:
2895
+ code = "doc_not_found" if "doc_not_found" in str(exc) else "invalid_supersedes"
2896
+ return error(
2897
+ {"status": code, "detail": str(exc)},
2898
+ meta={"command": "case doc add", "state_dir": str(args.state_dir)},
2899
+ json_mode=args.json_mode,
2900
+ )
2901
+ finally:
2902
+ conn.close()
2903
+ if res is None:
2904
+ return error(
2905
+ f"case not found: {args.case_id}",
2906
+ meta={"command": "case doc add", "state_dir": str(args.state_dir)},
2907
+ json_mode=args.json_mode,
2908
+ )
2909
+ result(
2910
+ res,
2911
+ meta={"command": "case doc add", "state_dir": str(args.state_dir)},
2912
+ json_mode=args.json_mode,
2913
+ )
2914
+ return 0
2915
+
2916
+
2917
+ def cmd_case_doc_list(args):
2918
+ from sud_cli.db import get_connection, init_db, list_case_documents
2919
+ init_db(args.state_dir)
2920
+ conn = get_connection(args.state_dir)
2921
+ try:
2922
+ docs = list_case_documents(conn, args.case_id)
2923
+ finally:
2924
+ conn.close()
2925
+ result(
2926
+ docs,
2927
+ meta={"command": "case doc list", "state_dir": str(args.state_dir)},
2928
+ json_mode=args.json_mode,
2929
+ )
2930
+ return 0
2931
+
2932
+
2933
+ def cmd_case_doc_history(args):
2934
+ """the wave §2: цепочка ревизий newest-first (хэши в каждой строке)."""
2935
+ from sud_cli.db import case_document_history, get_connection, init_db
2936
+ init_db(args.state_dir)
2937
+ conn = get_connection(args.state_dir)
2938
+ try:
2939
+ rows = case_document_history(conn, args.case_id, path=args.path)
2940
+ finally:
2941
+ conn.close()
2942
+ result(
2943
+ rows,
2944
+ meta={"command": "case doc history", "state_dir": str(args.state_dir)},
2945
+ json_mode=args.json_mode,
2946
+ )
2947
+ return 0
2948
+
2949
+
2950
+ def cmd_case_evidence_add(args):
2951
+ from sud_cli.db import (DocNotFoundError, EvidenceError, add_case_evidence,
2952
+ get_connection, init_db)
2953
+ init_db(args.state_dir)
2954
+ conn = get_connection(args.state_dir)
2955
+ try:
2956
+ try:
2957
+ res = add_case_evidence(
2958
+ conn, args.case_id, title=args.title, path=args.path,
2959
+ volume=args.volume, sheet=args.sheet, media=args.media, note=args.note)
2960
+ except (DocNotFoundError, EvidenceError) as exc:
2961
+ return error(
2962
+ {"status": "INVALID_ARGUMENTS", "detail": str(exc)},
2963
+ meta={"command": "case evidence add", "state_dir": str(args.state_dir)},
2964
+ json_mode=args.json_mode,
2965
+ )
2966
+ finally:
2967
+ conn.close()
2968
+ if res is None:
2969
+ return error(
2970
+ f"case not found: {args.case_id}",
2971
+ meta={"command": "case evidence add", "state_dir": str(args.state_dir)},
2972
+ json_mode=args.json_mode,
2973
+ )
2974
+ result(
2975
+ res,
2976
+ meta={"command": "case evidence add", "state_dir": str(args.state_dir)},
2977
+ json_mode=args.json_mode,
2978
+ )
2979
+ return 0
2980
+
2981
+
2982
+ def cmd_case_evidence_list(args):
2983
+ from sud_cli.db import get_connection, init_db, list_case_evidence
2984
+ init_db(args.state_dir)
2985
+ conn = get_connection(args.state_dir)
2986
+ try:
2987
+ rows = list_case_evidence(conn, args.case_id)
2988
+ finally:
2989
+ conn.close()
2990
+ result(
2991
+ rows,
2992
+ meta={"command": "case evidence list", "state_dir": str(args.state_dir)},
2993
+ json_mode=args.json_mode,
2994
+ )
2995
+ return 0
2996
+
2997
+
2998
+ def cmd_case_evidence_verify(args):
2999
+ """the wave §1: НИКОГДА не «всё хорошо» молча — per-item verdict + summary-строка."""
3000
+ from sud_cli.db import (evidence_summary, get_connection, init_db,
3001
+ verify_case_evidence)
3002
+ init_db(args.state_dir)
3003
+ conn = get_connection(args.state_dir)
3004
+ try:
3005
+ items = verify_case_evidence(conn, args.case_id)
3006
+ if not items and conn.execute(
3007
+ "SELECT case_id FROM cases WHERE case_id = ?", (args.case_id,)
3008
+ ).fetchone() is None:
3009
+ return error(
3010
+ f"case not found: {args.case_id}",
3011
+ meta={"command": "case evidence verify", "state_dir": str(args.state_dir)},
3012
+ json_mode=args.json_mode,
3013
+ )
3014
+ finally:
3015
+ conn.close()
3016
+ summary = evidence_summary(items)
3017
+ summary_line = (
3018
+ f"evidence verify: total={summary['total']} intact={summary['intact']} "
3019
+ f"CHANGED={summary['changed']} MISSING_FILE={summary['missing_file']} "
3020
+ f"unchecked={summary['unchecked']} problems={summary['problems']}"
3021
+ )
3022
+ payload = {
3023
+ "items": [{k: v for k, v in i.items() if k != "note"} for i in items],
3024
+ "summary": {k: v for k, v in summary.items() if k != "problem_items"},
3025
+ "summary_line": summary_line,
3026
+ }
3027
+ result(
3028
+ payload,
3029
+ meta={"command": "case evidence verify", "state_dir": str(args.state_dir)},
3030
+ json_mode=args.json_mode,
3031
+ )
3032
+ return 0
3033
+
3034
+
3035
+ def cmd_case_doc_checklist(args):
3036
+ """the wave §3 (S-71): справочный структурный чек-лист; exit 0 всегда."""
3037
+ from sud_cli.completeness import checklist_payload, load_rules
3038
+ from sud_cli.db import get_case_document, get_connection, init_db
3039
+ init_db(args.state_dir)
3040
+ conn = get_connection(args.state_dir)
3041
+ try:
3042
+ doc = get_case_document(conn, args.doc_id, case_id=args.case_id)
3043
+ finally:
3044
+ conn.close()
3045
+ try:
3046
+ payload = checklist_payload(doc, args.kind, rules=load_rules())
3047
+ except ValueError as exc:
3048
+ return error({"status": "INVALID_ARGUMENTS", "detail": str(exc)},
3049
+ meta={"command": "case doc checklist", "state_dir": str(args.state_dir)},
3050
+ json_mode=args.json_mode)
3051
+ result(
3052
+ payload,
3053
+ meta={"command": "case doc checklist", "state_dir": str(args.state_dir)},
3054
+ json_mode=args.json_mode,
3055
+ )
3056
+ return 0
3057
+
3058
+
3059
+ def cmd_form_new(args):
3060
+ """the wave §4: структурный скелет формы с данными дела из реестра."""
3061
+ from sud_cli.db import get_case, get_connection, init_db
3062
+ from sud_cli.forms import render_form
3063
+ init_db(args.state_dir)
3064
+ conn = get_connection(args.state_dir)
3065
+ try:
3066
+ case = get_case(conn, args.case_id)
3067
+ finally:
3068
+ conn.close()
3069
+ if case is None:
3070
+ return error(
3071
+ f"case not found: {args.case_id}",
3072
+ meta={"command": "form new", "state_dir": str(args.state_dir)},
3073
+ json_mode=args.json_mode,
3074
+ )
3075
+ payload = render_form(args.kind, case, fmt=args.format)
3076
+ result(
3077
+ payload,
3078
+ meta={"command": "form new", "state_dir": str(args.state_dir)},
3079
+ json_mode=args.json_mode,
3080
+ )
3081
+ return 0
3082
+
3083
+
3084
+ def cmd_form_docx_command(args):
3085
+ """the wave §4: bus-free генератор строки docx-cli (sud её не исполняет)."""
3086
+ from pathlib import Path
3087
+ from sud_cli.db import get_case, get_connection, init_db
3088
+ from sud_cli.forms import docx_command
3089
+ init_db(args.state_dir)
3090
+ conn = get_connection(args.state_dir)
3091
+ try:
3092
+ case = get_case(conn, args.case_id)
3093
+ finally:
3094
+ conn.close()
3095
+ if case is None:
3096
+ return error(
3097
+ f"case not found: {args.case_id}",
3098
+ meta={"command": "form docx-command", "state_dir": str(args.state_dir)},
3099
+ json_mode=args.json_mode,
3100
+ )
3101
+ payload = docx_command(args.kind, case,
3102
+ spec_dir=Path(args.spec_dir) if args.spec_dir else None)
3103
+ result(
3104
+ payload,
3105
+ meta={"command": "form docx-command", "state_dir": str(args.state_dir)},
3106
+ json_mode=args.json_mode,
3107
+ )
3108
+ return 0
3109
+
3110
+
3111
+ def cmd_case_note_add(args):
3112
+ from sud_cli.db import add_case_note, get_connection, init_db
3113
+ init_db(args.state_dir)
3114
+ conn = get_connection(args.state_dir)
3115
+ try:
3116
+ res = add_case_note(conn, args.case_id, args.text, author=args.author)
3117
+ finally:
3118
+ conn.close()
3119
+ if res is None:
3120
+ return error(
3121
+ f"case not found: {args.case_id}",
3122
+ meta={"command": "case note add", "state_dir": str(args.state_dir)},
3123
+ json_mode=args.json_mode,
3124
+ )
3125
+ result(
3126
+ res,
3127
+ meta={"command": "case note add", "state_dir": str(args.state_dir)},
3128
+ json_mode=args.json_mode,
3129
+ )
3130
+ return 0
3131
+
3132
+
3133
+ def cmd_case_note_list(args):
3134
+ from sud_cli.db import get_connection, init_db, list_case_notes
3135
+ init_db(args.state_dir)
3136
+ conn = get_connection(args.state_dir)
3137
+ try:
3138
+ notes = list_case_notes(conn, args.case_id)
3139
+ finally:
3140
+ conn.close()
3141
+ result(
3142
+ notes,
3143
+ meta={"command": "case note list", "state_dir": str(args.state_dir)},
3144
+ json_mode=args.json_mode,
3145
+ )
3146
+ return 0
3147
+
3148
+
3149
+ def cmd_case_note_supersede(args):
3150
+ from sud_cli.db import get_connection, init_db, supersede_case_note
3151
+ init_db(args.state_dir)
3152
+ conn = get_connection(args.state_dir)
3153
+ try:
3154
+ try:
3155
+ note_id = int(args.note_id)
3156
+ except ValueError:
3157
+ return error(
3158
+ {"status": "INVALID_ARGUMENTS", "detail": "note_id must be int"},
3159
+ meta={"command": "case note supersede", "state_dir": str(args.state_dir)},
3160
+ json_mode=args.json_mode,
3161
+ )
3162
+ res = supersede_case_note(conn, note_id, args.text, author=args.author)
3163
+ finally:
3164
+ conn.close()
3165
+ if res is None:
3166
+ return error(
3167
+ "note not found",
3168
+ meta={"command": "case note supersede", "state_dir": str(args.state_dir)},
3169
+ json_mode=args.json_mode,
3170
+ )
3171
+ result(
3172
+ res,
3173
+ meta={"command": "case note supersede", "state_dir": str(args.state_dir)},
3174
+ json_mode=args.json_mode,
3175
+ )
3176
+ return 0
3177
+
3178
+
3179
+ def cmd_case_list(args):
3180
+ from sud_cli.db import get_connection, init_db, list_cases
3181
+ init_db(args.state_dir)
3182
+ conn = get_connection(args.state_dir)
3183
+ try:
3184
+ cases = list_cases(
3185
+ conn, status=args.status, owner=args.owner, court=args.court, party=args.party
3186
+ )
3187
+ finally:
3188
+ conn.close()
3189
+ result(
3190
+ cases,
3191
+ meta={"command": "case list", "state_dir": str(args.state_dir)},
3192
+ json_mode=args.json_mode,
3193
+ )
3194
+ return 0
3195
+
3196
+
3197
+ def cmd_case_card(args):
3198
+ from sud_cli.db import get_case_card, get_connection, init_db
3199
+ init_db(args.state_dir)
3200
+ conn = get_connection(args.state_dir)
3201
+ try:
3202
+ card = get_case_card(conn, args.case_id)
3203
+ finally:
3204
+ conn.close()
3205
+ if card is None:
3206
+ return error(
3207
+ f"case not found: {args.case_id}",
3208
+ meta={"command": "case card", "state_dir": str(args.state_dir)},
3209
+ json_mode=args.json_mode,
3210
+ )
3211
+ result(
3212
+ card,
3213
+ meta={"command": "case card", "state_dir": str(args.state_dir)},
3214
+ json_mode=args.json_mode,
3215
+ )
3216
+ return 0
3217
+
3218
+
3219
+ # ---------------------------------------------------------------------------
3220
+ # the wave §2 (S-15): представители
3221
+ # ---------------------------------------------------------------------------
3222
+
3223
+
3224
+ def cmd_case_rep_add(args):
3225
+ from sud_cli.db import REPRESENTATIVE_HINT, add_case_representative, get_connection, init_db
3226
+
3227
+ init_db(args.state_dir)
3228
+ conn = get_connection(args.state_dir)
3229
+ try:
3230
+ try:
3231
+ row = add_case_representative(
3232
+ conn, args.case_id, side=args.side, name=args.name,
3233
+ authority_note=args.authority_note, power_docs=args.power_docs,
3234
+ )
3235
+ except ValueError as exc:
3236
+ return error({"status": "INVALID_ARGUMENTS", "detail": str(exc)},
3237
+ meta={"command": "case rep add",
3238
+ "state_dir": str(args.state_dir)},
3239
+ json_mode=args.json_mode)
3240
+ finally:
3241
+ conn.close()
3242
+ if row is None:
3243
+ return error(f"case not found: {args.case_id}",
3244
+ meta={"command": "case rep add",
3245
+ "state_dir": str(args.state_dir)},
3246
+ json_mode=args.json_mode)
3247
+ payload = dict(row)
3248
+ payload["hint"] = REPRESENTATIVE_HINT
3249
+ result(payload, meta={"command": "case rep add",
3250
+ "state_dir": str(args.state_dir)},
3251
+ json_mode=args.json_mode)
3252
+ return 0
3253
+
3254
+
3255
+ def cmd_case_rep_list(args):
3256
+ from sud_cli.db import REPRESENTATIVE_HINT, get_case, get_connection, init_db
3257
+ from sud_cli.db import list_case_representatives
3258
+
3259
+ init_db(args.state_dir)
3260
+ conn = get_connection(args.state_dir)
3261
+ try:
3262
+ if get_case(conn, args.case_id) is None:
3263
+ return error(f"case not found: {args.case_id}",
3264
+ meta={"command": "case rep list",
3265
+ "state_dir": str(args.state_dir)},
3266
+ json_mode=args.json_mode)
3267
+ rows = list_case_representatives(conn, args.case_id)
3268
+ finally:
3269
+ conn.close()
3270
+ result(rows, meta={"command": "case rep list",
3271
+ "state_dir": str(args.state_dir),
3272
+ "hint": REPRESENTATIVE_HINT},
3273
+ json_mode=args.json_mode)
3274
+ return 0
3275
+
3276
+
3277
+ # ---------------------------------------------------------------------------
3278
+ # the wave §1 (S-12): цепочка инстанций
3279
+ # ---------------------------------------------------------------------------
3280
+
3281
+
3282
+ def cmd_deadlines_chain(args):
3283
+ from .chain import build_chain
3284
+ from .legal_engine import load_rules
3285
+
3286
+ case_id = getattr(args, "case_id", None)
3287
+ if not case_id:
3288
+ return error({"status": "MISSING_REQUIRED_PARAM",
3289
+ "detail": "case_id required"},
3290
+ meta={"command": "deadlines chain"},
3291
+ json_mode=args.json_mode)
3292
+ try:
3293
+ rules = load_rules()
3294
+ except Exception as exc:
3295
+ return error({"status": "RULES_UNAVAILABLE", "detail": str(exc)},
3296
+ meta={"command": "deadlines chain"},
3297
+ json_mode=args.json_mode)
3298
+ try:
3299
+ payload = build_chain(args.state_dir, case_id,
3300
+ instance_override=getattr(args, "instance", None),
3301
+ rules=rules)
3302
+ except ValueError as exc:
3303
+ return error({"status": "INVALID_ARGUMENTS", "detail": str(exc)},
3304
+ meta={"command": "deadlines chain",
3305
+ "state_dir": str(args.state_dir)},
3306
+ json_mode=args.json_mode)
3307
+ payload["rules_version"] = rules.get("version")
3308
+ result(payload, meta={"command": "deadlines chain",
3309
+ "state_dir": str(args.state_dir)},
3310
+ json_mode=args.json_mode)
3311
+ return 0
3312
+
3313
+
3314
+ # ---------------------------------------------------------------------------
3315
+ # the wave §3 (S-27): конфликты расписания
3316
+ # ---------------------------------------------------------------------------
3317
+
3318
+
3319
+ def cmd_calendar_conflicts(args):
3320
+ from .conflicts import conflicts_payload
3321
+
3322
+ days = args.days
3323
+ if not isinstance(days, int) or days < 1 or days > 3650:
3324
+ return error({"status": "INVALID_ARGUMENTS",
3325
+ "detail": f"--days должен быть целым 1..3650, получен {days!r}"},
3326
+ meta={"command": "calendar conflicts",
3327
+ "state_dir": str(args.state_dir)},
3328
+ json_mode=args.json_mode)
3329
+ payload = conflicts_payload(args.state_dir, days=days)
3330
+ result(payload, meta={"command": "calendar conflicts",
3331
+ "state_dir": str(args.state_dir)},
3332
+ json_mode=args.json_mode)
3333
+ return 0
3334
+
3335
+
3336
+ # ---------------------------------------------------------------------------
3337
+ # the wave §4 (S-84): отчёты портфеля и дела
3338
+ # ---------------------------------------------------------------------------
3339
+
3340
+
3341
+ def cmd_report_portfolio(args):
3342
+ from .reports import portfolio_payload, render_portfolio_text
3343
+
3344
+ days = args.days
3345
+ if not isinstance(days, int) or days < 1 or days > 3650:
3346
+ return error({"status": "INVALID_ARGUMENTS",
3347
+ "detail": f"--days должен быть целым 1..3650, получен {days!r}"},
3348
+ meta={"command": "report portfolio"},
3349
+ json_mode=args.json_mode)
3350
+ try:
3351
+ payload = portfolio_payload(args.state_dir, as_of=args.as_of, days=days)
3352
+ except ValueError as exc:
3353
+ return error({"status": "INVALID_ARGUMENTS",
3354
+ "detail": f"--as-of: {exc}"},
3355
+ meta={"command": "report portfolio"},
3356
+ json_mode=args.json_mode)
3357
+ if not args.json_mode:
3358
+ for line in render_portfolio_text(payload):
3359
+ print(line)
3360
+ return 0
3361
+ result(payload, meta={"command": "report portfolio",
3362
+ "state_dir": str(args.state_dir)},
3363
+ json_mode=args.json_mode)
3364
+ return 0
3365
+
3366
+
3367
+ def cmd_report_case(args):
3368
+ from .reports import case_payload, render_case_text
3369
+
3370
+ payload = case_payload(args.state_dir, args.case_id)
3371
+ if payload is None:
3372
+ return error(f"case not found: {args.case_id}",
3373
+ meta={"command": "report case",
3374
+ "state_dir": str(args.state_dir)},
3375
+ json_mode=args.json_mode)
3376
+ if not args.json_mode:
3377
+ for line in render_case_text(payload):
3378
+ print(line)
3379
+ return 0
3380
+ result(payload, meta={"command": "report case",
3381
+ "state_dir": str(args.state_dir)},
3382
+ json_mode=args.json_mode)
3383
+ return 0
3384
+
3385
+
3386
+ # ---------------------------------------------------------------------------
3387
+ # история §5 (S-85): backup / verify / restore
3388
+ # ---------------------------------------------------------------------------
3389
+
3390
+
3391
+ def cmd_backup_create(args):
3392
+ from .backup import create_backup
3393
+
3394
+ try:
3395
+ payload = create_backup(args.state_dir, out=args.out)
3396
+ except FileNotFoundError as exc:
3397
+ return error({"status": "STATE_MISSING", "detail": str(exc)},
3398
+ meta={"command": "backup create",
3399
+ "state_dir": str(args.state_dir)},
3400
+ json_mode=args.json_mode)
3401
+ except OSError as exc:
3402
+ return error({"status": "INVALID_ARGUMENTS", "detail": f"--out: {exc}"},
3403
+ meta={"command": "backup create"},
3404
+ json_mode=args.json_mode)
3405
+ payload["disclaimer"] = (
3406
+ "Снимок содержит ПДн реестра: файл и sidecar записаны правами 0600. "
3407
+ "sud-cli не копирует их никуда дальше и никуда не отправляет."
3408
+ )
3409
+ result(payload, meta={"command": "backup create",
3410
+ "state_dir": str(args.state_dir)},
3411
+ json_mode=args.json_mode)
3412
+ return 0
3413
+
3414
+
3415
+ def cmd_backup_verify(args):
3416
+ from .backup import verify_backup
3417
+
3418
+ try:
3419
+ payload = verify_backup(args.file)
3420
+ except FileNotFoundError as exc:
3421
+ return error({"status": "FILE_MISSING", "detail": str(exc)},
3422
+ meta={"command": "backup verify"},
3423
+ json_mode=args.json_mode)
3424
+ payload["ok"] = bool(payload["integrity_ok"] and payload["quick_ok"])
3425
+ if not args.json_mode:
3426
+ print(f"backup: {payload['path']}")
3427
+ print(f"integrity_check: {payload['integrity_check']}")
3428
+ print(f"quick_check: {payload['quick_check']}")
3429
+ print(f"таблиц ({payload['table_count']}): "
3430
+ f"{', '.join(payload['tables']) if payload['tables'] else ''}")
3431
+ print(f"schema_version: {payload['schema_version']}")
3432
+ print(f"sha256: {payload['sha256']}")
3433
+ print(f"sidecar: {payload['sidecar_sha256']} (match: {payload['sidecar_match']})")
3434
+ return 0 if payload["ok"] else 1
3435
+ if not payload["ok"]:
3436
+ return error(payload, meta={"command": "backup verify"},
3437
+ json_mode=args.json_mode)
3438
+ result(payload, meta={"command": "backup verify"}, json_mode=args.json_mode)
3439
+ return 0
3440
+
3441
+
3442
+ def cmd_backup_restore(args):
3443
+ from .backup import RESTORE_DISCLAIMER, restore_backup
3444
+
3445
+ try:
3446
+ payload = restore_backup(args.state_dir, args.file,
3447
+ confirmed=bool(args.yes))
3448
+ except FileNotFoundError as exc:
3449
+ return error({"status": "FILE_MISSING", "detail": str(exc)},
3450
+ meta={"command": "backup restore"},
3451
+ json_mode=args.json_mode)
3452
+ except Exception as exc:
3453
+ return error({"status": "BACKUP_UNSAFE", "detail": str(exc)},
3454
+ meta={"command": "backup restore"},
3455
+ json_mode=args.json_mode)
3456
+ if payload.get("status") == "needs_confirmation":
3457
+ # деструктив без --yes: ничего не изменено, отказ и превью в stderr
3458
+ return error(payload, meta={"command": "backup restore",
3459
+ "state_dir": str(args.state_dir)},
3460
+ json_mode=args.json_mode)
3461
+ result(payload, meta={"command": "backup restore",
3462
+ "state_dir": str(args.state_dir),
3463
+ "disclaimer": RESTORE_DISCLAIMER},
3464
+ json_mode=args.json_mode)
3465
+ return 0
3466
+
3467
+
3468
+ # ---------------------------------------------------------------------------
3469
+ # история §5 (S-86): метрики
3470
+ # ---------------------------------------------------------------------------
3471
+
3472
+
3473
+ def cmd_metrics(args):
3474
+ from .metrics import metrics_payload
3475
+
3476
+ payload = metrics_payload(args.state_dir)
3477
+ if not args.json_mode:
3478
+ for line in render_metrics_text(payload):
3479
+ print(line)
3480
+ return 0
3481
+ result(payload, meta={"command": "metrics",
3482
+ "state_dir": str(args.state_dir)},
3483
+ json_mode=args.json_mode)
3484
+ return 0
3485
+
3486
+
3487
+ def render_metrics_text(payload):
3488
+ """Human-рендер метрик: только строки из payload, без «вывода»."""
3489
+ lines = [f"Метрики state-dir на {payload['as_of']}"]
3490
+ lines.append(f"дела: {payload['cases']['total']}")
3491
+ for status, count in sorted(payload["cases"]["by_status"].items()):
3492
+ lines.append(f" {status}: {count}")
3493
+ ds = payload["deadline_states"]
3494
+ lines.append(f"сроков (states): {ds['total']}")
3495
+ for state, count in sorted(ds["by_state"].items()):
3496
+ lines.append(f" {state}: {count}")
3497
+ win = payload["deadlines_window"]
3498
+ lines.append(f"окно: near<= {win['near_days']} дн. = {win['near']}, "
3499
+ f"просрочено = {win['overdue']}")
3500
+ lines.append(f"событий: {payload['events']}, документов: "
3501
+ f"{payload['documents']['total']} (ревизий "
3502
+ f"{payload['documents']['revisions']}), доказательств: "
3503
+ f"{payload['evidence']['total']} (без hash "
3504
+ f"{payload['evidence']['without_hash']})")
3505
+ lines.append(f"представителей: {payload['representatives']['total']}, "
3506
+ f"заметок: {payload['notes']}, сумм: {payload['amounts']}")
3507
+ lines.append(f"напоминаний к окну {payload['reminders']['pending_days']} дн.: "
3508
+ f"{payload['reminders']['pending']}")
3509
+ last = payload["backup"]["last"]
3510
+ lines.append(f"последний backup: {last['path']} (exists={last['exists']})")
3511
+ lines.append(payload["disclaimer"])
3512
+ return lines
3513
+
3514
+
3515
+ # ---------------------------------------------------------------------------
3516
+ # история §5 (S-07): экспорт/уничтожение данных дела
3517
+ # ---------------------------------------------------------------------------
3518
+
3519
+
3520
+ def cmd_case_export(args):
3521
+ from .pdn import export_case, render_export_md
3522
+
3523
+ try:
3524
+ payload = export_case(args.state_dir, args.case_id, fmt=args.format)
3525
+ except ValueError as exc:
3526
+ return error({"status": "INVALID_ARGUMENTS", "detail": str(exc)},
3527
+ meta={"command": "case export"},
3528
+ json_mode=args.json_mode)
3529
+ if payload is None:
3530
+ return error(f"case not found: {args.case_id}",
3531
+ meta={"command": "case export",
3532
+ "state_dir": str(args.state_dir)},
3533
+ json_mode=args.json_mode)
3534
+ text = None
3535
+ if args.format == "md":
3536
+ text = render_export_md(payload)
3537
+ # markdown лежит в конверте: --json + --format md не теряет рендер,
3538
+ # а --out пишет именно его.
3539
+ payload = dict(payload)
3540
+ payload["markdown"] = text
3541
+ if args.out is not None:
3542
+ import json as _json
3543
+
3544
+ try:
3545
+ args.out.parent.mkdir(parents=True, exist_ok=True)
3546
+ body = text if text is not None else _json.dumps(payload, ensure_ascii=False,
3547
+ indent=2, default=str)
3548
+ args.out.write_text(body + ("\n" if text is not None else ""), encoding="utf-8")
3549
+ except OSError as exc:
3550
+ return error({"status": "INVALID_ARGUMENTS", "detail": f"--out: {exc}"},
3551
+ meta={"command": "case export"},
3552
+ json_mode=args.json_mode)
3553
+ payload = dict(payload)
3554
+ payload["written_to"] = str(args.out)
3555
+ if text is not None and not args.json_mode:
3556
+ print(text, end="")
3557
+ return 0
3558
+ result(payload, meta={"command": "case export",
3559
+ "state_dir": str(args.state_dir)},
3560
+ json_mode=args.json_mode)
3561
+ return 0
3562
+
3563
+
3564
+ def cmd_case_purge(args):
3565
+ from .pdn import purge_apply, purge_preview
3566
+
3567
+ if not args.yes:
3568
+ preview = purge_preview(args.state_dir, args.case_id)
3569
+ if preview is None:
3570
+ return error(f"case not found: {args.case_id}",
3571
+ meta={"command": "case purge",
3572
+ "state_dir": str(args.state_dir)},
3573
+ json_mode=args.json_mode)
3574
+ return error(
3575
+ {"status": "needs_confirmation",
3576
+ "detail": "purge уничтожает данные дела безвозвратно: нужен --yes",
3577
+ "preview": preview},
3578
+ meta={"command": "case purge", "state_dir": str(args.state_dir)},
3579
+ json_mode=args.json_mode,
3580
+ )
3581
+ payload = purge_apply(args.state_dir, args.case_id, confirmed=True,
3582
+ actor="cli", remove_files=not args.keep_files)
3583
+ if payload.get("status") == "not_found":
3584
+ return error(f"case not found: {args.case_id}",
3585
+ meta={"command": "case purge",
3586
+ "state_dir": str(args.state_dir)},
3587
+ json_mode=args.json_mode)
3588
+ result(payload, meta={"command": "case purge",
3589
+ "state_dir": str(args.state_dir)},
3590
+ json_mode=args.json_mode)
3591
+ return 0
3592
+
3593
+ _CONFLICT_DISCLAIMER = (
3594
+ "Отсутствие совпадений не означает отсутствие конфликта: покрытие — "
3595
+ "только имена локального реестра; внешние стороны и аффилированные лица "
3596
+ "не охвачены. Решение остаётся за человеком (boundary IX-3)."
3597
+ )
3598
+
3599
+
3600
+ def _conflict_payload(hits):
3601
+ return {
3602
+ "clean": len(hits) == 0,
3603
+ "hits": hits,
3604
+ "method": "name_exact",
3605
+ "coverage": "local registry names only",
3606
+ "disclaimer": _CONFLICT_DISCLAIMER,
3607
+ }
3608
+
3609
+
3610
+ def cmd_conflict_check(args):
3611
+ from sud_cli.conflict import check as conflict_check
3612
+ from sud_cli.db import get_connection, init_db, list_cases
3613
+
3614
+ init_db(args.state_dir)
3615
+ conn = get_connection(args.state_dir)
3616
+ try:
3617
+ portfolio = list_cases(conn)
3618
+ candidate = {
3619
+ "parties": [],
3620
+ "court": args.court,
3621
+ "case_number": args.case_number,
3622
+ }
3623
+ if args.plaintiff:
3624
+ candidate["parties"].append(args.plaintiff)
3625
+ if args.defendant:
3626
+ candidate["parties"].append(args.defendant)
3627
+ hits = conflict_check(portfolio, candidate)
3628
+ finally:
3629
+ conn.close()
3630
+ result(
3631
+ _conflict_payload(hits),
3632
+ meta={"command": "conflict check", "state_dir": str(args.state_dir)},
3633
+ json_mode=args.json_mode,
3634
+ )
3635
+ return 0
3636
+
3637
+
3638
+ def cmd_conflict_case(args):
3639
+ from sud_cli.conflict import check as conflict_check
3640
+ from sud_cli.db import get_case, get_connection, init_db, list_cases
3641
+
3642
+ init_db(args.state_dir)
3643
+ conn = get_connection(args.state_dir)
3644
+ try:
3645
+ case = get_case(conn, args.case_id)
3646
+ if case is None:
3647
+ return error(
3648
+ f"case not found: {args.case_id}",
3649
+ meta={"command": "conflict case", "state_dir": str(args.state_dir)},
3650
+ json_mode=args.json_mode,
3651
+ )
3652
+ portfolio = [c for c in list_cases(conn) if c.get("case_id") != args.case_id]
3653
+ candidate = {
3654
+ "parties": [],
3655
+ "case_id": args.case_id,
3656
+ "court": case.get("court"),
3657
+ "case_number": case.get("case_number"),
3658
+ }
3659
+ for key in ("plaintiff", "defendant"):
3660
+ val = case.get(key)
3661
+ if val:
3662
+ candidate["parties"].append(val)
3663
+ hits = conflict_check(portfolio, candidate)
3664
+ finally:
3665
+ conn.close()
3666
+ result(
3667
+ _conflict_payload(hits),
3668
+ meta={"command": "conflict case", "state_dir": str(args.state_dir)},
3669
+ json_mode=args.json_mode,
3670
+ )
3671
+ return 0
3672
+
3673
+
3674
+ def cmd_jurisdiction_check(args):
3675
+ from sud_cli.jurisdiction import check_jurisdiction
3676
+
3677
+ payload = check_jurisdiction(
3678
+ code=args.code,
3679
+ claim=args.claim,
3680
+ party_org=args.party_org,
3681
+ party_phys=args.party_phys,
3682
+ unknown_rf_location=args.unknown_rf_location,
3683
+ immovable=args.immovable,
3684
+ corporate=args.corporate,
3685
+ bankruptcy=args.bankruptcy,
3686
+ ip_rights=args.ip_rights,
3687
+ contract_forum=args.contract_forum,
3688
+ arbitration_clause=args.arbitration_clause,
3689
+ multiple_defendants=args.multiple_defendants,
3690
+ foreign_element=args.foreign_element,
3691
+ consumer=args.consumer,
3692
+ alimony=args.alimony,
3693
+ )
3694
+ if getattr(args, "explain", False):
3695
+ from .explain import jurisdiction_explain
3696
+ payload["explain"] = jurisdiction_explain(payload=dict(payload), code=args.code, claim=args.claim)
3697
+ result(payload, meta={"command": "jurisdiction check", "state_dir": str(args.state_dir)}, json_mode=args.json_mode)
3698
+ return 0
3699
+
3700
+
3701
+ def cmd_pretrial_calc(args):
3702
+ from sud_cli.pretrial import calc_pretrial
3703
+
3704
+ payload = calc_pretrial(code=args.code, sent=args.sent)
3705
+ if getattr(args, "explain", False):
3706
+ from .explain import pretrial_explain
3707
+ payload["explain"] = pretrial_explain(payload=dict(payload), code=args.code, sent=args.sent)
3708
+ result(payload, meta={"command": "pretrial calc", "state_dir": str(args.state_dir)}, json_mode=args.json_mode)
3709
+ return 0
3710
+
3711
+
3712
+ def cmd_pretrial_consequences(args):
3713
+ from sud_cli.pretrial import consequences
3714
+
3715
+ payload = consequences(code=args.code, stage=args.stage)
3716
+ if getattr(args, "explain", False):
3717
+ from .explain import pretrial_consequences_explain
3718
+ payload["explain"] = pretrial_consequences_explain(
3719
+ payload=dict(payload), code=args.code, stage=args.stage)
3720
+ result(payload, meta={"command": "pretrial consequences", "state_dir": str(args.state_dir)}, json_mode=args.json_mode)
3721
+ return 0