sud-cli 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sud_cli/__init__.py +3 -0
- sud_cli/__main__.py +4 -0
- sud_cli/_wm.py +118 -0
- sud_cli/agenda.py +90 -0
- sud_cli/autofill.py +192 -0
- sud_cli/autofill_markers.json +56 -0
- sud_cli/backup.py +256 -0
- sud_cli/calendar_ics.py +191 -0
- sud_cli/case_diff.py +165 -0
- sud_cli/case_search_bridge.py +1322 -0
- sud_cli/chain.py +226 -0
- sud_cli/cli.py +3721 -0
- sud_cli/completeness.py +105 -0
- sud_cli/config.py +20 -0
- sud_cli/conflict.py +210 -0
- sud_cli/conflicts.py +207 -0
- sud_cli/court_alerts.py +156 -0
- sud_cli/court_source.py +108 -0
- sud_cli/data/completeness_rules.json +18 -0
- sud_cli/data/key_rates.json +59 -0
- sud_cli/data/npa_historical_editions.json +19 -0
- sud_cli/db.py +2072 -0
- sud_cli/deadline_matrix.py +99 -0
- sud_cli/deadline_state.py +25 -0
- sud_cli/deadlines.py +8 -0
- sud_cli/deadlines_state.py +1 -0
- sud_cli/deferral.py +5 -0
- sud_cli/efrsb_manual.py +85 -0
- sud_cli/efrsb_public.py +210 -0
- sud_cli/egrul_nalog.py +150 -0
- sud_cli/explain.py +190 -0
- sud_cli/fees_extra.py +104 -0
- sud_cli/forms.py +204 -0
- sud_cli/fssp_manual.py +91 -0
- sud_cli/fssp_semi.py +285 -0
- sud_cli/fssp_stats.py +105 -0
- sud_cli/government_transfers.json +8 -0
- sud_cli/holidays_rf.json +62 -0
- sud_cli/idempotency.py +78 -0
- sud_cli/importing.py +575 -0
- sud_cli/interest.py +144 -0
- sud_cli/jurisdiction.py +177 -0
- sud_cli/jurisdiction_rules.json +265 -0
- sud_cli/legal_deadlines.py +278 -0
- sud_cli/legal_engine.py +558 -0
- sud_cli/legal_rules.json +1058 -0
- sud_cli/limitation.py +70 -0
- sud_cli/mcp_server.py +1594 -0
- sud_cli/metrics.py +142 -0
- sud_cli/npa_as_of.py +21 -0
- sud_cli/output.py +89 -0
- sud_cli/payment.py +24 -0
- sud_cli/pdn.py +259 -0
- sud_cli/plenum_corpus.py +134 -0
- sud_cli/plenums_seed.json +45 -0
- sud_cli/practice_status.py +136 -0
- sud_cli/pravo_publication.py +361 -0
- sud_cli/pretrial.py +151 -0
- sud_cli/production_calendar.py +233 -0
- sud_cli/providers.py +95 -0
- sud_cli/readiness.py +234 -0
- sud_cli/regional_holidays.py +37 -0
- sud_cli/reminders.py +92 -0
- sud_cli/reports.py +349 -0
- sud_cli/sud_netguard.py +943 -0
- sud_cli/timeline_audit.py +80 -0
- sud_cli/verify.py +201 -0
- sud_cli-0.1.1.dist-info/METADATA +119 -0
- sud_cli-0.1.1.dist-info/RECORD +74 -0
- sud_cli-0.1.1.dist-info/WHEEL +4 -0
- sud_cli-0.1.1.dist-info/entry_points.txt +2 -0
- sud_cli-0.1.1.dist-info/licenses/AUTHORS +5 -0
- sud_cli-0.1.1.dist-info/licenses/LICENSE.md +661 -0
- sud_cli-0.1.1.dist-info/licenses/NOTICE +7 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Procedural deadline calculation (spec 1.0.0 + addendum 1.1.0).
|
|
2
|
+
|
|
3
|
+
Addendum 1.1.0 closed Б1–Б4 and changed three things this module implements:
|
|
4
|
+
|
|
5
|
+
* **Б1** — the working-day counter starts on the next *calendar* day after the
|
|
6
|
+
event, and each non-working day is skipped without incrementing (ч. 3 + ч. 4
|
|
7
|
+
ст. 113 АПК РФ). This is deliberately not "start on the next working day":
|
|
8
|
+
both readings coincide on a Friday event, so the distinction is asserted on a
|
|
9
|
+
mid-week event where they diverge.
|
|
10
|
+
* **Б2** — the production calendar is active. Deadlines landing on a non-working
|
|
11
|
+
day are shifted to the next working day (ч. 4 ст. 114 АПК / ч. 2 ст. 108 ГПК).
|
|
12
|
+
Government transfer decrees are encoded for 2026 (№ 1466) and 2027 (№ 1187);
|
|
13
|
+
later years carry a full ``CALENDAR_WARNING``, the encoded years carry a
|
|
14
|
+
source confirmation instead.
|
|
15
|
+
* **Б3** — the matrix gained court-order, КАС РФ and ruling-appeal rows.
|
|
16
|
+
|
|
17
|
+
**Б5** — was open, now RESOLVED by the colleague-legal opinion of 2026-10-08
|
|
18
|
+
(``LEGAL-OPINION-B5-MONTH-DEADLINES.md``): a month-long term expires on the
|
|
19
|
+
"соответствующее число" of the last month, and that number is the day number of
|
|
20
|
+
the **event date** (Day 0), not of the following day (ч. 2 ст. 114 АПК РФ,
|
|
21
|
+
ч. 1 ст. 108 ГПК РФ, ч. 1 ст. 92 КАС РФ). The "term starts the next day" rule
|
|
22
|
+
only excludes the event day from the count; it does not push the expiry forward.
|
|
23
|
+
If the target month has no such day (31 января + 1 месяц), the term expires on
|
|
24
|
+
that month's last day. The non-working-day rule is applied *after* this date is
|
|
25
|
+
fixed. The switch lives in ``legal_rules.json`` as ``month_calculation_base``;
|
|
26
|
+
the rejected ``from_day1`` reading raises instead of computing silently.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from dataclasses import dataclass, field
|
|
30
|
+
from datetime import date, datetime, time, timedelta
|
|
31
|
+
from typing import List, Optional
|
|
32
|
+
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
|
33
|
+
|
|
34
|
+
from .legal_engine import (
|
|
35
|
+
STATUS_MANUAL_REVIEW,
|
|
36
|
+
STATUS_MISSING_PARAM,
|
|
37
|
+
STATUS_OK,
|
|
38
|
+
LegalRulesError,
|
|
39
|
+
load_rules,
|
|
40
|
+
)
|
|
41
|
+
from .production_calendar import (
|
|
42
|
+
add_calendar_days,
|
|
43
|
+
add_working_days,
|
|
44
|
+
calendar_warnings,
|
|
45
|
+
is_working_day,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class DeadlineResult:
|
|
51
|
+
status: str
|
|
52
|
+
deadline: Optional[date] = None
|
|
53
|
+
detail: str = ""
|
|
54
|
+
legal_basis: str = ""
|
|
55
|
+
warnings: List[str] = field(default_factory=list)
|
|
56
|
+
trace: List[dict] = field(default_factory=list)
|
|
57
|
+
|
|
58
|
+
deadline_datetime: Optional[str] = None
|
|
59
|
+
time_with_tz: Optional[str] = None
|
|
60
|
+
region: Optional[str] = None
|
|
61
|
+
|
|
62
|
+
def to_dict(self) -> dict:
|
|
63
|
+
payload = {
|
|
64
|
+
"status": self.status,
|
|
65
|
+
"deadline": self.deadline.isoformat() if self.deadline else None,
|
|
66
|
+
"detail": self.detail,
|
|
67
|
+
"legal_basis": self.legal_basis,
|
|
68
|
+
"warnings": self.warnings,
|
|
69
|
+
"trace": self.trace,
|
|
70
|
+
}
|
|
71
|
+
if self.deadline_datetime is not None:
|
|
72
|
+
payload.update({"deadline_datetime": self.deadline_datetime, "time_with_tz": self.time_with_tz})
|
|
73
|
+
if self.region is not None:
|
|
74
|
+
payload["region"] = self.region
|
|
75
|
+
return payload
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _shift_to_working_day(day: date, trace: List[dict], warnings: List[str], region=None) -> date:
|
|
79
|
+
"""Move a deadline off a non-working day (ч. 4 ст. 114 АПК / ч. 2 ст. 108 ГПК)."""
|
|
80
|
+
if is_working_day(day, region=region):
|
|
81
|
+
return day
|
|
82
|
+
original = day
|
|
83
|
+
while not is_working_day(day, region=region):
|
|
84
|
+
day += timedelta(days=1)
|
|
85
|
+
trace.append(
|
|
86
|
+
{
|
|
87
|
+
"step": (
|
|
88
|
+
f"срок истекал {original.isoformat()} — нерабочий день, "
|
|
89
|
+
f"перенесён на {day.isoformat()}"
|
|
90
|
+
)
|
|
91
|
+
}
|
|
92
|
+
)
|
|
93
|
+
warnings.append(
|
|
94
|
+
f"Расчётная дата {original.isoformat()} приходилась на нерабочий день; "
|
|
95
|
+
f"срок перенесён на {day.isoformat()} (ч. 4 ст. 114 АПК РФ / ч. 2 ст. 108 ГПК РФ)."
|
|
96
|
+
)
|
|
97
|
+
return day
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _add_months(start: date, months: int) -> date:
|
|
101
|
+
"""Add calendar months, clamping to the last day of a shorter month.
|
|
102
|
+
|
|
103
|
+
Per ст. 192 ГК РФ a term set in months ends on the corresponding day of
|
|
104
|
+
the last month, so ``30 сентября + 1 месяц -> 30 октября``. Only when the
|
|
105
|
+
target month has no such day does the term end on that month's last day:
|
|
106
|
+
``31 января + 1 месяц -> 28/29 февраля``.
|
|
107
|
+
|
|
108
|
+
The earlier version branched on ``start.day > 28``, which sent every date
|
|
109
|
+
from the 29th onward to the month's last day — so 30 September became
|
|
110
|
+
31 October and 30 April became 31 May, each a day late.
|
|
111
|
+
"""
|
|
112
|
+
total_month = start.month - 1 + months
|
|
113
|
+
year = start.year + total_month // 12
|
|
114
|
+
month = total_month % 12 + 1
|
|
115
|
+
next_month_first = date(year + (month == 12), (month % 12) + 1, 1)
|
|
116
|
+
last_day = (next_month_first - timedelta(days=1)).day
|
|
117
|
+
return date(year, month, min(start.day, last_day))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def calculate_deadline(
|
|
121
|
+
*,
|
|
122
|
+
deadline_key: str,
|
|
123
|
+
event_date: Optional[date] = None,
|
|
124
|
+
rules: Optional[dict] = None,
|
|
125
|
+
region: Optional[str] = None,
|
|
126
|
+
tz: Optional[str] = None,
|
|
127
|
+
) -> DeadlineResult:
|
|
128
|
+
"""Calculate one procedural deadline from the approved matrix."""
|
|
129
|
+
rules = rules or load_rules()
|
|
130
|
+
if region is not None:
|
|
131
|
+
try:
|
|
132
|
+
from .regional_holidays import normalize_region
|
|
133
|
+
region = normalize_region(region)
|
|
134
|
+
except ValueError as exc:
|
|
135
|
+
return DeadlineResult(STATUS_MANUAL_REVIEW, detail=str(exc))
|
|
136
|
+
zone = None
|
|
137
|
+
if tz is not None:
|
|
138
|
+
try:
|
|
139
|
+
zone = ZoneInfo(tz)
|
|
140
|
+
except ZoneInfoNotFoundError:
|
|
141
|
+
return DeadlineResult(STATUS_MANUAL_REVIEW, detail=f"unknown timezone: {tz}")
|
|
142
|
+
matrix = rules.get("deadline_matrix", {})
|
|
143
|
+
|
|
144
|
+
entry = matrix.get(deadline_key)
|
|
145
|
+
if entry is None:
|
|
146
|
+
return DeadlineResult(
|
|
147
|
+
STATUS_MANUAL_REVIEW,
|
|
148
|
+
detail=(
|
|
149
|
+
f"срок '{deadline_key}' отсутствует в матрице спецификации; "
|
|
150
|
+
"возможен нестандартный срок — требуется ручная проверка"
|
|
151
|
+
),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if entry.get("always_manual_review"):
|
|
155
|
+
return DeadlineResult(
|
|
156
|
+
STATUS_MANUAL_REVIEW,
|
|
157
|
+
detail=(
|
|
158
|
+
"специальные сокращённые сроки (напр. по обеспечению иска) "
|
|
159
|
+
"в MVP не кодируются — требуется ручная проверка"
|
|
160
|
+
),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
duration = entry.get("duration")
|
|
164
|
+
if not duration:
|
|
165
|
+
return DeadlineResult(
|
|
166
|
+
STATUS_MANUAL_REVIEW,
|
|
167
|
+
detail=f"у срока '{deadline_key}' не задан срок — требуется ручная проверка",
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
if not event_date:
|
|
171
|
+
return DeadlineResult(
|
|
172
|
+
STATUS_MISSING_PARAM,
|
|
173
|
+
detail=f"MISSING_REQUIRED_PARAM: event_date (триггер: {entry['trigger']})",
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
juris_code = entry["jurisdiction"]
|
|
177
|
+
juris = rules["jisdictions"].get(juris_code)
|
|
178
|
+
if juris is None:
|
|
179
|
+
raise LegalRulesError(f"matrix references unknown jurisdiction: {juris_code}")
|
|
180
|
+
|
|
181
|
+
day_mode = juris["deadline_day_mode"]
|
|
182
|
+
warnings: List[str] = []
|
|
183
|
+
trace: List[dict] = [
|
|
184
|
+
{"step": f"событие (Day 0): {event_date.isoformat()}, не засчитывается"}
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
# Day 0 is never counted (п. 6): the term starts the next calendar day.
|
|
188
|
+
day1 = event_date + timedelta(days=1)
|
|
189
|
+
trace.append({"step": f"Day 1 = {day1.isoformat()} (следующий календарный день)"})
|
|
190
|
+
|
|
191
|
+
unit = duration["unit"]
|
|
192
|
+
b5_open = rules["spec_open_questions"].get("B5_monthly_base", "").startswith("UNRESOLVED")
|
|
193
|
+
|
|
194
|
+
if unit == "months":
|
|
195
|
+
# Б5 RESOLVED (colleague-legal, 2026-10-08, ч.2 ст.114 АПК РФ): a term counted in
|
|
196
|
+
# months expires on the corresponding day of the last month, and that
|
|
197
|
+
# "соответствующее число" is the day number of the EVENT date (Day 0).
|
|
198
|
+
# The "term starts the next day" rule (ч.4 ст.113 АПК РФ) only excludes the
|
|
199
|
+
# event day itself from the count — it does not push the expiry forward,
|
|
200
|
+
# otherwise the party would get one month plus a day.
|
|
201
|
+
base = rules.get("month_calculation_base", "event_date")
|
|
202
|
+
if base == "event_date":
|
|
203
|
+
deadline = _add_months(event_date, duration["value"])
|
|
204
|
+
trace.append(
|
|
205
|
+
{
|
|
206
|
+
"step": (
|
|
207
|
+
f"+{duration['value']} мес. от даты события "
|
|
208
|
+
f"{event_date.isoformat()} по правилу «число в число» "
|
|
209
|
+
"(ч.2 ст.114 АПК РФ)"
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
)
|
|
213
|
+
trace.append(
|
|
214
|
+
{
|
|
215
|
+
"step": (
|
|
216
|
+
"день события не засчитывается, но число окончания "
|
|
217
|
+
"не сдвигается на следующий день"
|
|
218
|
+
)
|
|
219
|
+
}
|
|
220
|
+
)
|
|
221
|
+
elif base == "from_day1":
|
|
222
|
+
# Disabled by the Б5 opinion: explicitly retained only so that a
|
|
223
|
+
# mis-set switch fails loudly instead of silently reverting to the
|
|
224
|
+
# rejected reading. Never selected by default.
|
|
225
|
+
raise LegalRulesError(
|
|
226
|
+
"month_calculation_base='from_day1' признан некорректным "
|
|
227
|
+
"заключением colleague-legal от 2026-10-08 (Б5 закрыт): допустимо "
|
|
228
|
+
"только 'event_date'"
|
|
229
|
+
)
|
|
230
|
+
else:
|
|
231
|
+
raise LegalRulesError(
|
|
232
|
+
f"unknown month_calculation_base: {base!r} "
|
|
233
|
+
"(допустимо только 'event_date')"
|
|
234
|
+
)
|
|
235
|
+
elif unit == "working_days":
|
|
236
|
+
# Б1: the counter starts at the next calendar day and skips non-working
|
|
237
|
+
# days without incrementing, so day1 is passed through unchanged.
|
|
238
|
+
deadline = add_working_days(day1, duration["value"], region=region)
|
|
239
|
+
trace.append(
|
|
240
|
+
{"step": f"+{duration['value']} рабочих дней (выходные и праздники пропущены)"}
|
|
241
|
+
)
|
|
242
|
+
elif unit == "calendar_days":
|
|
243
|
+
deadline = add_calendar_days(day1, duration["value"])
|
|
244
|
+
trace.append({"step": f"+{duration['value']} календарных дней"})
|
|
245
|
+
else:
|
|
246
|
+
raise LegalRulesError(f"unknown duration unit: {unit}")
|
|
247
|
+
|
|
248
|
+
deadline = _shift_to_working_day(deadline, trace, warnings, region)
|
|
249
|
+
|
|
250
|
+
warnings.extend(calendar_warnings(deadline.year, region=region))
|
|
251
|
+
|
|
252
|
+
if b5_open:
|
|
253
|
+
# Legacy branch: only reachable if the rules file is rolled back to the
|
|
254
|
+
# pre-opinion state. Kept so an old rules file fails with an explanation
|
|
255
|
+
# rather than silently computing on the rejected reading.
|
|
256
|
+
warnings.append(
|
|
257
|
+
"Б5 (открыт): для месячных сроков база отсчёта не подтверждена — "
|
|
258
|
+
"расчёт следует п.6 (отсчёт от дня, следующего за событием). "
|
|
259
|
+
"Пример п.7 спеки («31 января → 28 февраля») предполагает базу от даты "
|
|
260
|
+
"события и дал бы другой результат."
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
warnings.append(rules["boundary_note"])
|
|
264
|
+
|
|
265
|
+
result = DeadlineResult(
|
|
266
|
+
status=STATUS_OK,
|
|
267
|
+
deadline=deadline,
|
|
268
|
+
detail=f"{day_mode}, триггер: {entry['trigger']}",
|
|
269
|
+
legal_basis=entry["legal_basis"],
|
|
270
|
+
warnings=warnings,
|
|
271
|
+
trace=trace,
|
|
272
|
+
region=region,
|
|
273
|
+
)
|
|
274
|
+
if zone is not None:
|
|
275
|
+
local = datetime.combine(deadline, time(23, 59, 59), tzinfo=zone)
|
|
276
|
+
result.deadline_datetime = local.isoformat()
|
|
277
|
+
result.time_with_tz = f"{local:%Y-%m-%d %H:%M:%S} {local.tzname()} ({tz})"
|
|
278
|
+
return result
|