`_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` |
|
| `GET /directories/legal-entities` | `DIRECTORY_VIEW` | видимые ЮЛ/ИП: `company_group_id`, `outside_group`, `inn`, `q`, `closed`, `limit`/`offset` |
|
||||||
| `POST /directories/legal-entities` | `DIRECTORY_EDIT` | создать ЮЛ/ИП (ИНН: ЮЛ — 10 цифр, ИП — 12) |
|
| `POST /directories/legal-entities` | `DIRECTORY_EDIT` | создать ЮЛ/ИП (ИНН: ЮЛ — 10 цифр, ИП — 12) |
|
||||||
| `GET`, `PATCH /directories/legal-entities/{entity_id}` | `DIRECTORY_VIEW` / `DIRECTORY_EDIT` | карточка (с членствами и алиасами) / изменить |
|
| `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` |
|
| `POST /directories/legal-entities/{entity_id}/withdraw` | `DIRECTORY_EDIT` | вывести из ГК: `CLOSE` (с `close_date`) или `REMOVE_FROM_GROUP` |
|
||||||
| `GET /directories/legal-entities/{entity_id}/history` | `DIRECTORY_VIEW` | история ЮЛ/ИП, новые первыми |
|
| `GET /directories/legal-entities/{entity_id}/history` | `DIRECTORY_VIEW` | история ЮЛ/ИП, новые первыми |
|
||||||
| `POST /directories/legal-entities/{entity_id}/aliases` | `DIRECTORY_EDIT` | добавить алиас (`normalized_alias` по §3) |
|
| `POST /directories/legal-entities/{entity_id}/aliases` | `DIRECTORY_EDIT` | добавить алиас (`normalized_alias` по §3) |
|
||||||
@ -406,7 +406,7 @@ email без учёта регистра проверяет только сер
|
|||||||
| `OFFICE_NOT_FOUND` | 422 | филиал из тела запроса не существует (ГК, ЮЛ, пользователь) |
|
| `OFFICE_NOT_FOUND` | 422 | филиал из тела запроса не существует (ГК, ЮЛ, пользователь) |
|
||||||
| `OFFICE_NOT_ALLOWED` | 422 | ГК можно закрепить только за своим филиалом |
|
| `OFFICE_NOT_ALLOWED` | 422 | ГК можно закрепить только за своим филиалом |
|
||||||
| `INVALID_INN` | 422 | ИНН не из 10 (ЮЛ) / 12 (ИП) цифр |
|
| `INVALID_INN` | 422 | ИНН не из 10 (ЮЛ) / 12 (ИП) цифр |
|
||||||
| `EFFECTIVE_DATE_IN_PAST` | 422 | дата перевода/вывода раньше сегодняшней |
|
| `EFFECTIVE_DATE_IN_PAST` | 422 | дата перевода/вывода раньше сегодняшней (по Москве) |
|
||||||
| `EMPTY_ALIAS` | 422 | алиас пуст после нормализации (одни ОПФ и знаки препинания) |
|
| `EMPTY_ALIAS` | 422 | алиас пуст после нормализации (одни ОПФ и знаки препинания) |
|
||||||
| `DIFFERENT_COMPANY_GROUP` | 422 | ЮЛ не входит в ГК этой ГСК/ГСЗ |
|
| `DIFFERENT_COMPANY_GROUP` | 422 | ЮЛ не входит в ГК этой ГСК/ГСЗ |
|
||||||
| `UNKNOWN_ROLE` / `UNKNOWN_PERMISSION` | 422 | неизвестная или неактивная роль / неизвестный код права (поля `roles` / `permissions`) |
|
| `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
|
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:
|
def utc_now() -> datetime.datetime:
|
||||||
"""Текущий момент в UTC (aware)."""
|
"""Текущий момент в UTC (aware)."""
|
||||||
return datetime.datetime.now(datetime.UTC)
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from src.api.dependencies.audit import RequestAudit
|
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.common.exceptions import ConflictError, DomainValidationError, NotFoundError
|
||||||
from src.domain.inn import validate_inn
|
from src.domain.inn import validate_inn
|
||||||
from src.domain.policy import VisibilityScope, office_for_create
|
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:
|
def _today() -> datetime.date:
|
||||||
"""«Сегодня» для проверки `effective_date` — дата UTC (часовой пояс пользователя в Срезе 1 не известен)."""
|
"""«Сегодня» для проверки `effective_date` — дата по Москве (Europe/Moscow, issue #9 п.6)."""
|
||||||
return utc_now().date()
|
return moscow_date(utc_now())
|
||||||
|
|
||||||
|
|
||||||
def snapshot_entity( # noqa: PLR0913 - снимок ЮЛ: сессия, автор, ЮЛ, событие, момент и дата события
|
def snapshot_entity( # noqa: PLR0913 - снимок ЮЛ: сессия, автор, ЮЛ, событие, момент и дата события
|
||||||
@ -290,7 +290,7 @@ class LegalEntityService:
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _check_effective_date(effective_date: datetime.date) -> None:
|
def _check_effective_date(effective_date: datetime.date) -> None:
|
||||||
# ASSUMPTION(OQ-21): дата перевода/закрытия не раньше сегодняшней (ANL-046); «сегодня» — по UTC
|
# ASSUMPTION(OQ-21): дата перевода/закрытия не раньше сегодняшней (ANL-046); «сегодня» — по Москве
|
||||||
if effective_date < _today():
|
if effective_date < _today():
|
||||||
raise DomainValidationError("EFFECTIVE_DATE_IN_PAST", "Дата не может быть раньше сегодняшней")
|
raise DomainValidationError("EFFECTIVE_DATE_IN_PAST", "Дата не может быть раньше сегодняшней")
|
||||||
|
|
||||||
|
|||||||
@ -11,6 +11,7 @@ from fastapi import FastAPI
|
|||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.common.clock import moscow_date
|
||||||
from src.models.audit_ex import AuditEvent
|
from src.models.audit_ex import AuditEvent
|
||||||
from src.models.directories import (
|
from src.models.directories import (
|
||||||
LegalEntity,
|
LegalEntity,
|
||||||
@ -38,8 +39,8 @@ GROUP_NOT_FOUND = "ГК не найдена"
|
|||||||
|
|
||||||
|
|
||||||
def _today() -> datetime.date:
|
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:
|
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