`_today()` в `services/legal_entities.py` берёт московскую дату (`common/clock.py`: `moscow_date`, `MOSCOW_TZ` = Europe/Moscow; без базы часовых поясов — фиксированный UTC+3, Москва без перехода на летнее время с 2014 г.). Тесты на границе суток: 23:30 МСК (20:30 UTC) — ещё 4-е, 00:30 МСК (21:30 UTC того же дня) — уже 5-е, и 4-е становится прошлым. README: даты перевода/вывода — по Москве. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
parent
ef7458eddb
commit
b17176f97c
@ -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`) |
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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", "Дата не может быть раньше сегодняшней")
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
61
api/tests/test_clock.py
Normal file
61
api/tests/test_clock.py
Normal file
@ -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)
|
||||
Loading…
x
Reference in New Issue
Block a user