Compare commits

..

1 Commits

Author SHA1 Message Date
Раис Юсупалиев a3ce93b058 Добавлен CI, добавлена отправка письма о получении оплаты, добавлен order_id в success_url
Deploy / deploy (push) Failing after 10s
2026-05-27 23:34:03 +03:00
120 changed files with 9922 additions and 20166 deletions
+13 -75
View File
@@ -4,7 +4,6 @@ on:
push:
branches:
- master
- stage
jobs:
deploy:
@@ -14,53 +13,17 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve environment
run: |
set -euo pipefail
# Тег образа зависит только от коммита (одинаков для stage и prod)
echo "IMAGE_TAG=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
if [[ "${{ gitea.ref_name }}" == "stage" ]]; then
{
echo "ENV_NAME=stage"
echo "CONTAINER_SUFFIX=-stage"
echo "APP_PORT=8004"
echo "POSTGRES_PORT=5433"
echo "REDIS_PORT=6380"
echo 'METRICS_FILTER_EXPRESSION=resource.attributes["container.name"] != nil and not IsMatch(resource.attributes["container.name"], "-stage$")'
echo "COMPOSE_PROJECT=g2s-aggregator-stage"
echo "DEPLOY_DIR=/home/deploy/g2s-aggregator-stage"
} >> "$GITHUB_ENV"
else
{
echo "ENV_NAME=prod"
echo "CONTAINER_SUFFIX="
echo "APP_PORT=8003"
echo "POSTGRES_PORT=5432"
echo "REDIS_PORT=6379"
echo 'METRICS_FILTER_EXPRESSION=resource.attributes["container.name"] != nil and IsMatch(resource.attributes["container.name"], "-stage$")'
echo "COMPOSE_PROJECT=g2s-aggregator"
echo "DEPLOY_DIR=/home/deploy/g2s-aggregator"
} >> "$GITHUB_ENV"
fi
- name: Build and push image
run: |
docker login gitea.p4r4dls.ru \
-u ${{ secrets.REGISTRY_USER }} \
-p ${{ secrets.REGISTRY_TOKEN }}
IMG=gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:${IMAGE_TAG}
if docker manifest inspect "$IMG" >/dev/null 2>&1; then
echo "Образ $IMG уже есть в реестре — сборка пропущена"
else
docker buildx rm multibuilder 2>/dev/null || true
docker buildx create --use --name multibuilder --driver-opt network=host
docker buildx build \
--platform linux/amd64 \
--load \
-t "$IMG" \
.
docker push "$IMG"
fi
docker buildx create --use --name multibuilder
docker buildx build \
--platform linux/amd64 \
--push \
-t gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.0.9 \
.
- name: Setup SSH
run: |
@@ -70,61 +33,36 @@ jobs:
ssh-keyscan 194.58.121.203 >> ~/.ssh/known_hosts
- name: Render and copy configs
env:
ALL_SECRETS: ${{ toJSON(secrets) }}
run: |
set -euo pipefail
# Экспортируем все секреты как env-переменные
while IFS= read -r -d '' entry; do
key="${entry%%=*}"
value="${entry#*=}"
export "$key=$value"
done < <(echo "$ALL_SECRETS" | jq -j 'to_entries[] | "\(.key)=\(.value)\u0000"')
# Для stage подменяем базовые имена секретов на STAGE_-версии:
# STAGE_FOO -> FOO
if [[ "$ENV_NAME" == "stage" ]]; then
while IFS= read -r -d '' entry; do
key="${entry%%=*}"
value="${entry#*=}"
if [[ "$key" == STAGE_* ]]; then
export "${key#STAGE_}=$value"
fi
done < <(echo "$ALL_SECRETS" | jq -j 'to_entries[] | "\(.key)=\(.value)\u0000"')
fi
render() {
local src=$1 dst=$2
vars=$(grep -oP '\$\{\K[^}]+' "$src")
for var in $vars; do
if [[ -z "${!var+x}" ]]; then
if [[ -z "${!var:-}" ]]; then
echo "Error: $var не задан в Gitea Secrets" >&2
exit 1
fi
done
perl -pe 's/\$\{(\w+)\}/exists $ENV{$1} ? $ENV{$1} : ""/ge' "$src" > "$dst"
envsubst < "$src" > "$dst"
}
render config.template.yaml config.rendered.yaml
render docker-compose.template.yml docker-compose.rendered.yml
render otel-collector-config.template.yaml otel-collector-config.rendered.yaml
scp -i ~/.ssh/deploy_key config.rendered.yaml \
deploy@194.58.121.203:$DEPLOY_DIR/config.yaml
deploy@194.58.121.203:/app/g2s-aggregator/config.yaml
scp -i ~/.ssh/deploy_key docker-compose.rendered.yml \
deploy@194.58.121.203:$DEPLOY_DIR/docker-compose.yml
scp -i ~/.ssh/deploy_key otel-collector-config.rendered.yaml \
deploy@194.58.121.203:$DEPLOY_DIR/otel-collector-config.yaml
deploy@194.58.121.203:/app/g2s-aggregator/docker-compose.yml
- name: Deploy
run: |
ssh -i ~/.ssh/deploy_key deploy@194.58.121.203 "
cd $DEPLOY_DIR &&
cd /app/g2s-aggregator &&
docker login gitea.p4r4dls.ru \
-u ${{ secrets.REGISTRY_USER }} \
-p ${{ secrets.REGISTRY_TOKEN }} &&
docker compose -p $COMPOSE_PROJECT pull &&
docker compose -p $COMPOSE_PROJECT up -d --force-recreate otel-collector &&
docker compose -p $COMPOSE_PROJECT up -d
docker compose pull &&
docker compose up -d
"
-3
View File
@@ -2,10 +2,7 @@
*.iml
/config.yaml
/docker-compose.yml
/otel-collector-config.yaml
__pycache__
http-client.private.env.json
scripts
.codex
cse-api.pdf
cse_geography*.tsv
+7 -10
View File
@@ -1,20 +1,17 @@
FROM python:3.14-slim
ENV PYTHONUNBUFFERED=1 \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_FROZEN=1 \
PATH="/app/.venv/bin:$PATH"
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project --no-dev
RUN pip install --no-cache-dir poetry
COPY pyproject.toml poetry.lock ./
RUN poetry config virtualenvs.create false \
&& poetry install --no-interaction --no-root
COPY app ./app
COPY alembic.ini ./alembic.ini
COPY alembic ./alembic
CMD ["uv", "run", "--no-sync", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
CMD ["poetry", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+85
View File
@@ -0,0 +1,85 @@
You are Implementer.
You implement EXACTLY ONE next uncompleted task from spec/tasks/.
Before starting, you MUST read:
- spec/overview.md
- the assigned task file in spec/tasks/
You MUST NOT modify any files in spec/.
---
## Your responsibilities
- Implement the task exactly as specified
- Place business rules in Business Logic
- Place orchestration in Service
- Respect all constraints from the task
- Add or update required tests
- Keep changes strictly within task scope
---
## Mandatory verification steps (BEFORE finishing)
You MUST:
1. Verify all Definition of Done items from the task
2. Ensure Business Logic is fully unit-tested WITHOUT mocks
3. Ensure Service tests (if any) may use mocks/stubs
4. Run all tests specified in the task
5. Run all linters / type checks specified in the task
6. Verify no unrelated files were modified
7. Stage all intended changes:
- run: git add -A
- DO NOT commit
If ANY item fails, you MUST report it explicitly.
---
## Output format (MANDATORY)
Output EXACTLY the following sections.
## Task
- Task ID and title
## Changes made
- List of changed files
- Short description per file
- Explicit layer for each file (Controller / Service / Business Logic / Repository / Adapter / Tests)
## Definition of Done verification
- Checklist copied from the task
- Each item marked as PASSED or FAILED
## Tests
- Tests added or updated
- Test type:
- unit (no mocks)
- integration
- other
- Results
## Commands executed
- Exact commands run
- Result (success/failure)
## Git staging
- Confirm: git add -A executed
- Confirm: no commits created
## Notes
- Edge cases, trade-offs, or limitations
- If none, write "None"
---
## Hard rules
- No scope expansion
- No speculative refactoring
- No changes outside task scope
- No spec changes
- No business logic in Service or Adapter
+99
View File
@@ -0,0 +1,99 @@
You are Planner.
You analyze a programming task or specification and produce planning artifacts.
You do NOT write production code.
You MUST always read:
- spec/overview.md
- spec/index.md
- existing files in spec/tasks/
You are the ONLY role allowed to create or modify files in spec/tasks/.
Write tasks in russian except headings, frontmatter and terms
Follow AGENTS.md strictly.
---
## Your responsibilities
Depending on the input, you may:
- create one or more new task files in spec/tasks/
- update existing task files in spec/tasks/
For every change in spec/tasks/, you MUST:
- use the minimal task format defined below
- update spec/index.md via the generator workflow
You MUST NOT:
- modify any source code
- change files outside spec/
- introduce architectural decisions not requested
---
## Minimal task format (MANDATORY)
Every task file in spec/tasks/ MUST follow this structure.
### Front matter (required)
---
id: <numeric, zero-padded, unique>
title: <short, precise, action-oriented>
status: TODO
created: <YYYY-MM-DD>
---
### Body (required sections, minimal)
## Context
Why this task exists. Short and factual.
## Goal
What must be implemented or changed.
## Constraints
Hard limits:
- architectural
- scope
- forbidden changes
## Acceptance criteria
- Bullet list
- Objective and testable
## Definition of Done
Checklist. Task is DONE only if ALL items are satisfied.
## Tests
What tests must exist or be updated.
## Commands
Exact commands to verify completion
(e.g. pytest, ruff, mypy, docker compose).
---
## Planner output rules
When creating or updating tasks, output MUST include:
### Tasks created
- List of new spec/tasks/*.md files with full content
### Tasks updated
- List of modified spec/tasks/*.md files with changes
### Index update
- Explicit instruction to run:
python3 spec/gen_spec_index.py
If no tasks are created or updated, explicitly state:
- "No task changes required"
Rules:
- No code blocks outside task files
- No speculative tasks
- No refactoring-only tasks
- No vague goals
+93
View File
@@ -0,0 +1,93 @@
You are Reviewer.
You review code changes produced by Implementer.
Before reviewing, you MUST read:
- spec/overview.md
- the assigned task file in spec/tasks/
You MUST review ONLY staged changes:
- git diff --staged
You MUST NOT modify source code.
---
## Review scope (MANDATORY)
You must verify:
1. Task compliance
- Implementation matches task requirements
- No scope expansion
2. Architecture compliance
- Correct layer separation
- Business Logic purity
- Proper Service orchestration
- No business decisions in Repository or Adapter
3. Testing strategy
- Business Logic has unit tests WITHOUT mocks
- Tests cover task Definition of Done
- No missing critical tests
4. Project compliance
- Matches constraints and invariants from spec/overview.md
---
## Output format (MANDATORY)
Comments must be in russian language.
Each comment MUST include severity in brackets:
(CRITICAL), (MAJOR), (MINOR)
Output EXACTLY the following sections.
## Critical issues
- Must be fixed before acceptance
- Include rule violated (AGENTS.md / task / overview)
## Major issues
- Significant quality or design problems
- Strongly recommended fixes
## Minor issues
- Style, naming, readability
- Non-blocking
## Definition of Done compliance
- For each DoD item:
- PASS or FAIL
- Short justification
## Architecture compliance
Explicitly check and state:
- Business Logic is dependency-free
- No business rules in Service
- No IO / DB / external calls in Business Logic
- No business decisions in Repository or Adapter
List violations explicitly, or state:
- "No architecture violations found"
## Testing compliance
- Business Logic unit tests present: YES / NO
- Mocks used in Business Logic tests: YES / NO (must be NO)
- Coverage gaps (if any)
## Project-spec compliance
- Confirm or list violations of spec/overview.md
## Review input
- Confirm you reviewed: git diff --staged
---
## Hard rules
- No code changes
- No new requirements
- No architectural redesign
- No speculative improvements
@@ -1,34 +0,0 @@
"""Change orders.tariff_code to string."""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "20260530_037"
down_revision: str | None = "20260527_036"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.alter_column(
"orders",
"tariff_code",
existing_type=sa.Integer(),
type_=sa.String(length=128),
existing_nullable=False,
postgresql_using="tariff_code::varchar",
)
def downgrade() -> None:
op.alter_column(
"orders",
"tariff_code",
existing_type=sa.String(length=128),
type_=sa.Integer(),
existing_nullable=False,
postgresql_using="tariff_code::integer",
)
@@ -1,33 +0,0 @@
"""Add provider and CSE order number columns to orders table."""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "20260530_038"
down_revision: str | None = "20260530_037"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"orders",
sa.Column(
"provider",
sa.String(length=32),
nullable=False,
server_default="cdek",
),
)
op.add_column(
"orders",
sa.Column("cse_order_number", sa.String(length=128), nullable=True),
)
def downgrade() -> None:
op.drop_column("orders", "cse_order_number")
op.drop_column("orders", "provider")
@@ -1,115 +0,0 @@
"""Generalize provider order state columns."""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "20260626_039"
down_revision: str | None = "20260530_038"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"orders",
sa.Column("provider_order_id", sa.String(length=128), nullable=True),
)
op.add_column(
"orders",
sa.Column("provider_order_status", sa.String(length=64), nullable=True),
)
op.add_column(
"orders",
sa.Column("provider_waybill_id", sa.String(length=128), nullable=True),
)
op.add_column(
"orders",
sa.Column("provider_waybill_url", sa.String(length=2048), nullable=True),
)
op.add_column(
"orders",
sa.Column("provider_polled_at", sa.DateTime(timezone=True), nullable=True),
)
op.execute(
"""
UPDATE orders
SET
provider_order_id = cdek_order_uuid,
provider_order_status = cdek_order_status,
provider_waybill_id = cdek_waybill_uuid,
provider_waybill_url = cdek_waybill_url,
provider_polled_at = cdek_polled_at
WHERE provider = 'cdek'
"""
)
op.execute(
"""
UPDATE orders
SET provider_order_id = cse_order_number
WHERE provider = 'cse'
"""
)
op.drop_column("orders", "cse_order_number")
op.drop_column("orders", "cdek_polled_at")
op.drop_column("orders", "cdek_order_status")
op.drop_column("orders", "cdek_waybill_url")
op.drop_column("orders", "cdek_waybill_uuid")
op.drop_column("orders", "cdek_order_uuid")
def downgrade() -> None:
op.add_column(
"orders",
sa.Column("cdek_order_uuid", sa.String(length=128), nullable=True),
)
op.add_column(
"orders",
sa.Column("cdek_waybill_uuid", sa.String(length=128), nullable=True),
)
op.add_column(
"orders",
sa.Column("cdek_waybill_url", sa.String(length=2048), nullable=True),
)
op.add_column(
"orders",
sa.Column("cdek_order_status", sa.String(length=64), nullable=True),
)
op.add_column(
"orders",
sa.Column("cdek_polled_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"orders",
sa.Column("cse_order_number", sa.String(length=128), nullable=True),
)
op.execute(
"""
UPDATE orders
SET
cdek_order_uuid = provider_order_id,
cdek_order_status = provider_order_status,
cdek_waybill_uuid = provider_waybill_id,
cdek_waybill_url = provider_waybill_url,
cdek_polled_at = provider_polled_at
WHERE provider = 'cdek'
"""
)
op.execute(
"""
UPDATE orders
SET cse_order_number = provider_order_id
WHERE provider = 'cse'
"""
)
op.drop_column("orders", "provider_polled_at")
op.drop_column("orders", "provider_waybill_url")
op.drop_column("orders", "provider_waybill_id")
op.drop_column("orders", "provider_order_status")
op.drop_column("orders", "provider_order_id")
-23
View File
@@ -1,9 +1,7 @@
"""Base interface for delivery providers."""
from abc import ABC, abstractmethod
from typing import Protocol, runtime_checkable
from app.schemas.payment import InitPaymentRequest
from app.schemas.request import DeliveryCalculationRequest
from app.schemas.response import DeliveryPrice
@@ -20,27 +18,6 @@ class DeliveryProvider(ABC):
raise NotImplementedError
@runtime_checkable
class PaymentPriceValidationProvider(Protocol):
name: str
async def get_payment_price(
self,
request: InitPaymentRequest,
) -> DeliveryPrice | None: ...
@runtime_checkable
class OrderRegistrationProvider(Protocol):
name: str
async def register_order(
self,
request: InitPaymentRequest,
order_uuid: str,
) -> object: ...
class ProviderClientError(RuntimeError):
"""Raised when a provider call fails for temporary or provider-side reasons."""
+12 -44
View File
@@ -33,7 +33,7 @@ from app.adapters.delivery_providers.cdek.order_mapper import (
resolve_cdek_city_code,
)
from app.cities import cities_map
from app.config import CDEKDeliveryProviderConfig
from app.config import AdapterConfig
from app.schemas.payment import InitPaymentRequest
from app.schemas.request import DeliveryCalculationRequest
from app.schemas.response import DeliveryPrice
@@ -233,13 +233,6 @@ class CDEKClient:
url,
failure_message="CDEK get order failed",
)
requests_with_errors = _extract_requests_with_errors(raw_payload)
if requests_with_errors:
log.warning(
"cdek_order_request_errors",
cdek_order_uuid=cdek_order_uuid,
requests=requests_with_errors,
)
try:
return map_cdek_order_info_response(raw_payload)
except CDEKOrderMappingError as exc:
@@ -452,28 +445,28 @@ class CDEKProvider(DeliveryProvider):
self.cache_ttl_seconds = cache_ttl_seconds
@classmethod
def from_config(
def from_adapter_config(
cls,
*,
http_client: httpx.AsyncClient,
config: CDEKDeliveryProviderConfig,
adapter_config: AdapterConfig,
) -> "CDEKProvider":
auth_client = CDEKAuthClient(
http_client=http_client,
base_url=config.base_url,
client_id=config.client_id,
client_secret=config.client_secret,
timeout_seconds=config.timeout_seconds,
base_url=adapter_config.cdek_base_url,
client_id=adapter_config.cdek_client_id,
client_secret=adapter_config.cdek_client_secret,
timeout_seconds=adapter_config.cdek_timeout_seconds,
)
client = CDEKClient(
http_client=http_client,
auth_client=auth_client,
base_url=config.base_url,
timeout_seconds=config.timeout_seconds,
retry_attempts=config.retry_attempts,
retry_backoff_seconds=config.retry_backoff_seconds,
base_url=adapter_config.cdek_base_url,
timeout_seconds=adapter_config.cdek_timeout_seconds,
retry_attempts=adapter_config.cdek_retry_attempts,
retry_backoff_seconds=adapter_config.cdek_retry_backoff_seconds,
)
return cls(client=client, cache_ttl_seconds=config.cache_ttl_seconds)
return cls(client=client, cache_ttl_seconds=adapter_config.cdek_cache_ttl_seconds)
async def get_prices(
self, request: DeliveryCalculationRequest
@@ -520,28 +513,3 @@ def _response_text_or_none(response: httpx.Response) -> str | None:
return response.text
except Exception:
return None
def _extract_requests_with_errors(
payload: dict[str, Any],
) -> list[dict[str, object]]:
requests = payload.get("requests")
if not isinstance(requests, list):
return []
requests_with_errors: list[dict[str, object]] = []
for request in requests:
if not isinstance(request, dict):
continue
errors = request.get("errors")
if not isinstance(errors, list) or not errors:
continue
requests_with_errors.append(
{
"request_uuid": request.get("request_uuid"),
"type": request.get("type"),
"state": request.get("state"),
"errors": errors,
}
)
return requests_with_errors
@@ -22,7 +22,7 @@ def map_cdek_response(payload: dict[str, Any]) -> list[DeliveryPrice]:
def map_cdek_response_for_tariff_code(
payload: dict[str, Any],
*,
tariff_code: str,
tariff_code: int,
) -> DeliveryPrice | None:
tariff_codes = payload.get("tariff_codes")
if not isinstance(tariff_codes, list):
@@ -84,17 +84,19 @@ def _get_tariffs(payload: dict[str, Any]) -> list[dict[str, Any]]:
return tariffs
def _tariff_code_matches(value: object, expected_tariff_code: str) -> bool:
extracted = _extract_tariff_code(value)
if extracted is None:
def _tariff_code_matches(value: object, expected_tariff_code: int) -> bool:
if isinstance(value, bool) or value is None:
return False
try:
return int(value) == expected_tariff_code
except (TypeError, ValueError):
return False
return extracted == expected_tariff_code
def _extract_tariff_code(value: object) -> str | None:
def _extract_tariff_code(value: object) -> int | None:
if isinstance(value, bool) or value is None:
return None
try:
return str(int(value))
return int(value)
except (TypeError, ValueError):
return None
@@ -20,7 +20,6 @@ class CDEKOrderMappingError(ValueError):
_CDEK_WAYBILL_PRINT_TYPE = "WAYBILL"
_CDEK_WAYBILL_RELATED_ENTITY_TYPE = "waybill"
_CDEK_INSURANCE_SERVICE_CODE = "INSURANCE"
@dataclass(frozen=True)
@@ -49,7 +48,7 @@ def map_cdek_order_request(
payload: dict[str, Any] = {
"number": order_uuid,
"type": 2,
"tariff_code": _to_cdek_tariff_code(request.system_data.tariff.tariff_code),
"tariff_code": request.system_data.tariff.tariff_code,
"print": _CDEK_WAYBILL_PRINT_TYPE,
"sender": _map_party(request.sender_contact),
"recipient": _map_party(request.receiver_contact),
@@ -57,23 +56,11 @@ def map_cdek_order_request(
"to_location": _map_location(request.receiver_address),
"packages": [_map_package(request, order_uuid)],
}
services = _map_services(request)
if services:
payload["services"] = services
if request.content.description:
payload["comment"] = request.content.description
return payload
def _to_cdek_tariff_code(tariff_code: str) -> int:
try:
return int(tariff_code)
except (TypeError, ValueError) as exc:
raise CDEKOrderMappingError(
f"CDEK tariff_code must be numeric: {tariff_code!r}."
) from exc
def map_cdek_order_response(payload: dict[str, Any]) -> CDEKOrderRegistrationResult:
entity = payload.get("entity")
if not isinstance(entity, dict):
@@ -130,9 +117,7 @@ def map_cdek_order_info_response(payload: dict[str, Any]) -> CDEKOrderInfo:
"CDEK order info response must include entity.uuid."
)
status_code = _invalid_create_request_status(
payload.get("requests")
) or _latest_status_code(entity.get("statuses"))
status_code = _latest_status_code(entity.get("statuses"))
waybill_uuid, _ = _extract_waybill(payload)
return CDEKOrderInfo(
order_uuid=order_uuid,
@@ -178,29 +163,8 @@ def _latest_status_code(statuses: object) -> str | None:
return dated[-1][1]
def _invalid_create_request_status(requests: object) -> str | None:
if not isinstance(requests, list):
return None
for entry in requests:
if not isinstance(entry, dict):
continue
if entry.get("type") != "CREATE" or entry.get("state") != "INVALID":
continue
errors = entry.get("errors")
if isinstance(errors, list) and errors:
return "INVALID"
return None
def _extract_waybill(payload: dict[str, Any]) -> tuple[str | None, str | None]:
entity = payload.get("entity")
related_entities = (
entity.get("related_entities")
if isinstance(entity, dict)
else None
)
if not isinstance(related_entities, list):
related_entities = payload.get("related_entities")
related_entities = payload.get("related_entities")
if not isinstance(related_entities, list):
return None, None
for entry in related_entities:
@@ -332,18 +296,6 @@ def _map_package(request: InitPaymentRequest, order_uuid: str) -> dict[str, Any]
return package
def _map_services(request: InitPaymentRequest) -> list[dict[str, Any]]:
declared_value = request.content.declared_value
if declared_value <= 0:
return []
return [
{
"code": _CDEK_INSURANCE_SERVICE_CODE,
"parameter": declared_value,
}
]
def _map_dimensions(dimensions: Dimensions) -> dict[str, int]:
return {
"length": centimeters_string_to_int(dimensions.length, "length"),
@@ -1,15 +0,0 @@
"""CSE delivery provider adapter."""
from app.adapters.delivery_providers.cse.client import (
CSEClient,
CSEClientError,
CSEProvider,
CSERequestError,
)
__all__ = (
"CSEClient",
"CSEClientError",
"CSEProvider",
"CSERequestError",
)
@@ -1,389 +0,0 @@
"""CSE SOAP client and provider adapter."""
import asyncio
from collections.abc import Awaitable, Callable
import httpx
import structlog
from app.adapters.delivery_providers.base import DeliveryProvider
from app.adapters.delivery_providers.cse.constants import (
CSE_PROVIDER_NAME,
delivery_type_requires_pvz,
)
from app.adapters.delivery_providers.cse.errors import (
CSEClientError,
CSEMappingError,
CSERequestError,
)
from app.adapters.delivery_providers.cse.mapper import (
map_cse_calc_response,
map_cse_calc_response_for_tariff_code,
)
from app.adapters.delivery_providers.cse.order_mapper import (
CSEOrderInfo,
CSEOrderRegistrationParams,
CSEOrderRegistrationResult,
build_calc_body_for_calculation,
build_calc_body_for_payment,
build_tracking_body_for_order,
build_waybill_print_form_body,
map_cse_tracking_response,
map_cse_save_order_request,
map_cse_save_order_response,
map_cse_waybill_print_form_response,
split_tariff_code,
)
from app.adapters.delivery_providers.cse.soap import (
Element,
build_envelope,
make_field,
parse_response,
)
from app.config import CSEDeliveryProviderConfig
from app.schemas.payment import InitPaymentRequest
from app.schemas.request import DeliveryCalculationRequest
from app.schemas.response import DeliveryPrice
log = structlog.get_logger(__name__)
_SOAP_HEADERS = {"Content-Type": "application/soap+xml; charset=utf-8"}
class CSEClient:
def __init__(
self,
http_client: httpx.AsyncClient,
*,
base_url: str,
login: str,
password: str,
registration_params: CSEOrderRegistrationParams,
timeout_seconds: float = 10.0,
retry_attempts: int = 2,
retry_backoff_seconds: float = 0.2,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
) -> None:
self._http_client = http_client
self._url = base_url
self._login = login
self._password = password
self._registration_params = registration_params
self._timeout_seconds = timeout_seconds
self._retry_attempts = retry_attempts
self._retry_backoff_seconds = retry_backoff_seconds
self._sleep = sleep
async def calc(self, body: dict[str, Element]) -> Element:
return await self._post(
"Calc",
body,
request_error_message="CSE calc request was rejected with status",
)
async def get_delivery_types(self) -> list[tuple[str, str]]:
"""Return available delivery schemes as (name, human label) pairs."""
body = {
"parameters": Element(
key="parameters",
items=[make_field("Reference", "DeliveryType")],
)
}
root = await self._post(
"GetReferenceData",
body,
request_error_message="CSE delivery types request was rejected with status",
)
result: list[tuple[str, str]] = []
for entry in root.items:
name = entry.value
if name:
result.append((name, entry.field_value("Information") or name))
return result
async def save_order(
self, request: InitPaymentRequest, order_uuid: str
) -> CSEOrderRegistrationResult:
body = map_cse_save_order_request(
request, order_uuid, self._registration_params
)
root = await self._post(
"SaveDocuments",
body,
request_error_message="CSE SaveDocuments request was rejected with status",
)
try:
return map_cse_save_order_response(root)
except CSEMappingError as exc:
raise CSEClientError(str(exc)) from exc
async def get_order(self, order_number: str) -> CSEOrderInfo:
root = await self._post(
"Tracking",
build_tracking_body_for_order(order_number),
request_error_message="CSE Tracking request was rejected with status",
)
try:
return map_cse_tracking_response(root)
except CSEMappingError as exc:
raise CSEClientError(str(exc)) from exc
async def download_waybill_pdf(self, waybill_number: str) -> bytes:
root = await self._post(
"GetFormsForDocuments",
build_waybill_print_form_body(waybill_number),
request_error_message=(
"CSE GetFormsForDocuments request was rejected with status"
),
)
try:
return map_cse_waybill_print_form_response(root)
except CSEMappingError as exc:
raise CSEClientError(str(exc)) from exc
async def _post(
self,
operation: str,
body: dict[str, Element],
*,
request_error_message: str,
) -> Element:
envelope = build_envelope(
operation,
login=self._login,
password=self._password,
body=body,
)
for attempt in range(self._retry_attempts + 1):
try:
response = await self._http_client.post(
self._url,
content=envelope,
headers=_SOAP_HEADERS,
timeout=self._timeout_seconds,
)
except (httpx.TimeoutException, httpx.TransportError) as exc:
if attempt < self._retry_attempts:
await self._sleep(self._retry_delay(attempt))
continue
raise CSEClientError(
f"CSE {operation} request failed after retry attempts."
) from exc
if self._should_retry(response.status_code):
if attempt < self._retry_attempts:
await self._sleep(self._retry_delay(attempt))
continue
log.warning(
"cse_request_server_error",
operation=operation,
status_code=response.status_code,
response_excerpt=_response_excerpt(response.text),
)
raise CSEClientError(
f"CSE {operation} request failed with status "
f"{response.status_code}."
)
if 400 <= response.status_code < 500:
log.warning(
"cse_request_rejected",
operation=operation,
status_code=response.status_code,
)
raise CSERequestError(
f"{request_error_message} {response.status_code}."
)
try:
root = parse_response(response.text, operation)
except ValueError as exc:
raise CSEClientError(
f"CSE {operation} response payload is invalid."
) from exc
self._raise_for_response_error(root, operation, request_error_message)
return root
raise CSEClientError(f"CSE {operation} request failed unexpectedly.")
@staticmethod
def _raise_for_response_error(
root: Element,
operation: str,
request_error_message: str,
) -> None:
error_codes = _response_error_codes(root)
if error_codes:
log.warning(
"cse_response_error",
operation=operation,
error_codes=error_codes,
)
raise CSERequestError(
f"{request_error_message} application error {error_codes}."
)
@staticmethod
def _should_retry(status_code: int) -> bool:
return status_code == 429 or 500 <= status_code < 600
def _retry_delay(self, attempt: int) -> float:
return self._retry_backoff_seconds * (2**attempt)
def _response_excerpt(text: str, *, limit: int = 1000) -> str:
return " ".join(text.split())[:limit]
def _response_error_codes(root: Element) -> list[str]:
codes: list[str] = []
_collect_response_error_codes(root, codes)
return codes
def _collect_response_error_codes(element: Element, codes: list[str]) -> None:
for prop in element.properties:
if prop.key == "Error":
codes.extend(item.value for item in prop.items if item.value)
for child in (*element.items, *element.tables):
_collect_response_error_codes(child, codes)
class CSEProvider(DeliveryProvider):
name = CSE_PROVIDER_NAME
def __init__(
self,
client: CSEClient,
*,
cache_ttl_seconds: int = 900,
delivery_service_guids: tuple[str, ...] = (),
) -> None:
self._client = client
self.cache_ttl_seconds = cache_ttl_seconds
self._delivery_service_guids = delivery_service_guids
self._delivery_types: list[tuple[str, str]] | None = None
@classmethod
def from_config(
cls,
*,
http_client: httpx.AsyncClient,
config: CSEDeliveryProviderConfig,
) -> "CSEProvider":
client = CSEClient(
http_client=http_client,
base_url=config.base_url,
login=config.login,
password=config.password,
registration_params=CSEOrderRegistrationParams(
payer=config.payer,
payment_method=config.payment_method,
shipping_method=config.shipping_method,
),
timeout_seconds=config.timeout_seconds,
retry_attempts=config.retry_attempts,
retry_backoff_seconds=config.retry_backoff_seconds,
)
return cls(
client=client,
cache_ttl_seconds=config.cache_ttl_seconds,
delivery_service_guids=tuple(config.delivery_service_guids),
)
async def get_prices(
self, request: DeliveryCalculationRequest
) -> list[DeliveryPrice]:
delivery_types = await self._resolve_delivery_types()
results = await asyncio.gather(
*(
self._prices_for_delivery_type(request, name, label, service_guid)
for name, label in delivery_types
for service_guid in self._service_guids_for_price_calculation()
),
return_exceptions=True,
)
prices: list[DeliveryPrice] = []
errors: list[BaseException] = []
for result in results:
if isinstance(result, BaseException):
errors.append(result)
else:
prices.extend(result)
if not prices and errors:
raise errors[0]
return prices
async def _prices_for_delivery_type(
self,
request: DeliveryCalculationRequest,
delivery_type: str,
delivery_type_label: str,
service_guid: str | None,
) -> list[DeliveryPrice]:
body = build_calc_body_for_calculation(request, delivery_type, service_guid)
root = await self._client.calc(body)
try:
return map_cse_calc_response(
root,
delivery_type=delivery_type,
delivery_type_label=delivery_type_label,
service_guid=service_guid,
)
except CSEMappingError as exc:
raise CSEClientError("CSE calc response payload is invalid.") from exc
def _service_guids_for_price_calculation(self) -> tuple[str, ...]:
if not self._delivery_service_guids:
raise CSERequestError("CSE delivery service GUIDs are not configured.")
return self._delivery_service_guids
async def _resolve_delivery_types(self) -> list[tuple[str, str]]:
if self._delivery_types is None:
self._delivery_types = await self._client.get_delivery_types()
# Exclude schemes that require a pickup point (PVZ) at registration,
# since PVZ is not yet supported (only door-to-door is fulfillable).
usable = [
(name, label)
for name, label in self._delivery_types
if not delivery_type_requires_pvz(name, label)
]
# Fall back to the contract-default scheme (empty delivery_type) so
# calculation still works if no usable schemes are returned.
return usable or [("", "")]
async def get_payment_price(
self,
request: InitPaymentRequest,
) -> DeliveryPrice | None:
tariff_code = request.system_data.tariff.tariff_code
try:
delivery_type, service_guid, _ = split_tariff_code(tariff_code)
body = build_calc_body_for_payment(request, delivery_type, service_guid)
root = await self._client.calc(body)
return map_cse_calc_response_for_tariff_code(
root,
tariff_code=tariff_code,
)
except CSERequestError:
raise
except CSEMappingError as exc:
raise CSEClientError(
"CSE payment price validation response payload is invalid."
) from exc
async def register_order(
self, request: InitPaymentRequest, order_uuid: str
) -> CSEOrderRegistrationResult:
return await self._client.save_order(request, order_uuid)
async def get_order(self, order_number: str) -> CSEOrderInfo:
return await self._client.get_order(order_number)
async def download_waybill_pdf(self, waybill_number: str) -> bytes:
return await self._client.download_waybill_pdf(waybill_number)
@@ -1,82 +0,0 @@
"""CSE reference constants and geography resolution.
CSE identifies geography, cargo type and currency by GUID. Geography GUIDs per
city live in ``cities_map`` under the ``cse`` block (see task 037). Cargo-type
GUIDs come from ``GetReferenceData: TypesOfCargo`` and must be configured with
real values before production use.
"""
from app.adapters.delivery_providers.cse.errors import CSERequestError
from app.cities import cities_map
# Provider name surfaced in unified DeliveryPrice/SystemDataTariff.
CSE_PROVIDER_NAME = "cse"
# Keywords in a DeliveryType name/label that imply a pickup point (PVZ) is
# required at registration (SenderPVZ/RecipientPVZ). PVZ is not yet supported,
# so such schemes are excluded from price calculation.
_PVZ_SCHEME_KEYWORDS = ("склад", "самовывоз", "почтов", "пвз")
def delivery_type_requires_pvz(name: str, label: str) -> bool:
haystack = f"{name} {label}".lower()
return any(keyword in haystack for keyword in _PVZ_SCHEME_KEYWORDS)
# CSE returns currency short names (e.g. "RUR"); normalize to ISO-4217.
_CURRENCY_NAME_TO_CODE = {
"RUR": "RUB",
"RUB": "RUB",
"USD": "USD",
"EUR": "EUR",
}
# TypeOfCargo GUIDs by parcel type (GetReferenceData: TypesOfCargo).
TYPE_OF_CARGO_BY_PARCEL_TYPE: dict[str, str] = {
"doc": "81dd8a13-8235-494f-84fd-9c04c51d50ec", # Документы
"parcel": "4aab1fc6-fc2b-473a-8728-58bcd4ff79ba", # Груз
}
# Cargo type used when the parcel type is not known (price calculation flow).
DEFAULT_TYPE_OF_CARGO: str = "4aab1fc6-fc2b-473a-8728-58bcd4ff79ba" # Груз
def normalize_currency(currency_name: str | None) -> str:
if not currency_name:
return "RUB"
return _CURRENCY_NAME_TO_CODE.get(currency_name.strip().upper(), "RUB")
def resolve_type_of_cargo(parcel_type: str | None) -> str:
cargo_guid = (
TYPE_OF_CARGO_BY_PARCEL_TYPE.get(parcel_type)
if parcel_type is not None
else DEFAULT_TYPE_OF_CARGO
)
if not cargo_guid:
raise CSERequestError(
f"CSE cargo type is not configured for parcel type {parcel_type!r}."
)
return cargo_guid
def resolve_cse_geography(city_id: int) -> str:
"""Resolve CSE geography GUID from cities_map by city identifier."""
city_entry = cities_map.get(str(city_id))
if not isinstance(city_entry, dict):
raise CSERequestError(
f"CSE geography is not configured for city id {city_id}."
)
cse_data = city_entry.get("cse")
if not isinstance(cse_data, dict):
raise CSERequestError(
f"CSE geography is not configured for city id {city_id}."
)
geography_uid = cse_data.get("geography_uid")
if not isinstance(geography_uid, str) or not geography_uid:
raise CSERequestError(
f"CSE geography is not configured for city id {city_id}."
)
return geography_uid
@@ -1,18 +0,0 @@
"""CSE adapter error hierarchy."""
from app.adapters.delivery_providers.base import (
ProviderClientError,
ProviderRequestError,
)
class CSEClientError(ProviderClientError):
"""Raised when a CSE request fails for temporary or provider-side reasons."""
class CSERequestError(CSEClientError, ProviderRequestError):
"""Raised when CSE rejects request data as invalid."""
class CSEMappingError(ValueError):
"""Raised when a CSE SOAP response cannot be mapped."""
@@ -1,150 +0,0 @@
"""CSE Calc response mapper to the unified delivery schema."""
from decimal import Decimal, InvalidOperation
from app.adapters.delivery_providers.cse.constants import (
CSE_PROVIDER_NAME,
normalize_currency,
)
from app.adapters.delivery_providers.cse.errors import CSEMappingError
from app.adapters.delivery_providers.cse.soap import Element
from app.schemas.response import DeliveryPrice
_TARIFF_KEY = "Tariff"
def map_cse_calc_response(
root: Element,
*,
delivery_type: str = "",
delivery_type_label: str = "",
service_guid: str | None = None,
) -> list[DeliveryPrice]:
"""Map a parsed ``Calc`` ``return`` Element into unified delivery prices."""
prices: list[DeliveryPrice] = []
for tariff in _iter_tariffs(root):
if service_guid is not None and tariff.value != service_guid:
continue
price = _map_tariff(tariff, delivery_type, delivery_type_label)
if price is not None:
prices.append(price)
return prices
def map_cse_calc_response_for_tariff_code(
root: Element,
*,
tariff_code: str,
delivery_type_label: str = "",
) -> DeliveryPrice | None:
"""Return tariff matching ``"<DeliveryType>|<ServiceGuid>|<Urgency>"``."""
delivery_type, tariff_guid, urgency = _split_tariff_code(tariff_code)
for tariff in _iter_tariffs(root):
if _tariff_matches(tariff, tariff_guid, urgency):
price = _map_tariff(tariff, delivery_type, delivery_type_label)
if price is not None:
return price
return None
def _tariff_urgency(tariff: Element) -> str | None:
"""Urgency GUID of a Calc tariff (fallback: the tariff record GUID)."""
return tariff.field_value("Urgency") or tariff.value
def _tariff_matches(tariff: Element, tariff_guid: str, urgency: str) -> bool:
if _tariff_urgency(tariff) != urgency:
return False
return tariff.value == tariff_guid
def _split_tariff_code(tariff_code: str) -> tuple[str, str, str]:
parts = tariff_code.split("|")
if len(parts) != 3 or not parts[1] or not parts[2]:
raise CSEMappingError("CSE tariff_code has invalid format.")
return parts[0], parts[1], parts[2]
def _iter_tariffs(root: Element):
for destination in root.items:
for tariff in destination.items:
if tariff.key == _TARIFF_KEY:
yield tariff
def _map_tariff(
tariff: Element,
delivery_type: str,
delivery_type_label: str,
) -> DeliveryPrice | None:
if _is_additional_service_tariff(tariff):
return None
urgency = _tariff_urgency(tariff)
if not urgency:
return None
if not tariff.value:
return None
# Unified tariff_code carries the delivery scheme, CSE service GUID and
# urgency so payment validation can recalculate the exact selected service.
tariff_code = f"{delivery_type}|{tariff.value}|{urgency}"
raw_total = tariff.field_value("Total")
if raw_total is None:
raise CSEMappingError("CSE tariff is missing Total.")
try:
price = Decimal(raw_total)
except (InvalidOperation, TypeError, ValueError) as exc:
raise CSEMappingError("CSE tariff Total is not a valid decimal.") from exc
if not price.is_finite() or price <= 0:
return None
base_name = tariff.field_value("Service") or tariff.field_value("UrgencyName")
if not base_name:
base_name = urgency
service_name = (
f"{base_name}{delivery_type_label}" if delivery_type_label else base_name
)
currency = normalize_currency(tariff.field_value("CurrencyName"))
min_days = _to_int(tariff.field_value("MinPeriod"))
max_days = _to_int(tariff.field_value("MaxPeriod"))
if min_days is None:
min_days = 0
if max_days is None or max_days < min_days:
max_days = min_days
try:
return DeliveryPrice(
provider=CSE_PROVIDER_NAME,
service_name=str(service_name),
price=price,
currency=currency,
delivery_days_min=min_days,
delivery_days_max=max_days,
tariff_code=tariff_code,
bypass_parcel_type_filter=True,
)
except (ArithmeticError, TypeError, ValueError) as exc:
raise CSEMappingError("CSE tariff fields have invalid values.") from exc
def _is_additional_service_tariff(tariff: Element) -> bool:
value = tariff.field_value("AdditionalService")
if value is None:
return False
return value.strip().lower() == "true"
def _to_int(value: str | None) -> int | None:
if value is None or value == "":
return None
try:
return int(Decimal(value))
except (InvalidOperation, TypeError, ValueError):
return None
@@ -1,302 +0,0 @@
"""CSE Calc, SaveDocuments and waybill payload mappers."""
import base64
import binascii
from dataclasses import dataclass
from datetime import timedelta
from app.adapters.delivery_providers.cse.constants import (
resolve_cse_geography,
resolve_type_of_cargo,
)
from app.adapters.delivery_providers.cse.errors import CSEMappingError, CSERequestError
from app.adapters.delivery_providers.cse.soap import Element, make_field
from app.schemas.payment import Address, InitPaymentRequest
from app.schemas.request import DeliveryCalculationRequest
_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S"
_DELIVERY_DATE_OFFSET_DAYS = 7
@dataclass(frozen=True)
class CSEOrderRegistrationParams:
"""Contract-specific required parameters for SaveDocuments.
``Urgency`` is not here: it comes from the tariff the client selected
(``system_data.tariff.tariff_code`` holds the urgency GUID).
"""
payer: str
payment_method: str
shipping_method: str
@dataclass(frozen=True)
class CSEOrderRegistrationResult:
order_number: str
@dataclass(frozen=True)
class CSEOrderInfo:
order_number: str
status_code: str | None
waybill_number: str | None
def build_calc_body_for_calculation(
request: DeliveryCalculationRequest,
delivery_type: str = "",
service_guid: str | None = None,
) -> dict[str, Element]:
fields = [
make_field("SenderGeography", resolve_cse_geography(request.from_city)),
make_field("RecipientGeography", resolve_cse_geography(request.to_city)),
make_field("TypeOfCargo", resolve_type_of_cargo(_parcel_type(request))),
make_field("Weight", _format_decimal(request.weight_kg), "float"),
make_field("Qty", "1", "int"),
]
_append_delivery_type(fields, delivery_type)
if service_guid:
fields.append(make_field("Service", service_guid))
return _calc_body(Element(key="Destination", fields=fields))
def build_calc_body_for_payment(
request: InitPaymentRequest,
delivery_type: str = "",
service_guid: str | None = None,
) -> dict[str, Element]:
system_data = request.system_data
fields = [
make_field(
"SenderGeography",
resolve_cse_geography(request.sender_address.city_id),
),
make_field(
"RecipientGeography",
resolve_cse_geography(request.receiver_address.city_id),
),
make_field("TypeOfCargo", resolve_type_of_cargo(system_data.parcel_type)),
make_field("Weight", system_data.weight, "float"),
make_field("Qty", "1", "int"),
]
_append_delivery_type(fields, delivery_type)
if service_guid:
fields.append(make_field("Service", service_guid))
return _calc_body(Element(key="Destination", fields=fields))
def _append_delivery_type(fields: list[Element], delivery_type: str) -> None:
if delivery_type:
fields.append(make_field("DeliveryType", delivery_type))
def split_tariff_code(tariff_code: str) -> tuple[str, str, str]:
"""Split the CSE ``tariff_code`` into delivery type, service GUID, urgency.
The expected format is ``"<DeliveryType>|<ServiceGuid>|<Urgency>"``.
``DeliveryType`` may be empty when CSE contract defaults are used.
"""
parts = tariff_code.split("|")
if len(parts) != 3 or not parts[1] or not parts[2]:
raise CSERequestError("CSE tariff_code has invalid format.")
return parts[0], parts[1], parts[2]
def _calc_body(destination: Element) -> dict[str, Element]:
return {
"data": Element(key="Destinations", items=[destination]),
"parameters": Element(
key="parameters",
items=[make_field("ipaddress", "0.0.0.0")],
),
}
def map_cse_save_order_request(
request: InitPaymentRequest,
order_uuid: str,
params: CSEOrderRegistrationParams,
) -> dict[str, Element]:
system_data = request.system_data
take_date = request.pickup_date.strftime(_DATETIME_FORMAT)
delivery_date = (
request.pickup_date + timedelta(days=_DELIVERY_DATE_OFFSET_DAYS)
).strftime(_DATETIME_FORMAT)
delivery_type, _, urgency = split_tariff_code(system_data.tariff.tariff_code)
fields = [
make_field("TakeDate", take_date, "dateTime"),
make_field("TakeDateOf", take_date, "dateTime"),
make_field("Sender", request.sender_contact.full_name),
make_field(
"SenderGeography",
resolve_cse_geography(request.sender_address.city_id),
),
make_field("SenderAddress", _compose_address(request.sender_address)),
make_field("SenderPhone", request.sender_contact.phone),
make_field("Recipient", request.receiver_contact.full_name),
make_field(
"RecipientGeography",
resolve_cse_geography(request.receiver_address.city_id),
),
make_field("RecipientAddress", _compose_address(request.receiver_address)),
make_field("RecipientPhone", request.receiver_contact.phone),
make_field("Urgency", urgency),
make_field("Payer", params.payer, "float"),
make_field("PaymentMethod", params.payment_method, "float"),
make_field("ShippingMethod", params.shipping_method),
make_field("TypeOfCargo", resolve_type_of_cargo(system_data.parcel_type)),
make_field("Weight", system_data.weight, "float"),
make_field("CargoPackageQty", "1", "float"),
make_field("CargoCost", str(request.content.declared_value), "float"),
]
if delivery_type:
fields.append(make_field("DeliveryOfCargo", delivery_type))
fields.append(make_field("DeliveryDate", delivery_date, "dateTime"))
if request.content.description:
fields.append(make_field("CargoDescription", request.content.description))
fields.append(make_field("Comment", request.content.description))
if request.sender_contact.email:
fields.append(make_field("SenderEMail", request.sender_contact.email))
if request.receiver_contact.email:
fields.append(make_field("RecipientEMail", request.receiver_contact.email))
if system_data.parcel_type == "parcel" and system_data.dimensions is not None:
dimensions = system_data.dimensions
fields.append(make_field("Length", dimensions.length, "float"))
fields.append(make_field("Width", dimensions.width, "float"))
fields.append(make_field("Height", dimensions.height, "float"))
order = Element(
key="Order",
fields=fields,
properties=[make_field("ClientNumber", order_uuid)],
)
return {
"data": Element(key="Documents", items=[order]),
"parameters": Element(
key="parameters",
items=[make_field("DocumentType", "Order")],
),
}
def map_cse_save_order_response(root: Element) -> CSEOrderRegistrationResult:
for document in root.items:
number = document.property_value("Number")
if number:
return CSEOrderRegistrationResult(order_number=number)
raise CSEMappingError("CSE SaveDocuments response is missing document Number.")
def build_tracking_body_for_order(order_number: str) -> dict[str, Element]:
return {
"documents": Element(
key="Documents",
items=[Element(key=order_number)],
),
"parameters": Element(
key="parameters",
items=[
make_field("DocumentType", "Order"),
make_field("OnlySelectedType", True, "boolean"),
],
),
}
def map_cse_tracking_response(root: Element) -> CSEOrderInfo:
for document in root.items:
order_number = document.property_value("Number") or document.key
if not order_number:
continue
return CSEOrderInfo(
order_number=order_number,
status_code=_latest_tracking_status(document),
waybill_number=_extract_waybill_number(document),
)
raise CSEMappingError("CSE Tracking response is missing document data.")
def build_waybill_print_form_body(waybill_number: str) -> dict[str, Element]:
return {
"documents": Element(
key="Documents",
items=[Element(key=waybill_number)],
),
"parameters": Element(
key="parameters",
items=[
make_field("DocumentType", "waybill"),
make_field("Type", "print"),
make_field(
"Name",
"Универсальная печатная "
"форма документа НАКЛАДНАЯ",
),
make_field("Format", "pdf"),
make_field("OnlySelectedType", True, "boolean"),
],
),
}
def map_cse_waybill_print_form_response(root: Element) -> bytes:
for document in root.items:
bdata = document.bdata
if not bdata:
continue
try:
normalized_bdata = "".join(bdata.split())
return base64.b64decode(normalized_bdata, validate=True)
except (binascii.Error, ValueError) as exc:
raise CSEMappingError(
"CSE GetFormsForDocuments response contains invalid BData."
) from exc
raise CSEMappingError("CSE GetFormsForDocuments response is missing BData.")
def _parcel_type(request: DeliveryCalculationRequest) -> str | None:
return request.parcel_type.value if request.parcel_type is not None else None
def _compose_address(address: Address) -> str:
parts = [address.city, address.street, address.house]
line = ", ".join(part for part in parts if part)
if address.apartment:
line = f"{line}, кв. {address.apartment}"
return line
def _format_decimal(value: float) -> str:
return format(value, "g")
def _latest_tracking_status(document: Element) -> str | None:
dated: list[tuple[str, str]] = []
for state in document.items:
if not state.key:
continue
date_time = state.property_value("DateTime") or ""
dated.append((date_time, state.key))
if not dated:
return None
dated.sort(key=lambda item: item[0])
return dated[-1][1]
def _extract_waybill_number(document: Element) -> str | None:
for table in document.tables:
if table.key != "Waybills":
continue
for waybill in table.items:
document_type = waybill.property_value("DocumentType")
if document_type is not None and document_type.lower() != "waybill":
continue
number = waybill.property_value("Number") or waybill.key
if number:
return number
return None
-182
View File
@@ -1,182 +0,0 @@
"""SOAP (cargo3) Element serialization and parsing for CSE.
CSE exposes a SOAP/1C web service ("Карго") that transfers data through nested
universal ``Element`` structures (``Key``/``Value``/``ValueType``/
``Properties``/``Fields``/``List``/``Tables``). This module builds request
envelopes and parses responses into a plain Python representation that mappers
can navigate.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from xml.etree import ElementTree as ET
CARGO_NS = "http://www.cargo3.ru"
SOAP_NS = "http://www.w3.org/2003/05/soap-envelope"
_SCALAR_TAGS = ("Key", "Value", "ValueType")
_BINARY_TAG = "BData"
@dataclass
class Element:
"""In-memory representation of a cargo3 ``Element`` structure."""
key: str | None = None
value: str | None = None
value_type: str | None = None
bdata: str | None = None
fields: list["Element"] = field(default_factory=list)
items: list["Element"] = field(default_factory=list) # <List>
tables: list["Element"] = field(default_factory=list)
properties: list["Element"] = field(default_factory=list)
def field_value(self, key: str) -> str | None:
"""Return the ``Value`` of the ``Fields`` entry with the given ``Key``."""
return _find_value(self.fields, key)
def property_value(self, key: str) -> str | None:
"""Return the ``Value`` of the ``Properties`` entry with the given ``Key``."""
return _find_value(self.properties, key)
def _find_value(elements: list["Element"], key: str) -> str | None:
for element in elements:
if element.key == key:
return element.value
return None
def make_field(key: str, value: Any, value_type: str = "string") -> Element:
"""Build a leaf ``Element`` for a ``Fields`` entry."""
return Element(key=key, value=_stringify(value), value_type=value_type)
def _stringify(value: Any) -> str:
if isinstance(value, bool):
return "true" if value else "false"
return str(value)
def build_envelope(
operation: str,
*,
login: str,
password: str,
body: dict[str, Element],
) -> bytes:
"""Build a SOAP 1.2 request envelope for the given CSE operation.
``body`` maps the operation parameter tag (e.g. ``data``/``parameters``) to
the root ``Element`` placed under that tag.
"""
envelope = ET.Element(f"{{{SOAP_NS}}}Envelope")
soap_body = ET.SubElement(envelope, f"{{{SOAP_NS}}}Body")
op_node = ET.SubElement(soap_body, f"{{{CARGO_NS}}}{operation}")
login_node = ET.SubElement(op_node, f"{{{CARGO_NS}}}login")
login_node.text = login
password_node = ET.SubElement(op_node, f"{{{CARGO_NS}}}password")
password_node.text = password
for tag, element in body.items():
_append_element(op_node, tag, element)
ET.register_namespace("soap", SOAP_NS)
ET.register_namespace("m", CARGO_NS)
return ET.tostring(envelope, encoding="utf-8", xml_declaration=True)
def _append_element(parent: ET.Element, tag: str, element: Element) -> None:
node = ET.SubElement(parent, f"{{{CARGO_NS}}}{tag}")
if element.key is not None:
_scalar(node, "Key", element.key)
if element.value is not None:
_scalar(node, "Value", element.value)
if element.value_type is not None:
_scalar(node, "ValueType", element.value_type)
for child in element.properties:
_append_element(node, "Properties", child)
for child in element.fields:
_append_element(node, "Fields", child)
for child in element.items:
_append_element(node, "List", child)
for child in element.tables:
_append_element(node, "Tables", child)
def _scalar(parent: ET.Element, tag: str, text: str) -> None:
child = ET.SubElement(parent, f"{{{CARGO_NS}}}{tag}")
child.text = text
def parse_response(xml_payload: str | bytes, operation: str) -> Element:
"""Parse a CSE SOAP response and return the ``return`` Element.
Raises ``ValueError`` when the envelope is malformed or the expected
``<operation>Response`` / ``return`` nodes are missing.
"""
try:
root = ET.fromstring(xml_payload)
except ET.ParseError as exc:
raise ValueError("CSE response is not valid XML.") from exc
response_tag = f"{{{CARGO_NS}}}{operation}Response"
response_node = _find_descendant(root, response_tag)
if response_node is None:
raise ValueError(f"CSE response is missing <{operation}Response>.")
return_node = _find_child(response_node, "return")
if return_node is None:
raise ValueError("CSE response is missing <return>.")
return _parse_element(return_node)
def _parse_element(node: ET.Element) -> Element:
element = Element()
for child in node:
local = _localname(child.tag)
if local in _SCALAR_TAGS:
text = (child.text or "").strip()
if local == "Key":
element.key = text
elif local == "Value":
element.value = text
else:
element.value_type = text
elif local == "Fields":
element.fields.append(_parse_element(child))
elif local == "List":
element.items.append(_parse_element(child))
elif local == "Tables":
element.tables.append(_parse_element(child))
elif local == "Properties":
element.properties.append(_parse_element(child))
elif local == _BINARY_TAG:
element.bdata = (child.text or "").strip()
return element
def _find_descendant(root: ET.Element, tag: str) -> ET.Element | None:
if root.tag == tag:
return root
return root.find(f".//{tag}")
def _find_child(node: ET.Element, local: str) -> ET.Element | None:
for child in node:
if _localname(child.tag) == local:
return child
return None
def _localname(tag: str) -> str:
return tag.rsplit("}", 1)[-1]
@@ -1,67 +0,0 @@
"""Delivery provider registry and wiring."""
from dataclasses import dataclass
from typing import Mapping, Sequence
import httpx
from app.adapters.delivery_providers.base import (
DeliveryProvider,
OrderRegistrationProvider,
PaymentPriceValidationProvider,
)
from app.adapters.delivery_providers.cdek import CDEKProvider
from app.adapters.delivery_providers.cse import CSEProvider
from app.config import DeliveryProvidersConfig
@dataclass(frozen=True, slots=True)
class DeliveryProviderRegistry:
providers: Sequence[DeliveryProvider]
payment_price_validation_adapters: Mapping[str, PaymentPriceValidationProvider]
order_registration_adapters: Mapping[str, OrderRegistrationProvider]
def resolve_delivery_provider_timeout_seconds(
config: DeliveryProvidersConfig,
) -> float:
timeouts = [
provider_config.timeout_seconds
for provider_config in (config.cdek, config.cse)
if provider_config.enabled
]
return max(timeouts, default=10.0)
def build_delivery_provider_registry(
*,
http_client: httpx.AsyncClient,
config: DeliveryProvidersConfig,
) -> DeliveryProviderRegistry:
providers: list[DeliveryProvider] = []
payment_price_validation_adapters: dict[str, PaymentPriceValidationProvider] = {}
order_registration_adapters: dict[str, OrderRegistrationProvider] = {}
if config.cdek.enabled:
cdek_provider = CDEKProvider.from_config(
http_client=http_client,
config=config.cdek,
)
providers.append(cdek_provider)
payment_price_validation_adapters[cdek_provider.name] = cdek_provider
order_registration_adapters[cdek_provider.name] = cdek_provider
if config.cse.enabled:
cse_provider = CSEProvider.from_config(
http_client=http_client,
config=config.cse,
)
providers.append(cse_provider)
payment_price_validation_adapters[cse_provider.name] = cse_provider
order_registration_adapters[cse_provider.name] = cse_provider
return DeliveryProviderRegistry(
providers=tuple(providers),
payment_price_validation_adapters=payment_price_validation_adapters,
order_registration_adapters=order_registration_adapters,
)
+4706 -14399
View File
File diff suppressed because it is too large Load Diff
+10 -38
View File
@@ -38,40 +38,14 @@ class RepositoryConfig(BaseModel):
price_cache_ttl_seconds: int = 900
class DeliveryProviderBaseConfig(BaseModel):
enabled: bool = True
timeout_seconds: float = Field(default=10.0, gt=0)
retry_attempts: int = Field(default=2, ge=0)
retry_backoff_seconds: float = Field(default=0.2, ge=0)
cache_ttl_seconds: int = Field(default=900, gt=0)
class CDEKDeliveryProviderConfig(DeliveryProviderBaseConfig):
base_url: str = "https://api.cdek.ru/v2"
client_id: str = ""
client_secret: str = ""
class CSEDeliveryProviderConfig(DeliveryProviderBaseConfig):
base_url: str = "https://web.cse.ru/1c/ws/Web1C.1cws"
login: str = ""
password: str = ""
# CSE service GUIDs from GetReferenceData: Services that are allowed to be
# shown as delivery tariffs. Additional/non-delivery services must not be
# included here.
delivery_service_guids: list[str] = Field(default_factory=list)
# Contract-specific required parameters for SaveDocuments (order registration).
# Urgency is not here: it comes from the tariff selected by the client.
payer: str = ""
payment_method: str = ""
shipping_method: str = ""
class DeliveryProvidersConfig(BaseModel):
cdek: CDEKDeliveryProviderConfig = Field(
default_factory=CDEKDeliveryProviderConfig
)
cse: CSEDeliveryProviderConfig = Field(default_factory=CSEDeliveryProviderConfig)
class AdapterConfig(BaseModel):
cdek_base_url: str = "https://api.cdek.ru/v2"
cdek_client_id: str = ""
cdek_client_secret: str = ""
cdek_retry_attempts: int = Field(default=2, ge=0)
cdek_retry_backoff_seconds: float = Field(default=0.2, ge=0)
cdek_timeout_seconds: float = Field(default=10.0, gt=0)
cdek_cache_ttl_seconds: int = Field(default=900, gt=0)
class TBankPaymentAuthConfig(BaseModel):
@@ -171,9 +145,7 @@ class Settings(BaseSettings):
service: ServiceConfig = Field(default_factory=ServiceConfig)
business_logic: BusinessLogicConfig = Field(default_factory=BusinessLogicConfig)
repository: RepositoryConfig = Field(default_factory=RepositoryConfig)
delivery_providers: DeliveryProvidersConfig = Field(
default_factory=DeliveryProvidersConfig
)
adapter: AdapterConfig = Field(default_factory=AdapterConfig)
tbank_payment: TBankPaymentConfig
postgres: PostgresConfig
address_suggestions: AddressSuggestionsConfig = Field(
@@ -211,7 +183,7 @@ class _RequiredYamlSections(BaseModel):
service: dict[str, Any]
business_logic: dict[str, Any]
repository: dict[str, Any]
delivery_providers: dict[str, Any]
adapter: dict[str, Any]
tbank_payment: dict[str, Any]
postgres: dict[str, Any]
address_suggestions: dict[str, Any]
+11 -56
View File
@@ -1,6 +1,5 @@
"""Delivery API controller skeleton."""
import structlog
from fastapi import APIRouter, Depends, HTTPException, Request, status
from fastapi.responses import PlainTextResponse
@@ -10,23 +9,19 @@ from app.adapters.address_suggestions.tomtom import TomTomAddressSuggestionProvi
from app.adapters.address_suggestions.yandex_geosuggest import (
YandexGeosuggestAddressSuggestionProvider,
)
from app.adapters.delivery_providers.registry import (
build_delivery_provider_registry,
resolve_delivery_provider_timeout_seconds,
)
from app.adapters.delivery_providers.cdek import CDEKProvider
from app.adapters.email import SMTPEmailSender
from app.adapters.tbank import TBankAdapter
from app.config import Settings
from app.controllers.http_client import build_controller_http_client
from app.repositories.cache.redis_cache import PriceCache
from app.repositories.order import OrderRepository
from app.runtime.metrics import get_cache_metrics
from app.schemas.payment import (
InitPaymentRequest,
InitPaymentResponse,
TBankPaymentNotification,
)
from app.schemas.request import DeliveryCalculationRequest, SuggestAddressRequest
from app.schemas.request import AddressSuggestRequest, DeliveryCalculationRequest
from app.schemas.response import AddressSuggestion, DeliveryPrice
from app.services.aggregator import (
AddressSuggestionsUnavailableError,
@@ -41,8 +36,6 @@ from app.services.aggregator import (
UnsupportedAddressSuggestionCountryError,
)
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/delivery", tags=["delivery"])
@@ -51,13 +44,11 @@ _AGGREGATOR_SERVICE_STATE_KEY = "aggregator_service"
def _build_aggregator_service(settings: Settings) -> AggregatorService:
http_client = build_controller_http_client(
timeout_seconds=resolve_delivery_provider_timeout_seconds(
settings.delivery_providers
)
timeout_seconds=settings.adapter.cdek_timeout_seconds
)
delivery_provider_registry = build_delivery_provider_registry(
cdek_provider = CDEKProvider.from_adapter_config(
http_client=http_client,
config=settings.delivery_providers,
adapter_config=settings.adapter,
)
payment_adapter = TBankAdapter.from_config(
http_client=http_client,
@@ -75,10 +66,8 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
http_client=http_client,
config=settings.address_suggestions.tomtom,
)
cache = PriceCache.from_repository_config(
settings.repository,
metrics=get_cache_metrics(),
)
providers = (cdek_provider,)
cache = PriceCache.from_repository_config(settings.repository)
postgres_engine = create_postgres_engine(settings.postgres)
postgres_session_factory = create_postgres_session_factory(postgres_engine)
order_repository = OrderRepository(session_factory=postgres_session_factory)
@@ -92,16 +81,12 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
timeout_seconds=settings.email.timeout_seconds,
)
service = AggregatorService(
providers=delivery_provider_registry.providers,
providers=providers,
cache=cache,
payment_adapter=payment_adapter,
payment_price_validation_adapters=(
delivery_provider_registry.payment_price_validation_adapters
),
payment_price_validation_adapter=cdek_provider,
order_repository=order_repository,
order_registration_adapters=(
delivery_provider_registry.order_registration_adapters
),
order_registration_adapter=cdek_provider,
email_sender=email_sender,
address_suggestion_providers=(
dadata_provider,
@@ -161,15 +146,9 @@ async def get_delivery_price(
response_model=list[AddressSuggestion],
)
async def suggest_addresses(
address_request: SuggestAddressRequest,
address_request: AddressSuggestRequest,
service: AggregatorService = Depends(get_aggregator_service),
) -> list[AddressSuggestion]:
logger.info(
"suggest_address_requested",
city=address_request.city,
query=address_request.query,
limit=address_request.limit,
)
try:
return await service.suggest_addresses(address_request)
except UnsupportedAddressSuggestionCountryError as exc:
@@ -242,23 +221,9 @@ async def handle_tbank_payment_notification(
notification: TBankPaymentNotification,
service: AggregatorService = Depends(get_aggregator_service),
) -> PlainTextResponse:
logger.info(
"tbank_notification_received",
order_id=notification.OrderId,
status=notification.Status,
success=notification.Success,
payment_id=notification.PaymentId,
error_code=notification.ErrorCode,
amount=notification.Amount,
)
try:
response_body = await service.handle_tbank_payment_notification(notification)
except InvalidTBankPaymentNotificationError as exc:
logger.warning(
"tbank_notification_rejected",
order_id=notification.OrderId,
reason="invalid_token",
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
@@ -267,11 +232,6 @@ async def handle_tbank_payment_notification(
},
) from exc
except TBankPaymentNotificationProcessingError as exc:
logger.error(
"tbank_notification_processing_failed",
order_id=notification.OrderId,
exc_info=True,
)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={
@@ -280,11 +240,6 @@ async def handle_tbank_payment_notification(
},
) from exc
except AggregatorServiceError as exc:
logger.error(
"tbank_notification_processing_failed",
order_id=notification.OrderId,
exc_info=True,
)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={
+2 -39
View File
@@ -5,7 +5,7 @@ from enum import Enum
class TBankPaymentNotificationAction(str, Enum):
ACKNOWLEDGE_ONLY = "acknowledge_only"
REGISTER_PROVIDER_ORDER = "register_provider_order"
REGISTER_CDEK_ORDER = "register_cdek_order"
def resolve_tbank_payment_notification_action(
@@ -15,42 +15,5 @@ def resolve_tbank_payment_notification_action(
error_code: str,
) -> TBankPaymentNotificationAction:
if status == "CONFIRMED" and success is True and error_code == "0":
return TBankPaymentNotificationAction.REGISTER_PROVIDER_ORDER
return TBankPaymentNotificationAction.REGISTER_CDEK_ORDER
return TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY
# Монотонный приоритет статусов платежа TBank по жизненному циклу.
# Используется, чтобы внеочередное уведомление не понижало уже записанный статус
# (например, AUTHORIZED, пришедший после CONFIRMED, не должен затирать CONFIRMED).
# Неизвестные статусы получают ранг 0 и не перезаписывают известный статус.
_TBANK_PAYMENT_STATUS_RANK: dict[str, int] = {
"NEW": 10,
"FORM_SHOWED": 20,
"AUTHORIZING": 30,
"3DS_CHECKING": 30,
"3DS_CHECKED": 30,
"REJECTED": 40,
"AUTH_FAIL": 40,
"DEADLINE_EXPIRED": 40,
"AUTHORIZED": 40,
"CONFIRMING": 50,
"CONFIRMED": 60,
"REVERSING": 70,
"PARTIAL_REVERSED": 70,
"REVERSED": 70,
"REFUNDING": 70,
"PARTIAL_REFUNDED": 70,
"REFUNDED": 70,
"CANCELED": 70,
}
def tbank_payment_status_rank(status: str) -> int:
return _TBANK_PAYMENT_STATUS_RANK.get(status, 0)
def should_apply_tbank_payment_status(current: str | None, new: str) -> bool:
"""Применять новый статус, только если он не понижает текущий по жизненному циклу."""
if current is None:
return True
return tbank_payment_status_rank(new) >= tbank_payment_status_rank(current)
+2 -9
View File
@@ -47,7 +47,7 @@ class ProviderPrice:
currency: str
delivery_days_min: int
delivery_days_max: int
tariff_code: str | None = None
tariff_code: int | None = None
bypass_parcel_type_filter: bool = False
@@ -225,7 +225,7 @@ def _normalize_price(
currency=currency,
delivery_days_min=min_days,
delivery_days_max=max_days,
tariff_code=_try_to_str(_get_optional_attr(candidate, "tariff_code")),
tariff_code=_try_to_int(_get_optional_attr(candidate, "tariff_code")),
bypass_parcel_type_filter=_extract_bypass_parcel_type_filter(candidate),
)
@@ -271,13 +271,6 @@ def _normalize_text(value: object) -> str:
return str(value).strip()
def _try_to_str(value: object) -> str | None:
if value is _MISSING or value is None:
return None
text = str(value).strip()
return text or None
def _normalize_city_id(value: object) -> int:
city_id = _try_to_int(value)
if city_id is None:
-2
View File
@@ -5,14 +5,12 @@ from fastapi import FastAPI
from app.config import Settings, get_settings
from app.controllers.v1.delivery import router as delivery_router
from app.runtime.logging import configure_logging
from app.runtime.metrics import configure_metrics
from app.runtime.tracing import configure_tracing
def create_app(settings: Settings | None = None) -> FastAPI:
configure_logging()
resolved_settings = settings or get_settings()
configure_metrics(resolved_settings.observability)
application = FastAPI(title="G2S Aggregator", version="0.1.0")
application.state.settings = resolved_settings
+2 -52
View File
@@ -7,14 +7,10 @@ import json
from typing import Any, Callable, Protocol
from pydantic import BaseModel
import structlog
from app.config import RepositoryConfig
logger = structlog.get_logger(__name__)
class RedisClientProtocol(Protocol):
async def get(self, key: str) -> bytes | str | None: ...
@@ -23,14 +19,6 @@ class RedisClientProtocol(Protocol):
async def delete(self, key: str) -> Any: ...
class CacheMetricsProtocol(Protocol):
def record_request(self, *, operation: str, outcome: str) -> None: ...
def record_error(self, *, operation: str, error_type: str) -> None: ...
def record_hit(self, *, hit: bool) -> None: ...
class PriceCacheRepositoryError(RuntimeError):
"""Deterministic repository error raised on Redis operation failures."""
@@ -38,16 +26,9 @@ class PriceCacheRepositoryError(RuntimeError):
class PriceCache:
"""Repository for cached provider payloads."""
def __init__(
self,
redis_client: RedisClientProtocol,
*,
ttl_seconds: int,
metrics: CacheMetricsProtocol | None = None,
) -> None:
def __init__(self, redis_client: RedisClientProtocol, *, ttl_seconds: int) -> None:
self._redis_client = redis_client
self._ttl_seconds = ttl_seconds
self._metrics = metrics
@classmethod
def from_repository_config(
@@ -55,25 +36,20 @@ class PriceCache:
repository_config: RepositoryConfig,
*,
client_factory: Callable[[str], RedisClientProtocol] | None = None,
metrics: CacheMetricsProtocol | None = None,
) -> "PriceCache":
resolved_factory = client_factory or _default_redis_client_factory
redis_client = resolved_factory(repository_config.redis_dsn)
return cls(
redis_client=redis_client,
ttl_seconds=repository_config.price_cache_ttl_seconds,
metrics=metrics,
)
async def get(self, key: str) -> Any | None:
try:
payload = await self._redis_client.get(key)
except Exception as exc:
self._record_failure(operation="get", error=exc)
raise PriceCacheRepositoryError("Redis cache read failed.") from exc
if payload is None:
self._record_request(operation="get", outcome="miss")
self._record_hit(hit=False)
return None
if isinstance(payload, bytes):
@@ -83,10 +59,7 @@ class PriceCache:
else:
raise TypeError("Redis cache payload must be bytes or str.")
result = json.loads(serialized_payload)
self._record_request(operation="get", outcome="hit")
self._record_hit(hit=True)
return result
return json.loads(serialized_payload)
async def set(self, key: str, value: Any, ttl: int | None = None) -> None:
serialized_payload = _serialize(value)
@@ -94,36 +67,13 @@ class PriceCache:
try:
await self._redis_client.set(key, serialized_payload, ex=resolved_ttl)
except Exception as exc:
self._record_failure(operation="set", error=exc)
raise PriceCacheRepositoryError("Redis cache write failed.") from exc
self._record_request(operation="set", outcome="success")
async def invalidate(self, key: str) -> None:
try:
await self._redis_client.delete(key)
except Exception as exc:
self._record_failure(operation="invalidate", error=exc)
raise PriceCacheRepositoryError("Redis cache invalidate failed.") from exc
self._record_request(operation="invalidate", outcome="success")
def _record_failure(self, *, operation: str, error: Exception) -> None:
error_type = type(error).__name__
logger.warning(
"cache.operation_failed",
operation=operation,
error_type=error_type,
)
if self._metrics is not None:
self._metrics.record_request(operation=operation, outcome="error")
self._metrics.record_error(operation=operation, error_type=error_type)
def _record_request(self, *, operation: str, outcome: str) -> None:
if self._metrics is not None:
self._metrics.record_request(operation=operation, outcome=outcome)
def _record_hit(self, *, hit: bool) -> None:
if self._metrics is not None:
self._metrics.record_hit(hit=hit)
def _default_redis_client_factory(redis_dsn: str) -> RedisClientProtocol:
+6 -13
View File
@@ -32,12 +32,7 @@ class Order(Base):
order_uuid: Mapped[str] = mapped_column(String(128), nullable=False)
payment_url: Mapped[str] = mapped_column(String(2048), nullable=False)
price: Mapped[int] = mapped_column(Integer, nullable=False)
tariff_code: Mapped[str] = mapped_column(String(128), nullable=False)
provider: Mapped[str] = mapped_column(
String(32),
nullable=False,
server_default="cdek",
)
tariff_code: Mapped[int] = mapped_column(Integer, nullable=False)
account_email: Mapped[str] = mapped_column(String(320), nullable=False)
payload: Mapped[dict[str, Any]] = mapped_column(
_json_payload_type(),
@@ -45,13 +40,11 @@ class Order(Base):
)
payment_status: Mapped[str | None] = mapped_column(String(64), nullable=True)
tbank_payment_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
provider_order_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
provider_order_status: Mapped[str | None] = mapped_column(String(64), nullable=True)
provider_waybill_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
provider_waybill_url: Mapped[str | None] = mapped_column(
String(2048), nullable=True
)
provider_polled_at: Mapped[datetime | None] = mapped_column(
cdek_order_uuid: Mapped[str | None] = mapped_column(String(128), nullable=True)
cdek_order_status: Mapped[str | None] = mapped_column(String(64), nullable=True)
cdek_waybill_uuid: Mapped[str | None] = mapped_column(String(128), nullable=True)
cdek_waybill_url: Mapped[str | None] = mapped_column(String(2048), nullable=True)
cdek_polled_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
+25 -45
View File
@@ -6,7 +6,7 @@ from dataclasses import dataclass
from datetime import datetime
from typing import Any
from sqlalchemy import and_, or_, select
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from app.domain.cdek_polling import TERMINAL_ORDER_STATUSES
@@ -18,8 +18,7 @@ class OrderData:
order_uuid: str
payment_url: str
price: int
tariff_code: str
provider: str
tariff_code: int
account_email: str
payload: dict[str, Any]
@@ -37,7 +36,6 @@ class OrderRepository:
payment_url=order_data.payment_url,
price=order_data.price,
tariff_code=order_data.tariff_code,
provider=order_data.provider,
account_email=order_data.account_email,
payload=order_data.payload,
)
@@ -50,13 +48,10 @@ class OrderRepository:
self,
session: AsyncSession,
order_uuid: str,
*,
for_update: bool = False,
) -> Order | None:
statement = select(Order).where(Order.order_uuid == order_uuid)
if for_update:
statement = statement.with_for_update()
result = await session.execute(statement)
result = await session.execute(
select(Order).where(Order.order_uuid == order_uuid)
)
return result.scalar_one_or_none()
async def mark_payment_status(
@@ -75,17 +70,17 @@ class OrderRepository:
await session.flush()
return order
async def mark_provider_order_registered(
async def mark_cdek_order_registered(
self,
session: AsyncSession,
order_uuid: str,
provider_order_id: str,
cdek_order_uuid: str,
) -> Order | None:
order = await self.get_order_by_order_uuid(session, order_uuid)
if order is None:
return None
order.provider_order_id = provider_order_id
order.cdek_order_uuid = cdek_order_uuid
await session.flush()
return order
@@ -95,24 +90,17 @@ class OrderRepository:
*,
limit: int,
) -> Sequence[Order]:
cdek_pending = and_(
Order.provider == "cdek",
Order.provider_order_id.is_not(None),
Order.provider_waybill_url.is_(None),
(
Order.provider_order_status.is_(None)
| Order.provider_order_status.not_in(TERMINAL_ORDER_STATUSES)
),
)
cse_pending = and_(
Order.provider == "cse",
Order.provider_order_id.is_not(None),
Order.provider_waybill_id.is_(None),
)
statement = (
select(Order)
.where(or_(cdek_pending, cse_pending))
.order_by(Order.provider_polled_at.asc().nulls_first())
.where(
Order.cdek_order_uuid.is_not(None),
Order.cdek_waybill_url.is_(None),
(
Order.cdek_order_status.is_(None)
| Order.cdek_order_status.not_in(TERMINAL_ORDER_STATUSES)
),
)
.order_by(Order.cdek_polled_at.asc().nulls_first())
.limit(limit)
.with_for_update(skip_locked=True)
)
@@ -132,10 +120,10 @@ class OrderRepository:
if order is None:
return None
order.provider_order_status = order_status
if waybill_uuid is not None and order.provider_waybill_id is None:
order.provider_waybill_id = waybill_uuid
order.provider_polled_at = polled_at
order.cdek_order_status = order_status
if waybill_uuid is not None and order.cdek_waybill_uuid is None:
order.cdek_waybill_uuid = waybill_uuid
order.cdek_polled_at = polled_at
await session.flush()
return order
@@ -151,9 +139,9 @@ class OrderRepository:
if order is None:
return None
if waybill_url is not None and order.provider_waybill_url is None:
order.provider_waybill_url = waybill_url
order.provider_polled_at = polled_at
if waybill_url is not None and order.cdek_waybill_url is None:
order.cdek_waybill_url = waybill_url
order.cdek_polled_at = polled_at
await session.flush()
return order
@@ -179,18 +167,10 @@ class OrderRepository:
*,
limit: int,
) -> Sequence[Order]:
cdek_ready = and_(
Order.provider == "cdek",
Order.provider_waybill_url.is_not(None),
)
cse_ready = and_(
Order.provider == "cse",
Order.provider_waybill_id.is_not(None),
)
statement = (
select(Order)
.where(
or_(cdek_ready, cse_ready),
Order.cdek_waybill_url.is_not(None),
Order.waybill_email_sent_at.is_(None),
)
.order_by(Order.created_at.asc())
+3 -57
View File
@@ -1,13 +1,11 @@
"""Centralized runtime logging bootstrap."""
import json
import logging
import sys
from collections.abc import Iterable
from typing import Any, TextIO
from typing import TextIO
import structlog
from opentelemetry import trace
_UVICORN_LOGGER_NAMES = ("uvicorn", "uvicorn.error", "uvicorn.access")
@@ -20,11 +18,7 @@ def configure_logging(
) -> None:
resolved_stream = sys.stdout if stream is None else stream
formatter = structlog.stdlib.ProcessorFormatter(
foreign_pre_chain=[
_add_trace_context,
_render_context_in_event,
*_shared_processors(),
],
foreign_pre_chain=list(_shared_processors()),
processors=[
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
structlog.processors.JSONRenderer(sort_keys=True, ensure_ascii=False),
@@ -37,10 +31,8 @@ def configure_logging(
structlog.reset_defaults()
structlog.configure(
processors=[
structlog.stdlib.PositionalArgumentsFormatter(),
_add_trace_context,
_render_context_in_event,
*_shared_processors(),
structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
@@ -68,52 +60,6 @@ def _shared_processors() -> tuple[structlog.types.Processor, ...]:
)
def _add_trace_context(
_logger: Any,
_method_name: str,
event_dict: structlog.types.EventDict,
) -> structlog.types.EventDict:
span_context = trace.get_current_span().get_span_context()
if not span_context.is_valid:
return event_dict
event_dict["trace_id"] = format(span_context.trace_id, "032x")
event_dict["span_id"] = format(span_context.span_id, "016x")
return event_dict
def _render_context_in_event(
_logger: Any,
_method_name: str,
event_dict: structlog.types.EventDict,
) -> structlog.types.EventDict:
event = str(event_dict.get("event", ""))
context = " ".join(
f"{key}={_serialize_log_value(value)}"
for key, value in sorted(event_dict.items())
if key
not in {
"event",
"exc_info",
"stack_info",
"_record",
"_from_structlog",
}
)
event_dict["event"] = f"{event} {context}" if context else event
return event_dict
def _serialize_log_value(value: object) -> str:
return json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
)
def _configure_logger(
logger: logging.Logger,
*,
-77
View File
@@ -1,77 +0,0 @@
"""OpenTelemetry metrics bootstrap and application instruments."""
from opentelemetry import metrics
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.sdk.resources import Resource
from app.config import ObservabilityConfig
_METER_PROVIDER: MeterProvider | None = None
_CACHE_METRICS: "CacheMetrics | None" = None
class CacheMetrics:
"""Metrics emitted by the Redis-backed price cache."""
def __init__(self) -> None:
meter = metrics.get_meter("app.repositories.cache")
self._requests = meter.create_counter(
"cache_requests_total",
unit="{request}",
description="Number of cache operations by operation and outcome.",
)
self._errors = meter.create_counter(
"cache_errors_total",
unit="{error}",
description="Number of failed cache operations by error type.",
)
self._hit_ratio = meter.create_histogram(
"cache_hit_ratio",
unit="1",
description="Cache lookup result: 1 for a hit and 0 for a miss.",
)
def record_request(self, *, operation: str, outcome: str) -> None:
self._requests.add(1, {"operation": operation, "outcome": outcome})
def record_error(self, *, operation: str, error_type: str) -> None:
self._errors.add(1, {"operation": operation, "error.type": error_type})
def record_hit(self, *, hit: bool) -> None:
self._hit_ratio.record(1.0 if hit else 0.0)
def configure_metrics(observability: ObservabilityConfig) -> None:
"""Configure the process-wide OTLP metrics exporter once."""
global _METER_PROVIDER
if not observability.enabled or _METER_PROVIDER is not None:
return
meter_provider = _build_meter_provider(observability)
metrics.set_meter_provider(meter_provider)
_METER_PROVIDER = meter_provider
def get_cache_metrics() -> CacheMetrics:
"""Return the process-wide cache instruments."""
global _CACHE_METRICS
if _CACHE_METRICS is None:
_CACHE_METRICS = CacheMetrics()
return _CACHE_METRICS
def _build_meter_provider(observability: ObservabilityConfig) -> MeterProvider:
exporter = OTLPMetricExporter(
endpoint=observability.otlp_endpoint,
insecure=observability.otlp_insecure,
)
reader = PeriodicExportingMetricReader(exporter)
resource = Resource.create({"service.name": observability.service_name})
return MeterProvider(resource=resource, metric_readers=[reader])
+1 -2
View File
@@ -75,7 +75,6 @@ class Contact(_CamelModel):
class Content(_CamelModel):
description: str | None = None
declared_value: int = Field(alias="declared_value", strict=True)
class SystemDataTariff(_CamelModel):
@@ -84,7 +83,7 @@ class SystemDataTariff(_CamelModel):
price: int = Field(gt=0, strict=True, description="Payment amount in kopecks.")
delivery_days_min: int = Field(ge=0, strict=True)
delivery_days_max: int = Field(ge=0, strict=True)
tariff_code: str = Field(min_length=1)
tariff_code: int = Field(gt=0, strict=True)
class Dimensions(_CamelModel):
-14
View File
@@ -26,21 +26,7 @@ class DeliveryCalculationRequest(BaseModel):
parcel_type: ParcelType | None = None
class SuggestAddressRequest(BaseModel):
"""API request for the /suggest-address endpoint.
``city`` is a city identifier (key in ``cities_map``); the city name and
country code are resolved from it by the service layer.
"""
city: str = Field(min_length=1)
query: str = Field(min_length=1)
limit: int | None = Field(default=None, gt=0)
class AddressSuggestRequest(BaseModel):
"""Provider-facing address suggestion request."""
country_code: str = Field(min_length=2, max_length=2)
city: str = Field(min_length=1)
query: str = Field(min_length=1)
+1 -1
View File
@@ -12,7 +12,7 @@ class DeliveryPrice(BaseModel):
currency: str = Field(min_length=3, max_length=3)
delivery_days_min: int = Field(ge=0)
delivery_days_max: int = Field(ge=0)
tariff_code: str | None = Field(default=None, min_length=1)
tariff_code: int | None = Field(default=None, ge=0)
bypass_parcel_type_filter: bool = Field(default=False, exclude=True)
+62 -154
View File
@@ -22,6 +22,9 @@ from app.adapters.delivery_providers.base import (
ProviderClientError,
ProviderRequestError,
)
from app.adapters.delivery_providers.cdek.order_mapper import (
CDEKOrderRegistrationResult,
)
from app.adapters.tbank.base import (
TBankPaymentAdapterError,
TBankPaymentNotificationTokenError,
@@ -30,7 +33,6 @@ from app.adapters.tbank.base import (
from app.domain.payment_notifications import (
TBankPaymentNotificationAction,
resolve_tbank_payment_notification_action,
should_apply_tbank_payment_status,
)
from app.domain.price import (
DEFAULT_PROVIDER_PRICE_MULTIPLIER,
@@ -47,12 +49,7 @@ from app.schemas.payment import (
InitPaymentResponse,
TBankPaymentNotification,
)
from app.cities import cities_map
from app.schemas.request import (
AddressSuggestRequest,
DeliveryCalculationRequest,
SuggestAddressRequest,
)
from app.schemas.request import AddressSuggestRequest, DeliveryCalculationRequest
from app.schemas.response import AddressSuggestion, DeliveryPrice
logger = structlog.get_logger(__name__)
@@ -119,7 +116,7 @@ class PaymentPriceValidationAdapterProtocol(Protocol):
class OrderRegistrationAdapterProtocol(Protocol):
async def register_order(
self, request: InitPaymentRequest, order_uuid: str
) -> object: ...
) -> CDEKOrderRegistrationResult: ...
class OrderRepositoryProtocol(Protocol):
@@ -141,11 +138,11 @@ class OrderRepositoryProtocol(Protocol):
payment_id: int,
) -> object | None: ...
async def mark_provider_order_registered(
async def mark_cdek_order_registered(
self,
session: object,
order_uuid: str,
provider_order_id: str,
cdek_order_uuid: str,
) -> object | None: ...
@@ -175,13 +172,11 @@ class AggregatorService:
providers: Sequence[DeliveryProvider],
cache: PriceCacheProtocol | None = None,
payment_adapter: PaymentAdapterProtocol | None = None,
payment_price_validation_adapters: (
Mapping[str, PaymentPriceValidationAdapterProtocol] | None
payment_price_validation_adapter: (
PaymentPriceValidationAdapterProtocol | None
) = None,
order_repository: OrderRepositoryProtocol | None = None,
order_registration_adapters: (
Mapping[str, OrderRegistrationAdapterProtocol] | None
) = None,
order_registration_adapter: OrderRegistrationAdapterProtocol | None = None,
email_sender: EmailSenderProtocol | None = None,
address_suggestion_providers: Sequence[AddressSuggestionProvider] = (),
address_suggestion_country_to_provider: Mapping[str, str] | None = None,
@@ -194,11 +189,9 @@ class AggregatorService:
self._providers = tuple(providers)
self._cache = cache
self._payment_adapter = payment_adapter
self._payment_price_validation_adapters = dict(
payment_price_validation_adapters or {}
)
self._payment_price_validation_adapter = payment_price_validation_adapter
self._order_repository = order_repository
self._order_registration_adapters = dict(order_registration_adapters or {})
self._order_registration_adapter = order_registration_adapter
self._email_sender = email_sender
self._weight_round_scale = weight_round_scale
self._provider_price_multiplier = provider_price_multiplier
@@ -261,15 +254,12 @@ class AggregatorService:
return [self._coerce_delivery_price(price) for price in filtered_and_sorted]
async def suggest_addresses(
self, request: SuggestAddressRequest
self, request: AddressSuggestRequest
) -> list[AddressSuggestion]:
provider_request = self._build_address_suggest_request(request)
provider = self._resolve_address_suggestion_provider(
provider_request.country_code
)
provider = self._resolve_address_suggestion_provider(request.country_code)
try:
suggestions = await provider.suggest(provider_request)
suggestions = await provider.suggest(request)
except AddressSuggestionRequestError as exc:
raise InvalidAddressSuggestRequestError(
"Address suggestion request is invalid for the configured provider."
@@ -325,22 +315,17 @@ class AggregatorService:
request: InitPaymentRequest,
order_uuid: str,
) -> None:
provider = request.system_data.tariff.provider
validation_adapter = self._payment_price_validation_adapters.get(provider)
if validation_adapter is None:
logger.warning(
"init_payment_price_validation_provider_not_configured",
order_uuid=order_uuid,
provider=provider,
)
raise InvalidInitPaymentRequestError(
"Payment price validation is not configured for the tariff provider."
if self._payment_price_validation_adapter is None:
raise InitPaymentUnavailableError(
"Payment price validation adapter is not configured."
)
tariff_code = request.system_data.tariff.tariff_code
requested_price = request.system_data.tariff.price
try:
provider_price = await validation_adapter.get_payment_price(request)
provider_price = await self._payment_price_validation_adapter.get_payment_price(
request
)
except ProviderRequestError as exc:
logger.warning(
"init_payment_price_validation_request_rejected",
@@ -350,7 +335,7 @@ class AggregatorService:
error=str(exc),
)
raise InvalidInitPaymentRequestError(
"Payment init request is invalid for provider price validation."
"Payment init request is invalid for CDEK price validation."
) from exc
except ProviderClientError as exc:
logger.warning(
@@ -382,7 +367,7 @@ class AggregatorService:
requested_price_kopecks=requested_price,
)
raise InvalidInitPaymentRequestError(
"Provider did not return the requested tariff for payment validation."
"CDEK did not return the requested tariff for payment validation."
)
expected_amount_kopecks = calculate_expected_payment_amount_kopecks(
@@ -404,7 +389,7 @@ class AggregatorService:
provider_price=str(getattr(provider_price, "price", None)),
)
raise InvalidInitPaymentRequestError(
"Payment amount does not match provider validated delivery price."
"Payment amount does not match CDEK validated delivery price."
)
async def handle_tbank_payment_notification(
@@ -419,10 +404,6 @@ class AggregatorService:
try:
self._payment_adapter.verify_payment_notification(notification)
except TBankPaymentNotificationTokenError as exc:
logger.warning(
"tbank_notification_token_invalid",
order_id=notification.OrderId,
)
raise InvalidTBankPaymentNotificationError(
"TBank payment notification token is invalid."
) from exc
@@ -436,38 +417,21 @@ class AggregatorService:
success=notification.Success,
error_code=notification.ErrorCode,
)
logger.info(
"tbank_notification_action_resolved",
order_id=notification.OrderId,
status=notification.Status,
action=action.name,
)
order = await self._load_order_and_mark_payment_status(notification)
if action is TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY:
return "OK"
provider = self._resolve_order_provider(order)
if self._has_existing_registration(order, provider):
logger.info(
"tbank_notification_registration_skipped_duplicate",
order_id=notification.OrderId,
provider=provider,
)
existing_cdek_order_uuid = getattr(order, "cdek_order_uuid", None)
if existing_cdek_order_uuid:
return "OK"
registration_result = await self._register_provider_order(
order, order_uuid=notification.OrderId, provider=provider
registration_result = await self._register_cdek_order(
order, order_uuid=notification.OrderId
)
await self._save_order_registration(
await self._save_cdek_order_uuid(
order_uuid=notification.OrderId,
provider=provider,
result=registration_result,
)
logger.info(
"tbank_notification_order_registered",
order_id=notification.OrderId,
provider=provider,
cdek_order_uuid=registration_result.order_uuid,
)
await self._send_payment_confirmation_email(
order, order_uuid=notification.OrderId
@@ -531,12 +495,9 @@ class AggregatorService:
try:
async with self._order_repository.session() as session:
# FOR UPDATE: сериализуем конкурентные уведомления по одному заказу,
# чтобы внеочередной статус не затирал уже записанный (lost update).
order = await self._order_repository.get_order_by_order_uuid(
session,
notification.OrderId,
for_update=True,
)
if order is None:
logger.warning(
@@ -546,21 +507,19 @@ class AggregatorService:
raise TBankPaymentNotificationProcessingError(
"Order was not found for TBank payment notification."
)
if should_apply_tbank_payment_status(
order.payment_status, notification.Status
):
await self._order_repository.mark_payment_status(
session,
notification.OrderId,
notification.Status,
notification.PaymentId,
)
else:
logger.info(
"tbank_payment_status_downgrade_skipped",
updated_order = await self._order_repository.mark_payment_status(
session,
notification.OrderId,
notification.Status,
notification.PaymentId,
)
if updated_order is None:
logger.warning(
"tbank_payment_notification_order_not_found",
order_uuid=notification.OrderId,
current_status=order.payment_status,
incoming_status=notification.Status,
)
raise TBankPaymentNotificationProcessingError(
"Order was not found for TBank payment notification."
)
return order
except TBankPaymentNotificationProcessingError:
@@ -576,91 +535,65 @@ class AggregatorService:
"TBank payment notification order update failed."
) from exc
@staticmethod
def _resolve_order_provider(order: object) -> str:
provider = getattr(order, "provider", None)
if isinstance(provider, str) and provider:
return provider
request = AggregatorService._to_init_payment_request_from_order(order)
return request.system_data.tariff.provider
@staticmethod
def _has_existing_registration(order: object, provider: str) -> bool:
return bool(getattr(order, "provider_order_id", None))
@staticmethod
def _extract_registration_id(result: object) -> str:
for attr in ("order_uuid", "order_number"):
value = getattr(result, attr, None)
if isinstance(value, str) and value:
return value
raise TBankPaymentNotificationProcessingError(
"Order registration result is missing an identifier."
)
async def _register_provider_order(
self, order: object, *, order_uuid: str, provider: str
) -> object:
registration_adapter = self._order_registration_adapters.get(provider)
if registration_adapter is None:
async def _register_cdek_order(
self, order: object, *, order_uuid: str
) -> CDEKOrderRegistrationResult:
if self._order_registration_adapter is None:
raise TBankPaymentNotificationProcessingError(
"Order registration adapter is not configured for the provider."
"CDEK order registration adapter is not configured."
)
try:
request = self._to_init_payment_request_from_order(order)
return await registration_adapter.register_order(request, order_uuid)
return await self._order_registration_adapter.register_order(
request, order_uuid
)
except Exception as exc:
logger.exception(
"provider_order_registration_failed",
provider=provider,
"cdek_order_registration_failed",
order_uuid=getattr(order, "order_uuid", None),
)
raise TBankPaymentNotificationProcessingError(
"Provider order registration failed."
"CDEK order registration failed."
) from exc
async def _save_order_registration(
async def _save_cdek_order_uuid(
self,
*,
order_uuid: str,
provider: str,
result: object,
cdek_order_uuid: str,
) -> None:
if self._order_repository is None:
raise TBankPaymentNotificationProcessingError(
"Order repository is not configured."
)
registration_id = self._extract_registration_id(result)
try:
async with self._order_repository.session() as session:
order = await self._order_repository.mark_provider_order_registered(
order = await self._order_repository.mark_cdek_order_registered(
session,
order_uuid,
registration_id,
cdek_order_uuid,
)
if order is None:
logger.warning(
"order_registration_order_not_found",
provider=provider,
"cdek_order_uuid_order_not_found",
order_uuid=order_uuid,
registration_id=registration_id,
cdek_order_uuid=cdek_order_uuid,
)
raise TBankPaymentNotificationProcessingError(
"Order was not found while saving registration identifier."
"Order was not found while saving CDEK order UUID."
)
except TBankPaymentNotificationProcessingError:
raise
except Exception as exc:
logger.exception(
"order_registration_persistence_failed",
provider=provider,
"cdek_order_uuid_persistence_failed",
order_uuid=order_uuid,
registration_id=registration_id,
cdek_order_uuid=cdek_order_uuid,
)
raise TBankPaymentNotificationProcessingError(
"Order registration persistence failed."
"CDEK order UUID persistence failed."
) from exc
async def _persist_order(
@@ -701,7 +634,6 @@ class AggregatorService:
payment_url=payment_url,
price=request.system_data.tariff.price,
tariff_code=request.system_data.tariff.tariff_code,
provider=request.system_data.tariff.provider,
account_email=request.account_email,
payload=request.model_dump(mode="json", by_alias=True),
)
@@ -807,30 +739,6 @@ class AggregatorService:
def _coerce_address_suggestion(value: object) -> AddressSuggestion:
return AddressSuggestion.model_validate(value, from_attributes=True)
@staticmethod
def _build_address_suggest_request(
request: SuggestAddressRequest,
) -> AddressSuggestRequest:
city_entry = cities_map.get(request.city)
if not isinstance(city_entry, dict):
raise InvalidAddressSuggestRequestError(
f"City is not configured for id {request.city}."
)
city_name = city_entry.get("city")
country_code = city_entry.get("country")
if not isinstance(city_name, str) or not isinstance(country_code, str):
raise InvalidAddressSuggestRequestError(
f"City mapping is invalid for id {request.city}."
)
return AddressSuggestRequest(
country_code=country_code,
city=city_name,
query=request.query,
limit=request.limit,
)
def _resolve_address_suggestion_provider(
self, country_code: str
) -> AddressSuggestionProvider:
+15 -29
View File
@@ -1,7 +1,7 @@
"""Background service that e-mails provider waybill PDFs to customers."""
"""Background service that e-mails CDEK waybill PDFs to customers."""
import asyncio
from collections.abc import Callable, Mapping, Sequence
from collections.abc import Callable, Sequence
from contextlib import AbstractAsyncContextManager
from dataclasses import dataclass
from datetime import datetime, timezone
@@ -15,18 +15,15 @@ logger = structlog.get_logger(__name__)
_EMAIL_SUBJECT_TEMPLATE = "Накладная по заказу {order_uuid}"
_EMAIL_BODY_TEMPLATE = (
"Здравствуйте!\n\n"
"По вашему заказу {order_uuid} сформирована "
"транспортная накладная.\n"
"По вашему заказу {order_uuid} сформирована транспортная накладная CDEK.\n"
"PDF-файл накладной приложен к этому письму.\n"
)
_EMAIL_BODY_URL_LINE_TEMPLATE = (
"Также накладная доступна по ссылке: {waybill_url}\n"
)
_ATTACHMENT_FILENAME_TEMPLATE = "waybill_{order_uuid}.pdf"
class WaybillPDFDownloaderProtocol(Protocol):
async def download_waybill_pdf(self, order: "OrderRecord") -> bytes: ...
async def download_waybill_pdf(self, url: str) -> bytes: ...
class EmailSenderProtocol(Protocol):
@@ -43,10 +40,8 @@ class EmailSenderProtocol(Protocol):
class OrderRecord(Protocol):
order_uuid: str
provider: str
account_email: str
provider_waybill_id: str | None
provider_waybill_url: str | None
cdek_waybill_url: str | None
class WaybillEmailSenderRepositoryProtocol(Protocol):
@@ -77,13 +72,13 @@ class WaybillEmailSenderService:
self,
*,
order_repository: WaybillEmailSenderRepositoryProtocol,
waybill_downloaders: Mapping[str, WaybillPDFDownloaderProtocol],
waybill_downloader: WaybillPDFDownloaderProtocol,
email_sender: EmailSenderProtocol,
batch_size: int,
datetime_now: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
) -> None:
self._repository = order_repository
self._waybill_downloaders = dict(waybill_downloaders)
self._waybill_downloader = waybill_downloader
self._email_sender = email_sender
self._batch_size = batch_size
self._datetime_now = datetime_now
@@ -104,7 +99,6 @@ class WaybillEmailSenderService:
logger.exception(
"waybill_email_order_failed",
order_uuid=order.order_uuid,
provider=order.provider,
account_email=order.account_email,
)
return SendBatchSummary(
@@ -122,7 +116,7 @@ class WaybillEmailSenderService:
while not stop_event.is_set():
try:
summary = await self.poll_once()
logger.debug(
logger.info(
"waybill_email_tick",
processed=summary.processed,
succeeded=summary.succeeded,
@@ -139,17 +133,16 @@ class WaybillEmailSenderService:
continue
async def _handle_order(self, session: object, order: OrderRecord) -> None:
if order.provider_waybill_url is None and order.provider_waybill_id is None:
waybill_url = order.cdek_waybill_url
if waybill_url is None:
return
downloader = self._resolve_downloader(order.provider)
pdf_bytes = await downloader.download_waybill_pdf(order)
pdf_bytes = await self._waybill_downloader.download_waybill_pdf(waybill_url)
subject = _EMAIL_SUBJECT_TEMPLATE.format(order_uuid=order.order_uuid)
body = _EMAIL_BODY_TEMPLATE.format(order_uuid=order.order_uuid)
if order.provider_waybill_url is not None:
body += _EMAIL_BODY_URL_LINE_TEMPLATE.format(
waybill_url=order.provider_waybill_url,
)
body = _EMAIL_BODY_TEMPLATE.format(
order_uuid=order.order_uuid,
waybill_url=waybill_url,
)
filename = _ATTACHMENT_FILENAME_TEMPLATE.format(order_uuid=order.order_uuid)
await self._email_sender.send_email(
@@ -169,13 +162,6 @@ class WaybillEmailSenderService:
logger.info(
"waybill_email_sent",
order_uuid=order.order_uuid,
provider=order.provider,
account_email=order.account_email,
sent_at=sent_at,
)
def _resolve_downloader(self, provider: str) -> WaybillPDFDownloaderProtocol:
downloader = self._waybill_downloaders.get(provider)
if downloader is None:
raise RuntimeError(f"Waybill downloader is not configured for {provider}.")
return downloader
+33 -71
View File
@@ -1,7 +1,7 @@
"""Background service that polls providers for waybill updates."""
"""Background service that polls CDEK for waybill updates."""
import asyncio
from collections.abc import Callable, Mapping, Sequence
from collections.abc import Callable, Sequence
from contextlib import AbstractAsyncContextManager
from dataclasses import dataclass
from datetime import datetime, timezone
@@ -9,22 +9,26 @@ from typing import Protocol
import structlog
from app.adapters.delivery_providers.cdek.order_mapper import (
CDEKOrderInfo,
CDEKWaybillInfo,
)
logger = structlog.get_logger(__name__)
class OrderInfoAdapterProtocol(Protocol):
async def get_order(self, provider_order_id: str) -> object: ...
class CDEKOrderInfoAdapterProtocol(Protocol):
async def get_order(self, cdek_order_uuid: str) -> CDEKOrderInfo: ...
class WaybillInfoAdapterProtocol(Protocol):
async def get_waybill(self, provider_waybill_id: str) -> object: ...
class CDEKWaybillInfoAdapterProtocol(Protocol):
async def get_waybill(self, cdek_waybill_uuid: str) -> CDEKWaybillInfo: ...
class OrderRecord(Protocol):
order_uuid: str
provider: str
provider_order_id: str | None
provider_waybill_id: str | None
cdek_order_uuid: str | None
cdek_waybill_uuid: str | None
class WaybillPollerRepositoryProtocol(Protocol):
@@ -66,14 +70,14 @@ class WaybillPollerService:
self,
*,
order_repository: WaybillPollerRepositoryProtocol,
order_info_adapters: Mapping[str, OrderInfoAdapterProtocol],
waybill_info_adapters: Mapping[str, WaybillInfoAdapterProtocol],
order_info_adapter: CDEKOrderInfoAdapterProtocol,
waybill_info_adapter: CDEKWaybillInfoAdapterProtocol,
batch_size: int,
datetime_now: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
) -> None:
self._repository = order_repository
self._order_info_adapters = dict(order_info_adapters)
self._waybill_info_adapters = dict(waybill_info_adapters)
self._order_info_adapter = order_info_adapter
self._waybill_info_adapter = waybill_info_adapter
self._batch_size = batch_size
self._datetime_now = datetime_now
@@ -93,9 +97,8 @@ class WaybillPollerService:
logger.exception(
"waybill_poll_order_failed",
order_uuid=order.order_uuid,
provider=order.provider,
provider_order_id=order.provider_order_id,
provider_waybill_id=order.provider_waybill_id,
cdek_order_uuid=order.cdek_order_uuid,
cdek_waybill_uuid=order.cdek_waybill_uuid,
)
return PollBatchSummary(
processed=len(orders),
@@ -112,7 +115,7 @@ class WaybillPollerService:
while not stop_event.is_set():
try:
summary = await self.poll_once()
logger.debug(
logger.info(
"waybill_poll_tick",
processed=summary.processed,
succeeded=summary.succeeded,
@@ -130,78 +133,37 @@ class WaybillPollerService:
async def _handle_order(self, session: object, order: OrderRecord) -> None:
polled_at = self._datetime_now()
if order.provider_waybill_id is None:
provider_order_id = order.provider_order_id
if provider_order_id is None:
if order.cdek_waybill_uuid is None:
cdek_order_uuid = order.cdek_order_uuid
if cdek_order_uuid is None:
return
adapter = self._resolve_order_info_adapter(order.provider)
info = await adapter.get_order(provider_order_id)
status_code = _extract_status_code(info)
waybill_id = _extract_waybill_id(info)
info = await self._order_info_adapter.get_order(cdek_order_uuid)
await self._repository.record_order_poll(
session,
order_uuid=order.order_uuid,
order_status=status_code,
waybill_uuid=waybill_id,
order_status=info.status_code,
waybill_uuid=info.waybill_uuid,
polled_at=polled_at,
)
logger.info(
"waybill_poll_order_result",
order_uuid=order.order_uuid,
provider=order.provider,
provider_order_id=provider_order_id,
provider_order_status=status_code,
provider_waybill_id=waybill_id,
cdek_order_uuid=cdek_order_uuid,
cdek_order_status=info.status_code,
cdek_waybill_uuid=info.waybill_uuid,
)
return
adapter = self._resolve_waybill_info_adapter(order.provider)
waybill = await adapter.get_waybill(order.provider_waybill_id)
waybill_url = _extract_waybill_url(waybill)
waybill = await self._waybill_info_adapter.get_waybill(order.cdek_waybill_uuid)
await self._repository.record_waybill_poll(
session,
order_uuid=order.order_uuid,
waybill_url=waybill_url,
waybill_url=waybill.url,
polled_at=polled_at,
)
logger.info(
"waybill_poll_waybill_result",
order_uuid=order.order_uuid,
provider=order.provider,
provider_waybill_id=order.provider_waybill_id,
provider_waybill_url=waybill_url,
cdek_waybill_uuid=order.cdek_waybill_uuid,
cdek_waybill_url=waybill.url,
)
def _resolve_order_info_adapter(self, provider: str) -> OrderInfoAdapterProtocol:
adapter = self._order_info_adapters.get(provider)
if adapter is None:
raise RuntimeError(f"Order info adapter is not configured for {provider}.")
return adapter
def _resolve_waybill_info_adapter(
self, provider: str
) -> WaybillInfoAdapterProtocol:
adapter = self._waybill_info_adapters.get(provider)
if adapter is None:
raise RuntimeError(
f"Waybill info adapter is not configured for {provider}."
)
return adapter
def _extract_status_code(info: object) -> str | None:
value = getattr(info, "status_code", None)
return value if isinstance(value, str) and value else None
def _extract_waybill_id(info: object) -> str | None:
for attr in ("waybill_id", "waybill_uuid", "waybill_number"):
value = getattr(info, attr, None)
if isinstance(value, str) and value:
return value
return None
def _extract_waybill_url(info: object) -> str | None:
value = getattr(info, "url", None)
return value if isinstance(value, str) and value else None
+17 -73
View File
@@ -1,21 +1,13 @@
"""Background worker that e-mails provider waybill PDFs."""
"""Background worker that e-mails CDEK waybill PDFs."""
import asyncio
import signal
from dataclasses import dataclass
import httpx
import structlog
from app.adapters.delivery_providers.registry import (
resolve_delivery_provider_timeout_seconds,
)
from app.adapters.delivery_providers.cdek.auth import CDEKAuthClient
from app.adapters.delivery_providers.cdek.client import CDEKClient
from app.adapters.delivery_providers.cse.client import CSEClient
from app.adapters.delivery_providers.cse.order_mapper import (
CSEOrderRegistrationParams,
)
from app.adapters.email import SMTPEmailSender
from app.adapters.postgres.engine import (
create_postgres_engine,
@@ -29,71 +21,23 @@ from app.services.waybill_email_sender import WaybillEmailSenderService
logger = structlog.get_logger(__name__)
@dataclass(frozen=True)
class CDEKWaybillPDFDownloader:
client: CDEKClient
async def download_waybill_pdf(self, order: object) -> bytes:
url = getattr(order, "provider_waybill_url", None)
if not isinstance(url, str) or not url:
raise RuntimeError("CDEK waybill URL is missing.")
return await self.client.download_waybill_pdf(url)
@dataclass(frozen=True)
class CSEWaybillPDFDownloader:
client: CSEClient
async def download_waybill_pdf(self, order: object) -> bytes:
waybill_number = getattr(order, "provider_waybill_id", None)
if not isinstance(waybill_number, str) or not waybill_number:
raise RuntimeError("CSE waybill number is missing.")
return await self.client.download_waybill_pdf(waybill_number)
async def _run(settings: Settings, stop_event: asyncio.Event) -> None:
http_client = httpx.AsyncClient(
timeout=resolve_delivery_provider_timeout_seconds(settings.delivery_providers)
http_client = httpx.AsyncClient(timeout=settings.adapter.cdek_timeout_seconds)
auth_client = CDEKAuthClient(
http_client=http_client,
base_url=settings.adapter.cdek_base_url,
client_id=settings.adapter.cdek_client_id,
client_secret=settings.adapter.cdek_client_secret,
timeout_seconds=settings.adapter.cdek_timeout_seconds,
)
cdek_client = CDEKClient(
http_client=http_client,
auth_client=auth_client,
base_url=settings.adapter.cdek_base_url,
timeout_seconds=settings.adapter.cdek_timeout_seconds,
retry_attempts=settings.adapter.cdek_retry_attempts,
retry_backoff_seconds=settings.adapter.cdek_retry_backoff_seconds,
)
waybill_downloaders: dict[str, object] = {}
cdek_config = settings.delivery_providers.cdek
if cdek_config.enabled:
auth_client = CDEKAuthClient(
http_client=http_client,
base_url=cdek_config.base_url,
client_id=cdek_config.client_id,
client_secret=cdek_config.client_secret,
timeout_seconds=cdek_config.timeout_seconds,
)
cdek_client = CDEKClient(
http_client=http_client,
auth_client=auth_client,
base_url=cdek_config.base_url,
timeout_seconds=cdek_config.timeout_seconds,
retry_attempts=cdek_config.retry_attempts,
retry_backoff_seconds=cdek_config.retry_backoff_seconds,
)
waybill_downloaders["cdek"] = CDEKWaybillPDFDownloader(cdek_client)
cse_config = settings.delivery_providers.cse
if cse_config.enabled:
cse_client = CSEClient(
http_client=http_client,
base_url=cse_config.base_url,
login=cse_config.login,
password=cse_config.password,
registration_params=CSEOrderRegistrationParams(
payer=cse_config.payer,
payment_method=cse_config.payment_method,
shipping_method=cse_config.shipping_method,
),
timeout_seconds=cse_config.timeout_seconds,
retry_attempts=cse_config.retry_attempts,
retry_backoff_seconds=cse_config.retry_backoff_seconds,
)
waybill_downloaders["cse"] = CSEWaybillPDFDownloader(cse_client)
email_sender = SMTPEmailSender(
smtp_host=settings.email.smtp_host,
smtp_port=settings.email.smtp_port,
@@ -108,7 +52,7 @@ async def _run(settings: Settings, stop_event: asyncio.Event) -> None:
repository = OrderRepository(session_factory=session_factory)
service = WaybillEmailSenderService(
order_repository=repository,
waybill_downloaders=waybill_downloaders,
waybill_downloader=cdek_client,
email_sender=email_sender,
batch_size=settings.waybill_email_sender.batch_size,
)
+18 -53
View File
@@ -1,4 +1,4 @@
"""Background worker that polls delivery providers for waybill updates."""
"""Background worker that polls CDEK for waybill updates."""
import asyncio
import signal
@@ -6,15 +6,8 @@ import signal
import httpx
import structlog
from app.adapters.delivery_providers.registry import (
resolve_delivery_provider_timeout_seconds,
)
from app.adapters.delivery_providers.cdek.auth import CDEKAuthClient
from app.adapters.delivery_providers.cdek.client import CDEKClient
from app.adapters.delivery_providers.cse.client import CSEClient
from app.adapters.delivery_providers.cse.order_mapper import (
CSEOrderRegistrationParams,
)
from app.adapters.postgres.engine import (
create_postgres_engine,
create_postgres_session_factory,
@@ -28,57 +21,29 @@ logger = structlog.get_logger(__name__)
async def _run(settings: Settings, stop_event: asyncio.Event) -> None:
http_client = httpx.AsyncClient(
timeout=resolve_delivery_provider_timeout_seconds(settings.delivery_providers)
http_client = httpx.AsyncClient(timeout=settings.adapter.cdek_timeout_seconds)
auth_client = CDEKAuthClient(
http_client=http_client,
base_url=settings.adapter.cdek_base_url,
client_id=settings.adapter.cdek_client_id,
client_secret=settings.adapter.cdek_client_secret,
timeout_seconds=settings.adapter.cdek_timeout_seconds,
)
cdek_client = CDEKClient(
http_client=http_client,
auth_client=auth_client,
base_url=settings.adapter.cdek_base_url,
timeout_seconds=settings.adapter.cdek_timeout_seconds,
retry_attempts=settings.adapter.cdek_retry_attempts,
retry_backoff_seconds=settings.adapter.cdek_retry_backoff_seconds,
)
order_info_adapters: dict[str, object] = {}
waybill_info_adapters: dict[str, object] = {}
cdek_config = settings.delivery_providers.cdek
if cdek_config.enabled:
auth_client = CDEKAuthClient(
http_client=http_client,
base_url=cdek_config.base_url,
client_id=cdek_config.client_id,
client_secret=cdek_config.client_secret,
timeout_seconds=cdek_config.timeout_seconds,
)
cdek_client = CDEKClient(
http_client=http_client,
auth_client=auth_client,
base_url=cdek_config.base_url,
timeout_seconds=cdek_config.timeout_seconds,
retry_attempts=cdek_config.retry_attempts,
retry_backoff_seconds=cdek_config.retry_backoff_seconds,
)
order_info_adapters["cdek"] = cdek_client
waybill_info_adapters["cdek"] = cdek_client
cse_config = settings.delivery_providers.cse
if cse_config.enabled:
cse_client = CSEClient(
http_client=http_client,
base_url=cse_config.base_url,
login=cse_config.login,
password=cse_config.password,
registration_params=CSEOrderRegistrationParams(
payer=cse_config.payer,
payment_method=cse_config.payment_method,
shipping_method=cse_config.shipping_method,
),
timeout_seconds=cse_config.timeout_seconds,
retry_attempts=cse_config.retry_attempts,
retry_backoff_seconds=cse_config.retry_backoff_seconds,
)
order_info_adapters["cse"] = cse_client
engine = create_postgres_engine(settings.postgres)
session_factory = create_postgres_session_factory(engine)
repository = OrderRepository(session_factory=session_factory)
service = WaybillPollerService(
order_repository=repository,
order_info_adapters=order_info_adapters,
waybill_info_adapters=waybill_info_adapters,
order_info_adapter=cdek_client,
waybill_info_adapter=cdek_client,
batch_size=settings.waybill_poller.batch_size,
)
logger.info(
+11 -27
View File
@@ -11,38 +11,22 @@ business_logic:
provider_price_multiplier: 1.0
repository:
redis_dsn: "redis://redis:6379/0"
redis_dsn: "redis://localhost:6379/0"
price_cache_ttl_seconds: 900
delivery_providers:
cdek:
enabled: true
base_url: "https://api.edu.cdek.ru/v2"
client_id: "${CDEK_CLIENT_ID}"
client_secret: "${CDEK_CLIENT_SECRET}"
retry_attempts: 2
retry_backoff_seconds: 0.2
timeout_seconds: 10.0
cache_ttl_seconds: 900
cse:
enabled: true
base_url: "http://lk-test.cse.ru/1c/ws/web1c.1cws"
login: "${CSE_LOGIN}"
password: "${CSE_PASSWORD}"
retry_attempts: 2
retry_backoff_seconds: 0.2
timeout_seconds: 10.0
cache_ttl_seconds: 900
delivery_service_guids:
- "6da21fe8-4f13-11dc-bda1-0015170f8c09" # Россия доставка
payer: "0" # Заказчик
payment_method: "1" # Безналичный расчёт
shipping_method: "5052d0b3-5ea3-46f2-823f-1472686a51dd" # авто
adapter:
cdek_base_url: "https://api.edu.cdek.ru/v2"
cdek_client_id: "${CDEK_CLIENT_ID}"
cdek_client_secret: "${CDEK_CLIENT_SECRET}"
cdek_retry_attempts: 2
cdek_retry_backoff_seconds: 0.2
cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
notification_url: "${TBANK_NOTIFICATION_URL}"
success_url: "${TBANK_SUCCESS_URL}"
notification_url: "https://aggregator.get2send.com/api/v1/delivery/tbank/notifications"
success_url: "https://aggregator.get2send.com/checkout/success"
auth:
terminal_key: "${TBANK_TERMINAL_KEY}"
password: "${TBANK_PASSWORD}"
+8 -24
View File
@@ -14,30 +14,14 @@ repository:
redis_dsn: "redis://localhost:6379/0"
price_cache_ttl_seconds: 900
delivery_providers:
cdek:
enabled: true
base_url: "https://api.cdek.ru/v2"
client_id: "test-client-id"
client_secret: "test-client-secret"
retry_attempts: 2
retry_backoff_seconds: 0.2
timeout_seconds: 10.0
cache_ttl_seconds: 900
cse:
enabled: true
base_url: "http://lk-test.cse.ru/1c/ws/web1c.1cws"
login: "test"
password: "2016"
retry_attempts: 2
retry_backoff_seconds: 0.2
timeout_seconds: 10.0
cache_ttl_seconds: 900
delivery_service_guids:
- "tariff-guid-2"
payer: "0"
payment_method: "1"
shipping_method: "5052d0b3-5ea3-46f2-823f-1472686a51dd"
adapter:
cdek_base_url: "https://api.cdek.ru/v2"
cdek_client_id: "test-client-id"
cdek_client_secret: "test-client-secret"
cdek_retry_attempts: 2
cdek_retry_backoff_seconds: 0.2
cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
+18 -32
View File
@@ -1,9 +1,9 @@
services:
app:
image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:${IMAGE_TAG}
container_name: g2s-aggregator${CONTAINER_SUFFIX}
image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.0.9
container_name: g2s-aggregator
ports:
- "${APP_PORT}:8000"
- "8003:8000"
depends_on:
redis:
condition: service_started
@@ -17,23 +17,23 @@ services:
logging:
driver: "json-file"
options:
tag: "g2s-aggregator${CONTAINER_SUFFIX}"
tag: "g2s-aggregator"
redis:
image: redis:7-alpine
container_name: redis${CONTAINER_SUFFIX}
container_name: redis
command: ["redis-server", "--save", "", "--appendonly", "no"]
ports:
- "${REDIS_PORT}:6379"
- "6379:6379"
restart: unless-stopped
postgres:
image: postgres:16-alpine
container_name: postgres${CONTAINER_SUFFIX}
container_name: postgres
environment:
POSTGRES_DB: g2s_aggregator
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "${POSTGRES_PORT}:5432"
- "5432:5432"
volumes:
- postgres:/var/lib/postgresql/data
healthcheck:
@@ -42,22 +42,18 @@ services:
timeout: 3s
retries: 10
migrations:
image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:${IMAGE_TAG}
container_name: g2s-aggregator-migrations${CONTAINER_SUFFIX}
image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.0.9
container_name: g2s-aggregator-migrations
depends_on:
postgres:
condition: service_healthy
volumes:
- ./config.yaml:/config.yaml
command: ["uv", "run", "--no-sync", "alembic", "upgrade", "head"]
command: ["poetry", "run", "alembic", "upgrade", "head"]
restart: "no"
logging:
driver: "json-file"
options:
tag: "g2s-aggregator-migrations${CONTAINER_SUFFIX}"
waybill-poller:
image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:${IMAGE_TAG}
container_name: g2s-aggregator-waybill-poller${CONTAINER_SUFFIX}
image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.0.9
container_name: g2s-aggregator-waybill-poller
depends_on:
postgres:
condition: service_healthy
@@ -65,15 +61,11 @@ services:
condition: service_completed_successfully
volumes:
- ./config.yaml:/config.yaml
command: ["uv", "run", "--no-sync", "python", "-m", "app.workers.waybill_poller"]
command: ["poetry", "run", "python", "-m", "app.workers.waybill_poller"]
restart: unless-stopped
logging:
driver: "json-file"
options:
tag: "g2s-aggregator-waybill-poller${CONTAINER_SUFFIX}"
waybill-email-sender:
image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:${IMAGE_TAG}
container_name: g2s-aggregator-waybill-email-sender${CONTAINER_SUFFIX}
image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.0.9
container_name: g2s-aggregator-waybill-email-sender
depends_on:
postgres:
condition: service_healthy
@@ -82,18 +74,12 @@ services:
volumes:
- ./config.yaml:/config.yaml
command:
["uv", "run", "--no-sync", "python", "-m", "app.workers.waybill_email_sender"]
["poetry", "run", "python", "-m", "app.workers.waybill_email_sender"]
restart: unless-stopped
logging:
driver: "json-file"
options:
tag: "g2s-aggregator-waybill-email-sender${CONTAINER_SUFFIX}"
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
container_name: otel-collector${CONTAINER_SUFFIX}
container_name: otel-collector
user: "0"
environment:
ENV_NAME: ${ENV_NAME}
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
- /var/lib/docker/containers:/var/lib/docker/containers:ro
-4
View File
@@ -120,7 +120,3 @@ Authorization: Bearer {{auth_token}}
Accept: application/pdf
>>! waybill.pdf
### 10. Список городов
GET {{base_url}}/v2/location/cities?code=2662
Authorization: Bearer {{auth_token}}
-115
View File
@@ -1,115 +0,0 @@
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
filelog:
include:
- /var/lib/docker/containers/*/*-json.log
include_file_path: true
operators:
- type: json_parser
parse_to: attributes
on_error: send
- type: filter
expr: |
attributes["attrs"] == nil or
attributes["attrs"]["tag"] == nil or
(
attributes["attrs"]["tag"] != "g2s-aggregator${CONTAINER_SUFFIX}" and
attributes["attrs"]["tag"] != "g2s-aggregator-migrations${CONTAINER_SUFFIX}" and
attributes["attrs"]["tag"] != "g2s-aggregator-waybill-poller${CONTAINER_SUFFIX}" and
attributes["attrs"]["tag"] != "g2s-aggregator-waybill-email-sender${CONTAINER_SUFFIX}"
)
on_error: send
- type: regex_parser
parse_from: attributes["attrs"]["tag"]
regex: '^(?P<service_name>g2s-aggregator(?:-migrations|-waybill-poller|-waybill-email-sender)?)(?:-stage)?$'
on_error: send
- type: move
from: attributes["service_name"]
to: resource["service.name"]
on_error: send
- type: regex_parser
parse_from: attributes["log.file.path"]
regex: '^/var/lib/docker/containers/(?P<container_id>[^/]+)/'
on_error: send
- type: move
from: attributes["container_id"]
to: resource["service.instance.id"]
on_error: send
- type: remove
field: attributes["attrs"]
on_error: send
- type: json_parser
parse_from: attributes.log
parse_to: attributes
on_error: send
- type: trace_parser
trace_id:
parse_from: attributes.trace_id
span_id:
parse_from: attributes.span_id
on_error: send
- type: time_parser
parse_from: attributes.timestamp
layout: '%Y-%m-%dT%H:%M:%S.%fZ'
on_error: send
- type: severity_parser
parse_from: attributes.level
on_error: send
- type: move
from: attributes.event
to: body
on_error: send
- type: remove
field: attributes.log
on_error: send
docker_stats:
endpoint: unix:///var/run/docker.sock
collection_interval: 30s
container_labels_as_resource_attributes: true
api_version: "1.43"
processors:
resource/env:
attributes:
- key: deployment.environment
value: "${ENV_NAME}"
action: upsert
- key: service.namespace
value: g2s
action: upsert
resource/version:
attributes:
- key: service.version
value: "${IMAGE_TAG}"
action: upsert
filter/environment:
error_mode: ignore
metrics:
metric:
- '${METRICS_FILTER_EXPRESSION}'
exporters:
otlp:
endpoint: "${SIGNOZ_OTLP_ENDPOINT}"
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [resource/env, resource/version]
exporters: [otlp]
logs:
receivers: [filelog]
processors: [resource/env, resource/version]
exporters: [otlp]
metrics:
receivers: [otlp, docker_stats]
processors: [filter/environment, resource/env]
exporters: [otlp]
Generated
+2128
View File
File diff suppressed because it is too large Load Diff
+31 -27
View File
@@ -1,36 +1,40 @@
[project]
[tool.poetry]
name = "g2s-aggregator"
version = "0.1.0"
description = ""
authors = ["Your Name <you@example.com>"]
readme = "README.md"
requires-python = ">=3.14"
dependencies = [
"fastapi>=0.135.1,<0.136.0",
"uvicorn[standard]>=0.41.0,<0.42.0",
"httpx>=0.28.1,<0.29.0",
"pydantic>=2.12.5,<3.0.0",
"pydantic-settings>=2.13.1,<3.0.0",
"aioredis>=2.0.1,<3.0.0",
"structlog>=25.5.0,<26.0.0",
"opentelemetry-api>=1.40.0,<2.0.0",
"opentelemetry-sdk>=1.40.0,<2.0.0",
"opentelemetry-exporter-otlp>=1.40.0,<2.0.0",
"opentelemetry-instrumentation-fastapi>=0.61b0",
"opentelemetry-instrumentation-httpx>=0.61b0",
"opentelemetry-instrumentation-redis>=0.61b0",
"pytest>=9.0.2,<10.0.0",
"redis>=7.3.0,<8.0.0",
"sqlalchemy>=2.0.49,<3.0.0",
"asyncpg>=0.31.0,<0.32.0",
"alembic>=1.18.4,<2.0.0",
"aiosqlite>=0.22.1,<0.23.0",
"aiosmtplib>=4.0.2,<5.0.0",
"email-validator>=2.2.0,<3.0.0",
]
package-mode = false
[tool.uv]
package = false
[tool.poetry.dependencies]
python = "^3.14"
fastapi = "^0.135.1"
uvicorn = {extras = ["standard"], version = "^0.41.0"}
httpx = "^0.28.1"
pydantic = "^2.12.5"
pydantic-settings = "^2.13.1"
aioredis = "^2.0.1"
structlog = "^25.5.0"
opentelemetry-api = "^1.40.0"
opentelemetry-sdk = "^1.40.0"
opentelemetry-exporter-otlp = "^1.40.0"
opentelemetry-instrumentation-fastapi = "^0.61b0"
opentelemetry-instrumentation-httpx = "^0.61b0"
opentelemetry-instrumentation-redis = "^0.61b0"
pytest = "^9.0.2"
redis = "^7.3.0"
sqlalchemy = "^2.0.49"
asyncpg = "^0.31.0"
alembic = "^1.18.4"
aiosqlite = "^0.22.1"
aiosmtplib = "^4.0.2"
email-validator = "^2.2.0"
[tool.pytest.ini_options]
pythonpath = ["."]
addopts = ["--import-mode=importlib"]
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
+229
View File
@@ -0,0 +1,229 @@
#!/usr/bin/env python3
"""
Generate spec/index.md from spec/tasks/*.md
Assumptions:
- Each task file starts with YAML-like front matter:
---
id: 001
title: Add endpoint /reports/addons
status: TODO # TODO | DONE
created: 2026-02-01
---
Notes:
- status lives inside each task file (as requested).
- index.md is fully overwritten and marked as generated.
- No external deps (no PyYAML). Supports simple "key: value" scalars.
Usage:
python3 spec/gen_spec_index.py
python3 spec/gen_spec_index.py --check # verify index is up-to-date (CI)
"""
from __future__ import annotations
import argparse
import datetime as dt
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional, Tuple
ROOT = Path(__file__).resolve().parents[1]
SPEC_DIR = ROOT / "spec"
TASKS_DIR = SPEC_DIR / "tasks"
INDEX_PATH = SPEC_DIR / "index.md"
ALLOWED_STATUS = {"TODO", "DONE"}
@dataclass(frozen=True)
class TaskMeta:
file: Path
id: str
title: str
status: str
created: str # YYYY-MM-DD
FRONT_MATTER_RE = re.compile(r"^---\s*$")
def read_front_matter(md_path: Path) -> Tuple[Dict[str, str], int]:
"""
Returns (meta_dict, end_line_index_exclusive).
If no front matter, returns ({}, 0).
"""
text = md_path.read_text(encoding="utf-8")
lines = text.splitlines()
if not lines or not FRONT_MATTER_RE.match(lines[0]):
return {}, 0
meta: Dict[str, str] = {}
i = 1
while i < len(lines):
if FRONT_MATTER_RE.match(lines[i]):
return meta, i + 1
line = lines[i].strip()
i += 1
if not line or line.startswith("#"):
continue
# Very small YAML subset: key: value
if ":" not in line:
continue
key, val = line.split(":", 1)
key = key.strip()
val = val.strip()
# Strip optional quotes
if (val.startswith('"') and val.endswith('"')) or (val.startswith("'") and val.endswith("'")):
val = val[1:-1]
meta[key] = val
# Started front matter but never closed
raise ValueError(f"{md_path}: front matter starts with '---' but missing closing '---'")
def normalize_id(md_path: Path, meta: Dict[str, str]) -> str:
# Prefer explicit id, else derive from filename prefix like "001-something.md"
if "id" in meta and meta["id"].strip():
return meta["id"].strip()
m = re.match(r"^(\d+)", md_path.stem)
if m:
return m.group(1)
raise ValueError(f"{md_path}: missing 'id' in front matter and filename doesn't start with digits")
def normalize_title(md_path: Path, meta: Dict[str, str], content_start_line: int) -> str:
if "title" in meta and meta["title"].strip():
return meta["title"].strip()
# Fallback: first markdown heading after front matter: "# Title"
text = md_path.read_text(encoding="utf-8")
lines = text.splitlines()
for line in lines[content_start_line:]:
line = line.strip()
if line.startswith("# "):
return line[2:].strip()
raise ValueError(f"{md_path}: missing 'title' in front matter and no '# ' heading found")
def normalize_status(md_path: Path, meta: Dict[str, str]) -> str:
status = meta.get("status", "").strip().upper()
if status not in ALLOWED_STATUS:
raise ValueError(f"{md_path}: invalid or missing 'status' (must be one of: {sorted(ALLOWED_STATUS)})")
return status
def normalize_created(md_path: Path, meta: Dict[str, str]) -> str:
created = meta.get("created", "").strip()
if created:
# Validate YYYY-MM-DD
try:
dt.date.fromisoformat(created)
except ValueError:
raise ValueError(f"{md_path}: invalid 'created' date '{created}' (expected YYYY-MM-DD)")
return created
# Fallback: file mtime (not perfect, but better than failing)
mtime = dt.datetime.fromtimestamp(md_path.stat().st_mtime, tz=dt.timezone.utc).date().isoformat()
return mtime
def load_tasks() -> List[TaskMeta]:
if not TASKS_DIR.exists():
raise FileNotFoundError(f"Tasks directory not found: {TASKS_DIR}")
md_files = sorted(TASKS_DIR.glob("*.md"))
tasks: List[TaskMeta] = []
seen_ids: Dict[str, Path] = {}
for f in md_files:
meta, content_start = read_front_matter(f)
task_id = normalize_id(f, meta)
if task_id in seen_ids:
raise ValueError(f"Duplicate task id '{task_id}': {seen_ids[task_id]} and {f}")
seen_ids[task_id] = f
title = normalize_title(f, meta, content_start)
status = normalize_status(f, meta)
created = normalize_created(f, meta)
tasks.append(TaskMeta(file=f, id=task_id, title=title, status=status, created=created))
# Sort by numeric id if possible
def sort_key(t: TaskMeta):
try:
return (0, int(t.id))
except ValueError:
return (1, t.id)
return sorted(tasks, key=sort_key)
def render_index(tasks: List[TaskMeta]) -> str:
lines: List[str] = []
lines.append("# Spec Tasks Index")
lines.append("")
lines.append("> ⚠️ This file is generated. Do not edit manually.")
lines.append("")
lines.append("## Tasks")
lines.append("")
lines.append("| ID | Status | Created | Title | File |")
lines.append("|---:|:------:|:-------:|:------|:-----|")
for t in tasks:
rel = t.file.relative_to(ROOT).as_posix()
title = t.title.replace("|", "\\|")
lines.append(f"| {t.id} | {t.status} | {t.created} | {title} | `{rel}` |")
lines.append("")
lines.append("## Summary")
lines.append("")
todo = sum(1 for t in tasks if t.status == "TODO")
done = sum(1 for t in tasks if t.status == "DONE")
lines.append(f"- Total: **{len(tasks)}**")
lines.append(f"- TODO: **{todo}**")
lines.append(f"- DONE: **{done}**")
lines.append("")
return "\n".join(lines)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--check", action="store_true", help="fail if spec/index.md is not up-to-date")
args = parser.parse_args()
tasks = load_tasks()
content = render_index(tasks)
if args.check:
existing = INDEX_PATH.read_text(encoding="utf-8") if INDEX_PATH.exists() else ""
if existing != content:
print("spec/index.md is not up-to-date. Run: python3 spec/gen_spec_index.py", file=sys.stderr)
return 2
return 0
SPEC_DIR.mkdir(parents=True, exist_ok=True)
INDEX_PATH.write_text(content, encoding="utf-8")
print(f"Wrote {INDEX_PATH.relative_to(ROOT)} ({len(tasks)} tasks)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+49
View File
@@ -0,0 +1,49 @@
# Spec Tasks Index
> ⚠️ This file is generated. Do not edit manually.
## Tasks
| ID | Status | Created | Title | File |
|---:|:------:|:-------:|:------|:-----|
| 000 | DONE | 2026-02-01 | Task format reference | `spec/tasks/000_template.md` |
| 001 | DONE | 2026-03-07 | Create app skeleton and component configuration | `spec/tasks/001_create_app_skeleton_and_config.md` |
| 002 | DONE | 2026-03-07 | Implement pure domain price rules | `spec/tasks/002_implement_pure_domain_quote_rules.md` |
| 003 | DONE | 2026-03-07 | Add CDEK provider adapter | `spec/tasks/003_add_cdek_provider_adapter.md` |
| 004 | DONE | 2026-03-07 | Add Redis price cache repository | `spec/tasks/004_add_redis_price_cache_repository.md` |
| 005 | DONE | 2026-03-07 | Implement aggregator service workflow | `spec/tasks/005_implement_aggregator_service_workflow.md` |
| 006 | DONE | 2026-03-07 | Add delivery price controller endpoint | `spec/tasks/006_add_delivery_price_controller.md` |
| 007 | DONE | 2026-03-07 | Add observability correlation and telemetry | `spec/tasks/007_add_observability_correlation.md` |
| 008 | DONE | 2026-03-07 | Add SigNoz to Telegram alerting configuration | `spec/tasks/008_add_signoz_telegram_alerting.md` |
| 009 | DONE | 2026-03-07 | Add local infrastructure stack | `spec/tasks/009_add_local_infra_stack.md` |
| 010 | DONE | 2026-03-08 | Migrate to YAML-only configuration loading | `spec/tasks/010_migrate_to_yaml_only_configuration.md` |
| 011 | DONE | 2026-03-08 | Migrate Redis repository client to aioredis | `spec/tasks/011_migrate_redis_repository_to_aioredis.md` |
| 012 | DONE | 2026-03-08 | Remove observability and SigNoz stack for phase 1 | `spec/tasks/012_remove_observability_and_signoz_for_phase1.md` |
| 013 | DONE | 2026-03-09 | Add configurable provider price multiplier in domain logic | `spec/tasks/013_add_configurable_provider_price_multiplier.md` |
| 014 | DONE | 2026-03-12 | Add minimal structlog JSON logging | `spec/tasks/014_add_minimal_structlog_json_logging.md` |
| 015 | DONE | 2026-03-13 | Add minimal OpenTelemetry tracing | `spec/tasks/015_add_minimal_opentelemetry_tracing.md` |
| 016 | DONE | 2026-03-14 | Add CDEK order registration adapter | `spec/tasks/016_add_cdek_order_registration_adapter.md` |
| 017 | DONE | 2026-03-14 | Return all tariffs from CDEK price calculation | `spec/tasks/017_return_all_cdek_tariffs.md` |
| 018 | DONE | 2026-03-16 | Add optional parcel type filter to price request | `spec/tasks/018_add_optional_parcel_type_filter_to_price_request.md` |
| 019 | DONE | 2026-03-14 | Add CDEK order creation endpoint | `spec/tasks/019_add_cdek_order_creation_endpoint.md` |
| 020 | DONE | 2026-03-21 | Rename price request model and use cities_map for CDEK codes | `spec/tasks/020_rename_price_request_model_and_use_cities_map_for_cdek_codes.md` |
| 021 | DONE | 2026-03-25 | Add address suggestion adapter and country provider mapping | `spec/tasks/021_add_address_suggestion_adapter_and_country_mapping.md` |
| 022 | DONE | 2026-03-25 | Add address suggestion endpoint | `spec/tasks/022_add_address_suggestion_endpoint.md` |
| 023 | DONE | 2026-03-29 | Add Yandex Geosuggest address suggestion adapter and CIS routing | `spec/tasks/023_add_yandex_geosuggest_address_suggestion_adapter.md` |
| 024 | DONE | 2026-03-29 | Add TomTom address suggestion adapter and Europe routing | `spec/tasks/024_add_tomtom_address_suggestion_adapter.md` |
| 025 | DONE | 2026-04-03 | Remove company from Create Delivery Order parties | `spec/tasks/025_remove_company_from_create_delivery_order.md` |
| 026 | DONE | 2026-04-05 | Align CDEK order contract with single phone and kilogram package weight | `spec/tasks/026_align_cdek_order_contract_single_phone_and_weight_units.md` |
| 027 | DONE | 2026-04-11 | Add TBank payment adapter, init_payment endpoint and rename order flow | `spec/tasks/027_add_tbank_payment_adapter_and_order_payment_link.md` |
| 028 | DONE | 2026-04-12 | Add PostgreSQL adapter, order repository and persist order after payment link creation | `spec/tasks/028_add_postgresql_order_persistence.md` |
| 029 | DONE | 2026-04-18 | Add TBank payment notification and success URLs | `spec/tasks/029_add_tbank_payment_urls.md` |
| 030 | DONE | 2026-04-18 | Add TBank payment notification webhook and CDEK order creation | `spec/tasks/030_add_tbank_payment_notification_webhook.md` |
| 031 | TODO | 2026-04-18 | Validate init-payment price with CDEK tariff | `spec/tasks/031_validate_init_payment_price_with_cdek.md` |
| 032 | TODO | 2026-05-13 | Rework init-payment contract to camelCase and structured address/contact | `spec/tasks/032_rework_init_payment_contract_camelcase.md` |
| 033 | DONE | 2026-05-23 | Add CDEK waybill polling worker | `spec/tasks/033_add_waybill_polling_worker.md` |
| 034 | DONE | 2026-05-23 | Add CDEK waybill e-mail sender worker | `spec/tasks/034_add_waybill_email_sender_worker.md` |
## Summary
- Total: **35**
- TODO: **2**
- DONE: **33**
+1 -1
View File
@@ -1,4 +1,4 @@
# overview.md
# spec/overview.md
Контекст, специфичный для проекта агрегатора служб доставки.
Глобальные правила определены в AGENTS.md.
+34
View File
@@ -0,0 +1,34 @@
---
id: 000
title: Task format reference
status: DONE
created: 2026-02-01
---
## Context
В репозитории хранится эталонная задача, которая показывает обязательный формат задач.
## Goal
Дать каноничный пример минимального формата задачи, обязательного для всех файлов в `spec/tasks/`.
## Constraints
- Содержать только требования к формату, без продуктовой реализации.
- Оставаться совместимой с обязательной структурой задач Planner.
- Не добавлять требований к реализации.
## Acceptance criteria
- Front matter содержит `id`, `title`, `status`, `created`.
- Все обязательные секции присутствуют в требуемом порядке.
- Содержимое остаётся эталонной, неисполняемой задачей.
## Definition of Done
- [x] Front matter использует обязательные поля.
- [x] Обязательные секции присутствуют.
- [x] Файл пригоден только как reference формата.
## Tests
- Runtime-тесты не требуются.
- Формат валидируется успешной генерацией spec index.
## Commands
- `python3 spec/gen_spec_index.py --check`
@@ -0,0 +1,38 @@
---
id: 001
title: Create app skeleton and component configuration
status: DONE
created: 2026-03-07
---
## Context
Сейчас в репозитории есть только specification-файлы, при этом `spec/overview.md` задаёт конкретную структуру приложения и требования к конфигурации компонентов.
## Goal
Создать начальный `app/` skeleton и модели конфигурации из project overview, включая отдельные секции конфигурации для Controller, Service, Business Logic, Repository, Adapter, Observability и Alerts.
## Constraints
- Соблюдать layered architecture из `AGENTS.md`.
- Scope задачи: только scaffolding и configuration, без business workflows.
- Не реализовывать provider HTTP calls, cache behavior или aggregation logic в рамках этой задачи.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Структура `app/` создана согласно `spec/overview.md`.
- `app/config.py` содержит отдельные component configuration sections. Config must be a .yaml-file added to .gitignore.
- FastAPI entrypoint существует и успешно импортирует configuration.
- В файлах Controller, Service, Repository и Adapter отсутствуют business rules.
## Definition of Done
- [ ] Созданы обязательные директории `app/` и базовые файлы.
- [ ] Реализованы раздельные секции конфигурации по компонентам.
- [ ] Проходит app import smoke test.
- [ ] Изменения остаются строго в scope задачи.
## Tests
- Добавить/import smoke test для FastAPI app startup.
- Добавить unit tests, проверяющие загрузку configuration sections из environment variables.
## Commands
- `poetry run pytest tests/smoke/test_app_import.py -q`
- `poetry run pytest tests/config/test_config_sections.py -q`
@@ -0,0 +1,37 @@
---
id: 002
title: Implement pure domain price rules
status: DONE
created: 2026-03-07
---
## Context
`spec/overview.md` требует business rules для фильтрации тарифов, сравнения цен, сортировки и нормализации входных данных в чистом слое Business Logic.
## Goal
Реализовать детерминированные, dependency-free domain functions в `app/domain/price.py` для нормализации запроса и фильтрации/упорядочивания котировок.
## Constraints
- Business Logic должна быть pure и dependency-free.
- Запрещены прямые IO, adapters, repositories, framework imports и внешние вызовы.
- Слои Service и Controller не должны забирать domain rules из этой задачи.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Domain functions нормализуют значения запроса по явным детерминированным правилам.
- Domain functions отфильтровывают невалидные котировки по определённым domain conditions.
- Domain functions сортируют валидные котировки по возрастанию цены.
- Логика реализована только в `app/domain/` и доступна для вызова из Service.
## Definition of Done
- [ ] Реализованы pure domain functions для normalization/filtering/sorting.
- [ ] Поведение функций детерминированное и без side effects.
- [ ] В domain module нет импортов не-domain зависимостей.
- [ ] Unit tests (без mocks) покрывают стандартные и edge cases.
## Tests
- Добавить `tests/domain/test_price.py` только с unit tests.
- Покрыть edge cases: пустой input, невалидные price, одинаковые цены, границы нормализации.
## Commands
- `poetry run pytest tests/domain/test_price.py -q`
@@ -0,0 +1,43 @@
---
id: 003
title: Add CDEK provider adapter
status: DONE
created: 2026-03-07
---
## Context
Phase 1 в `spec/overview.md` требует поддержку provider CDEK с OAuth2 authentication и маппингом расчёта тарифа.
## Goal
Реализовать adapter interfaces и модули CDEK adapter: provider contract, OAuth2 token handling, HTTP client behavior, преобразование ответа в `DeliveryPrice` и чтение параметров CDEK из секции `adapter` YAML-конфига.
## Constraints
- Только Adapter layer; без business decisions и sorting logic.
- Внешний IO должен оставаться внутри adapter modules.
- Timeout для CDEK должен быть 10 секунд согласно specification.
- Параметры CDEK (`base_url`, OAuth2 credentials, retry policy, timeout, cache TTL) должны приходить из секции `adapter` в `.yaml` конфиге.
- Запрещён хардкод credentials и runtime-параметров адаптера.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Существует интерфейс `DeliveryProvider` со стабильным контрактом `get_price()`.
- Модуль CDEK auth получает OAuth2 token через client credentials и переиспользует валидный token до истечения.
- CDEK client выполняет tariff request с timeout и retry behavior.
- CDEK mapper преобразует provider response в унифицированную schema `DeliveryPrice`.
- Adapter наружу предоставляет только provider-facing API, необходимый Service.
- Адаптер использует значения из секции `adapter` YAML-конфига, включая `timeout_seconds=10` и `cache_ttl_seconds=900` по умолчанию.
## Definition of Done
- [ ] Реализован base provider interface.
- [ ] Реализованы модули CDEK auth/client/mapper.
- [ ] CDEK adapter возвращает унифицированную price model при успешном ответе.
- [ ] Adapter tests покрывают auth refresh, timeout/retry и mapping cases.
## Tests
- Добавить adapter unit tests для token lifecycle behavior.
- Добавить adapter tests для response mapping и HTTP error handling.
- Добавить tests, проверяющие применение параметров CDEK из секции `adapter` YAML-конфига.
- Для внешних HTTP взаимодействий использовать stubs/mocks.
## Commands
- `poetry run pytest tests/adapters/delivery_providers/cdek -q`
@@ -0,0 +1,40 @@
---
id: 004
title: Add Redis price cache repository
status: DONE
created: 2026-03-07
---
## Context
Product requirements требуют кеширование ответов provider с configurable TTL, а архитектура закрепляет доступ к данным за слоем Repository.
## Goal
Реализовать `PriceCache` repository на Redis с операциями `get`, `set` и `invalidate`, включая поддержку TTL из секции `repository` YAML-конфига.
## Constraints
- Только Repository layer: data access и persistence mapping.
- В repository code не допускаются business decisions и workflow orchestration.
- Генерация cache key не должна реализовываться в repository.
- Параметры Redis и TTL должны читаться из секции `repository` в `.yaml` конфиге.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Repository module существует по пути `app/repositories/cache/redis_cache.py`.
- `PriceCache` предоставляет async методы `get`, `set`, `invalidate`.
- `set` применяет TTL из configuration.
- Serialization/deserialization cached payload выполняются детерминированно.
- Repository использует только параметры секции `repository` YAML-конфига для подключения к Redis и настройки TTL.
## Definition of Done
- [ ] Реализован Redis-backed cache repository.
- [ ] Интерфейс repository соответствует требуемым операциям.
- [ ] Поведение TTL покрыто тестами.
- [ ] В repository отсутствует business logic.
## Tests
- Добавить repository tests для cache hit/miss, set/get roundtrip, invalidate и TTL expiration.
- Добавить tests, проверяющие чтение TTL и Redis connection settings из секции `repository` YAML-конфига.
- Использовать Redis test double или изолированный test Redis instance.
## Commands
- `poetry run pytest tests/repositories/cache/test_redis_cache.py -q`
@@ -0,0 +1,39 @@
---
id: 005
title: Implement aggregator service workflow
status: DONE
created: 2026-03-07
---
## Context
`spec/overview.md` определяет `AggregatorService.get_all_prices()` как application orchestrator для параллельных provider calls, graceful degradation, caching и отсортированного унифицированного результата.
## Goal
Реализовать `AggregatorService.get_all_prices(request: DeliveryRequest) -> list[DeliveryPrice]` в `app/services/aggregator.py` с параллельным выполнением, cache coordination и делегированием business rules в domain functions.
## Constraints
- Service выполняет только orchestration; pure business rules остаются в Business Logic.
- Использовать dependency injection для providers и repository.
- Для вызовов providers использовать `asyncio.gather(..., return_exceptions=True)`.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Service вызывает все зарегистрированные providers параллельно.
- Failures/timeouts отдельных providers не валят весь запрос; упавшие providers исключаются.
- Cache проверяется до внешнего provider request и обновляется после успешного provider response.
- Фильтрация/сортировка котировок делегируется domain functions.
- Возвращаемый список котировок унифицирован и отсортирован по цене по возрастанию.
## Definition of Done
- [ ] Метод Service реализован с injected dependencies.
- [ ] Параллельная orchestration использует `return_exceptions=True`.
- [ ] Реализована cache coordination для provider responses.
- [ ] Service tests покрывают full-success, partial-failure, all-failure и cache-hit сценарии.
## Tests
- Добавить service tests со stubs/mocks для adapters и repository.
- Проверить orchestration и исключение неуспешных providers.
- Проверить делегирование sorting/filtering в domain logic.
## Commands
- `poetry run pytest tests/services/test_aggregator.py -q`
@@ -0,0 +1,37 @@
---
id: 006
title: Add delivery price controller endpoint
status: DONE
created: 2026-03-07
---
## Context
API contract требует endpoint `POST /api/v1/delivery/price` с DTO validation и HTTP error mapping, при этом Controller должен вызывать ровно один метод Service.
## Goal
Реализовать request/response schemas и endpoint в `app/controllers/v1/delivery.py`, который валидирует входные данные, делегирует в `AggregatorService.get_all_prices()` и маппит service exceptions в HTTP responses.
## Constraints
- Controller не должен содержать business logic или provider-specific branching.
- На каждый запрос Controller должен вызывать ровно один метод Service.
- Validation должна использовать Pydantic models из schema layer.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- `POST /api/v1/delivery/price` принимает payload `DeliveryRequest` и возвращает `list[DeliveryPrice]`.
- Невалидный input возвращает validation error response.
- Controller делегирует обработку в `AggregatorService.get_all_prices()`.
- Service exceptions маппятся в детерминированные HTTP responses.
## Definition of Done
- [ ] Реализованы request и response schemas.
- [ ] Controller endpoint подключён в FastAPI router.
- [ ] Path и HTTP method endpoint соответствуют specification.
- [ ] API tests покрывают успешный ответ, validation failure и mapped service error.
## Tests
- Добавить controller tests для поведения route и делегирования в service.
- Добавить API-level tests для валидации request schema и response schema.
## Commands
- `poetry run pytest tests/controllers/v1/test_delivery.py -q`
@@ -0,0 +1,47 @@
---
id: 007
title: Add observability correlation and telemetry
status: DONE
created: 2026-03-07
---
## Context
Проект требует связать traces, metrics и logs через request correlation (`request_id` и `trace_id`) и добавить manual spans вокруг ключевых операций.
## Goal
Реализовать observability wiring: middleware для `request_id`, propagation structured logging context с корреляцией по `trace_id`, OpenTelemetry instrumentation для FastAPI/httpx, manual spans для операций service/provider/cache и метрики из `spec/overview.md`.
## Constraints
- Все observability concerns держать вне Business Logic.
- При добавлении telemetry не вводить business decision-making.
- Инструментировать только слои и операции, указанные в `spec/overview.md`.
- Конфигурация telemetry должна читаться из секции `observability` в `.yaml` конфиге.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Middleware назначает UUID `request_id` для каждого запроса.
- `request_id` добавляется в structured logs через contextvars.
- Логи коррелируются с трассировкой через `trace_id`.
- Включена OpenTelemetry instrumentation для FastAPI и httpx.
- Есть manual spans для `AggregatorService.get_all_prices`, provider `get_price` и cache operations.
- Span attributes включают `provider`, `from_city`, `to_city`, `weight_kg`, `cache_hit`, `tariffs_found` там, где применимо.
- Реализованы и экспортируются метрики: количество запросов, количество ошибок, latency (включая p50/p99), доступность провайдеров.
## Definition of Done
- [ ] Реализован middleware корреляции запросов.
- [ ] В logging context есть binding `request_id`.
- [ ] Реализована автоматическая и manual tracing instrumentation.
- [ ] Реализованы метрики, соответствующие overview.
- [ ] Observability tests проверяют создание spans, обязательные attributes и метрики.
## Tests
- Добавить middleware tests для генерации и propagation `request_id`.
- Добавить telemetry tests с in-memory span exporter для проверки names/attributes.
- Добавить logging tests, проверяющие наличие `request_id` в log context.
- Добавить tests для проверки экспорта метрик (request count, error count, latency, provider availability).
## Commands
- `poetry run pytest tests/controllers/test_middleware_request_id.py -q`
- `poetry run pytest tests/observability/test_tracing.py -q`
- `poetry run pytest tests/observability/test_logging_context.py -q`
- `poetry run pytest tests/observability/test_metrics.py -q`
@@ -0,0 +1,41 @@
---
id: 008
title: Add SigNoz to Telegram alerting configuration
status: DONE
created: 2026-03-07
---
## Context
`spec/overview.md` требует anomaly alerting через цепочку `SigNoz Alert Rules -> Webhook -> Telegram Bot API` с вынесением token в configuration.
## Goal
Добавить configuration и infrastructure artifacts, необходимые для маршрутизации anomaly alerts из SigNoz в Telegram и для соответствия alert thresholds требованиям проекта через секцию `alerts` в `.yaml` конфиге.
## Constraints
- Alert transport/configuration должны быть отделены от business logic.
- Telegram credentials должны приходить из секции `alerts` YAML-конфига и не быть hardcoded.
- Thresholds должны соответствовать alert conditions из `spec/overview.md`.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Configuration содержит отдельную секцию `alerts` в `.yaml` конфиге с Telegram settings.
- Infrastructure artifact(s) описывают routing от SigNoz alert webhook к Telegram Bot API.
- Представлены alert conditions для:
- provider 5xx: 5 ошибок за 5 минут
- provider p99 latency > 5000ms в течение 10 минут
- provider unavailable более 5 минут
- Setup instructions содержат пример секции `alerts` YAML-конфига и шаги верификации.
## Definition of Done
- [ ] Реализована schema конфигурации Alerts.
- [ ] Добавлены artifact(s) для маршрутизации SigNoz-to-Telegram.
- [ ] Определены все три обязательных anomaly conditions.
- [ ] Инструкции валидации исполнимы в local environment.
## Tests
- Добавить config tests для проверки загрузки alert settings из секции `alerts` YAML-конфига и обязательных полей.
- Добавить tests для alert payload transformation/transport logic, если он реализован в коде.
## Commands
- `poetry run pytest tests/config/test_alerts_config.py -q`
- `docker compose config`
+39
View File
@@ -0,0 +1,39 @@
---
id: 009
title: Add local infrastructure stack
status: DONE
created: 2026-03-07
---
## Context
Overview задаёт локальные infrastructure components `app`, `redis` и `signoz`, необходимые для runtime и observability.
## Goal
Добавить Docker Compose infrastructure definitions для local development, включая application service, Redis cache и SigNoz observability backend.
## Constraints
- Scope задачи: только infrastructure wiring.
- Не переносить business logic в infrastructure scripts.
- Имена services должны соответствовать overview: `app`, `redis`, `signoz`.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Compose file определяет services `app`, `redis`, `signoz`.
- Service `app` содержит environment wiring для cache и observability endpoints.
- Service `redis` достижим из `app` во внутренней сети.
- Service `signoz` достижим для telemetry export при локальном запуске.
## Definition of Done
- [ ] Добавлен compose definition с обязательными services.
- [ ] Задокументированы environment variables для локальной интеграции.
- [ ] `docker compose config` проходит валидацию.
- [ ] Добавлены базовые startup instructions.
## Tests
- Добавить/обновить infrastructure checks для валидации compose syntax.
- Добавить smoke sequence команд для проверки локального запуска.
## Commands
- `docker compose config`
- `docker compose up -d redis signoz`
- `docker compose ps`
@@ -0,0 +1,39 @@
---
id: 010
title: Migrate to YAML-only configuration loading
status: DONE
created: 2026-03-08
---
## Context
В проекте используется смешанный подход конфигурации с переменными окружения. Требуется унифицировать источник конфигурации и оставить только `config.yaml`.
## Goal
Убрать использование переменных окружения из runtime-конфигурации приложения и перевести загрузку всех параметров на чтение из `config.yaml`.
## Constraints
- Соблюдать layered architecture из `AGENTS.md`.
- Scope задачи: только механизм загрузки конфигурации и места её потребления.
- Запрещено менять бизнес-правила и поведение use-case, не связанное с конфигурацией.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Runtime-конфигурация приложения загружается только из `config.yaml`.
- Переменные окружения не используются для чтения параметров Controller, Service, Business Logic, Repository, Adapter, Observability и Alerts.
- При отсутствии обязательных полей в `config.yaml` приложение завершает запуск с детерминированной ошибкой валидации конфигурации.
- Тесты конфигурации больше не опираются на `monkeypatch` переменных окружения и проверяют чтение из YAML.
## Definition of Done
- [ ] Удалено чтение переменных окружения из конфигурационного слоя.
- [ ] Все компонентные секции конфигурации продолжают читаться из `config.yaml`.
- [ ] Обновлены тесты конфигурации под YAML-only поведение.
- [ ] Smoke test старта приложения проходит с валидным `config.yaml`.
## Tests
- Обновить `tests/config/test_config_sections.py` под проверку YAML-only загрузки.
- Добавить/обновить негативные тесты отсутствующих обязательных YAML-полей.
- Проверить app startup smoke test с валидным `config.yaml`.
## Commands
- `poetry run pytest tests/config/test_config_sections.py -q`
- `poetry run pytest tests/smoke/test_app_import.py -q`
@@ -0,0 +1,41 @@
---
id: 011
title: Migrate Redis repository client to aioredis
status: DONE
created: 2026-03-08
---
## Context
Текущая реализация кеш-репозитория использует клиент `redis`. Требуется перейти на `aioredis` для асинхронного доступа к Redis без изменения бизнес-поведения приложения.
## Goal
Перевести слой Repository (`PriceCache`) и связанные точки инициализации/конфигурации на `aioredis`, сохранив существующий контракт репозитория и runtime-поведение кеша.
## Constraints
- Изменения только в слоях Repository и инициализации зависимостей, необходимых для подключения клиента.
- Не изменять бизнес-правила, Controller/API-контракты и оркестрацию Service.
- SQL/внешний IO вне Repository и Adapter не добавлять.
- Публичный интерфейс `PriceCache` (`get`, `set`, `invalidate`) должен остаться совместимым.
- Не изменять файлы в `spec/` кроме этой задачи и сгенерированного `spec/index.md`.
## Acceptance criteria
- В кодовой базе для Redis repository используется `aioredis` вместо `redis`.
- Инициализация Redis-клиента и операции `get/set/invalidate` работают асинхронно через `aioredis`.
- Поведение TTL и сериализации кеша не изменено относительно текущего контракта.
- Конфигурация подключения к Redis продолжает читаться из секции `repository` YAML-конфига.
- Ошибки подключения/операций Redis обрабатываются детерминированно в рамках текущего repository-контракта.
## Definition of Done
- [ ] Обновлены зависимости проекта для использования `aioredis`.
- [ ] Репозиторий кеша переведен на `aioredis` без изменения публичного интерфейса.
- [ ] Обновлены/добавлены тесты репозитория для нового клиента.
- [ ] Все релевантные тесты проходят.
## Tests
- Обновить `tests/repositories/cache/test_redis_cache.py` под использование `aioredis`.
- Добавить/обновить тесты на cache hit/miss, `set/get` roundtrip, `invalidate`, TTL expiration.
- Добавить негативный тест на ошибку Redis-клиента при операции чтения или записи.
## Commands
- `poetry run pytest tests/repositories/cache/test_redis_cache.py -q`
- `poetry run pytest tests/services/test_aggregator.py -q`
@@ -0,0 +1,47 @@
---
id: 012
title: Remove observability and SigNoz stack for phase 1
status: DONE
created: 2026-03-08
---
## Context
На первом этапе принято решение отказаться от логирования, мониторинга, алертинга и инфраструктуры SigNoz. Текущая кодовая база и тесты содержат эти зависимости и сценарии.
## Goal
Удалить из проекта все runtime- и test-артефакты, связанные с логами, мониторингом, алертами, Telegram alerting и SigNoz, сохранив рабочий API расчета доставки и кеширование.
## Constraints
- Соблюдать layered architecture из `AGENTS.md`; не переносить бизнес-правила между слоями.
- Scope задачи ограничен удалением observability/alerting/SigNoz и зависимых конфигураций, инфраструктурных и тестовых артефактов.
- Не изменять бизнес-логику расчета тарифов, provider integration и API-контракт `POST /api/v1/delivery/price`.
- Не добавлять новую систему мониторинга или алертинга в рамках этой задачи.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Из runtime-приложения удалены middleware, instrumentation и иные механизмы, реализующие request/log correlation, tracing, metrics и alerting.
- В коде и конфигурации приложения отсутствуют секции и параметры `observability` и `alerts`, связанные с OpenTelemetry, structlog, SigNoz и Telegram.
- Из инфраструктурных файлов удалены сервис/настройки SigNoz и маршрутизация алертов.
- Удалены или обновлены тесты observability/alerts; тестовый набор для контроллера, сервиса, репозитория и конфигурации остается зеленым.
- По файлам приложения и инфраструктуры нет упоминаний `signoz`, `opentelemetry`, `structlog`, `telegram`.
## Definition of Done
- [ ] Удалены runtime-компоненты observability/alerting.
- [ ] Обновлены YAML-конфиги и config schemas без секций observability/alerts.
- [ ] Обновлены инфраструктурные файлы без SigNoz.
- [ ] Удалены/обновлены тесты observability и alerts.
- [ ] Пройдены все команды из раздела Commands.
## Tests
- Обновить `tests/config/test_config_sections.py` под конфигурацию без observability/alerts.
- Удалить или заменить `tests/config/test_alerts_config.py` и `tests/observability/*` в соответствии с новым scope.
- Обновить `tests/smoke/test_local_infra_stack.py` под инфраструктуру без SigNoz.
- Подтвердить, что `tests/controllers/v1/test_delivery.py`, `tests/services/test_aggregator.py`, `tests/repositories/cache/test_redis_cache.py` проходят без observability-зависимостей.
## Commands
- `poetry run pytest tests/controllers/v1/test_delivery.py -q`
- `poetry run pytest tests/services/test_aggregator.py -q`
- `poetry run pytest tests/repositories/cache/test_redis_cache.py -q`
- `poetry run pytest tests/config/test_config_sections.py -q`
- `poetry run pytest tests/smoke/test_local_infra_stack.py -q`
- `! rg -n "(signoz|opentelemetry|structlog|telegram)" app tests docker-compose.yml pyproject.toml config.yaml config.example.yaml config.test.yaml infra`
@@ -0,0 +1,47 @@
---
id: 013
title: Add configurable provider price multiplier in domain logic
status: DONE
created: 2026-03-09
---
## Context
Сейчас `app/domain/price.py` валидирует и сортирует тарифы провайдеров без дополнительной постобработки цены. Появилось новое требование: перед возвратом результата применять к цене провайдера конфигурируемый multiplier из YAML-конфига и округлять итог до целого числа.
## Goal
Добавить в Business Logic детерминированное правило пересчёта `DeliveryPrice.price` через multiplier из секции `business_logic` и провести это значение через конфигурацию и service orchestration без переноса формулы в Service или Adapter.
## Constraints
- Соблюдать layered architecture из `AGENTS.md`.
- Формула умножения и округления должна жить только в `app/domain/`; Service может только передавать нужный параметр и делегировать вызов.
- Значение multiplier должно читаться из YAML-конфига через секцию `business_logic`.
- После применения multiplier цена должна оставаться `Decimal`, но иметь целочисленное значение.
- Правило округления должно быть явным и детерминированным: до `Decimal("1")` c `ROUND_HALF_UP`.
- Не изменять provider adapters, внешние HTTP-контракты и формат ответа кроме нового значения `price`.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- В `business_logic` configuration section добавлено обязательное поле для multiplier цены провайдера с валидацией положительного значения.
- Domain functions применяют multiplier к каждой валидной цене провайдера до сортировки результата.
- Итоговая цена после умножения округляется до целого значения по правилу `ROUND_HALF_UP`.
- Service не содержит формулу расчёта и по-прежнему делегирует обработку цен в domain logic.
- Поведение одинаково для свежих ответов провайдера и для cache hit: в обоих случаях наружу возвращается уже пересчитанная цена.
## Definition of Done
- [ ] Обновлена конфигурация `business_logic` и YAML fixtures/example configs для multiplier.
- [ ] Реализована pure domain logic для пересчёта и округления цены.
- [ ] Service wiring передаёт multiplier в domain logic без добавления business rules в Service.
- [ ] Unit/service/config tests покрывают стандартные и edge-case сценарии.
## Tests
- Обновить `tests/domain/test_price.py` для проверки multiplier, округления `ROUND_HALF_UP`, пустого input и невалидных цен.
- Обновить `tests/services/test_aggregator.py` для проверки делегирования multiplier и одинакового поведения fresh/cache hit.
- Обновить `tests/config/test_config_sections.py` для проверки чтения multiplier из YAML и валидации невалидных значений.
- При необходимости обновить `tests/smoke/test_app_import.py` под обязательное новое поле конфигурации.
## Commands
- `poetry run pytest tests/domain/test_price.py -q`
- `poetry run pytest tests/services/test_aggregator.py -q`
- `poetry run pytest tests/config/test_config_sections.py -q`
- `poetry run pytest tests/smoke/test_app_import.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -0,0 +1,44 @@
---
id: 014
title: Add minimal structlog JSON logging
status: DONE
created: 2026-03-12
---
## Context
После удаления observability stack приложение осталось без централизованной конфигурации логирования. При этом в runtime-коде есть локальная настройка `logging.basicConfig(...)`, а новое требование состоит в том, чтобы все runtime-логи приложения выводились в JSON без возврата tracing, metrics и alerting.
## Goal
Подключить минимальную централизованную настройку `structlog`, чтобы runtime-логи приложения и Uvicorn выводились как JSON-объекты, а прикладные модули использовали общий logging setup вместо локальной конфигурации.
## Constraints
- Соблюдать layered architecture из `AGENTS.md`.
- Scope задачи: только logging wiring, JSON formatting и интеграция существующих logger calls.
- `structlog` использовать только для JSON logging; не добавлять `request_id`, `trace_id`, OpenTelemetry, metrics, SigNoz, Telegram и иные observability features.
- Конфигурация логирования должна быть централизована на уровне app startup или отдельного runtime-модуля; в Controller, Service, Repository, Adapter и Business Logic запрещено вызывать `logging.basicConfig(...)`.
- Не изменять API contract, business rules, provider protocol, cache behavior и логику обработки ошибок.
- используй context7, чтобы узнать контракт текущей версии пакета structlog
## Acceptance criteria
- При старте приложения выполняется единая инициализация logging на базе `structlog`.
- Логи приложения и `uvicorn.error`/`uvicorn.access` сериализуются в JSON, одна запись на строку.
- Каждая лог-запись содержит как минимум поля `event`, `level`, `timestamp` и `logger`.
- В прикладных модулях отсутствуют локальные вызовы `logging.basicConfig(...)` и текстовые formatter-конфигурации для runtime logging.
- Добавлен тест, который валидирует emitted log line как корректный JSON и проверяет обязательные поля.
## Definition of Done
- [ ] Добавлена централизованная конфигурация JSON logging на базе `structlog`.
- [ ] Удалены локальные настройки logging из прикладных модулей.
- [ ] `uvicorn.error` и `uvicorn.access` подключены к тому же JSON logging setup.
- [ ] Добавлены или обновлены тесты для logging bootstrap и JSON serialization.
- [ ] Пройдены все команды из раздела Commands.
## Tests
- Добавить `tests/logging/test_json_logging.py` для проверки JSON serialization и обязательных полей лог-записи.
- Обновить `tests/smoke/test_app_import.py` для проверки вызова централизованного logging bootstrap при создании app.
- При изменении конфигурации Uvicorn loggers добавить тест на `uvicorn.error` и `uvicorn.access` без запуска реального сервера.
## Commands
- `poetry run pytest tests/logging/test_json_logging.py -q`
- `poetry run pytest tests/smoke/test_app_import.py -q`
- `poetry run pytest tests/adapters/delivery_providers/cdek/test_client.py -q`
@@ -0,0 +1,50 @@
---
id: 015
title: Add minimal OpenTelemetry tracing
status: DONE
created: 2026-03-13
---
## Context
После удаления observability stack в задаче `012` runtime-приложение осталось без трассировки. Появилось новое требование: вернуть только минимальный tracing для входящих HTTP-запросов и исходящих вызовов провайдера/Redis, без возврата metrics, alerting и trace/log correlation.
## Goal
Подключить минимальный OpenTelemetry tracing через централизованный runtime bootstrap: настроить `TracerProvider` и OTLP exporter из YAML-конфига, добавить instrumentation для FastAPI, httpx и Redis и встроить этот bootstrap в startup приложения без переноса telemetry concern в Business Logic.
## Constraints
- Соблюдать layered architecture из `AGENTS.md`.
- Scope задачи: только tracing bootstrap, YAML configuration, instrumentation wiring и тесты на это поведение.
- Не добавлять metrics, request correlation middleware, `request_id`, `trace_id` в логи, SigNoz, Telegram alerting и иные observability features кроме tracing.
- Не добавлять business rules, branching или data access в runtime tracing bootstrap.
- Инициализация OpenTelemetry должна жить в отдельном runtime-модуле или app startup; Controller, Service, Repository, Adapter и Business Logic не должны вручную создавать exporter/provider.
- Конфигурация tracing должна читаться из отдельной секции `observability` в YAML-файлах конфигурации.
- Bootstrap tracing должен быть идемпотентным: повторные вызовы `create_app()` в тестах не должны дублировать global instrumentation и span processors.
- Не изменять API contract, логику расчета тарифов, кэш semantics и provider protocol.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- В конфигурации приложения добавлена секция `observability` с параметрами включения tracing, `service_name`, OTLP endpoint и флагом insecure transport.
- При `observability.enabled = true` приложение на старте создает `TracerProvider` с `Resource(service.name=...)` и OTLP exporter, используя значения из YAML-конфига.
- При `observability.enabled = true` централизованно включается instrumentation для FastAPI, httpx и Redis.
- При `observability.enabled = false` приложение стартует без инициализации exporter/provider и без регистрации instrumentation.
- Повторное создание приложения или повторный вызов tracing bootstrap не приводит к duplicate instrumentation/span processor registration.
- Реализация не восстанавливает metrics, alerts, request/log correlation и не меняет JSON logging contract.
## Definition of Done
- [ ] Добавлена секция `observability` в config schema и runtime YAML-файлы.
- [ ] Реализован централизованный runtime bootstrap для OpenTelemetry tracing.
- [ ] `create_app()` подключает tracing bootstrap без бизнес-логики и без дублирования инициализации.
- [ ] Добавлены tests на конфигурацию, enabled/disabled режимы и идемпотентность tracing setup.
- [ ] Добавлен tracing test, подтверждающий создание spans для FastAPI/httpx/Redis instrumentation или корректный wiring этих instrumentors.
## Tests
- Обновить `tests/config/test_config_sections.py` для проверки секции `observability`, обязательных полей и disabled/enabled конфигурации.
- Обновить `tests/smoke/test_app_import.py` для проверки вызова централизованного tracing bootstrap при создании app.
- Добавить `tests/tracing/test_bootstrap.py` для проверки OTLP exporter/provider setup, `service.name`, enabled/disabled режимов и идемпотентности instrumentation.
- Добавить tracing test с in-memory exporter или эквивалентным deterministic harness для проверки, что FastAPI/httpx/Redis instrumentation корректно подключается без запуска внешнего OTLP collector.
## Commands
- `poetry run pytest tests/config/test_config_sections.py -q`
- `poetry run pytest tests/smoke/test_app_import.py -q`
- `poetry run pytest tests/tracing/test_bootstrap.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -0,0 +1,43 @@
---
id: 016
title: Add CDEK order registration adapter
status: DONE
created: 2026-03-14
---
## Context
Сейчас CDEK adapter поддерживает только расчёт тарифа. Новый сценарий требует регистрацию заказа в CDEK по контракту из `http-client.http` в разделе `Регистрация заказа (тип "доставка", до двери)`.
## Goal
Расширить существующий CDEK adapter регистрацией заказа: добавить в текущий adapter/provider сбор payload по контракту, вызов `POST /v2/orders`, детерминированную обработку provider errors и маппинг успешного ответа во внутреннюю response model.
## Constraints
- Изменения ограничены существующим Adapter layer и schema/mapper моделями, необходимыми для стабильного adapter contract.
- Использовать существующий OAuth2 flow CDEK; не дублировать auth logic.
- Контракт запроса должен соответствовать разделу `Регистрация заказа (тип "доставка", до двери)` из `http-client.http`.
- В scope этой задачи входят только значения `type=2` и `tariff_code=535`; не расширять поддержку на другие типы заказа и тарифы.
- Не добавлять новые controller/service модули, controller routing, service orchestration, cache behavior и business logic.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Существующий CDEK adapter/provider предоставляет стабильный метод регистрации заказа, принимающий валидированную internal model вместо raw dict.
- Adapter отправляет `POST /v2/orders` с bearer token и JSON payload, соответствующим контракту из `http-client.http`.
- Успешный ответ CDEK маппится во внутреннюю response model с `order_uuid`, полученным из `entity.uuid`.
- Provider validation errors класса 4xx маппятся в детерминированную provider request error, а transport/5xx ошибки — в adapter client error.
- Adapter tests покрывают success case, payload mapping, response mapping и error handling для order registration.
## Definition of Done
- [ ] Расширен существующий adapter method set для регистрации заказа CDEK.
- [ ] Реализованы request/response mapper(s) для order registration.
- [ ] Переиспользуется существующий OAuth2 auth client.
- [ ] Добавлены adapter tests для order registration сценария.
## Tests
- Добавить `tests/adapters/delivery_providers/cdek/test_order_client.py` для success/error сценариев регистрации заказа.
- Проверить, что payload содержит поля из контракта `http-client.http`.
- Проверить маппинг `entity.uuid` в внутреннюю response model.
- Для внешних HTTP взаимодействий использовать stubs/mocks.
## Commands
- `poetry run pytest tests/adapters/delivery_providers/cdek/test_order_client.py -q`
- `python3 spec/gen_spec_index.py --check`
+49
View File
@@ -0,0 +1,49 @@
---
id: 017
title: Return all tariffs from CDEK price calculation
status: DONE
created: 2026-03-14
---
## Context
Сейчас `POST /api/v1/delivery/price` для CDEK возвращает только один тариф, хотя продуктовый контракт требует возвращать унифицированный список тарифов. Текущая реализация adapter/service использует однокотировочный provider contract и CDEK mapper берёт только первый элемент `tariff_codes[0]`.
## Goal
Изменить price flow так, чтобы CDEK adapter возвращал все тарифы из успешного ответа CDEK, а service агрегировал и сортировал объединённый список тарифов без изменения архитектурных границ слоёв.
## Constraints
- Scope задачи ограничен расчётом стоимости доставки и CDEK tariff list response; не изменять order creation flow.
- Controller должен сохранить текущий публичный endpoint `POST /api/v1/delivery/price` и по-прежнему вызывать ровно один метод Service.
- Service остаётся orchestration layer: собирает тарифы от провайдеров, работает с cache и делегирует фильтрацию/сортировку в Business Logic.
- Pure business rules не переносить в Service или Adapter.
- Изменение должно затронуть provider contract так, чтобы один provider мог вернуть несколько тарифов в одном запросе.
- Scope задачи не включает добавление новых провайдеров, новых endpoint'ов и расширение response schema beyond текущего `DeliveryPrice`, если это не требуется для возврата всех тарифов.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Интерфейс `DeliveryProvider` поддерживает возврат списка тарифов для одного provider request.
- CDEK mapper преобразует все элементы `tariff_codes` из валидного ответа CDEK в список `DeliveryPrice`, а не только первый тариф.
- CDEK provider adapter возвращает service полный список тарифов, полученный из ответа CDEK.
- `AggregatorService.get_all_prices()` корректно обрабатывает список тарифов от каждого provider, объединяет их в единый список и передаёт его в domain filtering/sorting.
- Cache coordination в service поддерживает сохранение и чтение списка тарифов для provider request.
- `POST /api/v1/delivery/price` возвращает все тарифы CDEK в унифицированном формате, отсортированные по цене по возрастанию.
- Existing graceful degradation сохраняется: failure одного provider исключает только его тарифы и не ломает успешные результаты других providers.
## Definition of Done
- [ ] Обновлён provider contract для возврата списка тарифов.
- [ ] Обновлены CDEK mapper и adapter/client для многотарифного ответа.
- [ ] Обновлён `AggregatorService.get_all_prices()` для объединения и кеширования списков тарифов.
- [ ] Добавлены или обновлены tests для adapter, service и controller/API сценариев многотарифного ответа.
## Tests
- Обновить `tests/adapters/delivery_providers/cdek/test_mapper.py` для проверки mapping всех тарифов из `tariff_codes`.
- Обновить `tests/adapters/delivery_providers/cdek/test_client.py` для проверки, что provider возвращает список тарифов, а не один объект.
- Обновить `tests/services/test_aggregator.py` для flattening provider results, cache hit/miss со списком тарифов и partial failure сценариев.
- Обновить `tests/controllers/v1/test_delivery.py` для проверки, что API возвращает несколько тарифов CDEK в одном ответе.
## Commands
- `poetry run pytest tests/adapters/delivery_providers/cdek/test_mapper.py -q`
- `poetry run pytest tests/adapters/delivery_providers/cdek/test_client.py -q`
- `poetry run pytest tests/services/test_aggregator.py -q`
- `poetry run pytest tests/controllers/v1/test_delivery.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -0,0 +1,49 @@
---
id: 018
title: Add optional parcel type filter to price request
status: DONE
created: 2026-03-16
---
## Context
Сейчас `POST /api/v1/delivery/price` возвращает все доступные тарифы без возможности отфильтровать их по типу отправления. Новый пользовательский сценарий требует опциональный параметр `parcel_type`, который должен фильтровать уже агрегированный список тарифов для всех провайдеров.
## Goal
Расширить контракт `DeliveryRequest` опциональным полем `parcel_type` и добавить детерминированную фильтрацию тарифов в price flow по правилам `doc` и `parcel` без изменения публичного path endpoint.
## Constraints
- `parcel_type` является необязательным полем запроса `POST /api/v1/delivery/price`.
- Допустимые значения ограничены `doc` и `parcel`; другие значения должны отклоняться schema validation.
- Правило фильтрации является pure business rule и должно жить в `app/domain/`; Controller только валидирует DTO, Service только оркестрирует и передаёт параметр в domain logic.
- Фильтрация должна применяться к унифицированному списку тарифов для всех провайдеров и не должна требовать provider-specific branching или изменения provider transport contract.
- Для `doc` включаются только тарифы, у которых `service_name` содержит `документ` или `document` без учёта регистра.
- Для `parcel` возвращаются все остальные тарифы, не попавшие под правило `doc`.
- Если `parcel_type` не передан, поведение endpoint остаётся прежним: возвращаются все тарифы.
- Scope задачи не включает изменение order flow, добавление новых endpoint'ов, расширение набора значений `parcel_type` и иные изменения вне price flow.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- `DeliveryRequest` поддерживает опциональное поле `parcel_type`.
- `POST /api/v1/delivery/price` принимает запросы без `parcel_type` и возвращает полный список тарифов без дополнительной фильтрации.
- При `parcel_type=doc` ответ содержит только тарифы, у которых `service_name` содержит `документ` или `document` без учёта регистра.
- При `parcel_type=parcel` ответ содержит только тарифы, у которых `service_name` не содержит `документ` и `document` без учёта регистра.
- Невалидное значение `parcel_type` приводит к 422 response на уровне schema validation.
- Фильтрация работает одинаково для тарифов, полученных из provider responses и из cache.
## Definition of Done
- [ ] Обновлён контракт price request с опциональным `parcel_type`.
- [ ] Реализована pure domain logic для фильтрации тарифов по `parcel_type`.
- [ ] Service wiring передаёт `parcel_type` в domain logic без добавления business rules в Service.
- [ ] Controller/API tests покрывают сценарии `doc`, `parcel`, отсутствие параметра и невалидное значение.
- [ ] Поведение одинаково для fresh provider results и cache hit.
## Tests
- Обновить `tests/domain/test_price.py` для проверки case-insensitive фильтрации по `документ` и `document`, а также поведения без `parcel_type`.
- Обновить `tests/services/test_aggregator.py` для проверки делегирования `parcel_type` в domain logic и одинакового результата для fresh path и cache hit.
- Обновить `tests/controllers/v1/test_delivery.py` для проверки optional request field, 422 на невалидное значение и response filtering для `doc` и `parcel`.
## Commands
- `poetry run pytest tests/domain/test_price.py -q`
- `poetry run pytest tests/services/test_aggregator.py -q`
- `poetry run pytest tests/controllers/v1/test_delivery.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -0,0 +1,53 @@
---
id: 019
title: Add CDEK order creation endpoint
status: DONE
created: 2026-03-14
---
## Context
Сейчас order flow должен принимать уже готовые точные адреса в полях `from_location.address` и `to_location.address`. Для нового пользовательского сценария нужен отдельный endpoint `/order`, который создаёт заказ в CDEK по контракту из `http-client.http`, не выполняя address suggestion lookup внутри order flow.
## Goal
Добавить `POST /api/v1/delivery/order` в существующий controller и существующий service с request/response schemas и error mapping для регистрации заказа в CDEK через adapter contract из задачи `016`.
## Constraints
- Controller отвечает только за DTO validation, routing и mapping service exceptions в HTTP responses.
- Endpoint должен вызывать ровно один метод Service: `AggregatorService.create_order()`.
- Новый endpoint должен быть добавлен в существующий controller модуль `app/controllers/v1/delivery.py`; не создавать отдельный controller модуль.
- Логика создания заказа должна быть добавлена в существующий service модуль `app/services/aggregator.py`; не создавать отдельный service модуль.
- Service оркестрирует только вызов injected CDEK order adapter и не содержит business logic или provider HTTP-деталей.
- `from_location.address` и `to_location.address` считаются уже выбранными точными строками адреса; в рамках этой задачи запрещено добавлять address suggestion routing, внешние address lookup вызовы и нормализацию адреса.
- Контракт входного запроса должен соответствовать разделу `Регистрация заказа (тип "доставка", до двери)` из `http-client.http`.
- В scope задачи входят только значения `type=2` и `tariff_code=535`; не расширять поддержку на другие типы заказа и тарифы.
- Scope задачи не включает `POST /api/v1/delivery/suggest-address`, конфигурацию address suggestion providers, кеширование, агрегацию тарифов, расчёт стоимости, новые провайдеры и расширение order flow за пределы CDEK.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Существует endpoint `POST /api/v1/delivery/order`, принимающий payload по контракту из `http-client.http`.
- Реализованы request/response schemas `OrderCreateRequest` и `OrderCreateResponse` для создания заказа.
- Endpoint реализован в существующем controller `app/controllers/v1/delivery.py`.
- Controller делегирует обработку только в `AggregatorService.create_order()`.
- Service вызывает injected adapter для регистрации заказа и возвращает `OrderCreateResponse` с `provider` и `order_uuid`.
- Логика orchestration размещена в существующем service `app/services/aggregator.py`.
- Endpoint принимает значения `from_location.address` и `to_location.address` как opaque input strings и не выполняет address suggestion lookup перед вызовом adapter.
- Ошибки валидации входного payload возвращают 422, provider request errors маппятся в 400, недоступность CDEK и transport failures — в 503.
- API и service tests покрывают success case и основные failure scenarios.
## Definition of Done
- [ ] Добавлены order request/response schemas.
- [ ] Реализован метод `AggregatorService.create_order()` для оркестрации регистрации заказа.
- [ ] Реализован endpoint `POST /api/v1/delivery/order` в существующем controller.
- [ ] Добавлены service и controller/API tests для order creation flow.
## Tests
- Добавить `tests/services/test_order.py` для проверки вызова adapter и маппинга ошибок сервиса.
- Добавить `tests/controllers/v1/test_order.py` для success case, schema validation и HTTP mapping ошибок.
- При необходимости обновить `tests/smoke/test_app_import.py` для проверки подключения нового endpoint и service wiring без новых модулей.
- Использовать test doubles для adapter dependency.
## Commands
- `poetry run pytest tests/services/test_order.py -q`
- `poetry run pytest tests/controllers/v1/test_order.py -q`
- `poetry run pytest tests/smoke/test_app_import.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -0,0 +1,58 @@
---
id: 020
title: Rename price request model and use cities_map for CDEK codes
status: DONE
created: 2026-03-21
---
## Context
Текущий price flow принимает `DeliveryRequest` со строковыми `from_city` и `to_city`, а также использует внешний lookup города через `_resolve_city_code()` в CDEK client. Новый контракт API переходит на идентификаторы городов и локальный справочник `cities_map`, который хранит provider-specific данные города. Для этого изменения нужен только минимальный набор новых валидаций, необходимый для корректного price flow.
## Goal
Переименовать request model расчёта доставки в `DeliveryCalculationRequest`, перевести поля `from_city` и `to_city` на тип `int`, убрать `country_code` из price API и использовать `cities_map` как источник provider city codes для CDEK tariff request вместо `_resolve_city_code()`, добавив только минимально необходимые валидации для этого перехода.
## Constraints
- Scope задачи ограничен price calculation flow: request schema, controller/service wiring, domain normalization, cache key composition, provider contract и CDEK adapter/client.
- `POST /api/v1/delivery/order`, `OrderCreateRequest`, `OrderCreateResponse` и `country_code` внутри order payload не изменять.
- Controller не добавляет business rules; Service не реализует provider-specific lookup; pure validation и normalization остаются в Business Logic.
- Для расчёта CDEK использовать только локальный `cities_map`; внешний city lookup через CDEK API и `_resolve_city_code()` должен быть удалён из client.
- Не требовать полноты `cities_map`; отсутствие записи города или provider-specific данных должно обрабатываться детерминированно.
- Учитывать provider-specific структуру `cities_map`, но не реализовывать новых providers в рамках этой задачи.
- Новые проверки ограничить минимально необходимыми для работы контракта и CDEK lookup: тип city identifier во входной schema и наличие валидного `cdek.code` для используемого города.
- Не добавлять предварительную валидацию всего `cities_map`, проверку неиспользуемых provider sections, проверку `city_uuid`, `full_name`, `label`, `country` или иные дополнительные consistency-checks.
- Не добавлять новые range/business validations для city identifier сверх тех, что нужны для поиска города в `cities_map`.
- Не изменять файлы в `spec/`, кроме этой задачи, `spec/overview.md` и сгенерированного `spec/index.md`.
## Acceptance criteria
- Модель запроса расчёта переименована в `DeliveryCalculationRequest`; controller, service и provider contract для price flow используют новое имя.
- Поля `from_city` и `to_city` в price request принимают целочисленные идентификаторы города; поле `country_code` отсутствует в публичном API расчёта доставки.
- Domain normalization и service cache key больше не используют `country_code` и корректно работают с city identifiers.
- Новые валидации, добавленные этой задачей, ограничены:
- schema-level проверкой, что `from_city` и `to_city` передаются как `int`
- runtime-проверкой, что для конкретного city identifier, использованного в CDEK price flow, существует валидный `cdek.code`
- CDEK client строит payload `from_location.code` и `to_location.code` на основе `cities_map` и значений `cdek.code` для переданных city identifiers.
- Если city identifier отсутствует в `cities_map` или для него нет валидного `cdek.code`, CDEK price flow завершается детерминированной provider request error без внешнего city lookup.
- Метод `_resolve_city_code()` и связанный HTTP lookup `location/suggest/cities` удалены из CDEK client; price flow не обращается к CDEK API подсказок городов.
- Existing order creation flow и order schemas остаются без изменения.
## Definition of Done
- [ ] Обновлены request schema, controller/service/provider type hints и связанные импорты для нового имени `DeliveryCalculationRequest`.
- [ ] Удалён `country_code` из price calculation contract, normalization и cache key.
- [ ] Реализован lookup CDEK city codes через `cities_map` без внешнего city suggest API.
- [ ] Удалён `_resolve_city_code()` и обновлены adapter tests под новое поведение.
- [ ] Добавлены или обновлены tests только для минимально необходимой schema validation, service wiring, cache key и CDEK payload mapping/error scenarios.
## Tests
- Обновить `tests/domain/test_price.py` только для проверки, что normalizer корректно принимает и сохраняет city identifiers без `country_code`.
- Обновить `tests/services/test_aggregator.py` для проверки нового request model, отсутствия `country_code` в service flow и обновлённого cache key.
- Обновить `tests/controllers/v1/test_delivery.py` только для проверки API schema с `from_city`/`to_city` типа `int` и 422 на невалидные значения типа.
- Обновить `tests/adapters/delivery_providers/cdek/test_client.py` для проверки построения payload по `cities_map`, ошибок при отсутствии `cdek.code` или city entry и отсутствия city lookup request.
- При необходимости обновить `tests/smoke/test_app_import.py` только для совместимости нового request model с app wiring.
## Commands
- `poetry run pytest tests/domain/test_price.py -q`
- `poetry run pytest tests/services/test_aggregator.py -q`
- `poetry run pytest tests/controllers/v1/test_delivery.py -q`
- `poetry run pytest tests/adapters/delivery_providers/cdek/test_client.py -q`
- `poetry run pytest tests/smoke/test_app_import.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -0,0 +1,45 @@
---
id: 021
title: Add address suggestion adapter and country provider mapping
status: DONE
created: 2026-03-25
---
## Context
Перед созданием заказа клиенту нужно получить точное значение адреса для полей `from_location.address` и `to_location.address`. Для этого нужен отдельный address suggestion flow с внешним provider, а выбор provider должен определяться по `country_code` через YAML-конфиг.
## Goal
Добавить contract для address suggestion adapters, секцию конфигурации address suggestions с маппингом `country_code -> provider_id` и реализовать интеграцию с `dadata.ru` для стран, сопоставленных с provider id `dadata`.
## Constraints
- Изменения ограничены Adapter layer, config schema/loading и моделями, необходимыми для стабильного adapter contract.
- Внешний IO должен оставаться внутри address suggestion adapter modules.
- В scope задачи входит только интеграция с `dadata.ru`; concrete HTTP integration второго европейского provider не реализовывать в рамках этой задачи.
- Конфигурация должна читаться из `.yaml` и содержать отдельную секцию для address suggestion providers и country mapping.
- Adapter не должен содержать business decisions; он только отправляет запрос, маппит ответ и детерминированно обрабатывает provider/transport errors.
- Не изменять файлы в `spec/`.
- Поищи описание api dadata и следуй её описанию при составлении payload запроса
## Acceptance criteria
- Существует интерфейс `AddressSuggestionProvider` со стабильным контрактом `suggest(request: AddressSuggestRequest) -> list[AddressSuggestion]`.
- В YAML-конфиге добавлена секция address suggestions с настройками `dadata` и маппингом `country_code -> provider_id`.
- Реализован adapter `dadata`, который принимает internal request model и возвращает унифицированный список `AddressSuggestion`.
- Provider-specific поля ответа `dadata` не утекают за пределы adapter contract.
- Ошибки `dadata` класса 4xx маппятся в детерминированную provider request error, а transport/5xx ошибки — в adapter client error.
- Тесты покрывают загрузку конфигурации address suggestions, request/response mapping `dadata` и основные error scenarios.
## Definition of Done
- [ ] Добавлен base contract для address suggestion providers.
- [ ] Добавлена YAML-конфигурация address suggestions и country mapping.
- [ ] Реализован adapter `dadata` для address suggestions.
- [ ] Добавлены config и adapter tests для нового flow.
## Tests
- Обновить `tests/config/test_config_sections.py` для проверки секции address suggestions и маппинга стран на provider id.
- Добавить `tests/adapters/address_suggestions/dadata/test_client.py` для success/error сценариев и mapping.
- Проверить, что наружу возвращается только унифицированная model `AddressSuggestion`.
## Commands
- `poetry run pytest tests/config/test_config_sections.py -q`
- `poetry run pytest tests/adapters/address_suggestions/dadata/test_client.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -0,0 +1,49 @@
---
id: 022
title: Add address suggestion endpoint
status: DONE
created: 2026-03-25
---
## Context
Перед реализацией order flow клиенту нужен отдельный endpoint, который возвращает подсказки адреса и позволяет выбрать точное значение для `from_location.address` и `to_location.address`. Выбор provider должен происходить по `country_code` через конфигурационный маппинг стран.
## Goal
Добавить `POST /api/v1/delivery/suggest-address` в существующий controller и существующий service с request/response schemas, routing на address suggestion provider по `country_code` и детерминированным HTTP error mapping.
## Constraints
- Controller отвечает только за DTO validation, routing и mapping service exceptions в HTTP responses.
- Endpoint должен вызывать ровно один метод Service: `AggregatorService.suggest_addresses()`.
- Новый endpoint должен быть добавлен в существующий controller модуль `app/controllers/v1/delivery.py`; не создавать отдельный controller модуль.
- Логика provider selection должна быть добавлена в существующий service модуль `app/services/aggregator.py`; не создавать отдельный service модуль.
- Service выбирает provider по `country_code` через injected config mapping и вызывает ровно один address suggestion adapter; provider HTTP-детали в Service запрещены.
- Scope задачи не включает создание заказа CDEK, изменение `POST /api/v1/delivery/order`, расчёт стоимости доставки, cache behavior и concrete HTTP integration европейского provider.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Существует endpoint `POST /api/v1/delivery/suggest-address`, принимающий `AddressSuggestRequest` и возвращающий `list[AddressSuggestion]`.
- Реализованы request/response schemas `AddressSuggestRequest` и `AddressSuggestion`.
- Endpoint реализован в существующем controller `app/controllers/v1/delivery.py`.
- Controller делегирует обработку только в `AggregatorService.suggest_addresses()`.
- Service определяет provider по `country_code` через конфигурационный маппинг и вызывает только соответствующий registered adapter.
- Если `country_code` отсутствует в маппинге или сопоставлен с незарегистрированным provider, endpoint возвращает детерминированный 400 response.
- Provider request errors маппятся в 400, недоступность внешнего сервиса и transport failures — в 503.
- Service и controller/API tests покрывают как минимум сценарии: route в `dadata`, route в второй provider через test double, unsupported country и provider failure.
## Definition of Done
- [ ] Добавлены address suggestion request/response schemas.
- [ ] Реализован метод `AggregatorService.suggest_addresses()` для provider routing и orchestration.
- [ ] Реализован endpoint `POST /api/v1/delivery/suggest-address` в существующем controller.
- [ ] Добавлены service и controller/API tests для address suggestion flow.
## Tests
- Добавить `tests/services/test_address_suggestions.py` для проверки routing по `country_code`, unsupported country, незарегистрированного provider и provider failures.
- Добавить `tests/controllers/v1/test_address_suggestions.py` для success case, schema validation и HTTP mapping ошибок.
- При необходимости обновить `tests/smoke/test_app_import.py` для проверки подключения нового endpoint и service wiring без новых controller/service модулей.
- Использовать test doubles для address suggestion adapters.
## Commands
- `poetry run pytest tests/services/test_address_suggestions.py -q`
- `poetry run pytest tests/controllers/v1/test_address_suggestions.py -q`
- `poetry run pytest tests/smoke/test_app_import.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -0,0 +1,55 @@
---
id: 023
title: Add Yandex Geosuggest address suggestion adapter and CIS routing
status: DONE
created: 2026-03-29
---
## Context
Текущий flow подсказок адреса поддерживает только `dadata` и уже использует routing по `country_code` через YAML-конфиг. Появилось новое требование: для части стран СНГ использовать отдельный provider `Yandex Geosuggest`, чтобы разгрузить `dadata` и зафиксировать routing по странам на уровне конфигурации.
## Goal
Добавить новый adapter `yandex_geosuggest` для address suggestions, настроить routing по `country_code` так, чтобы `RU`, `BY` и `KZ` оставались на `dadata`, а `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM` и `UZ` шли через `Yandex Geosuggest`, и покрыть это тестами без изменения публичного API endpoint.
## Constraints
- Scope задачи ограничен flow подсказок адреса: YAML-конфиг, wiring зависимостей, service routing, новый adapter и тесты.
- Controller path и публичный контракт `POST /api/v1/delivery/suggest-address` не изменять.
- Service остаётся orchestration layer: выбирает provider по injected config mapping и вызывает ровно один adapter без provider-specific HTTP-логики.
- Внешний IO должен оставаться только внутри нового adapter `app/adapters/address_suggestions/yandex_geosuggest/`.
- Для `RU`, `BY` и `KZ` existing mapping на `dadata` должен сохраниться без изменения контракта `dadata` adapter.
- Для `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM` и `UZ` конфиг должен выбирать provider id `yandex_geosuggest`.
- Новый adapter должен следовать официальному HTTP-контракту Yandex Geosuggest: `GET https://suggest-maps.yandex.ru/v1/suggest`, обязательные query-параметры `apikey` и `text`; для полного адреса использовать `print_address=1`; если в internal request передан `limit`, он должен маппиться в query-параметр `results`.
- Unified mapping наружу должен по-прежнему возвращать только `AddressSuggestion` без утечки provider-specific payload.
- Для Yandex Geosuggest поле `postal_code` не извлекается из provider response и должно возвращаться как `None`.
- Ошибки Yandex Geosuggest `400` должны маппиться в deterministic provider request error; `403`, `429`, transport errors и `5xx` должны маппиться в adapter client error/unavailable path.
- Scope задачи не включает добавление европейского provider, изменение order flow, price flow, новых endpoint'ов и расширение internal model `AddressSuggestion`.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- В конфиге address suggestion providers добавлен provider id `yandex_geosuggest` с параметрами, необходимыми для вызова Yandex Geosuggest.
- Country mapping в конфиге маршрутизирует `RU`, `BY`, `KZ` на `dadata`, а `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM`, `UZ` на `yandex_geosuggest`.
- Реализован adapter `app/adapters/address_suggestions/yandex_geosuggest/client.py`, который отправляет запрос в Yandex Geosuggest по официальному контракту и возвращает `list[AddressSuggestion]`.
- Adapter использует `request.city` и `request.query` как значение `text`, передаёт `print_address=1`, а `request.limit` при наличии маппит в `results`.
- Поля `address`, `street`, `house` и `flat` детерминированно извлекаются из ответа Yandex Geosuggest без утечки внешнего payload за пределы adapter contract, а `postal_code` возвращается как `None`.
- `AggregatorService.suggest_addresses()` по `country_code` выбирает `yandex_geosuggest` для стран `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM`, `UZ` и сохраняет routing на `dadata` для `RU`, `BY`, `KZ`.
- При ответе Yandex Geosuggest с `400` service flow возвращает deterministic bad-request path, а при `403`, `429`, `5xx` и transport failure — deterministic unavailable path.
## Definition of Done
- [ ] Добавлена конфигурация `yandex_geosuggest` и обновлён mapping стран для address suggestions.
- [ ] Реализован новый Yandex Geosuggest adapter без утечки HTTP-деталей в Service.
- [ ] Обновлён wiring address suggestion providers без изменения публичного endpoint контракта.
- [ ] Добавлены tests для config, adapter mapping/error handling и service routing по странам СНГ, включая `postal_code=None` для Yandex Geosuggest.
- [ ] Пройдены все команды из раздела Commands.
## Tests
- Обновить `tests/config/test_config_sections.py` для проверки секции `yandex_geosuggest` и явного country mapping: `RU`, `BY`, `KZ` -> `dadata`; `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM`, `UZ` -> `yandex_geosuggest`.
- Добавить `tests/adapters/address_suggestions/yandex_geosuggest/test_client.py` для success case, mapping `limit -> results`, извлечения `address`/`street`/`house`/`flat`, возврата `postal_code=None` и error scenarios `400`, `403`, `429`, `5xx`.
- Обновить `tests/services/test_address_suggestions.py` для проверки routing на `yandex_geosuggest` по странам `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM`, `UZ` и сохранения routing на `dadata` для `RU`, `BY`, `KZ`.
- При необходимости обновить `tests/controllers/v1/test_address_suggestions.py` только для совместимости существующего endpoint с новым provider routing без изменения публичного API.
## Commands
- `poetry run pytest tests/config/test_config_sections.py -q`
- `poetry run pytest tests/adapters/address_suggestions/yandex_geosuggest/test_client.py -q`
- `poetry run pytest tests/services/test_address_suggestions.py -q`
- `poetry run pytest tests/controllers/v1/test_address_suggestions.py -q`
- `python3 spec/gen_spec_index.py`
@@ -0,0 +1,56 @@
---
id: 024
title: Add TomTom address suggestion adapter and Europe routing
status: DONE
created: 2026-03-29
---
## Context
Текущий flow подсказок адреса поддерживает `dadata` для `RU`, `BY`, `KZ` и `yandex_geosuggest` для части стран СНГ. Следующий этап требует добавить третий provider `TomTom` для европейских стран, сохранив текущий публичный API и routing через YAML-конфиг.
## Goal
Добавить новый adapter `tomtom` для address suggestions на базе TomTom Search API Fuzzy Search, настроить routing европейских стран на provider id `tomtom` через конфиг и покрыть это config, adapter и service tests без изменения публичного API endpoint.
## Constraints
- Scope задачи ограничен flow подсказок адреса: YAML-конфиг, wiring зависимостей, service routing, новый adapter и тесты.
- Controller path и публичный контракт `POST /api/v1/delivery/suggest-address` не изменять.
- Service остаётся orchestration layer: выбирает provider по injected config mapping и вызывает ровно один adapter без provider-specific HTTP-логики.
- Внешний IO должен оставаться только внутри нового adapter `app/adapters/address_suggestions/tomtom/`.
- Новый adapter должен следовать официальному HTTP-контракту TomTom Search API Fuzzy Search: `GET https://api.tomtom.com/search/2/search/{query}.json`.
- TomTom adapter должен формировать `query` из `request.city` и `request.query`, передавать `countrySet=request.country_code`, `typeahead=true`, а `request.limit` при наличии маппить в query-параметр `limit`.
- Для исключения POI из address suggestion flow adapter должен отправлять address-oriented `idxSet=PAD,Addr,Str,EPP`.
- Routing европейских стран должен определяться только YAML-маппингом `country_code -> provider_id`; запрещено добавлять в Service hardcoded классификацию Европы.
- Unified mapping наружу должен возвращать только `AddressSuggestion` без утечки provider-specific payload.
- Для TomTom Search поля должны маппиться детерминированно: `address.freeformAddress -> address`, `address.streetName -> street`, `address.streetNumber -> house`, `address.postalCode -> postal_code`, `flat=None`.
- Ошибки TomTom `400` должны маппиться в deterministic provider request error; `403`, `429`, transport errors и `5xx` должны маппиться в adapter client error/unavailable path.
- Scope задачи не включает изменение order flow, price flow, новых endpoint'ов, расширение internal model `AddressSuggestion` и изменение контрактов существующих adapters `dadata` и `yandex_geosuggest`.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- В конфиге address suggestion providers добавлен provider id `tomtom` с параметрами, необходимыми для вызова TomTom Search API Fuzzy Search.
- Country mapping в конфиге маршрутизирует на `tomtom` европейские страны, явно перечисленные в YAML, при этом routing для `RU`, `BY`, `KZ` на `dadata` и для `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM`, `UZ` на `yandex_geosuggest` сохраняется без изменений.
- Реализован adapter `app/adapters/address_suggestions/tomtom/client.py`, который отправляет запрос в TomTom Search API Fuzzy Search по официальному контракту и возвращает `list[AddressSuggestion]`.
- Adapter объединяет `request.city` и `request.query` в search query, передаёт `countrySet=request.country_code`, `typeahead=true`, `idxSet=PAD,Addr,Str,EPP` и при наличии `request.limit` маппит его в `limit`.
- Поля `address`, `street`, `house` и `postal_code` детерминированно извлекаются из ответа TomTom (`freeformAddress`, `streetName`, `streetNumber`, `postalCode`), а `flat` возвращается как `None`.
- `AggregatorService.suggest_addresses()` по `country_code` выбирает `tomtom` для европейских стран, явно сопоставленных в YAML-конфиге, и сохраняет существующий routing на `dadata` и `yandex_geosuggest` для уже поддержанных стран.
- При ответе TomTom с `400` service flow возвращает deterministic bad-request path, а при `403`, `429`, `5xx` и transport failure — deterministic unavailable path.
## Definition of Done
- [ ] Добавлена конфигурация `tomtom` и обновлён country mapping для европейских стран.
- [ ] Реализован новый TomTom adapter без утечки HTTP-деталей в Service.
- [ ] Обновлён wiring address suggestion providers без изменения публичного endpoint контракта.
- [ ] Добавлены tests для config, adapter mapping/error handling и service routing на `tomtom` с сохранением существующего routing на `dadata` и `yandex_geosuggest`.
- [ ] Пройдены все команды из раздела Commands.
## Tests
- Обновить `tests/config/test_config_sections.py` для проверки секции `tomtom` и country mapping, в котором европейские страны маршрутизируются на `tomtom`, а существующие маппинги `RU`, `BY`, `KZ` -> `dadata` и `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM`, `UZ` -> `yandex_geosuggest` сохраняются.
- Добавить `tests/adapters/address_suggestions/tomtom/test_client.py` для success case, mapping `limit`, формирования search query из `city` и `query`, передачи `typeahead=true` и `idxSet=PAD,Addr,Str,EPP`, возврата `flat=None`, извлечения `address`/`street`/`house`/`postal_code` и error scenarios `400`, `403`, `429`, `5xx`.
- Обновить `tests/services/test_address_suggestions.py` для проверки routing на `tomtom` по европейским странам из YAML-конфига и сохранения routing на `dadata` и `yandex_geosuggest` для уже поддержанных стран.
- При необходимости обновить `tests/controllers/v1/test_address_suggestions.py` только для совместимости существующего endpoint с новым provider routing без изменения публичного API.
## Commands
- `poetry run pytest tests/config/test_config_sections.py -q`
- `poetry run pytest tests/adapters/address_suggestions/tomtom/test_client.py -q`
- `poetry run pytest tests/services/test_address_suggestions.py -q`
- `poetry run pytest tests/controllers/v1/test_address_suggestions.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -0,0 +1,51 @@
---
id: 025
title: Remove company from Create Delivery Order parties
status: DONE
created: 2026-04-03
---
## Context
Текущий контракт `POST /api/v1/delivery/order` допускает поле `company` в `sender`, потому что order party schema используется и для `sender`, и для `recipient`. Новый сценарий требует упростить Create Delivery Order payload: поля `sender.company` и `recipient.company` больше не должны приниматься и не должны отправляться в CDEK.
## Goal
Удалить `company` из request contract Create Delivery Order на уровне schema validation, controller/API contract и CDEK order registration payload без изменения endpoint path, service orchestration и response model.
## Constraints
- Изменения ограничены существующими модулями order flow: `app/schemas/order.py`, `app/controllers/v1/delivery.py`, `app/services/aggregator.py`, `app/adapters/delivery_providers/cdek/` и связанными тестами; не создавать новые controller/service модули.
- `POST /api/v1/delivery/order` должен оставаться в существующем controller и по-прежнему вызывать ровно один метод Service: `AggregatorService.create_order()`.
- Service не должен получать новую business logic; orchestration order flow должна остаться прежней.
- Поля `sender.company` и `recipient.company` должны отсутствовать в публичном request contract и не должны сериализоваться в payload, отправляемый в CDEK.
- Payload, содержащий `sender.company` или `recipient.company`, должен считаться невалидным и отклоняться schema validation с HTTP 422.
- `from_location.address` и `to_location.address` остаются opaque input strings; не добавлять address suggestion lookup, нормализацию адреса или иные изменения order flow.
- Scope задачи не включает изменение response contract, price flow, address suggestion flow, provider routing, кеширование, новые провайдеры и расширение поддерживаемых `type`/`tariff_code`.
- Обновить пример контракта Create Delivery Order в `http-client.http`, чтобы он не содержал `company`.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- `OrderCreateRequest` больше не содержит поле `company` ни для `sender`, ни для `recipient`.
- `POST /api/v1/delivery/order` успешно принимает прежний payload без `company` и не требует никаких новых полей.
- Если request payload содержит `sender.company` или `recipient.company`, endpoint возвращает 422 на уровне schema validation.
- `AggregatorService.create_order()` продолжает только оркестрировать вызов injected order adapter без новой business logic.
- CDEK adapter отправляет JSON payload для регистрации заказа без поля `company`.
- `http-client.http` содержит актуальный пример регистрации заказа без `company`.
- Service, controller/API и adapter tests покрывают success case после удаления поля и rejection scenario для `company`.
## Definition of Done
- [ ] Обновлён order request contract без поля `company` для `sender` и `recipient`.
- [ ] Schema validation отклоняет `sender.company` и `recipient.company`.
- [ ] CDEK order registration payload больше не содержит `company`.
- [ ] Обновлены order flow tests для service, controller/API и adapter layers.
- [ ] Обновлён пример Create Delivery Order в `http-client.http`.
## Tests
- Обновить `tests/services/test_order.py`, убрав `company` из валидных fixture payloads и сохранив проверку service orchestration.
- Обновить `tests/controllers/v1/test_order.py` для success case без `company` и добавить сценарий 422 при передаче `sender.company` и `recipient.company`.
- Обновить `tests/adapters/delivery_providers/cdek/test_order_client.py`, чтобы проверить отсутствие `company` в отправляемом CDEK payload.
- При необходимости обновить другие order-related tests, завязанные на старый request contract.
## Commands
- `poetry run pytest tests/services/test_order.py -q`
- `poetry run pytest tests/controllers/v1/test_order.py -q`
- `poetry run pytest tests/adapters/delivery_providers/cdek/test_order_client.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -0,0 +1,52 @@
---
id: 026
title: Align CDEK order contract with single phone and kilogram package weight
status: DONE
created: 2026-04-05
---
## Context
Текущий order flow принимает `sender.phones` и `recipient.phones` как список, требует обязательное поле `services` и пробрасывает `packages.weight` в CDEK payload без явной фиксации единиц измерения. Новый контракт должен принимать один телефон на сторону, разрешать отсутствие `services` и гарантировать, что во входном API вес упаковки задаётся в килограммах, а в CDEK отправляется в граммах.
## Goal
Обновить flow `POST /api/v1/delivery/order` по слоям Controller, Service и Adapter так, чтобы public/internal request contract использовал `phone` вместо `phones`, поле `services` было необязательным, а CDEK order mapper конвертировал `packages.weight` из килограммов во входном запросе в граммы во внешнем provider payload.
## Constraints
- Изменения ограничены существующими модулями order flow: `app/schemas/order.py`, `app/controllers/v1/delivery.py`, `app/services/aggregator.py`, `app/adapters/delivery_providers/cdek/`, `http-client.http` и связанными тестами.
- `POST /api/v1/delivery/order` должен оставаться в существующем controller и по-прежнему вызывать ровно один метод Service: `AggregatorService.create_order()`.
- Service остаётся orchestration layer и не получает новую business logic; он только принимает обновлённую order model и делегирует её adapter.
- Во внутреннем order flow и публичном API поле `phones` должно быть удалено; передача нескольких телефонов больше не поддерживается.
- CDEK adapter может сериализовать внутренний `phone` в provider-specific поле `phones`, если это требуется внешним контрактом CDEK, но множественность телефонов не должна возвращаться во внутренние модели и controller contract.
- `services` должно быть необязательным полем request schema; при отсутствии значения нельзя подставлять фиктивные service entries.
- Конвертация единиц `packages.weight` должна выполняться только на границе CDEK adapter mapping: входной order request использует килограммы, исходящий payload в CDEK использует граммы.
- Scope задачи не включает изменение response contract, price flow, address suggestion flow, provider routing, кеширование, новые провайдеры и расширение поддерживаемых `type`/`tariff_code`.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- `OrderCreateRequest` использует `sender.phone` и `recipient.phone` вместо `sender.phones` и `recipient.phones`.
- Если request payload содержит `sender.phones` или `recipient.phones`, endpoint возвращает 422 на уровне schema validation.
- `POST /api/v1/delivery/order` принимает валидный payload без поля `services`.
- Если `services` отсутствует или равен `null`, service и adapter flow успешно обрабатывают запрос без добавления `services` в исходящий CDEK payload.
- `AggregatorService.create_order()` продолжает только оркестрировать вызов injected order adapter и не содержит преобразования `phone`/`phones` или килограммов в граммы.
- CDEK order mapper формирует provider payload с полями `sender.phones` и `recipient.phones`, каждое из которых содержит ровно один элемент, полученный из соответствующего внутреннего поля `phone`.
- Для каждого элемента `packages` исходящий payload CDEK содержит `weight`, равный значению входного `packages.weight`, умноженному на `1000`.
- `http-client.http` содержит актуальный пример Create Delivery Order с `phone` вместо `phones`, без обязательного `services` и с весом, отражающим controller contract в килограммах.
## Definition of Done
- [ ] Обновлён public/internal order request contract на `phone` вместо `phones`.
- [ ] `services` сделано необязательным без изменения endpoint path и service orchestration.
- [ ] Реализован mapping одного телефона в provider payload `phones` и конвертация `packages.weight` из килограммов в граммы.
- [ ] Обновлены controller, service и adapter tests под новый контракт и unit conversion.
- [ ] Обновлён пример запроса в `http-client.http`.
## Tests
- Обновить `tests/controllers/v1/test_order.py` для success case с `phone`, сценариев 422 при передаче `sender.phones` и `recipient.phones`, а также для запроса без `services`.
- Обновить `tests/services/test_order.py` для проверки, что service принимает обновлённую order model, делегирует её adapter без новой логики и корректно работает с `services=None`.
- Обновить `tests/adapters/delivery_providers/cdek/test_order_client.py` для проверки mapping `phone -> phones[0]`, отсутствия `services` в исходящем payload при `None` и конвертации `packages.weight` из килограммов в граммы.
- При необходимости обновить другие order-related tests и fixtures, завязанные на старые поля `phones` и обязательность `services`.
## Commands
- `poetry run pytest tests/controllers/v1/test_order.py -q`
- `poetry run pytest tests/services/test_order.py -q`
- `poetry run pytest tests/adapters/delivery_providers/cdek/test_order_client.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -0,0 +1,72 @@
---
id: 027
title: Add TBank payment adapter, init_payment endpoint and rename order flow
status: DONE
created: 2026-04-11
---
## Context
В проекте ещё нет платёжного адаптера, каталог `app/adapters/` содержит только `delivery_providers/` и `address_suggestions/`. Текущий endpoint `POST /api/v1/delivery/order` регистрирует заказ в CDEK. В новом флоу endpoint будет инициировать оплату через TBank и возвращать ссылку на оплату, без регистрации заказа в CDEK. В связи с изменением бизнес-смысла необходимо переименовать endpoint, сервисный метод, все DTO и модели флоу.
## Goal
1. Добавить новый payment adapter `TBankAdapter` для генерации ссылки на оплату TBank.
2. Переименовать endpoint `POST /api/v1/delivery/order``POST /api/v1/delivery/init-payment`.
3. Переименовать сервисный метод `AggregatorService.create_order()``AggregatorService.init_payment()`.
4. Переименовать все DTO и модели флоу: `OrderCreateRequest``InitPaymentRequest`, `OrderCreateResponse``InitPaymentResponse`, `InvalidOrderCreateRequestError``InvalidInitPaymentRequestError`, `OrderCreationUnavailableError``InitPaymentUnavailableError`.
5. Переименовать файл схем `app/schemas/order.py``app/schemas/payment.py`.
6. Добавить в `InitPaymentRequest` обязательное поле `price: int` (в копейках) и вызвать `payment_adapter.create_payment_link()` из `init_payment()`, вернув `payment_url` в `InitPaymentResponse`.
## Constraints
- Новый payment adapter должен располагаться в отдельном каталоге `app/adapters/tbank/` с модулями `base.py` и `client.py`; не размещать payment logic внутри `delivery_providers/` или `address_suggestions/`.
- Payment adapter MUST инкапсулировать HTTP взаимодействие с TBank API, auth, retries, serialization и error handling; наружу должен экспонироваться только service-facing method (`create_payment_link(order_uuid: str, amount_kopecks: int) -> str`), без утечки HTTP деталей в Service.
- Payment adapter MUST владеть собственной секцией конфигурации (`TBankPaymentConfig`) с обязательными полями auth и URL; конфигурация читается из `config.yaml`, который остаётся в `.gitignore`.
- `InitPaymentRequest` MUST содержать обязательное поле `price: int` в копейках с валидацией `gt=0`; единица измерения явно зафиксирована в schema и в `spec/overview.md`.
- `AggregatorService.init_payment()` MUST вызывать `payment_adapter.create_payment_link()`. Payment adapter передаётся в service через dependency injection (новый аргумент конструктора).
- Ошибки payment adapter MUST маппиться в `InvalidInitPaymentRequestError` (→ 400) и `InitPaymentUnavailableError` (→ 503) на уровне controller.
- Controller `POST /api/v1/delivery/init-payment` MUST вызывать ровно один метод Service (`AggregatorService.init_payment()`); старый endpoint `/order` удаляется, новые дополнительные endpoints запрещены.
- Service слой не содержит HTTP, retries или TBank-специфичных деталей.
- Scope задачи не включает: повторные попытки оплаты, refund flow, webhook обработку платёжных уведомлений, persistence заказов, изменения price flow, address suggestion flow, а также поддержку других платёжных провайдеров.
- Не изменять файлы в `spec/` кроме создания этой задачи и обновления `spec/overview.md` при необходимости (обновление выполняется Planner).
## Acceptance criteria
- В `app/adapters/tbank/` существуют `base.py` с исключениями адаптера и `client.py` с реализацией `TBankAdapter`.
- `TBankAdapter` реализует async метод `create_payment_link(order_uuid: str, amount_kopecks: int) -> str`, принимает идентификатор заказа и сумму в копейках и возвращает URL.
- `TBankAdapter` имеет собственную конфигурацию `TBankPaymentConfig` с обязательными полями auth и URL; конфигурация читается из yaml.
- Файл `app/schemas/order.py` переименован в `app/schemas/payment.py`; все импорты обновлены.
- `InitPaymentRequest` (бывший `OrderCreateRequest`) содержит обязательное поле `price: int` (в копейках) с валидацией `gt=0`; запрос без `price` или с нецелым/отрицательным значением возвращает 422.
- `InitPaymentResponse` (бывший `OrderCreateResponse`) содержит поле `payment_url: str` (минимум 1 символ) и возвращается из endpoint `POST /api/v1/delivery/init-payment`.
- `AggregatorService.init_payment()` (бывший `create_order()`) принимает `InitPaymentRequest`, вызывает `payment_adapter.create_payment_link()` с `order_uuid` и `price`, и возвращает `InitPaymentResponse` с `payment_url`.
- Сервисные исключения переименованы: `InvalidInitPaymentRequestError` (бывший `InvalidOrderCreateRequestError`), `InitPaymentUnavailableError` (бывший `OrderCreationUnavailableError`).
- Endpoint `POST /api/v1/delivery/order` удалён; endpoint `POST /api/v1/delivery/init-payment` возвращает `InitPaymentResponse`.
- Ошибки TBank provider request мапятся в 400, transport/недоступность — в 503 на уровне controller.
- Controller продолжает вызывать ровно один метод Service.
- Пример запроса в `http-client.http` обновлён: использует `/init-payment`, содержит поле `price` в копейках и отражает обновлённый response contract.
- `config.yaml` пример (в `.gitignore`) содержит секцию с параметрами TBank adapter.
## Definition of Done
- [ ] Создан модуль `app/adapters/tbank/` с `base.py` и `client.py`, реализующий `TBankAdapter`.
- [ ] Добавлена конфигурация TBank adapter (`TBankPaymentConfig`) в `app/config.py` и соответствующий пример в `config.yaml`.
- [ ] `app/schemas/order.py` переименован в `app/schemas/payment.py`; все импорты в контроллере, сервисе и тестах обновлены.
- [ ] `OrderCreateRequest``InitPaymentRequest`, `OrderCreateResponse``InitPaymentResponse` во всех файлах.
- [ ] `InvalidOrderCreateRequestError``InvalidInitPaymentRequestError`, `OrderCreationUnavailableError``InitPaymentUnavailableError` во всех файлах.
- [ ] `AggregatorService.create_order()``AggregatorService.init_payment()`; метод вызывает TBank adapter и возвращает `InitPaymentResponse`.
- [ ] Payment adapter injected в service через wiring в `app/controllers/v1/delivery.py::_build_aggregator_service`.
- [ ] Endpoint `/order` удалён, добавлен `/init-payment`; controller function переименована в `init_payment`.
- [ ] Controller маппит `InvalidInitPaymentRequestError` → 400, `InitPaymentUnavailableError` → 503.
- [ ] Обновлены unit tests для schemas, service, controller, а также добавлены unit tests для TBank adapter (HTTP client с stub transport).
- [ ] Обновлён пример запроса в `http-client.http`.
## Tests
- Добавить `tests/adapters/tbank/test_client.py` с проверкой: формирование payment request payload (auth, сумма в копейках, order uuid), маппинг успешного ответа в URL, маппинг 4xx в provider request error, маппинг 5xx/transport в client error, retry behavior если реализован.
- Переименовать `tests/services/test_order.py``tests/services/test_init_payment.py`; обновить: stub payment adapter; success case включает вызов payment adapter и возврат `payment_url`; маппинг payment adapter errors в `InvalidInitPaymentRequestError` / `InitPaymentUnavailableError`.
- Переименовать `tests/controllers/v1/test_order.py``tests/controllers/v1/test_init_payment.py`; обновить: endpoint `/init-payment`; payload содержит `price`; success response включает `payment_url`; запрос без `price` и с `price <= 0` возвращает 422; сценарии 400/503 продолжают работать.
- Обновить `tests/config/test_config_sections.py` если добавлена новая обязательная секция в yaml.
- При необходимости обновить `tests/smoke/test_app_import.py` для проверки wiring payment adapter.
## Commands
- `poetry run pytest tests/adapters/tbank/test_client.py -q`
- `poetry run pytest tests/services/test_init_payment.py -q`
- `poetry run pytest tests/controllers/v1/test_init_payment.py -q`
- `poetry run pytest tests/config/test_config_sections.py -q`
- `poetry run pytest tests/smoke/test_app_import.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -0,0 +1,75 @@
---
id: 028
title: Add PostgreSQL adapter, order repository and persist order after payment link creation
status: DONE
created: 2026-04-12
---
## Context
После создания ссылки на оплату через TBank adapter данные заявки нигде не сохраняются. Необходимо добавить PostgreSQL-адаптер для управления подключением к базе данных, репозиторий для сохранения данных заявки и интегрировать сохранение в существующий flow `AggregatorService.init_payment()`. Зависимости `sqlalchemy` (2.0.49) и `asyncpg` (0.31.0) уже присутствуют в `pyproject.toml`. Alembic (1.18.4) также доступен для миграций.
## Goal
1. Добавить PostgreSQL-адаптер (`app/adapters/postgres/`) для управления async-сессиями SQLAlchemy (`AsyncEngine`, `async_sessionmaker`).
2. Добавить репозиторий заявок (`app/repositories/order/`) с методом `create_order()` для сохранения данных заявки вместе со ссылкой на оплату в PostgreSQL.
3. Определить SQLAlchemy model для таблицы заявок в `app/repositories/order/models.py`.
4. Создать Alembic-миграцию для создания таблицы заявок.
5. Расширить `AggregatorService.init_payment()`: после успешного получения `payment_url` от TBank adapter сохранять данные `InitPaymentRequest` вместе с `payment_url` через order repository.
6. Добавить секцию конфигурации PostgreSQL (`PostgresConfig`) в `app/config.py` и пример в `config.yaml`.
7. Добавить сервис PostgreSQL в `docker-compose.yml`.
## Constraints
- PostgreSQL adapter (`app/adapters/postgres/`) MUST содержать только управление подключением (engine, session factory). Без бизнес-логики, без SQL-запросов.
- Repository (`app/repositories/order/`) MUST содержать только операции с базой данных. Без бизнес-решений, без workflow-логики.
- Service MUST оркестрировать вызовы TBank adapter и order repository. Если сохранение в БД завершается ошибкой после успешного получения `payment_url`, Service MUST всё равно вернуть `payment_url` клиенту (сохранение не должно блокировать ответ); ошибку сохранения логировать.
- SQLAlchemy model MUST использовать `sqlalchemy.orm.DeclarativeBase` (SQLAlchemy 2.0 style).
- Для миграций использовать Alembic с async-конфигурацией (`asyncpg`).
- Конфигурация PostgreSQL MUST быть в отдельной секции `postgres` в `config.yaml` с обязательным полем `dsn`; `config.yaml` уже в `.gitignore`.
- `_RequiredYamlSections` в `app/config.py` MUST быть обновлён для включения секции `postgres`.
- Order repository передаётся в `AggregatorService` через dependency injection (новый параметр конструктора).
- PostgreSQL adapter создаётся в wiring (`_build_aggregator_service`) в controller и передаёт session factory в order repository.
- Таблица заявок MUST содержать как минимум: `id` (UUID, PK), `order_uuid` (str, unique), `payment_url` (str), `price` (int, копейки), `tariff_code` (int), `sender` (JSONB), `recipient` (JSONB), `from_location` (JSONB), `to_location` (JSONB), `packages` (JSONB), `services` (JSONB, nullable), `comment` (str, nullable), `created_at` (timestamp with timezone, server default).
- Scope НЕ включает: чтение/обновление/удаление заявок, API-endpoint для списка заявок, webhook-обработку платёжных уведомлений, изменения price flow, address suggestion flow.
- НЕ изменять существующие тесты TBank adapter, не изменять поведение price и address suggestion endpoints.
## Acceptance criteria
- В `app/adapters/postgres/` существует модуль с функцией создания `AsyncEngine` и `async_sessionmaker` из конфигурации.
- В `app/repositories/order/` существует `OrderRepository` с async-методом `create_order(session, order_data)`, сохраняющим запись заявки.
- В `app/repositories/order/models.py` определена SQLAlchemy ORM model таблицы `orders` со всеми обязательными полями.
- Alembic инициализирован с async-конфигурацией; существует миграция для создания таблицы `orders`.
- `AggregatorService.__init__()` принимает опциональный `order_repository` через DI.
- `AggregatorService.init_payment()` после успешного получения `payment_url` вызывает `order_repository.create_order()` с данными из `InitPaymentRequest` и `payment_url`.
- Если `order_repository.create_order()` выбрасывает исключение, `init_payment()` логирует ошибку и возвращает `InitPaymentResponse(payment_url=...)` без ошибки клиенту.
- В `app/config.py` добавлена `PostgresConfig` с полем `dsn: str`.
- Секция `postgres` присутствует в `_RequiredYamlSections`.
- В `docker-compose.yml` добавлен сервис `postgres` и `app` зависит от него.
- Wiring в `_build_aggregator_service` создаёт PostgreSQL engine, session factory, `OrderRepository` и передаёт его в `AggregatorService`.
- Запросы к эндпоинту `POST /api/v1/delivery/order` продолжают возвращать `InitPaymentResponse` с `payment_url`.
## Definition of Done
- [ ] Создан модуль `app/adapters/postgres/` с engine/session factory.
- [ ] Создан `app/repositories/order/repository.py` с `OrderRepository.create_order()`.
- [ ] Создан `app/repositories/order/models.py` с ORM model таблицы `orders`.
- [ ] Alembic инициализирован (`alembic.ini`, `alembic/`), создана миграция для таблицы `orders`.
- [ ] Добавлена `PostgresConfig` в `app/config.py`; `_RequiredYamlSections` обновлён.
- [ ] `AggregatorService` принимает `order_repository` через DI и использует его в `init_payment()`.
- [ ] Ошибки сохранения заявки не блокируют возврат `payment_url` клиенту.
- [ ] Обновлён wiring в `app/controllers/v1/delivery.py`.
- [ ] Добавлен сервис `postgres` в `docker-compose.yml`.
- [ ] `config.yaml` пример содержит секцию `postgres`.
- [ ] `config.test.yaml` содержит секцию `postgres` (может использовать sqlite или тестовый DSN).
- [ ] Все существующие тесты продолжают проходить.
- [ ] Добавлены новые тесты.
## Tests
- Добавить `tests/repositories/order/test_repository.py`: проверка `create_order()` с in-memory SQLite async engine (SQLAlchemy async); проверка, что все обязательные поля сохраняются; проверка обработки дублирования `order_uuid` (unique constraint).
- Обновить `tests/services/test_init_payment.py`: добавить test case, где `order_repository.create_order()` вызывается после успешного создания payment link; добавить test case, где `order_repository.create_order()` выбрасывает исключение, а `init_payment()` всё равно возвращает `payment_url`.
- Обновить `tests/config/test_config_sections.py` для проверки наличия секции `postgres` в yaml.
- При необходимости обновить `tests/smoke/test_app_import.py` для проверки wiring order repository.
## Commands
- `poetry run pytest tests/repositories/order/test_repository.py -q`
- `poetry run pytest tests/services/test_init_payment.py -q`
- `poetry run pytest tests/config/test_config_sections.py -q`
- `poetry run pytest tests/smoke/test_app_import.py -q`
- `poetry run pytest -q`
- `python3 spec/gen_spec_index.py --check`
+54
View File
@@ -0,0 +1,54 @@
---
id: 029
title: Add TBank payment notification and success URLs
status: DONE
created: 2026-04-18
---
## Context
TBank Init API должен получать URLs для обработки webhook-уведомлений и возврата клиента после успешной оплаты. Сейчас `TBankAdapter` и секция `tbank_payment` не фиксируют передачу `NotificationURL` и `SuccessURL`.
## Goal
Добавить в конфигурацию TBank payment обязательные параметры `notification_url` и `success_url` и передавать их в payload инициализации платежа как `NotificationURL` и `SuccessURL`.
## Constraints
- Изменения ограничены TBank payment config, TBank adapter payload mapping, wiring конфигурации и тестами.
- `NotificationURL` и `SuccessURL` MUST приходить из секции `tbank_payment` YAML-конфига.
- TBank adapter MUST продолжать инкапсулировать HTTP-взаимодействие, auth token, retries, timeout, serialization и error handling.
- Service и Controller MUST NOT содержать TBank-specific payload fields или provider HTTP-детали.
- Не изменять публичный request/response contract `POST /api/v1/delivery/order`.
- Не изменять price flow, address suggestion flow, CDEK adapter и order repository.
- Не добавлять новые endpoints.
## Acceptance criteria
- `TBankPaymentConfig` содержит обязательные поля `notification_url: str` и `success_url: str`.
- Runtime YAML-конфиг и test YAML-конфиг содержат секцию `tbank_payment` с `notification_url` и `success_url`.
- `TBankAdapter` при вызове TBank Init API отправляет `NotificationURL` со значением `tbank_payment.notification_url`.
- `TBankAdapter` при вызове TBank Init API отправляет `SuccessURL` со значением `tbank_payment.success_url`.
- Auth token TBank формируется с учётом тех же полей payload, которые отправляются в Init API, включая `NotificationURL` и `SuccessURL`, если текущая реализация token generation строится по request payload.
- Отсутствие `notification_url` или `success_url` в YAML-конфиге приводит к детерминированной ошибке валидации конфигурации.
- `AggregatorService.init_payment()` продолжает вызывать `payment_adapter.create_payment_link(order_uuid, price)` без дополнительных URL-аргументов.
- Endpoint `POST /api/v1/delivery/order` продолжает возвращать только `InitPaymentResponse(payment_url=...)`.
## Definition of Done
- [ ] В `app/config.py` добавлены поля `notification_url` и `success_url` в TBank payment config.
- [ ] YAML-конфиги обновлены новыми параметрами TBank payment.
- [ ] `TBankAdapter` передаёт `NotificationURL` и `SuccessURL` в payload TBank Init API.
- [ ] Token generation для TBank request остаётся корректной после добавления новых payload fields.
- [ ] Service и Controller не знают о `NotificationURL` и `SuccessURL`.
- [ ] Добавлены или обновлены тесты конфигурации.
- [ ] Добавлены или обновлены тесты TBank adapter payload.
- [ ] Все команды из раздела Commands проходят.
## Tests
- Обновить `tests/config/test_config_sections.py`: проверить обязательность `tbank_payment.notification_url` и `tbank_payment.success_url`.
- Обновить `tests/adapters/tbank/test_client.py`: проверить, что successful Init request payload содержит `NotificationURL` и `SuccessURL` из конфигурации.
- Обновить `tests/adapters/tbank/test_client.py`: проверить, что token generation остаётся детерминированной после добавления новых payload fields.
- При необходимости обновить `tests/smoke/test_app_import.py`, если smoke config требует новые обязательные поля.
## Commands
- `poetry run pytest tests/config/test_config_sections.py -q`
- `poetry run pytest tests/adapters/tbank/test_client.py -q`
- `poetry run pytest tests/smoke/test_app_import.py -q`
- `poetry run pytest -q`
- `python3 spec/gen_spec_index.py --check`
@@ -0,0 +1,81 @@
---
id: 030
title: Add TBank payment notification webhook and CDEK order creation
status: DONE
created: 2026-04-18
---
## Context
TBank Init API уже получает `NotificationURL`, а данные заявки сохраняются в PostgreSQL после создания payment link. Сейчас приложение не принимает HTTP-уведомления TBank и не создаёт заказ в CDEK после подтверждения оплаты.
## Goal
Добавить `POST /api/v1/delivery/tbank/notifications`, который принимает payment notification от TBank, проверяет token уведомления, при валидном статусе `CONFIRMED` находит сохранённую заявку по `OrderId` и регистрирует заказ в CDEK. Валидные уведомления с другими статусами должны подтверждаться без регистрации заказа в CDEK.
## Constraints
- Controller отвечает только за routing, DTO validation и HTTP error mapping; endpoint вызывает ровно один метод Service: `AggregatorService.handle_tbank_payment_notification()`.
- Service выполняет только orchestration: verification через TBank adapter, выбор действия через pure Business Logic, чтение/обновление заявки через OrderRepository и вызов injected CDEK order adapter.
- Правило `CONFIRMED` -> создать заказ CDEK, остальные статусы -> подтвердить без CDEK должно быть pure Business Logic в `app/domain/`.
- TBank-specific token verification должна быть инкапсулирована в `app/adapters/tbank/` и использовать `tbank_payment.auth.password`.
- Проверка token MUST следовать контракту TBank HTTP-уведомлений: использовать top-level scalar fields кроме `Token`, не включать вложенные объекты (`Data`, `Receipt`), добавить `Password`, отсортировать ключи по алфавиту, конкатенировать значения, посчитать SHA-256 и сравнить с `Token`.
- Успешно обработанное уведомление MUST возвращать `HTTP 200` с plain text body `OK`.
- Валидные уведомления со статусами, отличными от `CONFIRMED`, MUST возвращать `OK` без чтения CDEK и без создания заказа.
- Повторное валидное уведомление `CONFIRMED` для заявки с уже сохранённым `cdek_order_uuid` MUST возвращать `OK` без повторного вызова CDEK.
- Если token невалиден, endpoint MUST возвращать deterministic `400` и не обращаться к repository или CDEK.
- Если заявка для `OrderId` не найдена или CDEK registration не завершилась успешно, endpoint MUST не возвращать `OK`, чтобы TBank мог повторить notification delivery.
- Repository содержит только CRUD/query/update primitives; без workflow logic и business decisions.
- CDEK order payload MUST передавать `order_uuid` как external идентификатор заказа CDEK (поле `number` в CDEK order contract), чтобы повторные вызовы CDEK при HTTP timeout/ретрае обрабатывались идемпотентно на стороне CDEK и никогда не создавали дубль заказа.
- Service MUST трактовать ответ CDEK "заказ с таким external id уже существует" (возврат существующего `entity.uuid` либо CDEK-specific duplicate response) как успех, сохранять возвращённый `cdek_order_uuid` и отвечать `OK`, а не как ошибку с повторной регистрацией.
- Не изменять public contract `POST /api/v1/delivery/order`, price flow и address suggestion flow.
- Не добавлять новые payment providers, refund flow, recurring payments, ручной retry endpoint или endpoint чтения заявок.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Существует schema `TBankPaymentNotification` с обязательными полями `TerminalKey`, `OrderId`, `Success`, `Status`, `PaymentId`, `ErrorCode`, `Amount`, `Token` и поддержкой дополнительных top-level полей TBank. `PaymentId` валидируется как положительное целое (TBank присылает long integer) и сохраняется в `tbank_payment_id` колонке `BIGINT`.
- `POST /api/v1/delivery/tbank/notifications` реализован в существующем controller `app/controllers/v1/delivery.py` и возвращает `PlainTextResponse("OK")` при успешной обработке.
- Controller делегирует обработку ровно в `AggregatorService.handle_tbank_payment_notification()` и не содержит branching по TBank status.
- `TBankAdapter` умеет проверять token payment notification через `tbank_payment.auth.password`; невалидный token маппится в service/controller error path без repository/CDEK side effects.
- В `app/domain/` есть pure function, которая для `Status == "CONFIRMED"`, `Success == true` и `ErrorCode == "0"` возвращает действие регистрации CDEK, а для остальных статусов возвращает действие acknowledge-only.
- `OrderRepository` предоставляет primitive для получения заявки по `order_uuid`, сохранения последнего TBank payment status/payment id и сохранения `cdek_order_uuid`.
- Таблица `orders` содержит минимум поля `payment_status`, `tbank_payment_id` (BIGINT), `cdek_order_uuid`, `updated_at` в исходной миграции; отдельная миграция не вводится, так как production БД ещё не развёрнута.
- При валидном `CONFIRMED` notification service получает order по `OrderId`, реконструирует `InitPaymentRequest` из сохранённых данных, вызывает injected CDEK order adapter registration и сохраняет полученный `cdek_order_uuid`.
- Если для `OrderId` уже сохранён `cdek_order_uuid`, повторный `CONFIRMED` notification возвращает `OK` без повторного вызова CDEK.
- CDEK order mapper проставляет `InitPaymentRequest.order_uuid` в поле external номера заказа CDEK (`number`), так что повторный POST с тем же external id не создаёт второй заказ в CDEK.
- Если CDEK create фактически завершился успешно, но сохранение `cdek_order_uuid` в DB не прошло, следующий валидный `CONFIRMED` notification MUST завершиться сохранением того же `cdek_order_uuid` (полученного по тому же external id) без создания второго заказа в CDEK.
- Валидные notification со статусами `AUTHORIZED`, `REJECTED`, `CANCELED`, `DEADLINE_EXPIRED` и неизвестными status values возвращают `OK` без регистрации CDEK order.
- Ошибка поиска заявки или ошибка CDEK registration возвращает deterministic non-OK HTTP response и логируется.
- `NotificationURL` в runtime/test/example конфигурации, если он присутствует в репозитории, указывает на `/api/v1/delivery/tbank/notifications`.
## Definition of Done
- [ ] Добавлена schema `TBankPaymentNotification`.
- [ ] Добавлена pure Business Logic для выбора действия по TBank payment notification status.
- [ ] Добавлена token verification logic в TBank adapter.
- [ ] Расширена SQLAlchemy model и обновлена существующая Alembic migration `20260412_028_create_orders_table` payment/CDEK status fields (production БД отсутствует, новая миграция не нужна).
- [ ] Расширен `OrderRepository` primitives для read/update операций, нужных webhook flow.
- [ ] Реализован `AggregatorService.handle_tbank_payment_notification()` с DI dependencies.
- [ ] Добавлен endpoint `POST /api/v1/delivery/tbank/notifications` в существующий delivery controller.
- [ ] Wiring использует существующие `TBankAdapter`, `OrderRepository` и CDEK provider/adapter без provider HTTP details в Service/Controller.
- [ ] Повторные `CONFIRMED` notification обрабатываются идемпотентно.
- [ ] CDEK order mapper проставляет `order_uuid` в поле external номера заказа CDEK.
- [ ] Service корректно обрабатывает ответ CDEK "заказ уже существует" как успех и сохраняет возвращённый `cdek_order_uuid`.
- [ ] Валидные non-`CONFIRMED` statuses подтверждаются без CDEK side effects.
- [ ] Обновлены tests для domain, adapter, repository, service, controller и smoke wiring.
- [ ] Все команды из раздела Commands проходят.
## Tests
- Добавить `tests/domain/test_payment_notifications.py`: `CONFIRMED` + `Success=true` + `ErrorCode=0` -> create CDEK order action; остальные known/unknown statuses -> acknowledge-only.
- Обновить или добавить `tests/adapters/tbank/test_notifications.py`: valid token, invalid token, exclusion of `Token`, exclusion of nested `Data`/`Receipt`, inclusion of extra scalar fields, deterministic SHA-256 comparison.
- Обновить `tests/repositories/order/test_repository.py`: получение заявки по `order_uuid`, сохранение `payment_status`/`tbank_payment_id`, сохранение `cdek_order_uuid`, duplicate/idempotency scenario.
- Обновить `tests/adapters/delivery_providers/cdek/test_order_mapper.py`: CDEK order payload содержит `InitPaymentRequest.order_uuid` в поле external номера заказа (`number`).
- Добавить `tests/services/test_tbank_notifications.py`: valid `CONFIRMED` вызывает repository lookup, CDEK registration и save `cdek_order_uuid`; duplicate `CONFIRMED` не вызывает CDEK; non-`CONFIRMED` возвращает `OK` без CDEK; invalid token не обращается к repository; missing order/CDEK failure возвращает service error; сценарий "CDEK create succeeded, save `cdek_order_uuid` raised" — следующий `CONFIRMED` notification вызывает CDEK повторно с тем же external id, получает тот же `cdek_order_uuid`, сохраняет его и возвращает `OK` (суммарно ровно один реальный заказ в CDEK).
- Добавить `tests/controllers/v1/test_tbank_notifications.py`: endpoint возвращает plain text `OK` для successful service response, 400 для invalid token, 503 для temporary processing failure, controller делегирует ровно один service method.
- Обновить `tests/smoke/test_app_import.py` при необходимости для проверки wiring нового endpoint.
## Commands
- `poetry run pytest tests/domain/test_payment_notifications.py -q`
- `poetry run pytest tests/adapters/tbank/test_notifications.py -q`
- `poetry run pytest tests/repositories/order/test_repository.py -q`
- `poetry run pytest tests/services/test_tbank_notifications.py -q`
- `poetry run pytest tests/controllers/v1/test_tbank_notifications.py -q`
- `poetry run pytest tests/smoke/test_app_import.py -q`
- `poetry run pytest -q`
- `python3 spec/gen_spec_index.py --check`
@@ -0,0 +1,71 @@
---
id: 031
title: Validate init-payment price with CDEK tariff
status: TODO
created: 2026-04-18
---
## Context
`POST /api/v1/delivery/order` сейчас принимает `price` от клиента и использует это значение как сумму платежа TBank. Клиент может передать произвольную сумму, не соответствующую тарифу CDEK.
## Goal
Перед созданием ссылки на оплату валидировать `InitPaymentRequest.price` через CDEK `POST /calculator/tarifflist` для данных заявки. Если сумма не совпадает с валидированной суммой CDEK, не создавать payment link и вернуть детерминированную ошибку.
## Constraints
- Controller не должен содержать business logic или provider-specific branching.
- Endpoint `POST /api/v1/delivery/order` должен по-прежнему вызывать ровно один метод Service: `AggregatorService.init_payment()`.
- Service выполняет только orchestration: вызывает injected CDEK adapter для расчёта цены, делегирует сравнение в Business Logic, затем вызывает TBank adapter только при успешной валидации.
- Pure правило сравнения цены должно жить в `app/domain/`: сравнение выполняется в копейках, с применением существующего provider price multiplier и округления `ROUND_HALF_UP`.
- Внешний IO для CDEK должен оставаться только в CDEK adapter; TBank-specific logic остаётся только в TBank adapter.
- CDEK validation MUST использовать существующий endpoint CDEK `POST /calculator/tarifflist`; запрещено использовать `/orders`, `/calculator/tariff` или registration flow для проверки цены.
- CDEK adapter должен переиспользовать существующие auth, retry, timeout, HTTP error handling и response mapping для tariff list там, где это совместимо с `InitPaymentRequest`.
- CDEK validation должна использовать данные существующего `InitPaymentRequest`: `tariff_code`, `from_location`, `to_location`, `packages` и `services` при наличии.
- Публичный request/response contract `POST /api/v1/delivery/order` не менять, кроме ужесточения runtime validation поля `price`.
- При mismatch цены нельзя вызывать TBank adapter и нельзя сохранять заявку в PostgreSQL.
- При ошибке CDEK validation нельзя вызывать TBank adapter и нельзя сохранять заявку в PostgreSQL.
- Не изменять price flow, address suggestion flow, payment notification webhook flow и CDEK order registration flow.
- Не добавлять новые endpoints, payment providers, ручной retry endpoint или endpoint чтения заявок.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- CDEK adapter предоставляет service-facing метод для расчёта валидной цены доставки по `InitPaymentRequest` через `POST /calculator/tarifflist` без регистрации заказа в CDEK.
- Новый метод переиспользует существующую tariff-list инфраструктуру CDEK adapter: OAuth2 token, retry/timeout policy, handling 4xx/5xx/transport errors и mapping `tariff_codes` в internal price model.
- CDEK validation request использует `request.tariff_code`, `from_location`, `to_location`, `packages` и `services` из `InitPaymentRequest`.
- CDEK validation request отправляется на тот же configured base URL path `/calculator/tarifflist`, который используется текущим CDEK price flow.
- Если CDEK не возвращает цену для `request.tariff_code`, service завершает `init_payment()` детерминированной `InvalidInitPaymentRequestError`.
- Business Logic рассчитывает expected payment amount в копейках: provider price в `RUB` после существующего multiplier и `ROUND_HALF_UP` конвертируется в копейки и сравнивается с `InitPaymentRequest.price`.
- Если `InitPaymentRequest.price` не равен expected amount, `AggregatorService.init_payment()` поднимает `InvalidInitPaymentRequestError`.
- При успешной price validation `AggregatorService.init_payment()` вызывает TBank adapter с исходным `order_uuid` и валидированным `price`.
- При успешной price validation и успешном TBank response сохранение заявки в PostgreSQL остаётся прежним.
- При CDEK provider request error service маппит ошибку в `InvalidInitPaymentRequestError`.
- При CDEK transport, timeout или 5xx error service маппит ошибку в `InitPaymentUnavailableError`.
- Controller маппит `InvalidInitPaymentRequestError` в HTTP 400 и `InitPaymentUnavailableError` в HTTP 503 по текущему error contract.
- Tests подтверждают, что при mismatch цены TBank adapter и OrderRepository не вызываются.
## Definition of Done
- [ ] Добавлен service-facing CDEK adapter method для расчёта цены по `InitPaymentRequest` через `POST /calculator/tarifflist`.
- [ ] Метод переиспользует существующие auth, retry, timeout, error handling и tariff response mapping CDEK adapter.
- [ ] Добавлена pure Business Logic для сравнения requested price с CDEK validated price в копейках.
- [ ] `AggregatorService.init_payment()` выполняет CDEK price validation до вызова TBank adapter.
- [ ] Ошибки CDEK validation маппятся в существующие service/controller error paths.
- [ ] TBank adapter вызывается только после успешной CDEK price validation.
- [ ] OrderRepository вызывается только после успешной CDEK price validation и успешного получения `payment_url`.
- [ ] Публичный contract `POST /api/v1/delivery/order` не изменён, кроме runtime rejection невалидной цены.
- [ ] Обновлены tests для domain, CDEK adapter, service и controller.
- [ ] Все команды из раздела Commands проходят.
## Tests
- Добавить или обновить `tests/domain/test_payment_price_validation.py`: exact match, mismatch, multiplier с `ROUND_HALF_UP`, конвертация `RUB` в копейки, не-`RUB` currency как deterministic validation failure.
- Добавить или обновить `tests/adapters/delivery_providers/cdek/test_payment_price_validation.py`: validation request отправляется на `/calculator/tarifflist`; payload строится из `InitPaymentRequest`, используется `tariff_code`, `packages`, `services`, `from_location`, `to_location`; success/error mapping покрыт stubs/mocks.
- Обновить `tests/services/test_init_payment.py`: success вызывает CDEK validation до TBank; mismatch возвращает `InvalidInitPaymentRequestError` без TBank и repository calls; CDEK request error маппится в `InvalidInitPaymentRequestError`; CDEK client error маппится в `InitPaymentUnavailableError`.
- Обновить `tests/controllers/v1/test_init_payment.py`: price mismatch возвращает HTTP 400; временная ошибка CDEK validation возвращает HTTP 503; controller продолжает делегировать ровно один service method.
- При необходимости обновить `tests/smoke/test_app_import.py` для wiring нового CDEK validation dependency.
## Commands
- `poetry run pytest tests/domain/test_payment_price_validation.py -q`
- `poetry run pytest tests/adapters/delivery_providers/cdek/test_payment_price_validation.py -q`
- `poetry run pytest tests/services/test_init_payment.py -q`
- `poetry run pytest tests/controllers/v1/test_init_payment.py -q`
- `poetry run pytest tests/smoke/test_app_import.py -q`
- `poetry run pytest -q`
- `python3 spec/gen_spec_index.py --check`
@@ -0,0 +1,102 @@
---
id: 032
title: Rework init-payment contract to camelCase and structured address/contact
status: TODO
created: 2026-05-13
---
## Context
Фронт переходит на новый контракт ручки `POST /api/v1/delivery/order`. В нём
адрес/контакт структурированы по-новому, появились флаг юр-лица и реквизиты
(`isCompany`/`companyName`/`inn`/`kpp`/`phoneExt`), описание груза и вес
(`content.description`), даты `pickupDate`/`deliveryDate`,
`accountEmail`, блок `systemData` с зафиксированным тарифом, parcelType,
docPackaging и dimensions. Поле наименования — camelCase. Это breaking change
без обратной совместимости.
## Goal
Привести бэкенд к новому контракту `/api/v1/delivery/order`, сохранив текущий
flow «валидация цены через CDEK → создание payment URL в TBank → persist в
PostgreSQL → возврат `payment_url`; webhook `CONFIRMED` → регистрация заказа в
CDEK».
## Constraints
- camelCase в API; внутренние имена остаются Python-friendly через Pydantic
alias-generator. JSON принимается ТОЛЬКО в camelCase.
- `orderUuid` и `systemData.tariff.tariffCode` приходят от фронта.
- `systemData.tariff.price` — итоговая сумма в копейках; backend использует её
как `amount_kopecks` для TBank без пересчёта.
- Расширить `senderAddress` и `receiverAddress` обязательным полем `cityId: int`
(то же, что в `/price`), чтобы резолвить CDEK city code из `cities_map`.
- При `isCompany=true` поля `companyName`, `inn`, `kpp` обязательны.
- При `parcelType='doc'` `dimensions` принимает значение `null`; CDEK packages
отправляются без length/width/height.
- При `parcelType='parcel'` `dimensions` обязателен.
- Адрес для CDEK собирается строкой `"{city}, {street}, {house}, кв. {apartment}"`;
если `apartment` пустой/отсутствует — без хвоста `, кв. ...`.
- `pickupDate` → CDEK `shipment_point.date`; `deliveryDate` → CDEK
`delivery_point.date` (если задан).
- `content.description` пробрасывается в CDEK `packages[0].items[0].name` и
`comment` верхнего уровня.
- `phoneExt` пробрасывается в CDEK `phones[0].additional`.
- Сохранять весь принятый payload в JSONB-колонке `payload` записи `orders`.
- ORM-модель `orders` рефакторится: вместо колонок `sender/recipient/
from_location/to_location/packages/services/comment/delivery_type` —
одна колонка `payload JSONB NOT NULL`, плюс `account_email VARCHAR`.
Колонки `order_uuid`, `payment_url`, `price`, `tariff_code`, `payment_status`,
`tbank_payment_id`, `cdek_order_uuid`, `created_at`, `updated_at` сохраняются.
## Acceptance criteria
- `POST /api/v1/delivery/order` принимает новый camelCase payload и возвращает
`InitPaymentResponse` без изменений (`{ "payment_url": str }`).
- Pydantic-модели валидируют: обязательность реквизитов юр-лица при
`isCompany=true`; `parcelType='doc'` ⇒ `dimensions=null`/отсутствует;
`parcelType='parcel'` ⇒ `dimensions` обязателен; `price > 0`.
- Service вызывает `payment_price_validation_adapter.get_payment_price(request)`
и `payment_adapter.create_payment_link(order_uuid, amount_kopecks)` с
`amount_kopecks = request.systemData.tariff.price`.
- CDEK order mapper из нового `InitPaymentRequest` формирует payload с полями:
`number`, `type=2`, `tariff_code`, `sender`, `recipient`,
`from_location.code/address/postal_code`, `to_location.code/address/postal_code`,
`packages[0]` с `weight` (граммы) и опциональными dimensions,
`packages[0].items` с описанием груза, `shipment_point.date`,
`delivery_point.date` (если есть), `comment`.
- При `isCompany=true` CDEK получает `contragent_type="LEGAL_ENTITY"`, `company`,
`inn`, `kpp` для соответствующей стороны.
- ORM `Order` хранит весь payload в `payload` JSONB; репозиторий сохраняет и
читает его без потерь; миграция переводит таблицу со старой структуры на
новую (drop старые колонки, добавить `payload`, `account_email`).
## Definition of Done
- [ ] Pydantic-модели `payment.py` переписаны под новый camelCase-контракт с
обязательными валидациями.
- [ ] CDEK order mapper и `_build_payment_price_payload` собирают payload из
новой структуры.
- [ ] `AggregatorService.init_payment` использует `systemData.tariff.price` и
`orderUuid` напрямую; persist использует обновлённый `OrderData`.
- [ ] ORM-модель `Order`, `OrderData` и репозиторий перешли на `payload` JSONB
и `account_email`.
- [ ] Alembic-миграция `20260513_032_rework_orders_payload.py` применяется и
откатывается.
- [ ] Тесты на schemas, mapper, payment validation, controller и service
обновлены и проходят.
- [ ] `http-client.http` обновлён под новый payload.
## Tests
- `tests/controllers/v1/test_init_payment.py` — fixture/assertion обновлены под
camelCase; добавлены кейсы валидации юр-лица и doc/parcel dimensions.
- `tests/adapters/delivery_providers/cdek/test_order_mapper.py` —
mapping `phoneExt → additional`, контрагент юр-лица, address composition,
doc без dimensions, shipment/delivery point dates.
- `tests/adapters/delivery_providers/cdek/test_payment_price_validation.py` —
validation payload собирается из `senderAddress.cityId`, `systemData.weight`,
`systemData.dimensions`.
- `tests/services/test_init_payment.py` — `init_payment` берёт сумму из
`systemData.tariff.price`, `orderUuid` пробрасывается.
- `tests/repositories/order/test_repository.py` (если есть) — обновлён под
новую модель `Order`.
## Commands
- `pytest tests/`
- `alembic upgrade head` (smoke на пустой базе)
- `python3 spec/gen_spec_index.py --check`
@@ -0,0 +1,94 @@
---
id: 033
title: Add CDEK waybill polling worker
status: DONE
created: 2026-05-23
---
## Context
`POST /v2/orders` в CDEK асинхронный. Когда `entity.related_entities[type=waybill].uuid`
синхронно приходит в ответе, поле `url` практически всегда пустое — PDF генерится
позже и его url доступен только при `GET /v2/print/orders/{waybill_uuid}` после
перехода накладной в `READY`. Бывают и случаи, когда waybill_uuid тоже не приходит
синхронно, или заказ уходит в терминальный `INVALID` без генерации накладной.
Ранее `_save_cdek_order_uuid` (`app/services/aggregator.py`) писал waybill-поля из
синхронного ответа POST, из-за чего запись прыгала между двумя источниками и
требовала покрытия граничных случаев в двух местах.
## Goal
Перевести запись waybill-данных под единоличную ответственность отдельного
фонового worker-а. При регистрации заказа сохранять только `cdek_order_uuid`,
а waybill_uuid / waybill_url пополнять поллингом до тех пор, пока заказ не
дойдёт до waybill_url IS NOT NULL либо до терминального статуса.
## Constraints
- Запуск — отдельный процесс `python -m app.workers.waybill_poller`, отдельный
сервис в `docker-compose.yml`, одна реплика.
- Никаких записей waybill-полей при регистрации заказа: `_save_cdek_order_uuid`
должен сохранять только `cdek_order_uuid`.
- Поллер — единственный writer полей `cdek_order_status`, `cdek_waybill_uuid`,
`cdek_waybill_url`, `cdek_polled_at`.
- На терминальных статусах заказа (`INVALID`, `DELIVERED`, `NOT_DELIVERED`,
`CANCELLED`) опросы по этому заказу прекращаются.
- Бизнес-логика терминальности — pure функция в `app/domain/cdek_polling.py`,
без зависимостей на репозиторий/адаптер.
- Соблюсти слои AGENTS.md: новые HTTP-вызовы CDEK живут только в адаптере,
работа с БД — только в репозитории, оркестрация — в сервисе.
## Acceptance criteria
- При успешной регистрации заказа в БД заполняется только `cdek_order_uuid`;
`cdek_waybill_uuid` и `cdek_waybill_url` остаются `NULL`.
- Worker раз в `waybill_poller.interval_seconds` секунд выбирает заказы с
`cdek_order_uuid IS NOT NULL AND cdek_waybill_url IS NULL` и нетерминальным
`cdek_order_status` (с упорядочиванием `cdek_polled_at NULLS FIRST` и лимитом
`waybill_poller.batch_size`).
- Если у заказа `cdek_waybill_uuid IS NULL`, worker делает `GET /v2/orders/{uuid}`
и сохраняет последний статус заказа + waybill_uuid из `related_entities`.
- Если `cdek_waybill_uuid IS NOT NULL` и url пуст, worker делает
`GET /v2/print/orders/{waybill_uuid}` и сохраняет `entity.url`.
- Ошибка CDEK по одному заказу логируется и не валит весь батч.
- Worker корректно завершает работу по SIGTERM/SIGINT: закрывает HTTP-клиент и
async engine.
## Definition of Done
- [ ] Миграция `20260524_034_add_cdek_polling_columns.py` добавляет
`cdek_order_status` и `cdek_polled_at`; модель синхронизирована.
- [ ] `_save_cdek_order_uuid` и `OrderRepository.mark_cdek_order_registered`
больше не принимают waybill-поля.
- [ ] `CDEKClient.get_order` / `get_waybill` и соответствующие мапперы
(`map_cdek_order_info_response`, `map_cdek_waybill_info_response`,
dataclasses `CDEKOrderInfo`, `CDEKWaybillInfo`) реализованы; `CDEKProvider`
предоставляет обёртки.
- [ ] `app/domain/cdek_polling.py` содержит `TERMINAL_ORDER_STATUSES` и
`is_terminal_order_status`.
- [ ] `OrderRepository.list_orders_pending_waybill`, `record_order_poll`,
`record_waybill_poll` реализованы.
- [ ] `WaybillPollerService.poll_once` и `run_forever` реализованы.
- [ ] `app/workers/waybill_poller.py` поднимает зависимости, поддерживает
graceful shutdown по сигналу.
- [ ] `config.example.yaml` содержит секцию `waybill_poller`, `app/config.py`
`WaybillPollerConfig`.
- [ ] `docker-compose.yml` содержит сервис `waybill-poller`.
## Tests
- `tests/domain/test_cdek_polling.py` — терминальные/нетерминальные статусы.
- `tests/adapters/delivery_providers/cdek/test_order_mapper.py` — мапперы
ответов GET /v2/orders/{uuid} и GET /v2/print/orders/{uuid}.
- `tests/adapters/delivery_providers/cdek/test_order_info_client.py`
`CDEKClient.get_order` и `get_waybill`: 200/4xx/5xx, ретраи, парсинг.
- `tests/repositories/order/test_repository.py``list_orders_pending_waybill`,
`record_order_poll`, `record_waybill_poll` (фильтр, упорядочивание, защита
waybill_uuid/waybill_url от перезаписи).
- `tests/services/test_waybill_poller.py``poll_once` на стабах (два пути,
терминальный статус, изоляция ошибок), `run_forever` завершается по
stop_event.
- `tests/services/test_tbank_notifications.py` — регистрация заказа больше не
сохраняет waybill-поля.
- `tests/workers/test_waybill_poller_main.py``_run` собирает зависимости и
завершается по stop_event.
## Commands
- `poetry run pytest -q`
- `poetry run alembic upgrade head`
- `poetry run python -m app.workers.waybill_poller`
- `python3 spec/gen_spec_index.py --check`
@@ -0,0 +1,87 @@
---
id: 034
title: Add CDEK waybill e-mail sender worker
status: DONE
created: 2026-05-23
---
## Context
После задачи 033 фоновый `WaybillPollerService` дотягивает у CDEK
`cdek_waybill_url` для зарегистрированных заказов и на этом цепочка
обрывается: PDF-накладная остаётся доступной только по URL, клиенту на почту
не отправляется. Адрес получателя уже сохраняется в `Order.account_email`
(приходит из `InitPaymentRequest.account_email`).
## Goal
Добавить отдельный фоновый сервис и worker, который для заказов с
`cdek_waybill_url IS NOT NULL AND waybill_email_sent_at IS NULL` скачивает PDF
накладной у CDEK и отправляет его вложением на `Order.account_email`.
## Constraints
- Запуск — отдельный процесс `python -m app.workers.waybill_email_sender`,
отдельный сервис в `docker-compose.yml`, одна реплика, тот же
`config.yaml`, что и у `waybill-poller`.
- Поллер waybill-данных (033) не меняем; новый сервис — единственный writer
колонки `waybill_email_sent_at`.
- Соблюдение слоёв AGENTS.md: SMTP — только в адаптере, скачивание PDF —
в CDEK-адаптере, работа с БД — в репозитории, оркестрация — в сервисе.
- Текст письма (тема, тело, имя вложения) — приватные константы сервиса,
без отдельного domain-модуля и без шаблонов в config.
- На вход (POST /api/v1/delivery/order) `accountEmail` валидируется как
`pydantic.EmailStr` — заведомо некорректный адрес отбрасывается на
контроллере и не доходит до воркера.
## Acceptance criteria
- При появлении у заказа `cdek_waybill_url` worker в течение
`waybill_email_sender.interval_seconds` скачивает PDF и отправляет письмо
на `Order.account_email`, после чего проставляет `waybill_email_sent_at`.
- Письмо содержит тему `Накладная по заказу {order_uuid}`, тело — краткое
уведомление со ссылкой на `cdek_waybill_url`, и вложение
`waybill_{order_uuid}.pdf` (`application/pdf`).
- Повторный запуск worker-а не приводит к повторной отправке (фильтр
`waybill_email_sent_at IS NULL`).
- Ошибка скачивания PDF или SMTP по одному заказу логируется и не валит
батч; заказ остаётся pending и будет переотправлен в следующем тике.
- `POST /api/v1/delivery/order` с заведомо невалидным `accountEmail`
возвращает 422.
- Worker корректно завершает работу по SIGTERM/SIGINT: закрывает HTTP-
клиент и async engine.
## Definition of Done
- [ ] Миграция `20260524_035_add_waybill_email_sent_at.py` добавляет
`waybill_email_sent_at`; модель синхронизирована.
- [ ] `OrderRepository.list_orders_pending_waybill_email` и
`record_waybill_email_sent` реализованы.
- [ ] `CDEKClient.download_waybill_pdf(url)` реализован.
- [ ] Адаптер `app/adapters/email/smtp_client.py` (`aiosmtplib`) реализован.
- [ ] `WaybillEmailSenderService.poll_once` и `run_forever` реализованы.
- [ ] `app/workers/waybill_email_sender.py` поднимает зависимости и
поддерживает graceful shutdown.
- [ ] `config.example.yaml` содержит секции `email` и
`waybill_email_sender`; `app/config.py``EmailAdapterConfig` и
`WaybillEmailSenderConfig`.
- [ ] `docker-compose.yml` содержит сервис `waybill-email-sender`.
- [ ] `pyproject.toml` содержит `aiosmtplib` и `email-validator`.
- [ ] `InitPaymentRequest.account_email` имеет тип `EmailStr`.
## Tests
- `tests/adapters/email/test_smtp_client.py` — корректное MIME-сообщение,
вложение PDF, проброс SMTP-ошибок.
- `tests/adapters/delivery_providers/cdek/test_waybill_download_client.py`
`download_waybill_pdf`: 200 → bytes, 4xx/5xx, ретраи, auth header.
- `tests/repositories/order/test_repository.py` — расширить:
`list_orders_pending_waybill_email` (фильтр и порядок по `created_at`),
`record_waybill_email_sent` (защита от перезаписи).
- `tests/services/test_waybill_email_sender.py``poll_once` на стабах:
успешная отправка, ошибка скачивания PDF, ошибка SMTP, изоляция ошибок
между заказами, `run_forever` завершается по `stop_event`.
- `tests/workers/test_waybill_email_sender_main.py``_run` собирает
зависимости и завершается по `stop_event`.
- `tests/controllers/v1/test_init_payment.py` — 422 на невалидный
`accountEmail`.
## Commands
- `poetry run pytest -q`
- `poetry run alembic upgrade head`
- `poetry run python -m app.workers.waybill_email_sender`
- `python3 spec/gen_spec_index.py --check`
@@ -13,7 +13,7 @@ from app.adapters.delivery_providers.cdek.client import (
)
from app.cities import cities_map
from app import config as config_module
from app.config import CDEKDeliveryProviderConfig, Settings
from app.config import AdapterConfig, Settings
from app.schemas.request import DeliveryCalculationRequest, DeliveryEntity
@@ -287,17 +287,14 @@ repository:
redis_dsn: "redis://localhost:6379/0"
price_cache_ttl_seconds: 900
delivery_providers:
cdek:
base_url: "https://api.cdek.test/v2"
client_id: "yaml-id"
client_secret: "yaml-secret"
retry_attempts: 0
retry_backoff_seconds: 0.1
timeout_seconds: 7.5
cache_ttl_seconds: 777
cse:
enabled: false
adapter:
cdek_base_url: "https://api.cdek.test/v2"
cdek_client_id: "yaml-id"
cdek_client_secret: "yaml-secret"
cdek_retry_attempts: 0
cdek_retry_backoff_seconds: 0.1
cdek_timeout_seconds: 7.5
cdek_cache_ttl_seconds: 777
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
@@ -326,9 +323,9 @@ email:
monkeypatch.setattr(config_module, "_resolve_runtime_config_file", lambda: str(config_file))
settings = Settings()
http_client = RecordingHTTPClient()
provider = CDEKProvider.from_config(
provider = CDEKProvider.from_adapter_config(
http_client=http_client, # type: ignore[arg-type]
config=settings.delivery_providers.cdek,
adapter_config=settings.adapter,
)
result = asyncio.run(provider.get_prices(_make_request()))
@@ -352,14 +349,14 @@ email:
def test_provider_uses_default_adapter_timeout_and_cache_ttl() -> None:
config = CDEKDeliveryProviderConfig(
client_id="default-id",
client_secret="default-secret",
adapter_config = AdapterConfig(
cdek_client_id="default-id",
cdek_client_secret="default-secret",
)
http_client = RecordingHTTPClient()
provider = CDEKProvider.from_config(
provider = CDEKProvider.from_adapter_config(
http_client=http_client, # type: ignore[arg-type]
config=config,
adapter_config=adapter_config,
)
result = asyncio.run(provider.get_prices(_make_request()))
@@ -34,7 +34,7 @@ def test_map_cdek_response_maps_all_tariffs_to_unified_model() -> None:
assert [price.currency for price in result] == ["RUB", "USD"]
assert [price.delivery_days_min for price in result] == [2, 5]
assert [price.delivery_days_max for price in result] == [4, 7]
assert [price.tariff_code for price in result] == ["7", "136"]
assert [price.tariff_code for price in result] == [7, 136]
def test_map_cdek_response_returns_none_tariff_code_when_missing() -> None:
@@ -1,6 +1,5 @@
import asyncio
from typing import Any
from unittest.mock import patch
import httpx
import pytest
@@ -65,10 +64,10 @@ def test_get_order_parses_status_and_waybill_uuid() -> None:
"statuses": [
{"code": "ACCEPTED", "date_time": "2026-05-24T10:00:00+0000"}
],
"related_entities": [
{"type": "waybill", "uuid": "waybill-uuid-1"}
],
},
"related_entities": [
{"type": "waybill", "uuid": "waybill-uuid-1"}
],
},
request=httpx.Request("GET", "https://api.cdek.test/v2/orders/cdek-order-uuid"),
)
@@ -88,55 +87,6 @@ def test_get_order_parses_status_and_waybill_uuid() -> None:
assert http_client.calls[0]["headers"] == {"Authorization": "Bearer test-token"}
def test_get_order_logs_request_errors() -> None:
response = httpx.Response(
200,
json={
"entity": {"uuid": "cdek-order-uuid", "statuses": []},
"requests": [
{
"request_uuid": "request-uuid",
"type": "CREATE",
"state": "INVALID",
"errors": [
{
"code": "invalid_order",
"message": "Order data is invalid",
}
],
}
],
},
request=httpx.Request(
"GET", "https://api.cdek.test/v2/orders/cdek-order-uuid"
),
)
client = _make_client(SequenceHTTPClient([response]))
with patch(
"app.adapters.delivery_providers.cdek.client.log.warning"
) as warning_mock:
asyncio.run(client.get_order("cdek-order-uuid"))
warning_mock.assert_called_once_with(
"cdek_order_request_errors",
cdek_order_uuid="cdek-order-uuid",
requests=[
{
"request_uuid": "request-uuid",
"type": "CREATE",
"state": "INVALID",
"errors": [
{
"code": "invalid_order",
"message": "Order data is invalid",
}
],
}
],
)
def test_get_order_retries_on_5xx_and_succeeds() -> None:
flaky = httpx.Response(
503,
@@ -29,23 +29,6 @@ def test_order_payload_requests_waybill_print() -> None:
assert payload["print"] == "WAYBILL"
def test_order_payload_maps_declared_value_to_insurance_service() -> None:
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
assert payload["services"] == [{"code": "INSURANCE", "parameter": 50000}]
def test_order_payload_omits_insurance_service_for_zero_declared_value() -> None:
payload = map_cdek_order_request(
make_init_payment_request(
content={"description": "Docs", "declared_value": 0}
),
"order-uuid-1",
)
assert "services" not in payload
def test_map_cdek_order_response_extracts_order_uuid_without_waybill() -> None:
result = map_cdek_order_response({"entity": {"uuid": "cdek-order-uuid"}})
@@ -57,37 +40,15 @@ def test_map_cdek_order_response_extracts_order_uuid_without_waybill() -> None:
def test_map_cdek_order_response_extracts_waybill_from_related_entities() -> None:
result = map_cdek_order_response(
{
"entity": {
"uuid": "cdek-order-uuid",
"related_entities": [
{"type": "delivery", "uuid": "ignored"},
{
"type": "waybill",
"uuid": "waybill-uuid-1",
"url": "https://cdek.test/waybill/1.pdf",
},
],
},
}
)
assert result == CDEKOrderRegistrationResult(
order_uuid="cdek-order-uuid",
waybill_uuid="waybill-uuid-1",
waybill_url="https://cdek.test/waybill/1.pdf",
)
def test_map_cdek_order_response_supports_root_related_entities() -> None:
result = map_cdek_order_response(
{
"entity": {"uuid": "cdek-order-uuid"},
"related_entities": [
{"type": "delivery", "uuid": "ignored"},
{
"type": "waybill",
"uuid": "waybill-uuid-legacy",
"uuid": "waybill-uuid-1",
"url": "https://cdek.test/waybill/1.pdf",
},
],
}
@@ -95,8 +56,8 @@ def test_map_cdek_order_response_supports_root_related_entities() -> None:
assert result == CDEKOrderRegistrationResult(
order_uuid="cdek-order-uuid",
waybill_uuid="waybill-uuid-legacy",
waybill_url=None,
waybill_uuid="waybill-uuid-1",
waybill_url="https://cdek.test/waybill/1.pdf",
)
@@ -160,7 +121,7 @@ def test_order_payload_for_parcel_includes_dimensions() -> None:
def test_order_payload_for_doc_omits_dimensions() -> None:
request = make_init_payment_request(
content={"description": "Docs", "declared_value": 25000},
content={"description": "Docs"},
systemData={
"tariff": {
"provider": "СДЭК",
@@ -168,7 +129,7 @@ def test_order_payload_for_doc_omits_dimensions() -> None:
"price": 50000,
"deliveryDaysMin": 1,
"deliveryDaysMax": 2,
"tariffCode": "535",
"tariffCode": 535,
},
"parcelType": "doc",
"docPackaging": "envelope",
@@ -215,7 +176,7 @@ def test_order_payload_includes_company_requisites_for_legal_entity() -> None:
assert sender["kpp"] == "770701001"
def test_order_payload_maps_content_to_comments_without_items() -> None:
def test_order_payload_propagates_description_to_comments_without_items() -> None:
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
assert payload["comment"] == "Headphones"
@@ -225,14 +186,10 @@ def test_order_payload_maps_content_to_comments_without_items() -> None:
def test_order_payload_falls_back_package_comment_to_order_uuid() -> None:
payload = map_cdek_order_request(
make_init_payment_request(
content={"description": None, "declared_value": 50000}
),
"order-uuid-1",
make_init_payment_request(content={"description": None}), "order-uuid-1"
)
assert payload["packages"][0]["comment"] == "order-uuid-1"
assert "items" not in payload["packages"][0]
def test_order_payload_includes_company_for_individual_sender_as_full_name() -> None:
@@ -267,10 +224,10 @@ def test_map_cdek_order_info_response_picks_latest_status_by_date_time() -> None
{"code": "ACCEPTED", "date_time": "2026-05-24T10:00:00+0000"},
{"code": "INVALID", "date_time": "2026-05-24T10:00:05+0000"},
],
"related_entities": [
{"type": "waybill", "uuid": "waybill-uuid-1"}
],
},
"related_entities": [
{"type": "waybill", "uuid": "waybill-uuid-1"}
],
}
)
@@ -293,37 +250,6 @@ def test_map_cdek_order_info_response_returns_none_status_when_statuses_empty()
)
def test_map_cdek_order_info_response_maps_invalid_create_request_to_invalid_status() -> None:
result = map_cdek_order_info_response(
{
"entity": {
"uuid": "cdek-order-uuid",
"statuses": [
{"code": "ACCEPTED", "date_time": "2026-05-24T10:00:00+0000"}
],
},
"requests": [
{
"type": "CREATE",
"state": "INVALID",
"errors": [
{
"code": "ve_delivery_can_not_has_goods",
"message": "Заказ типа доставка не может содержать товары",
}
],
}
],
}
)
assert result == CDEKOrderInfo(
order_uuid="cdek-order-uuid",
status_code="INVALID",
waybill_uuid=None,
)
def test_map_cdek_order_info_response_raises_for_missing_entity() -> None:
with pytest.raises(CDEKOrderMappingError):
map_cdek_order_info_response({"requests": []})
@@ -124,7 +124,7 @@ def test_provider_get_payment_price_omits_dimensions_for_doc() -> None:
)
)
request = make_init_payment_request(
content={"description": "Docs", "declared_value": 25000},
content={"description": "Docs"},
systemData={
"tariff": {
"provider": "СДЭК",
@@ -132,7 +132,7 @@ def test_provider_get_payment_price_omits_dimensions_for_doc() -> None:
"price": 50000,
"deliveryDaysMin": 1,
"deliveryDaysMax": 2,
"tariffCode": "535",
"tariffCode": 535,
},
"parcelType": "doc",
"docPackaging": "envelope",
@@ -1,750 +0,0 @@
import asyncio
from decimal import Decimal
from typing import Any
import httpx
import pytest
from app.adapters.delivery_providers.cse import (
CSEClient,
CSEClientError,
CSEProvider,
CSERequestError,
)
from app.adapters.delivery_providers.cse import order_mapper
from app.adapters.delivery_providers.cse.order_mapper import (
CSEOrderRegistrationParams,
)
from app.adapters.delivery_providers.cse.soap import (
build_envelope,
parse_response,
)
from app.schemas.request import DeliveryCalculationRequest, DeliveryEntity
from tests.payment_fixtures import make_init_payment_request
_CALC_RESPONSE = """<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">
<soap:Body>
<m:CalcResponse xmlns:m="http://www.cargo3.ru">
<m:return>
<m:Key>Calc</m:Key>
<m:List>
<m:Key>Destination</m:Key>
<m:List>
<m:Key>Tariff</m:Key>
<m:Value>tariff-guid-1</m:Value>
<m:Fields><m:Key>Total</m:Key><m:Value>1114.92</m:Value></m:Fields>
<m:Fields><m:Key>CurrencyName</m:Key><m:Value>RUR</m:Value></m:Fields>
<m:Fields><m:Key>Service</m:Key><m:Value>Россия доставка</m:Value></m:Fields>
<m:Fields><m:Key>Urgency</m:Key><m:Value>urg-std</m:Value></m:Fields>
<m:Fields><m:Key>MinPeriod</m:Key><m:Value>3</m:Value></m:Fields>
<m:Fields><m:Key>MaxPeriod</m:Key><m:Value>5</m:Value></m:Fields>
</m:List>
<m:List>
<m:Key>Tariff</m:Key>
<m:Value>tariff-guid-2</m:Value>
<m:Fields><m:Key>Total</m:Key><m:Value>2000.00</m:Value></m:Fields>
<m:Fields><m:Key>CurrencyName</m:Key><m:Value>RUR</m:Value></m:Fields>
<m:Fields><m:Key>Service</m:Key><m:Value>Экспресс</m:Value></m:Fields>
<m:Fields><m:Key>Urgency</m:Key><m:Value>urg-exp</m:Value></m:Fields>
<m:Fields><m:Key>MinPeriod</m:Key><m:Value>1</m:Value></m:Fields>
<m:Fields><m:Key>MaxPeriod</m:Key><m:Value>2</m:Value></m:Fields>
</m:List>
</m:List>
</m:return>
</m:CalcResponse>
</soap:Body>
</soap:Envelope>"""
_SAVE_RESPONSE = """<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">
<soap:Body>
<m:SaveDocumentsResponse xmlns:m="http://www.cargo3.ru">
<m:return>
<m:Key>SaveDocuments</m:Key>
<m:List>
<m:Key>Order</m:Key>
<m:Properties><m:Key>Number</m:Key><m:Value>CSE-000123</m:Value></m:Properties>
</m:List>
</m:return>
</m:SaveDocumentsResponse>
</soap:Body>
</soap:Envelope>"""
_SAVE_RESPONSE_DOCUMENT_ERROR = """<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">
<soap:Body>
<m:SaveDocumentsResponse xmlns:m="http://www.cargo3.ru">
<m:return>
<m:Key>SaveDocuments</m:Key>
<m:List>
<m:Key>Order</m:Key>
<m:Properties>
<m:Key>Error</m:Key>
<m:Value>true</m:Value>
<m:ValueType>boolean</m:ValueType>
<m:List>
<m:Key>Description</m:Key>
<m:Value>SenderAddress is invalid</m:Value>
</m:List>
</m:Properties>
</m:List>
</m:return>
</m:SaveDocumentsResponse>
</soap:Body>
</soap:Envelope>"""
_TRACKING_RESPONSE = """<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">
<soap:Body>
<m:TrackingResponse xmlns:m="http://www.cargo3.ru">
<m:return>
<m:Key>Tracking</m:Key>
<m:List>
<m:Key>CSE-000123</m:Key>
<m:Value>Order</m:Value>
<m:Properties><m:Key>Number</m:Key><m:Value>CSE-000123</m:Value></m:Properties>
<m:List>
<m:Key>Заказ принят, идет обработка заказа.</m:Key>
<m:Properties>
<m:Key>DateTime</m:Key><m:Value>2026-06-01T10:00:00</m:Value>
</m:Properties>
</m:List>
<m:List>
<m:Key>Накладная оформлена.</m:Key>
<m:Properties>
<m:Key>DateTime</m:Key><m:Value>2026-06-01T10:02:00</m:Value>
</m:Properties>
</m:List>
<m:Tables>
<m:Key>Waybills</m:Key>
<m:List>
<m:Key>496-AA-1676378</m:Key>
<m:Properties>
<m:Key>DocumentType</m:Key><m:Value>Waybill</m:Value>
</m:Properties>
<m:Properties>
<m:Key>Number</m:Key><m:Value>496-AA-1676378</m:Value>
</m:Properties>
</m:List>
</m:Tables>
</m:List>
</m:return>
</m:TrackingResponse>
</soap:Body>
</soap:Envelope>"""
_FORM_RESPONSE = """<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">
<soap:Body>
<m:GetFormsForDocumentsResponse xmlns:m="http://www.cargo3.ru">
<m:return>
<m:Key>GetPrintForms</m:Key>
<m:List>
<m:Key>496-AA-1676378</m:Key>
<m:Properties><m:Key>FormFormat</m:Key><m:Value>PDF</m:Value></m:Properties>
<m:BData>JVBERg==</m:BData>
</m:List>
</m:return>
</m:GetFormsForDocumentsResponse>
</soap:Body>
</soap:Envelope>"""
_CALC_RESPONSE_WITH_ADDITIONAL_SERVICE_FIRST = """<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">
<soap:Body>
<m:CalcResponse xmlns:m="http://www.cargo3.ru">
<m:return>
<m:Key>Calc</m:Key>
<m:List>
<m:Key>Destination</m:Key>
<m:List>
<m:Key>Tariff</m:Key>
<m:Value>additional-service-guid</m:Value>
<m:Fields><m:Key>Total</m:Key><m:Value>100.00</m:Value></m:Fields>
<m:Fields><m:Key>CurrencyName</m:Key><m:Value>RUR</m:Value></m:Fields>
<m:Fields>
<m:Key>Service</m:Key><m:Value>Доп. услуга</m:Value>
</m:Fields>
<m:Fields><m:Key>Urgency</m:Key><m:Value>urg-exp</m:Value></m:Fields>
<m:Fields>
<m:Key>AdditionalService</m:Key><m:Value>true</m:Value>
</m:Fields>
</m:List>
<m:List>
<m:Key>Tariff</m:Key>
<m:Value>delivery-guid</m:Value>
<m:Fields><m:Key>Total</m:Key><m:Value>793.00</m:Value></m:Fields>
<m:Fields><m:Key>CurrencyName</m:Key><m:Value>RUR</m:Value></m:Fields>
<m:Fields><m:Key>Service</m:Key><m:Value>Экспресс</m:Value></m:Fields>
<m:Fields><m:Key>Urgency</m:Key><m:Value>urg-exp</m:Value></m:Fields>
<m:Fields>
<m:Key>AdditionalService</m:Key><m:Value>false</m:Value>
</m:Fields>
<m:Fields><m:Key>MinPeriod</m:Key><m:Value>1</m:Value></m:Fields>
<m:Fields><m:Key>MaxPeriod</m:Key><m:Value>2</m:Value></m:Fields>
</m:List>
</m:List>
</m:return>
</m:CalcResponse>
</soap:Body>
</soap:Envelope>"""
_CALC_RESPONSE_WITH_BUYOUT_SERVICE_FIRST = """<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">
<soap:Body>
<m:CalcResponse xmlns:m="http://www.cargo3.ru">
<m:return>
<m:Key>Calc</m:Key>
<m:List>
<m:Key>Destination</m:Key>
<m:List>
<m:Key>Tariff</m:Key>
<m:Value>buyout-service-guid</m:Value>
<m:Fields><m:Key>Total</m:Key><m:Value>100.00</m:Value></m:Fields>
<m:Fields><m:Key>CurrencyName</m:Key><m:Value>RUR</m:Value></m:Fields>
<m:Fields>
<m:Key>Service</m:Key><m:Value>Частичный выкуп</m:Value>
</m:Fields>
<m:Fields><m:Key>Urgency</m:Key><m:Value>urg-exp</m:Value></m:Fields>
</m:List>
<m:List>
<m:Key>Tariff</m:Key>
<m:Value>delivery-guid</m:Value>
<m:Fields><m:Key>Total</m:Key><m:Value>793.00</m:Value></m:Fields>
<m:Fields><m:Key>CurrencyName</m:Key><m:Value>RUR</m:Value></m:Fields>
<m:Fields><m:Key>Service</m:Key><m:Value>Экспресс</m:Value></m:Fields>
<m:Fields><m:Key>Urgency</m:Key><m:Value>urg-exp</m:Value></m:Fields>
<m:Fields>
<m:Key>AdditionalService</m:Key><m:Value>false</m:Value>
</m:Fields>
<m:Fields><m:Key>MinPeriod</m:Key><m:Value>1</m:Value></m:Fields>
<m:Fields><m:Key>MaxPeriod</m:Key><m:Value>2</m:Value></m:Fields>
</m:List>
</m:List>
</m:return>
</m:CalcResponse>
</soap:Body>
</soap:Envelope>"""
_ERROR_RESPONSE = """<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">
<soap:Body>
<m:CalcResponse xmlns:m="http://www.cargo3.ru">
<m:return>
<m:Key>Calc</m:Key>
<m:Properties>
<m:Key>Error</m:Key>
<m:List><m:Key>Description</m:Key><m:Value>1001</m:Value></m:List>
</m:Properties>
</m:return>
</m:CalcResponse>
</soap:Body>
</soap:Envelope>"""
_DELIVERY_TYPES_RESPONSE = """<?xml version="1.0" encoding="UTF-8"?>
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">
<soap:Body>
<m:GetReferenceDataResponse xmlns:m="http://www.cargo3.ru">
<m:return>
<m:Key>deliverytype</m:Key>
<m:List>
<m:Key>NameOfdeliverytype</m:Key>
<m:Value>ДоставкаДоДверей</m:Value>
<m:Fields><m:Key>Information</m:Key><m:Value>Дверь-Дверь</m:Value></m:Fields>
</m:List>
<m:List>
<m:Key>NameOfdeliverytype</m:Key>
<m:Value>СкладДверь</m:Value>
<m:Fields><m:Key>Information</m:Key><m:Value>Склад-Дверь</m:Value></m:Fields>
</m:List>
</m:return>
</m:GetReferenceDataResponse>
</soap:Body>
</soap:Envelope>"""
class SequenceHTTPClient:
def __init__(self, results: list[Any]) -> None:
self._results = results
self.calls: list[dict[str, Any]] = []
async def post(
self,
url: str,
*,
content: bytes | None = None,
headers: dict[str, str] | None = None,
timeout: float | None = None,
) -> httpx.Response:
self.calls.append({"url": url, "content": content, "headers": headers})
result = self._results[len(self.calls) - 1]
if isinstance(result, Exception):
raise result
return result
def _params() -> CSEOrderRegistrationParams:
return CSEOrderRegistrationParams(
payer="payer-1",
payment_method="pm-1",
shipping_method="sm-1",
)
def _build_client(results: list[Any]) -> tuple[CSEClient, SequenceHTTPClient]:
http_client = SequenceHTTPClient(results)
client = CSEClient(
http_client=http_client, # type: ignore[arg-type]
base_url="http://lk-test.cse.ru/1c/ws/web1c.1cws",
login="test",
password="2016",
registration_params=_params(),
retry_attempts=0,
)
return client, http_client
@pytest.fixture(autouse=True)
def _patch_references(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
order_mapper, "resolve_cse_geography", lambda city_id: f"geo-{city_id}"
)
monkeypatch.setattr(
order_mapper, "resolve_type_of_cargo", lambda parcel_type: "cargo-guid"
)
def _calc_request() -> DeliveryCalculationRequest:
return DeliveryCalculationRequest(
entity=DeliveryEntity.INDIVIDUAL,
from_city=1,
to_city=2,
weight_kg=1.0,
length_cm=10.0,
width_cm=10.0,
height_cm=10.0,
)
def test_build_envelope_contains_credentials_and_payload() -> None:
body = order_mapper.build_calc_body_for_calculation(_calc_request())
envelope = build_envelope("Calc", login="test", password="2016", body=body)
text = envelope.decode("utf-8")
assert "Calc" in text
assert "<m:login>test</m:login>" in text or ">test<" in text
assert "geo-1" in text
assert "geo-2" in text
def test_save_order_envelope_serializes_properties_before_fields() -> None:
body = order_mapper.map_cse_save_order_request(
make_init_payment_request(
systemData={
"tariff": {
"provider": "cse",
"serviceName": "Экспресс",
"price": 200000,
"deliveryDaysMin": 1,
"deliveryDaysMax": 2,
"tariffCode": "ДоставкаДоДверей|tariff-guid-2|urg-exp",
},
"parcelType": "parcel",
"weight": "1.0",
"dimensions": {"length": "10", "width": "10", "height": "10"},
}
),
"order-uuid-1",
_params(),
)
text = build_envelope(
"SaveDocuments", login="test", password="2016", body=body
).decode("utf-8")
order_start = text.index("<m:Key>Order</m:Key>")
properties_index = text.index("<m:Properties>", order_start)
fields_index = text.index("<m:Fields>", order_start)
assert properties_index < fields_index
def test_parse_calc_response_round_trip() -> None:
root = parse_response(_CALC_RESPONSE, "Calc")
assert root.key == "Calc"
assert root.items[0].items[0].field_value("Total") == "1114.92"
def test_provider_get_prices_maps_all_tariffs() -> None:
client, http_client = _build_client(
[
httpx.Response(200, text=_DELIVERY_TYPES_RESPONSE),
httpx.Response(200, text=_CALC_RESPONSE),
httpx.Response(200, text=_CALC_RESPONSE),
]
)
provider = CSEProvider(
client,
delivery_service_guids=("tariff-guid-1", "tariff-guid-2"),
)
prices = asyncio.run(provider.get_prices(_calc_request()))
# PVZ-requiring scheme (Склад-Дверь) is excluded: only delivery types + one
# Calc per configured CSE delivery service are requested.
assert len(http_client.calls) == 3
assert [price.provider for price in prices] == ["cse", "cse"]
assert [price.tariff_code for price in prices] == [
"ДоставкаДоДверей|tariff-guid-1|urg-std",
"ДоставкаДоДверей|tariff-guid-2|urg-exp",
]
assert prices[0].price == Decimal("1114.92")
assert prices[0].currency == "RUB"
assert prices[0].delivery_days_min == 3
assert prices[0].delivery_days_max == 5
assert prices[0].service_name == "Россия доставка — Дверь-Дверь"
def test_provider_get_payment_price_selects_matching_tariff() -> None:
response = httpx.Response(200, text=_CALC_RESPONSE)
client, http_client = _build_client([response])
provider = CSEProvider(client)
request = make_init_payment_request(
systemData={
"tariff": {
"provider": "cse",
"serviceName": "Экспресс",
"price": 200000,
"deliveryDaysMin": 1,
"deliveryDaysMax": 2,
"tariffCode": "ДоставкаДоДверей|tariff-guid-2|urg-exp",
},
"parcelType": "parcel",
"weight": "1.0",
"dimensions": {"length": "10", "width": "10", "height": "10"},
}
)
price = asyncio.run(provider.get_payment_price(request))
assert price is not None
assert price.tariff_code == "ДоставкаДоДверей|tariff-guid-2|urg-exp"
assert price.price == Decimal("2000.00")
content = http_client.calls[0]["content"].decode("utf-8")
assert "Service" in content
assert "tariff-guid-2" in content
def test_provider_get_payment_price_ignores_matching_additional_service() -> None:
response = httpx.Response(200, text=_CALC_RESPONSE_WITH_ADDITIONAL_SERVICE_FIRST)
client, _ = _build_client([response])
provider = CSEProvider(client)
request = make_init_payment_request(
systemData={
"tariff": {
"provider": "cse",
"serviceName": "Экспресс",
"price": 79300,
"deliveryDaysMin": 1,
"deliveryDaysMax": 2,
"tariffCode": "ДоставкаДоДверей|delivery-guid|urg-exp",
},
"parcelType": "parcel",
"weight": "1.0",
"dimensions": {"length": "10", "width": "10", "height": "10"},
}
)
price = asyncio.run(provider.get_payment_price(request))
assert price is not None
assert price.tariff_code == "ДоставкаДоДверей|delivery-guid|urg-exp"
assert price.price == Decimal("793.00")
def test_provider_get_payment_price_selects_matching_service_guid() -> None:
response = httpx.Response(200, text=_CALC_RESPONSE_WITH_BUYOUT_SERVICE_FIRST)
client, _ = _build_client([response])
provider = CSEProvider(client)
request = make_init_payment_request(
systemData={
"tariff": {
"provider": "cse",
"serviceName": "Экспресс",
"price": 79300,
"deliveryDaysMin": 1,
"deliveryDaysMax": 2,
"tariffCode": "ДоставкаДоДверей|delivery-guid|urg-exp",
},
"parcelType": "parcel",
"weight": "1.0",
"dimensions": {"length": "10", "width": "10", "height": "10"},
}
)
price = asyncio.run(provider.get_payment_price(request))
assert price is not None
assert price.tariff_code == "ДоставкаДоДверей|delivery-guid|urg-exp"
assert price.price == Decimal("793.00")
def test_provider_get_prices_excludes_additional_service_tariffs() -> None:
client, _ = _build_client(
[
httpx.Response(200, text=_DELIVERY_TYPES_RESPONSE),
httpx.Response(200, text=_CALC_RESPONSE_WITH_ADDITIONAL_SERVICE_FIRST),
]
)
provider = CSEProvider(client, delivery_service_guids=("delivery-guid",))
prices = asyncio.run(provider.get_prices(_calc_request()))
assert [price.price for price in prices] == [Decimal("793.00")]
assert [price.service_name for price in prices] == [
"Экспресс — Дверь-Дверь"
]
def test_provider_get_prices_uses_configured_service_guid_filter() -> None:
client, http_client = _build_client(
[
httpx.Response(200, text=_DELIVERY_TYPES_RESPONSE),
httpx.Response(200, text=_CALC_RESPONSE_WITH_BUYOUT_SERVICE_FIRST),
]
)
provider = CSEProvider(client, delivery_service_guids=("delivery-guid",))
prices = asyncio.run(provider.get_prices(_calc_request()))
assert [price.price for price in prices] == [Decimal("793.00")]
assert [price.service_name for price in prices] == [
"Экспресс — Дверь-Дверь"
]
content = http_client.calls[1]["content"].decode("utf-8")
assert "Service" in content
assert "delivery-guid" in content
def test_provider_get_prices_requires_delivery_service_guid_configuration() -> None:
client, _ = _build_client([httpx.Response(200, text=_DELIVERY_TYPES_RESPONSE)])
provider = CSEProvider(client)
with pytest.raises(CSERequestError):
asyncio.run(provider.get_prices(_calc_request()))
def test_provider_register_order_returns_document_number() -> None:
response = httpx.Response(200, text=_SAVE_RESPONSE)
client, http_client = _build_client([response])
provider = CSEProvider(client)
result = asyncio.run(
provider.register_order(
make_init_payment_request(
systemData={
"tariff": {
"provider": "cse",
"serviceName": "Экспресс",
"price": 200000,
"deliveryDaysMin": 1,
"deliveryDaysMax": 2,
"tariffCode": "ДоставкаДоДверей|tariff-guid-2|urg-exp",
},
"parcelType": "parcel",
"weight": "1.0",
"dimensions": {"length": "10", "width": "10", "height": "10"},
}
),
"order-uuid-1",
)
)
assert result.order_number == "CSE-000123"
assert http_client.calls[0]["url"] == "http://lk-test.cse.ru/1c/ws/web1c.1cws"
def test_provider_register_order_raises_document_level_error() -> None:
response = httpx.Response(200, text=_SAVE_RESPONSE_DOCUMENT_ERROR)
client, _ = _build_client([response])
provider = CSEProvider(client)
with pytest.raises(CSERequestError, match="SenderAddress is invalid"):
asyncio.run(
provider.register_order(
make_init_payment_request(
systemData={
"tariff": {
"provider": "cse",
"serviceName": "Экспресс",
"price": 200000,
"deliveryDaysMin": 1,
"deliveryDaysMax": 2,
"tariffCode": "ДоставкаДоДверей|tariff-guid-2|urg-exp",
},
"parcelType": "parcel",
"weight": "1.0",
"dimensions": {
"length": "10",
"width": "10",
"height": "10",
},
}
),
"order-uuid-1",
)
)
def test_provider_get_order_extracts_waybill_number_from_tracking() -> None:
response = httpx.Response(200, text=_TRACKING_RESPONSE)
client, http_client = _build_client([response])
provider = CSEProvider(client)
result = asyncio.run(provider.get_order("CSE-000123"))
assert result.order_number == "CSE-000123"
assert result.status_code == "Накладная оформлена."
assert result.waybill_number == "496-AA-1676378"
content = http_client.calls[0]["content"].decode("utf-8")
assert "Tracking" in content
assert "DocumentType" in content
assert "Order" in content
def test_provider_download_waybill_pdf_uses_print_form() -> None:
response = httpx.Response(200, text=_FORM_RESPONSE)
client, http_client = _build_client([response])
provider = CSEProvider(client)
pdf = asyncio.run(provider.download_waybill_pdf("496-AA-1676378"))
assert pdf == b"%PDF"
content = http_client.calls[0]["content"].decode("utf-8")
assert "GetFormsForDocuments" in content
assert "DocumentType" in content
assert "waybill" in content
assert "Type" in content
assert "print" in content
assert "Format" in content
assert "pdf" in content
def test_save_order_request_uses_selected_tariff_urgency() -> None:
request = make_init_payment_request(
systemData={
"tariff": {
"provider": "cse",
"serviceName": "Экспресс",
"price": 200000,
"deliveryDaysMin": 1,
"deliveryDaysMax": 2,
"tariffCode": "ДоставкаДоДверей|tariff-guid-2|urg-exp",
},
"parcelType": "parcel",
"weight": "1.0",
"dimensions": {"length": "10", "width": "10", "height": "10"},
}
)
body = order_mapper.map_cse_save_order_request(request, "order-uuid-1", _params())
order = body["data"].items[0]
assert order.field_value("Urgency") == "urg-exp"
assert order.field_value("DeliveryOfCargo") == "ДоставкаДоДверей"
assert order.field_value("Payer") == "payer-1"
assert order.field_value("ShippingMethod") == "sm-1"
assert order.field_value("CargoCost") == "50000"
def test_save_order_request_uses_pickup_date_plus_seven_days_as_delivery_date() -> None:
request = make_init_payment_request(
deliveryDate="2026-05-18T18:00:00.000Z",
systemData={
"tariff": {
"provider": "cse",
"serviceName": "Экспресс",
"price": 200000,
"deliveryDaysMin": 1,
"deliveryDaysMax": 2,
"tariffCode": "ДоставкаДоДверей|tariff-guid-2|urg-exp",
},
"parcelType": "parcel",
"weight": "1.0",
"dimensions": {"length": "10", "width": "10", "height": "10"},
},
)
body = order_mapper.map_cse_save_order_request(request, "order-uuid-1", _params())
order = body["data"].items[0]
assert order.field_value("TakeDate") == "2026-05-15T10:00:00"
assert order.field_value("DeliveryDate") == "2026-05-22T10:00:00"
def test_application_error_in_response_raises_request_error() -> None:
client, _ = _build_client(
[
httpx.Response(200, text=_DELIVERY_TYPES_RESPONSE),
httpx.Response(200, text=_ERROR_RESPONSE),
]
)
provider = CSEProvider(client, delivery_service_guids=("delivery-guid",))
with pytest.raises(CSERequestError):
asyncio.run(provider.get_prices(_calc_request()))
def test_server_error_logs_response_excerpt(monkeypatch: pytest.MonkeyPatch) -> None:
warning_calls: list[dict[str, Any]] = []
def capture_warning(event: str, **kwargs: Any) -> None:
warning_calls.append({"event": event, **kwargs})
monkeypatch.setattr(
"app.adapters.delivery_providers.cse.client.log.warning",
capture_warning,
)
response = httpx.Response(
500,
text="<fault>\n CSE rejected SaveDocuments because field X is invalid \n</fault>",
)
client, _ = _build_client([response])
provider = CSEProvider(client)
with pytest.raises(CSEClientError):
asyncio.run(
provider.register_order(
make_init_payment_request(
systemData={
"tariff": {
"provider": "cse",
"serviceName": "Экспресс",
"price": 200000,
"deliveryDaysMin": 1,
"deliveryDaysMax": 2,
"tariffCode": "ДоставкаДоДверей|tariff-guid-2|urg-exp",
},
"parcelType": "parcel",
"weight": "1.0",
"dimensions": {
"length": "10",
"width": "10",
"height": "10",
},
}
),
"order-uuid-1",
)
)
assert warning_calls == [
{
"event": "cse_request_server_error",
"operation": "SaveDocuments",
"status_code": 500,
"response_excerpt": (
"<fault> CSE rejected SaveDocuments because field X is invalid "
"</fault>"
),
}
]
@@ -1,50 +0,0 @@
import asyncio
import httpx
from app.adapters.delivery_providers.registry import (
build_delivery_provider_registry,
resolve_delivery_provider_timeout_seconds,
)
from app.config import (
CDEKDeliveryProviderConfig,
CSEDeliveryProviderConfig,
DeliveryProvidersConfig,
)
def test_registry_builds_enabled_provider_capability_maps() -> None:
config = DeliveryProvidersConfig(
cdek=CDEKDeliveryProviderConfig(
base_url="https://cdek.test/v2",
client_id="id",
client_secret="secret",
cache_ttl_seconds=111,
),
cse=CSEDeliveryProviderConfig(
enabled=False,
),
)
http_client = httpx.AsyncClient()
try:
registry = build_delivery_provider_registry(
http_client=http_client,
config=config,
)
finally:
asyncio.run(http_client.aclose())
assert [provider.name for provider in registry.providers] == ["cdek"]
assert sorted(registry.payment_price_validation_adapters) == ["cdek"]
assert sorted(registry.order_registration_adapters) == ["cdek"]
assert registry.providers[0].cache_ttl_seconds == 111
def test_resolve_delivery_provider_timeout_uses_max_enabled_timeout() -> None:
config = DeliveryProvidersConfig(
cdek=CDEKDeliveryProviderConfig(timeout_seconds=3.0),
cse=CSEDeliveryProviderConfig(timeout_seconds=7.5),
)
assert resolve_delivery_provider_timeout_seconds(config) == 7.5
+3 -6
View File
@@ -11,12 +11,9 @@ business_logic:
repository:
redis_dsn: "redis://localhost:6379/0"
delivery_providers:
cdek:
client_id: "yaml-id"
client_secret: "yaml-secret"
cse:
enabled: false
adapter:
cdek_client_id: "yaml-id"
cdek_client_secret: "yaml-secret"
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
@@ -14,17 +14,14 @@ repository:
redis_dsn: "redis://localhost:6379/0"
price_cache_ttl_seconds: 900
delivery_providers:
cdek:
base_url: "https://api.cdek.ru/v2"
client_id: "test-client-id"
client_secret: "test-client-secret"
retry_attempts: 2
retry_backoff_seconds: 0.2
timeout_seconds: 10.0
cache_ttl_seconds: 900
cse:
enabled: false
adapter:
cdek_base_url: "https://api.cdek.ru/v2"
cdek_client_id: "test-client-id"
cdek_client_secret: "test-client-secret"
cdek_retry_attempts: 2
cdek_retry_backoff_seconds: 0.2
cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
@@ -14,17 +14,14 @@ repository:
redis_dsn: "redis://localhost:6379/0"
price_cache_ttl_seconds: 900
delivery_providers:
cdek:
base_url: "https://api.cdek.ru/v2"
client_id: "test-client-id"
client_secret: "test-client-secret"
retry_attempts: 2
retry_backoff_seconds: 0.2
timeout_seconds: 10.0
cache_ttl_seconds: 900
cse:
enabled: false
adapter:
cdek_base_url: "https://api.cdek.ru/v2"
cdek_client_id: "test-client-id"
cdek_client_secret: "test-client-secret"
cdek_retry_attempts: 2
cdek_retry_backoff_seconds: 0.2
cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
@@ -14,17 +14,14 @@ repository:
redis_dsn: "redis://localhost:6379/0"
price_cache_ttl_seconds: 900
delivery_providers:
cdek:
base_url: "https://api.cdek.ru/v2"
client_id: "test-client-id"
client_secret: "test-client-secret"
retry_attempts: 2
retry_backoff_seconds: 0.2
timeout_seconds: 10.0
cache_ttl_seconds: 900
cse:
enabled: false
adapter:
cdek_base_url: "https://api.cdek.ru/v2"
cdek_client_id: "test-client-id"
cdek_client_secret: "test-client-secret"
cdek_retry_attempts: 2
cdek_retry_backoff_seconds: 0.2
cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
@@ -14,17 +14,14 @@ repository:
redis_dsn: "redis://localhost:6379/0"
price_cache_ttl_seconds: 900
delivery_providers:
cdek:
base_url: "https://api.cdek.ru/v2"
client_id: "test-client-id"
client_secret: "test-client-secret"
retry_attempts: 2
retry_backoff_seconds: 0.2
timeout_seconds: 10.0
cache_ttl_seconds: 900
cse:
enabled: false
adapter:
cdek_base_url: "https://api.cdek.ru/v2"
cdek_client_id: "test-client-id"
cdek_client_secret: "test-client-secret"
cdek_retry_attempts: 2
cdek_retry_backoff_seconds: 0.2
cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
@@ -13,17 +13,14 @@ repository:
redis_dsn: "redis://localhost:6379/0"
price_cache_ttl_seconds: 900
delivery_providers:
cdek:
base_url: "https://api.cdek.ru/v2"
client_id: "test-client-id"
client_secret: "test-client-secret"
retry_attempts: 2
retry_backoff_seconds: 0.2
timeout_seconds: 10.0
cache_ttl_seconds: 900
cse:
enabled: false
adapter:
cdek_base_url: "https://api.cdek.ru/v2"
cdek_client_id: "test-client-id"
cdek_client_secret: "test-client-secret"
cdek_retry_attempts: 2
cdek_retry_backoff_seconds: 0.2
cdek_timeout_seconds: 10.0
cdek_cache_ttl_seconds: 900
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
@@ -11,12 +11,9 @@ business_logic:
repository:
redis_dsn: "redis://localhost:6379/0"
delivery_providers:
cdek:
client_id: "test-id"
client_secret: "test-secret"
cse:
enabled: false
adapter:
cdek_client_id: "test-id"
cdek_client_secret: "test-secret"
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
+10 -29
View File
@@ -132,7 +132,7 @@ business_logic:
repository: {{}}
delivery_providers: {{}}
adapter: {{}}
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
@@ -174,32 +174,13 @@ def test_configuration_sections_are_loaded_from_yaml_file(
assert settings.business_logic.provider_price_multiplier == Decimal("1.0")
assert settings.repository.redis_dsn == "redis://localhost:6379/0"
assert settings.repository.price_cache_ttl_seconds == 900
assert settings.delivery_providers.cdek.enabled is True
assert settings.delivery_providers.cdek.base_url == "https://api.cdek.ru/v2"
assert settings.delivery_providers.cdek.client_id == "test-client-id"
assert settings.delivery_providers.cdek.client_secret == "test-client-secret"
assert settings.delivery_providers.cdek.retry_attempts == 2
assert settings.delivery_providers.cdek.retry_backoff_seconds == 0.2
assert settings.delivery_providers.cdek.timeout_seconds == 10.0
assert settings.delivery_providers.cdek.cache_ttl_seconds == 900
assert settings.delivery_providers.cse.enabled is True
assert (
settings.delivery_providers.cse.base_url
== "http://lk-test.cse.ru/1c/ws/web1c.1cws"
)
assert settings.delivery_providers.cse.login == "test"
assert settings.delivery_providers.cse.password == "2016"
assert settings.delivery_providers.cse.retry_attempts == 2
assert settings.delivery_providers.cse.retry_backoff_seconds == 0.2
assert settings.delivery_providers.cse.timeout_seconds == 10.0
assert settings.delivery_providers.cse.cache_ttl_seconds == 900
assert settings.delivery_providers.cse.delivery_service_guids == ["tariff-guid-2"]
assert settings.delivery_providers.cse.payer == "0"
assert settings.delivery_providers.cse.payment_method == "1"
assert (
settings.delivery_providers.cse.shipping_method
== "5052d0b3-5ea3-46f2-823f-1472686a51dd"
)
assert settings.adapter.cdek_base_url == "https://api.cdek.ru/v2"
assert settings.adapter.cdek_client_id == "test-client-id"
assert settings.adapter.cdek_client_secret == "test-client-secret"
assert settings.adapter.cdek_retry_attempts == 2
assert settings.adapter.cdek_retry_backoff_seconds == 0.2
assert settings.adapter.cdek_timeout_seconds == 10.0
assert settings.adapter.cdek_cache_ttl_seconds == 900
assert settings.tbank_payment.init_url == "https://securepay.tinkoff.ru/v2/Init"
assert (
settings.tbank_payment.notification_url
@@ -252,7 +233,7 @@ def test_get_settings_fails_when_required_yaml_section_is_missing(
get_settings()
locations = {tuple(item["loc"]) for item in error.value.errors()}
assert ("delivery_providers",) in locations
assert ("adapter",) in locations
get_settings.cache_clear()
@@ -306,7 +287,7 @@ def test_get_settings_uses_config_test_yaml_in_pytest_environment(
assert settings.service.provider_timeout_seconds == 17.0
assert settings.controller.api_prefix == "/from-config-test-yaml"
assert settings.business_logic.provider_price_multiplier == Decimal("1.25")
assert settings.delivery_providers.cdek.client_id == "test-id"
assert settings.adapter.cdek_client_id == "test-id"
assert settings.tbank_payment.auth.terminal_key == "override-terminal-key"
assert settings.tbank_payment.auth.password == "override-password"
assert (

Some files were not shown because too many files have changed in this diff Show More