diff --git a/README.md b/README.md index d818087..14213c5 100644 --- a/README.md +++ b/README.md @@ -220,7 +220,7 @@ docker compose -f ../docs/dev/docker-compose.yml down -v # полный сб | `GET /directories/legal-entities` | `DIRECTORY_VIEW` | видимые ЮЛ/ИП: `company_group_id`, `outside_group`, `inn`, `q`, `closed`, `limit`/`offset` | | `POST /directories/legal-entities` | `DIRECTORY_EDIT` | создать ЮЛ/ИП (ИНН: ЮЛ — 10 цифр, ИП — 12) | | `GET`, `PATCH /directories/legal-entities/{entity_id}` | `DIRECTORY_VIEW` / `DIRECTORY_EDIT` | карточка (с членствами и алиасами) / изменить | -| `POST /directories/legal-entities/{entity_id}/move` | `DIRECTORY_EDIT` | перевести в другую ГК с датой `effective_date` ≥ сегодня; членства ГСК/ГСЗ закрываются | +| `POST /directories/legal-entities/{entity_id}/move` | `DIRECTORY_EDIT` | перевести в другую ГК с датой `effective_date` ≥ сегодня (дата по Москве, Europe/Moscow); членства ГСК/ГСЗ закрываются | | `POST /directories/legal-entities/{entity_id}/withdraw` | `DIRECTORY_EDIT` | вывести из ГК: `CLOSE` (с `close_date`) или `REMOVE_FROM_GROUP` | | `GET /directories/legal-entities/{entity_id}/history` | `DIRECTORY_VIEW` | история ЮЛ/ИП, новые первыми | | `POST /directories/legal-entities/{entity_id}/aliases` | `DIRECTORY_EDIT` | добавить алиас (`normalized_alias` по §3) | @@ -406,7 +406,7 @@ email без учёта регистра проверяет только сер | `OFFICE_NOT_FOUND` | 422 | филиал из тела запроса не существует (ГК, ЮЛ, пользователь) | | `OFFICE_NOT_ALLOWED` | 422 | ГК можно закрепить только за своим филиалом | | `INVALID_INN` | 422 | ИНН не из 10 (ЮЛ) / 12 (ИП) цифр | -| `EFFECTIVE_DATE_IN_PAST` | 422 | дата перевода/вывода раньше сегодняшней | +| `EFFECTIVE_DATE_IN_PAST` | 422 | дата перевода/вывода раньше сегодняшней (по Москве) | | `EMPTY_ALIAS` | 422 | алиас пуст после нормализации (одни ОПФ и знаки препинания) | | `DIFFERENT_COMPANY_GROUP` | 422 | ЮЛ не входит в ГК этой ГСК/ГСЗ | | `UNKNOWN_ROLE` / `UNKNOWN_PERMISSION` | 422 | неизвестная или неактивная роль / неизвестный код права (поля `roles` / `permissions`) | diff --git a/api/src/common/clock.py b/api/src/common/clock.py index c7bf2e5..26b42dd 100644 --- a/api/src/common/clock.py +++ b/api/src/common/clock.py @@ -1,8 +1,24 @@ -"""Текущее время сервисов: одно место для «сейчас» (UTC, с часовым поясом) — `valid_from`/`valid_to`, `updated_at`.""" +"""Текущее время сервисов: одно место для «сейчас» (UTC, с часовым поясом) — `valid_from`/`valid_to`, `updated_at`. + +Календарная дата бизнеса («сегодня» для дат перевода/закрытия ЮЛ) — московская: `moscow_date` (issue #9 п.6). +""" import datetime +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + +try: + MOSCOW_TZ: datetime.tzinfo = ZoneInfo("Europe/Moscow") +except ZoneInfoNotFoundError: # нет базы часовых поясов (tzdata) в образе: Москва с 2014 г. — постоянно UTC+3 + MOSCOW_TZ = datetime.timezone(datetime.timedelta(hours=3), "MSK") def utc_now() -> datetime.datetime: """Текущий момент в UTC (aware).""" return datetime.datetime.now(datetime.UTC) + + +def moscow_date(moment: datetime.datetime) -> datetime.date: + """Календарная дата момента `moment` (aware) по Москве: 21:30 UTC 4-го — уже 5-е.""" + if moment.tzinfo is None: + raise ValueError("ожидается момент с часовым поясом (aware datetime)") + return moment.astimezone(MOSCOW_TZ).date() diff --git a/api/src/services/legal_entities.py b/api/src/services/legal_entities.py index 404e3ce..e2732e8 100644 --- a/api/src/services/legal_entities.py +++ b/api/src/services/legal_entities.py @@ -14,7 +14,7 @@ from typing import Any from sqlalchemy.ext.asyncio import AsyncSession from src.api.dependencies.audit import RequestAudit -from src.common.clock import utc_now +from src.common.clock import moscow_date, utc_now from src.common.exceptions import ConflictError, DomainValidationError, NotFoundError from src.domain.inn import validate_inn from src.domain.policy import VisibilityScope, office_for_create @@ -41,8 +41,8 @@ TRACKED_FIELDS = ("name", "entity_type", "crm_id", "regional_office_id") # по def _today() -> datetime.date: - """«Сегодня» для проверки `effective_date` — дата UTC (часовой пояс пользователя в Срезе 1 не известен).""" - return utc_now().date() + """«Сегодня» для проверки `effective_date` — дата по Москве (Europe/Moscow, issue #9 п.6).""" + return moscow_date(utc_now()) def snapshot_entity( # noqa: PLR0913 - снимок ЮЛ: сессия, автор, ЮЛ, событие, момент и дата события @@ -290,7 +290,7 @@ class LegalEntityService: @staticmethod def _check_effective_date(effective_date: datetime.date) -> None: - # ASSUMPTION(OQ-21): дата перевода/закрытия не раньше сегодняшней (ANL-046); «сегодня» — по UTC + # ASSUMPTION(OQ-21): дата перевода/закрытия не раньше сегодняшней (ANL-046); «сегодня» — по Москве if effective_date < _today(): raise DomainValidationError("EFFECTIVE_DATE_IN_PAST", "Дата не может быть раньше сегодняшней") diff --git a/api/tests/test_api_legal_entities.py b/api/tests/test_api_legal_entities.py index f4b180e..95396f8 100644 --- a/api/tests/test_api_legal_entities.py +++ b/api/tests/test_api_legal_entities.py @@ -11,6 +11,7 @@ from fastapi import FastAPI from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from src.common.clock import moscow_date from src.models.audit_ex import AuditEvent from src.models.directories import ( LegalEntity, @@ -38,8 +39,8 @@ GROUP_NOT_FOUND = "ГК не найдена" def _today() -> datetime.date: - """«Сегодня» сервиса — дата UTC (тест не зависит от часового пояса машины).""" - return datetime.datetime.now(datetime.UTC).date() + """«Сегодня» сервиса — дата по Москве (тест не зависит от часового пояса машины).""" + return moscow_date(datetime.datetime.now(datetime.UTC)) async def _office(session: AsyncSession, name: str) -> RegionalOffice: diff --git a/api/tests/test_clock.py b/api/tests/test_clock.py new file mode 100644 index 0000000..8338cf9 --- /dev/null +++ b/api/tests/test_clock.py @@ -0,0 +1,61 @@ +"""«Сегодня» по Москве для дат перевода/закрытия ЮЛ (issue #9 п.6, follow-up #8): граница суток — московская.""" + +import datetime + +import pytest + +from src.common.clock import MOSCOW_TZ, moscow_date +from src.common.exceptions import DomainValidationError +from src.services import legal_entities +from src.services.legal_entities import LegalEntityService + +UTC = datetime.UTC +# 23:30 МСК 4 октября = 20:30 UTC 4 октября; 00:30 МСК 5 октября = 21:30 UTC 4 октября (UTC ещё 4-е) +LATE_EVENING_MSK = datetime.datetime(2026, 10, 4, 20, 30, tzinfo=UTC) +AFTER_MIDNIGHT_MSK = datetime.datetime(2026, 10, 4, 21, 30, tzinfo=UTC) +OCT_4 = datetime.date(2026, 10, 4) +OCT_5 = datetime.date(2026, 10, 5) + + +def test_moscow_zone_is_utc_plus_3() -> None: + assert LATE_EVENING_MSK.astimezone(MOSCOW_TZ).utcoffset() == datetime.timedelta(hours=3) + + +@pytest.mark.parametrize( + ("moment", "expected"), + [(LATE_EVENING_MSK, OCT_4), (AFTER_MIDNIGHT_MSK, OCT_5)], + ids=["23:30-msk", "00:30-msk"], +) +def test_moscow_date_follows_moscow_midnight(moment: datetime.datetime, expected: datetime.date) -> None: + assert moscow_date(moment) == expected + assert moment.date() == OCT_4 # дата UTC в обоих случаях — 4-е + + +def test_moscow_date_rejects_naive_datetime() -> None: + with pytest.raises(ValueError, match="часов"): + moscow_date(datetime.datetime(2026, 10, 4, 21, 30)) # noqa: DTZ001 - проверяется отказ на naive + + +@pytest.mark.parametrize( + ("moment", "today"), + [(LATE_EVENING_MSK, OCT_4), (AFTER_MIDNIGHT_MSK, OCT_5)], + ids=["23:30-msk", "00:30-msk"], +) +def test_effective_date_today_is_moscow_date( + monkeypatch: pytest.MonkeyPatch, moment: datetime.datetime, today: datetime.date +) -> None: + monkeypatch.setattr(legal_entities, "utc_now", lambda: moment) + + assert legal_entities._today() == today + LegalEntityService._check_effective_date(today) # сегодня по Москве — можно + with pytest.raises(DomainValidationError) as info: + LegalEntityService._check_effective_date(today - datetime.timedelta(days=1)) + assert info.value.code == "EFFECTIVE_DATE_IN_PAST" + + +def test_after_moscow_midnight_utc_date_is_already_past(monkeypatch: pytest.MonkeyPatch) -> None: + """00:30 МСК 5-го: 4-е (ещё «сегодня» по UTC) — уже прошлое.""" + monkeypatch.setattr(legal_entities, "utc_now", lambda: AFTER_MIDNIGHT_MSK) + + with pytest.raises(DomainValidationError): + LegalEntityService._check_effective_date(OCT_4)