Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a3ce93b058 | |||
| 50124fb2c9 | |||
| 5526f90cb3 | |||
| c494d50566 | |||
| 39cd5ddc7a | |||
| 6b66af5eb3 | |||
| 78e9ad4fa9 | |||
| bddac60965 | |||
| 2b201a08be | |||
| ab0b66e1c2 |
@@ -0,0 +1,68 @@
|
|||||||
|
name: Deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- master
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Build and push image
|
||||||
|
run: |
|
||||||
|
docker login gitea.p4r4dls.ru \
|
||||||
|
-u ${{ secrets.REGISTRY_USER }} \
|
||||||
|
-p ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
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: |
|
||||||
|
mkdir -p ~/.ssh
|
||||||
|
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
|
||||||
|
chmod 600 ~/.ssh/deploy_key
|
||||||
|
ssh-keyscan 194.58.121.203 >> ~/.ssh/known_hosts
|
||||||
|
|
||||||
|
- name: Render and copy configs
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
render() {
|
||||||
|
local src=$1 dst=$2
|
||||||
|
vars=$(grep -oP '\$\{\K[^}]+' "$src")
|
||||||
|
for var in $vars; do
|
||||||
|
if [[ -z "${!var:-}" ]]; then
|
||||||
|
echo "Error: $var не задан в Gitea Secrets" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
envsubst < "$src" > "$dst"
|
||||||
|
}
|
||||||
|
|
||||||
|
render config.template.yaml config.rendered.yaml
|
||||||
|
render docker-compose.template.yml docker-compose.rendered.yml
|
||||||
|
|
||||||
|
scp -i ~/.ssh/deploy_key config.rendered.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:/app/g2s-aggregator/docker-compose.yml
|
||||||
|
|
||||||
|
- name: Deploy
|
||||||
|
run: |
|
||||||
|
ssh -i ~/.ssh/deploy_key deploy@194.58.121.203 "
|
||||||
|
cd /app/g2s-aggregator &&
|
||||||
|
docker login gitea.p4r4dls.ru \
|
||||||
|
-u ${{ secrets.REGISTRY_USER }} \
|
||||||
|
-p ${{ secrets.REGISTRY_TOKEN }} &&
|
||||||
|
docker compose pull &&
|
||||||
|
docker compose up -d
|
||||||
|
"
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
.idea
|
.idea
|
||||||
*.iml
|
*.iml
|
||||||
/config.yaml
|
/config.yaml
|
||||||
|
/docker-compose.yml
|
||||||
__pycache__
|
__pycache__
|
||||||
http-client.private.env.json
|
http-client.private.env.json
|
||||||
scripts
|
scripts
|
||||||
|
|||||||
+2
-1
@@ -11,6 +11,7 @@ RUN poetry config virtualenvs.create false \
|
|||||||
&& poetry install --no-interaction --no-root
|
&& poetry install --no-interaction --no-root
|
||||||
|
|
||||||
COPY app ./app
|
COPY app ./app
|
||||||
COPY config.yaml ./config.yaml
|
COPY alembic.ini ./alembic.ini
|
||||||
|
COPY alembic ./alembic
|
||||||
|
|
||||||
CMD ["poetry", "run", "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"]
|
||||||
|
|||||||
+41
@@ -0,0 +1,41 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
path_separator = os
|
||||||
|
sqlalchemy.url =
|
||||||
|
|
||||||
|
[post_write_hooks]
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARNING
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARNING
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
"""Alembic async migration environment."""
|
||||||
|
|
||||||
|
from asyncio import run
|
||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import pool
|
||||||
|
from sqlalchemy.engine import Connection
|
||||||
|
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
from app.repositories.order.models import Base
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def _database_url() -> str:
|
||||||
|
configured_url = config.get_main_option("sqlalchemy.url")
|
||||||
|
if configured_url:
|
||||||
|
return configured_url
|
||||||
|
return get_settings().postgres.dsn
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
context.configure(
|
||||||
|
url=_database_url(),
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def do_run_migrations(connection: Connection) -> None:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_async_migrations() -> None:
|
||||||
|
configuration = config.get_section(config.config_ini_section, {})
|
||||||
|
configuration["sqlalchemy.url"] = _database_url()
|
||||||
|
connectable = async_engine_from_config(
|
||||||
|
configuration,
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with connectable.connect() as connection:
|
||||||
|
await connection.run_sync(do_run_migrations)
|
||||||
|
|
||||||
|
await connectable.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run(run_async_migrations())
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
"""${message}"""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: str | None = ${repr(down_revision)}
|
||||||
|
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
|
||||||
|
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""Create orders table."""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision: str = "20260412_028"
|
||||||
|
down_revision: str | None = None
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"orders",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||||
|
sa.Column("order_uuid", sa.String(length=128), nullable=False),
|
||||||
|
sa.Column("payment_url", sa.String(length=2048), nullable=False),
|
||||||
|
sa.Column("price", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("delivery_type", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("tariff_code", sa.Integer(), nullable=False),
|
||||||
|
sa.Column("sender", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column("recipient", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column(
|
||||||
|
"from_location",
|
||||||
|
postgresql.JSONB(astext_type=sa.Text()),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"to_location",
|
||||||
|
postgresql.JSONB(astext_type=sa.Text()),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("packages", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||||
|
sa.Column("services", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||||
|
sa.Column("comment", sa.String(length=1024), nullable=True),
|
||||||
|
sa.Column("payment_status", sa.String(length=64), nullable=True),
|
||||||
|
sa.Column("tbank_payment_id", sa.BigInteger(), nullable=True),
|
||||||
|
sa.Column("cdek_order_uuid", sa.String(length=128), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"updated_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
sa.UniqueConstraint("order_uuid", name="uq_orders_order_uuid"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table("orders")
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
"""Rework orders table to a single payload JSONB column."""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260513_032"
|
||||||
|
down_revision: str | None = "20260412_028"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_column("orders", "sender")
|
||||||
|
op.drop_column("orders", "recipient")
|
||||||
|
op.drop_column("orders", "from_location")
|
||||||
|
op.drop_column("orders", "to_location")
|
||||||
|
op.drop_column("orders", "packages")
|
||||||
|
op.drop_column("orders", "services")
|
||||||
|
op.drop_column("orders", "comment")
|
||||||
|
op.drop_column("orders", "delivery_type")
|
||||||
|
op.add_column(
|
||||||
|
"orders",
|
||||||
|
sa.Column(
|
||||||
|
"payload",
|
||||||
|
postgresql.JSONB(astext_type=sa.Text()),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"orders",
|
||||||
|
sa.Column("account_email", sa.String(length=320), nullable=False),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("orders", "account_email")
|
||||||
|
op.drop_column("orders", "payload")
|
||||||
|
op.add_column(
|
||||||
|
"orders",
|
||||||
|
sa.Column("delivery_type", sa.Integer(), nullable=False),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"orders",
|
||||||
|
sa.Column("comment", sa.String(length=1024), nullable=True),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"orders",
|
||||||
|
sa.Column(
|
||||||
|
"services",
|
||||||
|
postgresql.JSONB(astext_type=sa.Text()),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"orders",
|
||||||
|
sa.Column(
|
||||||
|
"packages",
|
||||||
|
postgresql.JSONB(astext_type=sa.Text()),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"orders",
|
||||||
|
sa.Column(
|
||||||
|
"to_location",
|
||||||
|
postgresql.JSONB(astext_type=sa.Text()),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"orders",
|
||||||
|
sa.Column(
|
||||||
|
"from_location",
|
||||||
|
postgresql.JSONB(astext_type=sa.Text()),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"orders",
|
||||||
|
sa.Column(
|
||||||
|
"recipient",
|
||||||
|
postgresql.JSONB(astext_type=sa.Text()),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"orders",
|
||||||
|
sa.Column(
|
||||||
|
"sender",
|
||||||
|
postgresql.JSONB(astext_type=sa.Text()),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Add CDEK waybill columns to orders table."""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260523_033"
|
||||||
|
down_revision: str | None = "20260513_032"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("orders", "cdek_waybill_url")
|
||||||
|
op.drop_column("orders", "cdek_waybill_uuid")
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
"""Add CDEK polling columns to orders table."""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260524_034"
|
||||||
|
down_revision: str | None = "20260523_033"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("orders", "cdek_polled_at")
|
||||||
|
op.drop_column("orders", "cdek_order_status")
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Add waybill_email_sent_at column to orders table."""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260524_035"
|
||||||
|
down_revision: str | None = "20260524_034"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"orders",
|
||||||
|
sa.Column(
|
||||||
|
"waybill_email_sent_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("orders", "waybill_email_sent_at")
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
"""Add payment_email_sent_at column to orders table."""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = "20260527_036"
|
||||||
|
down_revision: str | None = "20260524_035"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"orders",
|
||||||
|
sa.Column(
|
||||||
|
"payment_email_sent_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("orders", "payment_email_sent_at")
|
||||||
@@ -18,5 +18,9 @@ class DeliveryProvider(ABC):
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class ProviderClientError(RuntimeError):
|
||||||
|
"""Raised when a provider call fails for temporary or provider-side reasons."""
|
||||||
|
|
||||||
|
|
||||||
class ProviderRequestError(RuntimeError):
|
class ProviderRequestError(RuntimeError):
|
||||||
"""Raised when a provider rejects input request data."""
|
"""Raised when a provider rejects input request data."""
|
||||||
|
|||||||
@@ -3,27 +3,44 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import Awaitable, Callable
|
from collections.abc import Awaitable, Callable
|
||||||
from typing import Any
|
from typing import Any
|
||||||
import logging
|
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
import structlog
|
||||||
|
|
||||||
from app.adapters.delivery_providers.base import DeliveryProvider, ProviderRequestError
|
from app.adapters.delivery_providers.base import (
|
||||||
|
DeliveryProvider,
|
||||||
|
ProviderClientError,
|
||||||
|
ProviderRequestError,
|
||||||
|
)
|
||||||
from app.adapters.delivery_providers.cdek.auth import CDEKAuthClient
|
from app.adapters.delivery_providers.cdek.auth import CDEKAuthClient
|
||||||
from app.adapters.delivery_providers.cdek.mapper import map_cdek_response
|
from app.adapters.delivery_providers.cdek.mapper import (
|
||||||
|
CDEKMappingError,
|
||||||
|
map_cdek_response,
|
||||||
|
map_cdek_response_for_tariff_code,
|
||||||
|
)
|
||||||
from app.adapters.delivery_providers.cdek.order_mapper import (
|
from app.adapters.delivery_providers.cdek.order_mapper import (
|
||||||
|
CDEKOrderInfo,
|
||||||
CDEKOrderMappingError,
|
CDEKOrderMappingError,
|
||||||
|
CDEKOrderRegistrationResult,
|
||||||
|
CDEKWaybillInfo,
|
||||||
|
centimeters_string_to_int,
|
||||||
|
kilograms_string_to_grams,
|
||||||
|
map_cdek_existing_order_response,
|
||||||
|
map_cdek_order_info_response,
|
||||||
map_cdek_order_request,
|
map_cdek_order_request,
|
||||||
map_cdek_order_response,
|
map_cdek_order_response,
|
||||||
|
map_cdek_waybill_info_response,
|
||||||
|
resolve_cdek_city_code,
|
||||||
)
|
)
|
||||||
from app.cities import cities_map
|
from app.cities import cities_map
|
||||||
from app.config import AdapterConfig
|
from app.config import AdapterConfig
|
||||||
from app.schemas.order import OrderCreateRequest, OrderCreateResponse
|
from app.schemas.payment import InitPaymentRequest
|
||||||
from app.schemas.request import DeliveryCalculationRequest
|
from app.schemas.request import DeliveryCalculationRequest
|
||||||
from app.schemas.response import DeliveryPrice
|
from app.schemas.response import DeliveryPrice
|
||||||
log = logging.getLogger(__name__)
|
log = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class CDEKClientError(RuntimeError):
|
class CDEKClientError(ProviderClientError):
|
||||||
"""Raised when CDEK tariff request fails."""
|
"""Raised when CDEK tariff request fails."""
|
||||||
|
|
||||||
|
|
||||||
@@ -48,6 +65,7 @@ class CDEKClient:
|
|||||||
normalized_base_url = base_url.rstrip("/")
|
normalized_base_url = base_url.rstrip("/")
|
||||||
self._tariff_url = f"{normalized_base_url}/calculator/tarifflist"
|
self._tariff_url = f"{normalized_base_url}/calculator/tarifflist"
|
||||||
self._orders_url = f"{normalized_base_url}/orders"
|
self._orders_url = f"{normalized_base_url}/orders"
|
||||||
|
self._print_orders_url = f"{normalized_base_url}/print/orders"
|
||||||
self._timeout_seconds = timeout_seconds
|
self._timeout_seconds = timeout_seconds
|
||||||
self._retry_attempts = retry_attempts
|
self._retry_attempts = retry_attempts
|
||||||
self._retry_backoff_seconds = retry_backoff_seconds
|
self._retry_backoff_seconds = retry_backoff_seconds
|
||||||
@@ -57,6 +75,29 @@ class CDEKClient:
|
|||||||
self, request: DeliveryCalculationRequest
|
self, request: DeliveryCalculationRequest
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
payload = await self._build_payload(request)
|
payload = await self._build_payload(request)
|
||||||
|
return await self._post_tariff_payload(
|
||||||
|
payload,
|
||||||
|
request_error_message=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def get_raw_payment_price(
|
||||||
|
self,
|
||||||
|
request: InitPaymentRequest,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
payload = self._build_payment_price_payload(request)
|
||||||
|
return await self._post_tariff_payload(
|
||||||
|
payload,
|
||||||
|
request_error_message=(
|
||||||
|
"CDEK payment price validation request was rejected with status"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _post_tariff_payload(
|
||||||
|
self,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
*,
|
||||||
|
request_error_message: str | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
for attempt in range(self._retry_attempts + 1):
|
for attempt in range(self._retry_attempts + 1):
|
||||||
try:
|
try:
|
||||||
token = await self._auth_client.get_access_token()
|
token = await self._auth_client.get_access_token()
|
||||||
@@ -78,10 +119,27 @@ class CDEKClient:
|
|||||||
if attempt < self._retry_attempts:
|
if attempt < self._retry_attempts:
|
||||||
await self._sleep(self._retry_delay(attempt))
|
await self._sleep(self._retry_delay(attempt))
|
||||||
continue
|
continue
|
||||||
|
log.warning(
|
||||||
|
"cdek_tariff_request_server_error",
|
||||||
|
status_code=response.status_code,
|
||||||
|
response_body=_response_text_or_none(response),
|
||||||
|
request_payload=payload,
|
||||||
|
)
|
||||||
raise CDEKClientError(
|
raise CDEKClientError(
|
||||||
f"CDEK tariff request failed with status {response.status_code}."
|
f"CDEK tariff request failed with status {response.status_code}."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if request_error_message is not None and 400 <= response.status_code < 500:
|
||||||
|
log.warning(
|
||||||
|
"cdek_tariff_request_rejected",
|
||||||
|
status_code=response.status_code,
|
||||||
|
response_body=_response_text_or_none(response),
|
||||||
|
request_payload=payload,
|
||||||
|
)
|
||||||
|
raise CDEKRequestError(
|
||||||
|
f"{request_error_message} {response.status_code}."
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
raw_payload = response.json()
|
raw_payload = response.json()
|
||||||
@@ -94,8 +152,10 @@ class CDEKClient:
|
|||||||
|
|
||||||
raise CDEKClientError("CDEK tariff request failed unexpectedly.")
|
raise CDEKClientError("CDEK tariff request failed unexpectedly.")
|
||||||
|
|
||||||
async def register_order(self, request: OrderCreateRequest) -> OrderCreateResponse:
|
async def register_order(
|
||||||
payload = map_cdek_order_request(request)
|
self, request: InitPaymentRequest, order_uuid: str
|
||||||
|
) -> CDEKOrderRegistrationResult:
|
||||||
|
payload = map_cdek_order_request(request, order_uuid)
|
||||||
for attempt in range(self._retry_attempts + 1):
|
for attempt in range(self._retry_attempts + 1):
|
||||||
try:
|
try:
|
||||||
token = await self._auth_client.get_access_token()
|
token = await self._auth_client.get_access_token()
|
||||||
@@ -123,6 +183,17 @@ class CDEKClient:
|
|||||||
)
|
)
|
||||||
|
|
||||||
if 400 <= response.status_code < 500:
|
if 400 <= response.status_code < 500:
|
||||||
|
response_payload = _response_json_or_none(response)
|
||||||
|
existing_result = map_cdek_existing_order_response(response_payload)
|
||||||
|
if existing_result is not None:
|
||||||
|
return existing_result
|
||||||
|
log.warning(
|
||||||
|
"cdek_order_register_rejected",
|
||||||
|
status_code=response.status_code,
|
||||||
|
request_payload=payload,
|
||||||
|
response_payload=response_payload,
|
||||||
|
response_body=_response_text_or_none(response),
|
||||||
|
)
|
||||||
raise CDEKRequestError(
|
raise CDEKRequestError(
|
||||||
"CDEK order registration request was rejected with status "
|
"CDEK order registration request was rejected with status "
|
||||||
f"{response.status_code}."
|
f"{response.status_code}."
|
||||||
@@ -141,6 +212,12 @@ class CDEKClient:
|
|||||||
"CDEK order registration payload must be a JSON object."
|
"CDEK order registration payload must be a JSON object."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
"cdek_order_register_raw_response",
|
||||||
|
status_code=response.status_code,
|
||||||
|
response_payload=raw_payload,
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
return map_cdek_order_response(raw_payload)
|
return map_cdek_order_response(raw_payload)
|
||||||
except CDEKOrderMappingError as exc:
|
except CDEKOrderMappingError as exc:
|
||||||
@@ -150,6 +227,134 @@ class CDEKClient:
|
|||||||
|
|
||||||
raise CDEKClientError("CDEK order registration failed unexpectedly.")
|
raise CDEKClientError("CDEK order registration failed unexpectedly.")
|
||||||
|
|
||||||
|
async def get_order(self, cdek_order_uuid: str) -> CDEKOrderInfo:
|
||||||
|
url = f"{self._orders_url}/{cdek_order_uuid}"
|
||||||
|
raw_payload = await self._get_json(
|
||||||
|
url,
|
||||||
|
failure_message="CDEK get order failed",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return map_cdek_order_info_response(raw_payload)
|
||||||
|
except CDEKOrderMappingError as exc:
|
||||||
|
raise CDEKClientError(
|
||||||
|
"CDEK get order response payload is invalid."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
async def get_waybill(self, cdek_waybill_uuid: str) -> CDEKWaybillInfo:
|
||||||
|
url = f"{self._print_orders_url}/{cdek_waybill_uuid}"
|
||||||
|
raw_payload = await self._get_json(
|
||||||
|
url,
|
||||||
|
failure_message="CDEK get waybill failed",
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return map_cdek_waybill_info_response(raw_payload)
|
||||||
|
except CDEKOrderMappingError as exc:
|
||||||
|
raise CDEKClientError(
|
||||||
|
"CDEK get waybill response payload is invalid."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
async def download_waybill_pdf(self, url: str) -> bytes:
|
||||||
|
failure_message = "CDEK waybill PDF download failed"
|
||||||
|
for attempt in range(self._retry_attempts + 1):
|
||||||
|
try:
|
||||||
|
token = await self._auth_client.get_access_token()
|
||||||
|
response = await self._http_client.get(
|
||||||
|
url,
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
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 CDEKClientError(
|
||||||
|
f"{failure_message} 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
|
||||||
|
raise CDEKClientError(
|
||||||
|
f"{failure_message} with retriable status "
|
||||||
|
f"{response.status_code}."
|
||||||
|
)
|
||||||
|
|
||||||
|
if 400 <= response.status_code < 500:
|
||||||
|
log.warning(
|
||||||
|
"cdek_waybill_pdf_download_rejected",
|
||||||
|
url=url,
|
||||||
|
status_code=response.status_code,
|
||||||
|
response_body=_response_text_or_none(response),
|
||||||
|
)
|
||||||
|
raise CDEKRequestError(
|
||||||
|
f"{failure_message} with status {response.status_code}."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response.raise_for_status()
|
||||||
|
except httpx.HTTPError as exc:
|
||||||
|
raise CDEKClientError(
|
||||||
|
f"{failure_message}: invalid response."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return response.content
|
||||||
|
|
||||||
|
raise CDEKClientError(f"{failure_message} unexpectedly.")
|
||||||
|
|
||||||
|
async def _get_json(self, url: str, *, failure_message: str) -> dict[str, Any]:
|
||||||
|
for attempt in range(self._retry_attempts + 1):
|
||||||
|
try:
|
||||||
|
token = await self._auth_client.get_access_token()
|
||||||
|
response = await self._http_client.get(
|
||||||
|
url,
|
||||||
|
headers={"Authorization": f"Bearer {token}"},
|
||||||
|
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 CDEKClientError(
|
||||||
|
f"{failure_message} 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
|
||||||
|
raise CDEKClientError(
|
||||||
|
f"{failure_message} with retriable status "
|
||||||
|
f"{response.status_code}."
|
||||||
|
)
|
||||||
|
|
||||||
|
if 400 <= response.status_code < 500:
|
||||||
|
log.warning(
|
||||||
|
"cdek_get_request_rejected",
|
||||||
|
url=url,
|
||||||
|
status_code=response.status_code,
|
||||||
|
response_body=_response_text_or_none(response),
|
||||||
|
)
|
||||||
|
raise CDEKRequestError(
|
||||||
|
f"{failure_message} with status {response.status_code}."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response.raise_for_status()
|
||||||
|
raw_payload = response.json()
|
||||||
|
except (httpx.HTTPError, TypeError, ValueError) as exc:
|
||||||
|
raise CDEKClientError(
|
||||||
|
f"{failure_message}: invalid response payload."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if not isinstance(raw_payload, dict):
|
||||||
|
raise CDEKClientError(
|
||||||
|
f"{failure_message}: payload must be a JSON object."
|
||||||
|
)
|
||||||
|
return raw_payload
|
||||||
|
|
||||||
|
raise CDEKClientError(f"{failure_message} unexpectedly.")
|
||||||
|
|
||||||
def _retry_delay(self, attempt: int) -> float:
|
def _retry_delay(self, attempt: int) -> float:
|
||||||
return self._retry_backoff_seconds * (attempt + 1)
|
return self._retry_backoff_seconds * (attempt + 1)
|
||||||
|
|
||||||
@@ -176,6 +381,34 @@ class CDEKClient:
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_payment_price_payload(
|
||||||
|
request: InitPaymentRequest,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
try:
|
||||||
|
from_code = resolve_cdek_city_code(request.sender_address.city_id)
|
||||||
|
to_code = resolve_cdek_city_code(request.receiver_address.city_id)
|
||||||
|
weight_grams = kilograms_string_to_grams(request.system_data.weight)
|
||||||
|
except CDEKOrderMappingError as exc:
|
||||||
|
raise CDEKRequestError(str(exc)) from exc
|
||||||
|
|
||||||
|
package: dict[str, Any] = {"weight": weight_grams}
|
||||||
|
dimensions = request.system_data.dimensions
|
||||||
|
if request.system_data.parcel_type == "parcel" and dimensions is not None:
|
||||||
|
try:
|
||||||
|
package["length"] = centimeters_string_to_int(dimensions.length, "length")
|
||||||
|
package["width"] = centimeters_string_to_int(dimensions.width, "width")
|
||||||
|
package["height"] = centimeters_string_to_int(dimensions.height, "height")
|
||||||
|
except CDEKOrderMappingError as exc:
|
||||||
|
raise CDEKRequestError(str(exc)) from exc
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": 2,
|
||||||
|
"from_location": {"code": from_code},
|
||||||
|
"to_location": {"code": to_code},
|
||||||
|
"packages": [package],
|
||||||
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_cdek_city_code(city_id: int) -> int:
|
def _get_cdek_city_code(city_id: int) -> int:
|
||||||
city_entry = cities_map.get(str(city_id))
|
city_entry = cities_map.get(str(city_id))
|
||||||
@@ -241,5 +474,42 @@ class CDEKProvider(DeliveryProvider):
|
|||||||
raw_payload = await self._client.get_raw_price(request)
|
raw_payload = await self._client.get_raw_price(request)
|
||||||
return map_cdek_response(raw_payload)
|
return map_cdek_response(raw_payload)
|
||||||
|
|
||||||
async def register_order(self, request: OrderCreateRequest) -> OrderCreateResponse:
|
async def get_payment_price(
|
||||||
return await self._client.register_order(request)
|
self,
|
||||||
|
request: InitPaymentRequest,
|
||||||
|
) -> DeliveryPrice | None:
|
||||||
|
raw_payload = await self._client.get_raw_payment_price(request)
|
||||||
|
try:
|
||||||
|
return map_cdek_response_for_tariff_code(
|
||||||
|
raw_payload,
|
||||||
|
tariff_code=request.system_data.tariff.tariff_code,
|
||||||
|
)
|
||||||
|
except CDEKMappingError as exc:
|
||||||
|
raise CDEKClientError(
|
||||||
|
"CDEK payment price validation response payload is invalid."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
async def register_order(
|
||||||
|
self, request: InitPaymentRequest, order_uuid: str
|
||||||
|
) -> CDEKOrderRegistrationResult:
|
||||||
|
return await self._client.register_order(request, order_uuid)
|
||||||
|
|
||||||
|
async def get_order(self, cdek_order_uuid: str) -> CDEKOrderInfo:
|
||||||
|
return await self._client.get_order(cdek_order_uuid)
|
||||||
|
|
||||||
|
async def get_waybill(self, cdek_waybill_uuid: str) -> CDEKWaybillInfo:
|
||||||
|
return await self._client.get_waybill(cdek_waybill_uuid)
|
||||||
|
|
||||||
|
|
||||||
|
def _response_json_or_none(response: httpx.Response) -> object | None:
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _response_text_or_none(response: httpx.Response) -> str | None:
|
||||||
|
try:
|
||||||
|
return response.text
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|||||||
@@ -19,6 +19,24 @@ def map_cdek_response(payload: dict[str, Any]) -> list[DeliveryPrice]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def map_cdek_response_for_tariff_code(
|
||||||
|
payload: dict[str, Any],
|
||||||
|
*,
|
||||||
|
tariff_code: int,
|
||||||
|
) -> DeliveryPrice | None:
|
||||||
|
tariff_codes = payload.get("tariff_codes")
|
||||||
|
if not isinstance(tariff_codes, list):
|
||||||
|
raise CDEKMappingError("CDEK response must include tariff_codes.")
|
||||||
|
|
||||||
|
payload_currency = payload.get("currency")
|
||||||
|
for tariff in tariff_codes:
|
||||||
|
if not isinstance(tariff, dict):
|
||||||
|
raise CDEKMappingError("CDEK tariff entry must be an object.")
|
||||||
|
if _tariff_code_matches(tariff.get("tariff_code"), tariff_code):
|
||||||
|
return _map_tariff(tariff, payload_currency=payload_currency)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _map_tariff(
|
def _map_tariff(
|
||||||
tariff: dict[str, Any],
|
tariff: dict[str, Any],
|
||||||
*,
|
*,
|
||||||
@@ -47,6 +65,8 @@ def _map_tariff(
|
|||||||
currency=str(raw_currency).upper(),
|
currency=str(raw_currency).upper(),
|
||||||
delivery_days_min=int(period_min),
|
delivery_days_min=int(period_min),
|
||||||
delivery_days_max=int(period_max),
|
delivery_days_max=int(period_max),
|
||||||
|
tariff_code=_extract_tariff_code(tariff.get("tariff_code")),
|
||||||
|
bypass_parcel_type_filter=True,
|
||||||
)
|
)
|
||||||
except (ArithmeticError, TypeError, ValueError) as exc:
|
except (ArithmeticError, TypeError, ValueError) as exc:
|
||||||
raise CDEKMappingError("CDEK response fields have invalid values.") from exc
|
raise CDEKMappingError("CDEK response fields have invalid values.") from exc
|
||||||
@@ -62,3 +82,21 @@ def _get_tariffs(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
|||||||
raise CDEKMappingError("CDEK tariff entry must be an object.")
|
raise CDEKMappingError("CDEK tariff entry must be an object.")
|
||||||
tariffs.append(tariff)
|
tariffs.append(tariff)
|
||||||
return tariffs
|
return tariffs
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_tariff_code(value: object) -> int | None:
|
||||||
|
if isinstance(value, bool) or value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|||||||
@@ -1,19 +1,67 @@
|
|||||||
"""CDEK order registration payload mappers."""
|
"""CDEK order registration payload mappers."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from app.schemas.order import OrderCreateRequest, OrderCreateResponse
|
from app.cities import cities_map
|
||||||
|
from app.schemas.payment import (
|
||||||
|
Address,
|
||||||
|
Contact,
|
||||||
|
Dimensions,
|
||||||
|
InitPaymentRequest,
|
||||||
|
SystemData,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CDEKOrderMappingError(ValueError):
|
class CDEKOrderMappingError(ValueError):
|
||||||
"""Raised when CDEK order payload cannot be mapped."""
|
"""Raised when CDEK order payload cannot be mapped."""
|
||||||
|
|
||||||
|
|
||||||
def map_cdek_order_request(request: OrderCreateRequest) -> dict[str, Any]:
|
_CDEK_WAYBILL_PRINT_TYPE = "WAYBILL"
|
||||||
return request.model_dump(mode="python", exclude_none=True)
|
_CDEK_WAYBILL_RELATED_ENTITY_TYPE = "waybill"
|
||||||
|
|
||||||
|
|
||||||
def map_cdek_order_response(payload: dict[str, Any]) -> OrderCreateResponse:
|
@dataclass(frozen=True)
|
||||||
|
class CDEKOrderRegistrationResult:
|
||||||
|
order_uuid: str
|
||||||
|
waybill_uuid: str | None
|
||||||
|
waybill_url: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CDEKOrderInfo:
|
||||||
|
order_uuid: str
|
||||||
|
status_code: str | None
|
||||||
|
waybill_uuid: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CDEKWaybillInfo:
|
||||||
|
waybill_uuid: str
|
||||||
|
url: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def map_cdek_order_request(
|
||||||
|
request: InitPaymentRequest, order_uuid: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"number": order_uuid,
|
||||||
|
"type": 2,
|
||||||
|
"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),
|
||||||
|
"from_location": _map_location(request.sender_address),
|
||||||
|
"to_location": _map_location(request.receiver_address),
|
||||||
|
"packages": [_map_package(request, order_uuid)],
|
||||||
|
}
|
||||||
|
if request.content.description:
|
||||||
|
payload["comment"] = request.content.description
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def map_cdek_order_response(payload: dict[str, Any]) -> CDEKOrderRegistrationResult:
|
||||||
entity = payload.get("entity")
|
entity = payload.get("entity")
|
||||||
if not isinstance(entity, dict):
|
if not isinstance(entity, dict):
|
||||||
raise CDEKOrderMappingError("CDEK order response must include entity object.")
|
raise CDEKOrderMappingError("CDEK order response must include entity object.")
|
||||||
@@ -22,4 +70,266 @@ def map_cdek_order_response(payload: dict[str, Any]) -> OrderCreateResponse:
|
|||||||
if not isinstance(order_uuid, str) or not order_uuid:
|
if not isinstance(order_uuid, str) or not order_uuid:
|
||||||
raise CDEKOrderMappingError("CDEK order response must include entity.uuid.")
|
raise CDEKOrderMappingError("CDEK order response must include entity.uuid.")
|
||||||
|
|
||||||
return OrderCreateResponse(provider="cdek", order_uuid=order_uuid)
|
waybill_uuid, waybill_url = _extract_waybill(payload)
|
||||||
|
return CDEKOrderRegistrationResult(
|
||||||
|
order_uuid=order_uuid,
|
||||||
|
waybill_uuid=waybill_uuid,
|
||||||
|
waybill_url=waybill_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_CDEK_DUPLICATE_ORDER_ERROR_CODES = frozenset({"v2_entity_already_exists"})
|
||||||
|
|
||||||
|
|
||||||
|
def map_cdek_existing_order_response(
|
||||||
|
payload: object,
|
||||||
|
) -> CDEKOrderRegistrationResult | None:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
if not _contains_cdek_duplicate_error_code(payload):
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
return map_cdek_order_response(payload)
|
||||||
|
except CDEKOrderMappingError:
|
||||||
|
order_uuid = _find_first_uuid(payload)
|
||||||
|
if order_uuid is None:
|
||||||
|
return None
|
||||||
|
waybill_uuid, waybill_url = _extract_waybill(payload)
|
||||||
|
return CDEKOrderRegistrationResult(
|
||||||
|
order_uuid=order_uuid,
|
||||||
|
waybill_uuid=waybill_uuid,
|
||||||
|
waybill_url=waybill_url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_cdek_order_info_response(payload: dict[str, Any]) -> CDEKOrderInfo:
|
||||||
|
entity = payload.get("entity")
|
||||||
|
if not isinstance(entity, dict):
|
||||||
|
raise CDEKOrderMappingError(
|
||||||
|
"CDEK order info response must include entity object."
|
||||||
|
)
|
||||||
|
|
||||||
|
order_uuid = entity.get("uuid")
|
||||||
|
if not isinstance(order_uuid, str) or not order_uuid:
|
||||||
|
raise CDEKOrderMappingError(
|
||||||
|
"CDEK order info response must include entity.uuid."
|
||||||
|
)
|
||||||
|
|
||||||
|
status_code = _latest_status_code(entity.get("statuses"))
|
||||||
|
waybill_uuid, _ = _extract_waybill(payload)
|
||||||
|
return CDEKOrderInfo(
|
||||||
|
order_uuid=order_uuid,
|
||||||
|
status_code=status_code,
|
||||||
|
waybill_uuid=waybill_uuid,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def map_cdek_waybill_info_response(payload: dict[str, Any]) -> CDEKWaybillInfo:
|
||||||
|
entity = payload.get("entity")
|
||||||
|
if not isinstance(entity, dict):
|
||||||
|
raise CDEKOrderMappingError(
|
||||||
|
"CDEK waybill info response must include entity object."
|
||||||
|
)
|
||||||
|
|
||||||
|
waybill_uuid = entity.get("uuid")
|
||||||
|
if not isinstance(waybill_uuid, str) or not waybill_uuid:
|
||||||
|
raise CDEKOrderMappingError(
|
||||||
|
"CDEK waybill info response must include entity.uuid."
|
||||||
|
)
|
||||||
|
|
||||||
|
url_value = entity.get("url")
|
||||||
|
waybill_url = url_value if isinstance(url_value, str) and url_value else None
|
||||||
|
return CDEKWaybillInfo(waybill_uuid=waybill_uuid, url=waybill_url)
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_status_code(statuses: object) -> str | None:
|
||||||
|
if not isinstance(statuses, list) or not statuses:
|
||||||
|
return None
|
||||||
|
dated: list[tuple[str, str]] = []
|
||||||
|
for entry in statuses:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
code = entry.get("code")
|
||||||
|
if not isinstance(code, str) or not code:
|
||||||
|
continue
|
||||||
|
date_time = entry.get("date_time")
|
||||||
|
date_time_key = date_time if isinstance(date_time, str) else ""
|
||||||
|
dated.append((date_time_key, code))
|
||||||
|
if not dated:
|
||||||
|
return None
|
||||||
|
dated.sort(key=lambda item: item[0])
|
||||||
|
return dated[-1][1]
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_waybill(payload: dict[str, Any]) -> tuple[str | None, str | None]:
|
||||||
|
related_entities = payload.get("related_entities")
|
||||||
|
if not isinstance(related_entities, list):
|
||||||
|
return None, None
|
||||||
|
for entry in related_entities:
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
continue
|
||||||
|
if entry.get("type") != _CDEK_WAYBILL_RELATED_ENTITY_TYPE:
|
||||||
|
continue
|
||||||
|
uuid_value = entry.get("uuid")
|
||||||
|
url_value = entry.get("url")
|
||||||
|
waybill_uuid = uuid_value if isinstance(uuid_value, str) and uuid_value else None
|
||||||
|
waybill_url = url_value if isinstance(url_value, str) and url_value else None
|
||||||
|
if waybill_uuid is None and waybill_url is None:
|
||||||
|
continue
|
||||||
|
return waybill_uuid, waybill_url
|
||||||
|
return None, None
|
||||||
|
|
||||||
|
|
||||||
|
def compose_address_line(address: Address) -> str:
|
||||||
|
"""Build a CDEK address line from structured fields."""
|
||||||
|
|
||||||
|
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 resolve_cdek_city_code(city_id: int) -> int:
|
||||||
|
"""Resolve CDEK city code from cities_map by city identifier."""
|
||||||
|
|
||||||
|
city_entry = cities_map.get(str(city_id))
|
||||||
|
if not isinstance(city_entry, dict):
|
||||||
|
raise CDEKOrderMappingError(
|
||||||
|
f"CDEK city mapping is not configured for city id {city_id}."
|
||||||
|
)
|
||||||
|
|
||||||
|
cdek_data = city_entry.get("cdek")
|
||||||
|
if not isinstance(cdek_data, dict):
|
||||||
|
raise CDEKOrderMappingError(
|
||||||
|
f"CDEK city mapping is not configured for city id {city_id}."
|
||||||
|
)
|
||||||
|
|
||||||
|
raw_city_code = cdek_data.get("code")
|
||||||
|
if raw_city_code is None or isinstance(raw_city_code, bool):
|
||||||
|
raise CDEKOrderMappingError(
|
||||||
|
f"CDEK city code is invalid for city id {city_id}."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return int(raw_city_code)
|
||||||
|
except (TypeError, ValueError) as exc:
|
||||||
|
raise CDEKOrderMappingError(
|
||||||
|
f"CDEK city code is invalid for city id {city_id}."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
_GRAMS_IN_KILOGRAM = Decimal(1000)
|
||||||
|
_INTEGER_QUANTIZER = Decimal(1)
|
||||||
|
|
||||||
|
|
||||||
|
def kilograms_string_to_grams(value: str) -> int:
|
||||||
|
kilograms = _parse_positive_decimal(value, field_name="weight")
|
||||||
|
return int(
|
||||||
|
(kilograms * _GRAMS_IN_KILOGRAM).quantize(
|
||||||
|
_INTEGER_QUANTIZER, rounding=ROUND_HALF_UP
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def centimeters_string_to_int(value: str, field_name: str) -> int:
|
||||||
|
measurement = _parse_positive_decimal(value, field_name=field_name)
|
||||||
|
return int(measurement.quantize(_INTEGER_QUANTIZER, rounding=ROUND_HALF_UP))
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_positive_decimal(value: str, *, field_name: str) -> Decimal:
|
||||||
|
try:
|
||||||
|
parsed = Decimal(value)
|
||||||
|
except (InvalidOperation, TypeError, ValueError) as exc:
|
||||||
|
raise CDEKOrderMappingError(
|
||||||
|
f"{field_name} is not a valid decimal: {value!r}."
|
||||||
|
) from exc
|
||||||
|
if not parsed.is_finite() or parsed <= 0:
|
||||||
|
raise CDEKOrderMappingError(
|
||||||
|
f"{field_name} must be a positive decimal: {value!r}."
|
||||||
|
)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
def _map_party(contact: Contact) -> dict[str, Any]:
|
||||||
|
phone: dict[str, Any] = {"number": contact.phone}
|
||||||
|
if contact.phone_ext:
|
||||||
|
phone["additional"] = contact.phone_ext
|
||||||
|
|
||||||
|
party: dict[str, Any] = {
|
||||||
|
"name": contact.full_name,
|
||||||
|
"phones": [phone],
|
||||||
|
}
|
||||||
|
if contact.email:
|
||||||
|
party["email"] = contact.email
|
||||||
|
if contact.is_company:
|
||||||
|
party["contragent_type"] = "LEGAL_ENTITY"
|
||||||
|
party["company"] = contact.company_name
|
||||||
|
party["inn"] = contact.inn
|
||||||
|
party["kpp"] = contact.kpp
|
||||||
|
else:
|
||||||
|
party["company"] = contact.full_name
|
||||||
|
return party
|
||||||
|
|
||||||
|
|
||||||
|
def _map_location(address: Address) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"code": resolve_cdek_city_code(address.city_id),
|
||||||
|
"address": compose_address_line(address),
|
||||||
|
"postal_code": address.zip,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _map_package(request: InitPaymentRequest, order_uuid: str) -> dict[str, Any]:
|
||||||
|
system_data: SystemData = request.system_data
|
||||||
|
weight_grams = kilograms_string_to_grams(request.system_data.weight)
|
||||||
|
description = request.content.description or order_uuid
|
||||||
|
package: dict[str, Any] = {
|
||||||
|
"number": order_uuid,
|
||||||
|
"weight": weight_grams,
|
||||||
|
"comment": description,
|
||||||
|
}
|
||||||
|
if system_data.parcel_type == "parcel" and system_data.dimensions is not None:
|
||||||
|
package.update(_map_dimensions(system_data.dimensions))
|
||||||
|
return package
|
||||||
|
|
||||||
|
|
||||||
|
def _map_dimensions(dimensions: Dimensions) -> dict[str, int]:
|
||||||
|
return {
|
||||||
|
"length": centimeters_string_to_int(dimensions.length, "length"),
|
||||||
|
"width": centimeters_string_to_int(dimensions.width, "width"),
|
||||||
|
"height": centimeters_string_to_int(dimensions.height, "height"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _contains_cdek_duplicate_error_code(payload: object) -> bool:
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
code = payload.get("code")
|
||||||
|
if isinstance(code, str) and code in _CDEK_DUPLICATE_ORDER_ERROR_CODES:
|
||||||
|
return True
|
||||||
|
return any(
|
||||||
|
_contains_cdek_duplicate_error_code(value)
|
||||||
|
for value in payload.values()
|
||||||
|
)
|
||||||
|
if isinstance(payload, list):
|
||||||
|
return any(_contains_cdek_duplicate_error_code(item) for item in payload)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _find_first_uuid(payload: object) -> str | None:
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
value = payload.get("uuid")
|
||||||
|
if isinstance(value, str) and value:
|
||||||
|
return value
|
||||||
|
for nested_value in payload.values():
|
||||||
|
nested_uuid = _find_first_uuid(nested_value)
|
||||||
|
if nested_uuid is not None:
|
||||||
|
return nested_uuid
|
||||||
|
if isinstance(payload, list):
|
||||||
|
for item in payload:
|
||||||
|
nested_uuid = _find_first_uuid(item)
|
||||||
|
if nested_uuid is not None:
|
||||||
|
return nested_uuid
|
||||||
|
return None
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""E-mail adapters."""
|
||||||
|
|
||||||
|
from app.adapters.email.smtp_client import SMTPEmailSender, SMTPEmailSenderError
|
||||||
|
|
||||||
|
__all__ = ["SMTPEmailSender", "SMTPEmailSenderError"]
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""SMTP e-mail adapter built on top of aiosmtplib."""
|
||||||
|
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
import aiosmtplib
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
log = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SMTPEmailSenderError(Exception):
|
||||||
|
"""Raised when the SMTP delivery fails."""
|
||||||
|
|
||||||
|
|
||||||
|
class SMTPSendFn(Protocol):
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
message: EmailMessage,
|
||||||
|
*,
|
||||||
|
hostname: str,
|
||||||
|
port: int,
|
||||||
|
username: str | None,
|
||||||
|
password: str | None,
|
||||||
|
use_tls: bool,
|
||||||
|
start_tls: bool,
|
||||||
|
timeout: float,
|
||||||
|
) -> object: ...
|
||||||
|
|
||||||
|
|
||||||
|
class SMTPEmailSender:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
smtp_host: str,
|
||||||
|
smtp_port: int,
|
||||||
|
username: str,
|
||||||
|
password: str,
|
||||||
|
from_address: str,
|
||||||
|
use_tls: bool = True,
|
||||||
|
timeout_seconds: float = 10.0,
|
||||||
|
send: SMTPSendFn | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._smtp_host = smtp_host
|
||||||
|
self._smtp_port = smtp_port
|
||||||
|
self._username = username
|
||||||
|
self._password = password
|
||||||
|
self._from_address = from_address
|
||||||
|
self._use_tls = use_tls
|
||||||
|
self._timeout_seconds = timeout_seconds
|
||||||
|
self._send = send or _default_send
|
||||||
|
|
||||||
|
async def send_email(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
to: str,
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
attachment_bytes: bytes | None = None,
|
||||||
|
attachment_filename: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
message = self._build_message(
|
||||||
|
to=to,
|
||||||
|
subject=subject,
|
||||||
|
body=body,
|
||||||
|
attachment_bytes=attachment_bytes,
|
||||||
|
attachment_filename=attachment_filename,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self._send(
|
||||||
|
message,
|
||||||
|
hostname=self._smtp_host,
|
||||||
|
port=self._smtp_port,
|
||||||
|
username=self._username or None,
|
||||||
|
password=self._password or None,
|
||||||
|
use_tls=self._use_tls and self._smtp_port == 465,
|
||||||
|
start_tls=self._use_tls and self._smtp_port != 465,
|
||||||
|
timeout=self._timeout_seconds,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
log.warning(
|
||||||
|
"smtp_send_failed",
|
||||||
|
smtp_host=self._smtp_host,
|
||||||
|
smtp_port=self._smtp_port,
|
||||||
|
to=to,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
raise SMTPEmailSenderError(
|
||||||
|
f"SMTP send to {to} failed: {exc}"
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
def _build_message(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
to: str,
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
attachment_bytes: bytes | None = None,
|
||||||
|
attachment_filename: str | None = None,
|
||||||
|
) -> EmailMessage:
|
||||||
|
message = EmailMessage()
|
||||||
|
message["From"] = self._from_address
|
||||||
|
message["To"] = to
|
||||||
|
message["Subject"] = subject
|
||||||
|
message.set_content(body)
|
||||||
|
if attachment_bytes is not None and attachment_filename is not None:
|
||||||
|
message.add_attachment(
|
||||||
|
attachment_bytes,
|
||||||
|
maintype="application",
|
||||||
|
subtype="pdf",
|
||||||
|
filename=attachment_filename,
|
||||||
|
)
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
async def _default_send(
|
||||||
|
message: EmailMessage,
|
||||||
|
*,
|
||||||
|
hostname: str,
|
||||||
|
port: int,
|
||||||
|
username: str | None,
|
||||||
|
password: str | None,
|
||||||
|
use_tls: bool,
|
||||||
|
start_tls: bool,
|
||||||
|
timeout: float,
|
||||||
|
) -> object:
|
||||||
|
return await aiosmtplib.send(
|
||||||
|
message,
|
||||||
|
hostname=hostname,
|
||||||
|
port=port,
|
||||||
|
username=username,
|
||||||
|
password=password,
|
||||||
|
use_tls=use_tls,
|
||||||
|
start_tls=start_tls,
|
||||||
|
timeout=timeout,
|
||||||
|
)
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
"""PostgreSQL async engine and session factory management."""
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
|
from app.config import PostgresConfig
|
||||||
|
|
||||||
|
|
||||||
|
def create_postgres_engine(config: PostgresConfig) -> AsyncEngine:
|
||||||
|
return create_async_engine(config.dsn, pool_pre_ping=True)
|
||||||
|
|
||||||
|
|
||||||
|
def create_postgres_session_factory(
|
||||||
|
engine: AsyncEngine,
|
||||||
|
) -> async_sessionmaker[AsyncSession]:
|
||||||
|
return async_sessionmaker(engine, expire_on_commit=False)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""TBank payment adapter package."""
|
||||||
|
|
||||||
|
from app.adapters.tbank.client import TBankAdapter
|
||||||
|
|
||||||
|
__all__ = ["TBankAdapter"]
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""TBank payment adapter errors."""
|
||||||
|
|
||||||
|
|
||||||
|
class TBankPaymentAdapterError(RuntimeError):
|
||||||
|
"""Raised when a TBank payment adapter call fails."""
|
||||||
|
|
||||||
|
|
||||||
|
class TBankPaymentRequestError(TBankPaymentAdapterError):
|
||||||
|
"""Raised when TBank rejects payment request data."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
status_code: int | None = None,
|
||||||
|
error_code: str | None = None,
|
||||||
|
provider_message: str | None = None,
|
||||||
|
details: str | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.status_code = status_code
|
||||||
|
self.error_code = error_code
|
||||||
|
self.provider_message = provider_message
|
||||||
|
self.details = details
|
||||||
|
|
||||||
|
|
||||||
|
class TBankPaymentNotificationTokenError(TBankPaymentRequestError):
|
||||||
|
"""Raised when a TBank payment notification token is invalid."""
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"""TBank payment adapter."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.adapters.tbank.base import (
|
||||||
|
TBankPaymentAdapterError,
|
||||||
|
TBankPaymentNotificationTokenError,
|
||||||
|
TBankPaymentRequestError,
|
||||||
|
)
|
||||||
|
from app.config import TBankPaymentConfig
|
||||||
|
from app.schemas.payment import TBankPaymentNotification
|
||||||
|
|
||||||
|
|
||||||
|
class TBankAdapter:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
http_client: httpx.AsyncClient,
|
||||||
|
*,
|
||||||
|
init_url: str,
|
||||||
|
notification_url: str,
|
||||||
|
success_url: str,
|
||||||
|
terminal_key: str,
|
||||||
|
password: str,
|
||||||
|
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._init_url = init_url
|
||||||
|
self._notification_url = notification_url
|
||||||
|
self._success_url = success_url
|
||||||
|
self._terminal_key = terminal_key
|
||||||
|
self._password = password
|
||||||
|
self._timeout_seconds = timeout_seconds
|
||||||
|
self._retry_attempts = retry_attempts
|
||||||
|
self._retry_backoff_seconds = retry_backoff_seconds
|
||||||
|
self._sleep = sleep
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_config(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
http_client: httpx.AsyncClient,
|
||||||
|
config: TBankPaymentConfig,
|
||||||
|
) -> "TBankAdapter":
|
||||||
|
return cls(
|
||||||
|
http_client=http_client,
|
||||||
|
init_url=config.init_url,
|
||||||
|
notification_url=config.notification_url,
|
||||||
|
success_url=config.success_url,
|
||||||
|
terminal_key=config.auth.terminal_key,
|
||||||
|
password=config.auth.password,
|
||||||
|
timeout_seconds=config.timeout_seconds,
|
||||||
|
retry_attempts=config.retry_attempts,
|
||||||
|
retry_backoff_seconds=config.retry_backoff_seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def create_payment_link(self, order_uuid: str, amount_kopecks: int) -> str:
|
||||||
|
payload = self._build_payload(
|
||||||
|
order_uuid=order_uuid,
|
||||||
|
amount_kopecks=amount_kopecks,
|
||||||
|
)
|
||||||
|
|
||||||
|
for attempt in range(self._retry_attempts + 1):
|
||||||
|
try:
|
||||||
|
response = await self._http_client.post(
|
||||||
|
self._init_url,
|
||||||
|
json=payload,
|
||||||
|
headers={
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
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 TBankPaymentAdapterError(
|
||||||
|
"TBank payment init 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
|
||||||
|
raise TBankPaymentAdapterError(
|
||||||
|
"TBank payment init request failed with retriable status "
|
||||||
|
f"{response.status_code}."
|
||||||
|
)
|
||||||
|
|
||||||
|
if 400 <= response.status_code < 500:
|
||||||
|
raise _build_tbank_request_error(
|
||||||
|
"TBank payment init request was rejected.",
|
||||||
|
status_code=response.status_code,
|
||||||
|
payload=_response_json_or_none(response),
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response.raise_for_status()
|
||||||
|
raw_payload = response.json()
|
||||||
|
except (httpx.HTTPError, TypeError, ValueError) as exc:
|
||||||
|
raise TBankPaymentAdapterError(
|
||||||
|
"TBank payment init returned invalid payload."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
return self._map_payment_url(raw_payload)
|
||||||
|
|
||||||
|
raise TBankPaymentAdapterError("TBank payment init request failed unexpectedly.")
|
||||||
|
|
||||||
|
def verify_payment_notification(
|
||||||
|
self,
|
||||||
|
notification: TBankPaymentNotification,
|
||||||
|
) -> None:
|
||||||
|
payload = notification.model_dump(mode="python")
|
||||||
|
expected_token = _build_tbank_token(payload, password=self._password)
|
||||||
|
if not hmac.compare_digest(expected_token, notification.Token):
|
||||||
|
raise TBankPaymentNotificationTokenError(
|
||||||
|
"TBank payment notification token is invalid."
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_payload(self, *, order_uuid: str, amount_kopecks: int) -> dict[str, Any]:
|
||||||
|
if not order_uuid.strip():
|
||||||
|
raise TBankPaymentRequestError("TBank payment order id must be non-empty.")
|
||||||
|
if amount_kopecks <= 0:
|
||||||
|
raise TBankPaymentRequestError("TBank payment amount must be positive.")
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"TerminalKey": self._terminal_key,
|
||||||
|
"Amount": amount_kopecks,
|
||||||
|
"OrderId": order_uuid,
|
||||||
|
"NotificationURL": self._notification_url,
|
||||||
|
"SuccessURL": f"{self._success_url}/{order_uuid}",
|
||||||
|
}
|
||||||
|
payload["Token"] = _build_tbank_token(payload, password=self._password)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
def _retry_delay(self, attempt: int) -> float:
|
||||||
|
return self._retry_backoff_seconds * (attempt + 1)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _should_retry(status_code: int) -> bool:
|
||||||
|
return status_code == 429 or status_code >= 500
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _map_payment_url(payload: object) -> str:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
raise TBankPaymentAdapterError(
|
||||||
|
"TBank payment init payload must be a JSON object."
|
||||||
|
)
|
||||||
|
|
||||||
|
if payload.get("Success") is False:
|
||||||
|
raise _build_tbank_request_error(
|
||||||
|
"TBank payment init request was rejected.",
|
||||||
|
payload=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
payment_url = payload.get("PaymentURL")
|
||||||
|
if not isinstance(payment_url, str) or not payment_url.strip():
|
||||||
|
raise TBankPaymentAdapterError(
|
||||||
|
"TBank payment init response must include PaymentURL."
|
||||||
|
)
|
||||||
|
return payment_url.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _build_tbank_token(payload: dict[str, Any], *, password: str) -> str:
|
||||||
|
token_payload = {
|
||||||
|
key: value
|
||||||
|
for key, value in payload.items()
|
||||||
|
if key != "Token" and not isinstance(value, (dict, list))
|
||||||
|
}
|
||||||
|
token_payload["Password"] = password
|
||||||
|
token_source = "".join(
|
||||||
|
_stringify_token_value(token_payload[key]) for key in sorted(token_payload)
|
||||||
|
)
|
||||||
|
return hashlib.sha256(token_source.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _stringify_token_value(value: Any) -> str:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "true" if value else "false"
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _response_json_or_none(response: httpx.Response) -> object | None:
|
||||||
|
try:
|
||||||
|
return response.json()
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_tbank_request_error(
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
status_code: int | None = None,
|
||||||
|
payload: object | None = None,
|
||||||
|
) -> TBankPaymentRequestError:
|
||||||
|
error_code = _payload_text_value(payload, "ErrorCode")
|
||||||
|
provider_message = _payload_text_value(payload, "Message")
|
||||||
|
details = _payload_text_value(payload, "Details")
|
||||||
|
return TBankPaymentRequestError(
|
||||||
|
_format_tbank_request_error_message(
|
||||||
|
message,
|
||||||
|
status_code=status_code,
|
||||||
|
error_code=error_code,
|
||||||
|
provider_message=provider_message,
|
||||||
|
details=details,
|
||||||
|
),
|
||||||
|
status_code=status_code,
|
||||||
|
error_code=error_code,
|
||||||
|
provider_message=provider_message,
|
||||||
|
details=details,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _payload_text_value(payload: object | None, key: str) -> str | None:
|
||||||
|
if not isinstance(payload, dict):
|
||||||
|
return None
|
||||||
|
|
||||||
|
value = payload.get(key)
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
text = str(value).strip()
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def _format_tbank_request_error_message(
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
status_code: int | None,
|
||||||
|
error_code: str | None,
|
||||||
|
provider_message: str | None,
|
||||||
|
details: str | None,
|
||||||
|
) -> str:
|
||||||
|
fields: list[str] = []
|
||||||
|
if status_code is not None:
|
||||||
|
fields.append(f"status_code={status_code}")
|
||||||
|
if error_code is not None:
|
||||||
|
fields.append(f"error_code={error_code}")
|
||||||
|
if provider_message is not None:
|
||||||
|
fields.append(f"message={provider_message}")
|
||||||
|
if details is not None:
|
||||||
|
fields.append(f"details={details}")
|
||||||
|
|
||||||
|
if not fields:
|
||||||
|
return message
|
||||||
|
return f"{message} {' '.join(fields)}"
|
||||||
@@ -48,6 +48,25 @@ class AdapterConfig(BaseModel):
|
|||||||
cdek_cache_ttl_seconds: int = Field(default=900, gt=0)
|
cdek_cache_ttl_seconds: int = Field(default=900, gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class TBankPaymentAuthConfig(BaseModel):
|
||||||
|
terminal_key: str = Field(..., min_length=1)
|
||||||
|
password: str = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class TBankPaymentConfig(BaseModel):
|
||||||
|
init_url: str = Field(..., min_length=1)
|
||||||
|
notification_url: str = Field(..., min_length=1)
|
||||||
|
success_url: str = Field(..., min_length=1)
|
||||||
|
auth: TBankPaymentAuthConfig
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
class PostgresConfig(BaseModel):
|
||||||
|
dsn: str = Field(..., min_length=1)
|
||||||
|
|
||||||
|
|
||||||
class DadataAddressSuggestionsConfig(BaseModel):
|
class DadataAddressSuggestionsConfig(BaseModel):
|
||||||
url: str = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
|
url: str = "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
|
||||||
api_key: str = ""
|
api_key: str = ""
|
||||||
@@ -97,6 +116,26 @@ class ObservabilityConfig(BaseModel):
|
|||||||
otlp_insecure: bool = True
|
otlp_insecure: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class WaybillPollerConfig(BaseModel):
|
||||||
|
interval_seconds: float = Field(default=30.0, gt=0)
|
||||||
|
batch_size: int = Field(default=50, gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class EmailAdapterConfig(BaseModel):
|
||||||
|
smtp_host: str = Field(..., min_length=1)
|
||||||
|
smtp_port: int = Field(..., gt=0, le=65535)
|
||||||
|
username: str = ""
|
||||||
|
password: str = ""
|
||||||
|
from_address: str = Field(..., min_length=1)
|
||||||
|
use_tls: bool = True
|
||||||
|
timeout_seconds: float = Field(default=10.0, gt=0)
|
||||||
|
|
||||||
|
|
||||||
|
class WaybillEmailSenderConfig(BaseModel):
|
||||||
|
interval_seconds: float = Field(default=30.0, gt=0)
|
||||||
|
batch_size: int = Field(default=50, gt=0)
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
extra="ignore",
|
extra="ignore",
|
||||||
@@ -107,10 +146,17 @@ class Settings(BaseSettings):
|
|||||||
business_logic: BusinessLogicConfig = Field(default_factory=BusinessLogicConfig)
|
business_logic: BusinessLogicConfig = Field(default_factory=BusinessLogicConfig)
|
||||||
repository: RepositoryConfig = Field(default_factory=RepositoryConfig)
|
repository: RepositoryConfig = Field(default_factory=RepositoryConfig)
|
||||||
adapter: AdapterConfig = Field(default_factory=AdapterConfig)
|
adapter: AdapterConfig = Field(default_factory=AdapterConfig)
|
||||||
|
tbank_payment: TBankPaymentConfig
|
||||||
|
postgres: PostgresConfig
|
||||||
address_suggestions: AddressSuggestionsConfig = Field(
|
address_suggestions: AddressSuggestionsConfig = Field(
|
||||||
default_factory=AddressSuggestionsConfig
|
default_factory=AddressSuggestionsConfig
|
||||||
)
|
)
|
||||||
observability: ObservabilityConfig
|
observability: ObservabilityConfig
|
||||||
|
waybill_poller: WaybillPollerConfig = Field(default_factory=WaybillPollerConfig)
|
||||||
|
email: EmailAdapterConfig
|
||||||
|
waybill_email_sender: WaybillEmailSenderConfig = Field(
|
||||||
|
default_factory=WaybillEmailSenderConfig
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def settings_customise_sources(
|
def settings_customise_sources(
|
||||||
@@ -138,8 +184,11 @@ class _RequiredYamlSections(BaseModel):
|
|||||||
business_logic: dict[str, Any]
|
business_logic: dict[str, Any]
|
||||||
repository: dict[str, Any]
|
repository: dict[str, Any]
|
||||||
adapter: dict[str, Any]
|
adapter: dict[str, Any]
|
||||||
|
tbank_payment: dict[str, Any]
|
||||||
|
postgres: dict[str, Any]
|
||||||
address_suggestions: dict[str, Any]
|
address_suggestions: dict[str, Any]
|
||||||
observability: dict[str, Any]
|
observability: dict[str, Any]
|
||||||
|
email: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
def _resolve_runtime_config_file() -> str:
|
def _resolve_runtime_config_file() -> str:
|
||||||
|
|||||||
@@ -1,17 +1,26 @@
|
|||||||
"""Delivery API controller skeleton."""
|
"""Delivery API controller skeleton."""
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
|
from fastapi.responses import PlainTextResponse
|
||||||
|
|
||||||
|
from app.adapters.postgres.engine import create_postgres_engine, create_postgres_session_factory
|
||||||
from app.adapters.address_suggestions.dadata import DadataAddressSuggestionProvider
|
from app.adapters.address_suggestions.dadata import DadataAddressSuggestionProvider
|
||||||
from app.adapters.address_suggestions.tomtom import TomTomAddressSuggestionProvider
|
from app.adapters.address_suggestions.tomtom import TomTomAddressSuggestionProvider
|
||||||
from app.adapters.address_suggestions.yandex_geosuggest import (
|
from app.adapters.address_suggestions.yandex_geosuggest import (
|
||||||
YandexGeosuggestAddressSuggestionProvider,
|
YandexGeosuggestAddressSuggestionProvider,
|
||||||
)
|
)
|
||||||
from app.adapters.delivery_providers.cdek import CDEKProvider
|
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.config import Settings
|
||||||
from app.controllers.http_client import build_controller_http_client
|
from app.controllers.http_client import build_controller_http_client
|
||||||
from app.repositories.cache.redis_cache import PriceCache
|
from app.repositories.cache.redis_cache import PriceCache
|
||||||
from app.schemas.order import OrderCreateRequest, OrderCreateResponse
|
from app.repositories.order import OrderRepository
|
||||||
|
from app.schemas.payment import (
|
||||||
|
InitPaymentRequest,
|
||||||
|
InitPaymentResponse,
|
||||||
|
TBankPaymentNotification,
|
||||||
|
)
|
||||||
from app.schemas.request import AddressSuggestRequest, DeliveryCalculationRequest
|
from app.schemas.request import AddressSuggestRequest, DeliveryCalculationRequest
|
||||||
from app.schemas.response import AddressSuggestion, DeliveryPrice
|
from app.schemas.response import AddressSuggestion, DeliveryPrice
|
||||||
from app.services.aggregator import (
|
from app.services.aggregator import (
|
||||||
@@ -20,7 +29,10 @@ from app.services.aggregator import (
|
|||||||
AggregatorServiceError,
|
AggregatorServiceError,
|
||||||
InvalidAddressSuggestRequestError,
|
InvalidAddressSuggestRequestError,
|
||||||
InvalidDeliveryRequestError,
|
InvalidDeliveryRequestError,
|
||||||
InvalidOrderCreateRequestError,
|
InvalidInitPaymentRequestError,
|
||||||
|
InvalidTBankPaymentNotificationError,
|
||||||
|
InitPaymentUnavailableError,
|
||||||
|
TBankPaymentNotificationProcessingError,
|
||||||
UnsupportedAddressSuggestionCountryError,
|
UnsupportedAddressSuggestionCountryError,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -38,6 +50,10 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
|
|||||||
http_client=http_client,
|
http_client=http_client,
|
||||||
adapter_config=settings.adapter,
|
adapter_config=settings.adapter,
|
||||||
)
|
)
|
||||||
|
payment_adapter = TBankAdapter.from_config(
|
||||||
|
http_client=http_client,
|
||||||
|
config=settings.tbank_payment,
|
||||||
|
)
|
||||||
dadata_provider = DadataAddressSuggestionProvider.from_config(
|
dadata_provider = DadataAddressSuggestionProvider.from_config(
|
||||||
http_client=http_client,
|
http_client=http_client,
|
||||||
config=settings.address_suggestions.dadata,
|
config=settings.address_suggestions.dadata,
|
||||||
@@ -52,10 +68,26 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
|
|||||||
)
|
)
|
||||||
providers = (cdek_provider,)
|
providers = (cdek_provider,)
|
||||||
cache = PriceCache.from_repository_config(settings.repository)
|
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)
|
||||||
|
email_sender = SMTPEmailSender(
|
||||||
|
smtp_host=settings.email.smtp_host,
|
||||||
|
smtp_port=settings.email.smtp_port,
|
||||||
|
username=settings.email.username,
|
||||||
|
password=settings.email.password,
|
||||||
|
from_address=settings.email.from_address,
|
||||||
|
use_tls=settings.email.use_tls,
|
||||||
|
timeout_seconds=settings.email.timeout_seconds,
|
||||||
|
)
|
||||||
service = AggregatorService(
|
service = AggregatorService(
|
||||||
providers=providers,
|
providers=providers,
|
||||||
cache=cache,
|
cache=cache,
|
||||||
order_adapter=cdek_provider,
|
payment_adapter=payment_adapter,
|
||||||
|
payment_price_validation_adapter=cdek_provider,
|
||||||
|
order_repository=order_repository,
|
||||||
|
order_registration_adapter=cdek_provider,
|
||||||
|
email_sender=email_sender,
|
||||||
address_suggestion_providers=(
|
address_suggestion_providers=(
|
||||||
dadata_provider,
|
dadata_provider,
|
||||||
yandex_geosuggest_provider,
|
yandex_geosuggest_provider,
|
||||||
@@ -147,27 +179,72 @@ async def suggest_addresses(
|
|||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/order",
|
"/order",
|
||||||
response_model=OrderCreateResponse,
|
response_model=InitPaymentResponse,
|
||||||
)
|
)
|
||||||
async def create_delivery_order(
|
async def init_payment(
|
||||||
order_request: OrderCreateRequest,
|
payment_request: InitPaymentRequest,
|
||||||
service: AggregatorService = Depends(get_aggregator_service),
|
service: AggregatorService = Depends(get_aggregator_service),
|
||||||
) -> OrderCreateResponse:
|
) -> InitPaymentResponse:
|
||||||
try:
|
try:
|
||||||
return await service.create_order(order_request)
|
return await service.init_payment(payment_request)
|
||||||
except InvalidOrderCreateRequestError as exc:
|
except InvalidInitPaymentRequestError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail={
|
detail={
|
||||||
"code": "invalid_order_create_request",
|
"code": "invalid_init_payment_request",
|
||||||
"message": "Order request contains invalid or unsupported CDEK data.",
|
"message": "Payment request contains invalid or unsupported TBank data.",
|
||||||
|
},
|
||||||
|
) from exc
|
||||||
|
except InitPaymentUnavailableError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail={
|
||||||
|
"code": "init_payment_unavailable",
|
||||||
|
"message": "Payment initialization is temporarily unavailable.",
|
||||||
},
|
},
|
||||||
) from exc
|
) from exc
|
||||||
except AggregatorServiceError as exc:
|
except AggregatorServiceError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail={
|
detail={
|
||||||
"code": "order_creation_unavailable",
|
"code": "init_payment_unavailable",
|
||||||
"message": "CDEK order creation is temporarily unavailable.",
|
"message": "Payment initialization is temporarily unavailable.",
|
||||||
},
|
},
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/tbank/notifications",
|
||||||
|
response_class=PlainTextResponse,
|
||||||
|
)
|
||||||
|
async def handle_tbank_payment_notification(
|
||||||
|
notification: TBankPaymentNotification,
|
||||||
|
service: AggregatorService = Depends(get_aggregator_service),
|
||||||
|
) -> PlainTextResponse:
|
||||||
|
try:
|
||||||
|
response_body = await service.handle_tbank_payment_notification(notification)
|
||||||
|
except InvalidTBankPaymentNotificationError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
|
detail={
|
||||||
|
"code": "invalid_tbank_payment_notification",
|
||||||
|
"message": "TBank payment notification token is invalid.",
|
||||||
|
},
|
||||||
|
) from exc
|
||||||
|
except TBankPaymentNotificationProcessingError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail={
|
||||||
|
"code": "tbank_payment_notification_processing_unavailable",
|
||||||
|
"message": "TBank payment notification processing is temporarily unavailable.",
|
||||||
|
},
|
||||||
|
) from exc
|
||||||
|
except AggregatorServiceError as exc:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
|
detail={
|
||||||
|
"code": "tbank_payment_notification_processing_unavailable",
|
||||||
|
"message": "TBank payment notification processing is temporarily unavailable.",
|
||||||
|
},
|
||||||
|
) from exc
|
||||||
|
return PlainTextResponse(response_body)
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
"""Pure rules for CDEK order polling lifecycle."""
|
||||||
|
|
||||||
|
TERMINAL_ORDER_STATUSES: frozenset[str] = frozenset(
|
||||||
|
{"INVALID", "DELIVERED", "NOT_DELIVERED", "CANCELLED"}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def is_terminal_order_status(code: str | None) -> bool:
|
||||||
|
if code is None:
|
||||||
|
return False
|
||||||
|
return code in TERMINAL_ORDER_STATUSES
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
"""Pure rules for TBank payment notification handling."""
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class TBankPaymentNotificationAction(str, Enum):
|
||||||
|
ACKNOWLEDGE_ONLY = "acknowledge_only"
|
||||||
|
REGISTER_CDEK_ORDER = "register_cdek_order"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_tbank_payment_notification_action(
|
||||||
|
*,
|
||||||
|
status: str,
|
||||||
|
success: bool,
|
||||||
|
error_code: str,
|
||||||
|
) -> TBankPaymentNotificationAction:
|
||||||
|
if status == "CONFIRMED" and success is True and error_code == "0":
|
||||||
|
return TBankPaymentNotificationAction.REGISTER_CDEK_ORDER
|
||||||
|
return TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY
|
||||||
+56
-1
@@ -12,6 +12,7 @@ DIMENSIONS_ROUND_SCALE = 1
|
|||||||
MIN_WEIGHT_KG = Decimal("0.01")
|
MIN_WEIGHT_KG = Decimal("0.01")
|
||||||
MIN_DIMENSION_CM = Decimal("0.1")
|
MIN_DIMENSION_CM = Decimal("0.1")
|
||||||
INTEGER_PRICE_QUANTIZER = Decimal("1")
|
INTEGER_PRICE_QUANTIZER = Decimal("1")
|
||||||
|
KOPECKS_IN_RUBLE = Decimal("100")
|
||||||
DOC_SERVICE_MARKERS = ("документ", "document")
|
DOC_SERVICE_MARKERS = ("документ", "document")
|
||||||
|
|
||||||
_MISSING = object()
|
_MISSING = object()
|
||||||
@@ -46,6 +47,8 @@ class ProviderPrice:
|
|||||||
currency: str
|
currency: str
|
||||||
delivery_days_min: int
|
delivery_days_min: int
|
||||||
delivery_days_max: int
|
delivery_days_max: int
|
||||||
|
tariff_code: int | None = None
|
||||||
|
bypass_parcel_type_filter: bool = False
|
||||||
|
|
||||||
|
|
||||||
def normalize_delivery_request(
|
def normalize_delivery_request(
|
||||||
@@ -118,6 +121,48 @@ def filter_and_sort_prices(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_expected_payment_amount_kopecks(
|
||||||
|
provider_price: object,
|
||||||
|
*,
|
||||||
|
price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER,
|
||||||
|
) -> int | None:
|
||||||
|
"""Return expected payment amount in kopecks for a RUB provider price."""
|
||||||
|
|
||||||
|
currency = _normalize_text(_get_optional_attr(provider_price, "currency")).upper()
|
||||||
|
if currency != "RUB":
|
||||||
|
return None
|
||||||
|
|
||||||
|
price = _try_to_decimal(_get_optional_attr(provider_price, "price"))
|
||||||
|
if price is None or not price.is_finite() or price <= 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
adjusted_price = _apply_price_multiplier_and_round(
|
||||||
|
price,
|
||||||
|
price_multiplier=_normalize_price_multiplier(price_multiplier),
|
||||||
|
)
|
||||||
|
if adjusted_price is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return int(adjusted_price * KOPECKS_IN_RUBLE)
|
||||||
|
|
||||||
|
|
||||||
|
def is_init_payment_price_valid(
|
||||||
|
requested_amount_kopecks: object,
|
||||||
|
provider_price: object,
|
||||||
|
*,
|
||||||
|
price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER,
|
||||||
|
) -> bool:
|
||||||
|
expected_amount_kopecks = calculate_expected_payment_amount_kopecks(
|
||||||
|
provider_price,
|
||||||
|
price_multiplier=price_multiplier,
|
||||||
|
)
|
||||||
|
if expected_amount_kopecks is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
requested_amount = _try_to_int(requested_amount_kopecks)
|
||||||
|
return requested_amount == expected_amount_kopecks
|
||||||
|
|
||||||
|
|
||||||
def filter_prices_by_parcel_type(
|
def filter_prices_by_parcel_type(
|
||||||
prices: Iterable[ProviderPrice],
|
prices: Iterable[ProviderPrice],
|
||||||
*,
|
*,
|
||||||
@@ -132,7 +177,8 @@ def filter_prices_by_parcel_type(
|
|||||||
return [
|
return [
|
||||||
price
|
price
|
||||||
for price in prices
|
for price in prices
|
||||||
if _matches_parcel_type(price.service_name, normalized_parcel_type)
|
if price.bypass_parcel_type_filter
|
||||||
|
or _matches_parcel_type(price.service_name, normalized_parcel_type)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -179,9 +225,18 @@ def _normalize_price(
|
|||||||
currency=currency,
|
currency=currency,
|
||||||
delivery_days_min=min_days,
|
delivery_days_min=min_days,
|
||||||
delivery_days_max=max_days,
|
delivery_days_max=max_days,
|
||||||
|
tariff_code=_try_to_int(_get_optional_attr(candidate, "tariff_code")),
|
||||||
|
bypass_parcel_type_filter=_extract_bypass_parcel_type_filter(candidate),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_bypass_parcel_type_filter(candidate: object) -> bool:
|
||||||
|
value = _get_optional_attr(candidate, "bypass_parcel_type_filter")
|
||||||
|
if value is _MISSING or value is None:
|
||||||
|
return False
|
||||||
|
return bool(value)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_weight(weight: Decimal, scale: int) -> Decimal:
|
def _normalize_weight(weight: Decimal, scale: int) -> Decimal:
|
||||||
rounded = _round_half_up(weight, scale)
|
rounded = _round_half_up(weight, scale)
|
||||||
if rounded < MIN_WEIGHT_KG:
|
if rounded < MIN_WEIGHT_KG:
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
"""Order repository exports."""
|
||||||
|
|
||||||
|
from app.repositories.order.repository import OrderData, OrderRepository
|
||||||
|
|
||||||
|
__all__ = ("OrderData", "OrderRepository")
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"""SQLAlchemy models for order persistence."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from sqlalchemy import BigInteger, DateTime, Integer, String, UniqueConstraint, Uuid, func
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||||
|
from sqlalchemy.types import JSON
|
||||||
|
|
||||||
|
|
||||||
|
def _json_payload_type() -> JSON:
|
||||||
|
return JSON().with_variant(JSONB, "postgresql")
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Order(Base):
|
||||||
|
__tablename__ = "orders"
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("order_uuid", name="uq_orders_order_uuid"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[UUID] = mapped_column(
|
||||||
|
Uuid(as_uuid=True),
|
||||||
|
primary_key=True,
|
||||||
|
default=uuid4,
|
||||||
|
)
|
||||||
|
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[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(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
payment_status: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
tbank_payment_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
payment_email_sent_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
waybill_email_sent_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=True,
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""PostgreSQL order repository."""
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from contextlib import AbstractAsyncContextManager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
|
from app.domain.cdek_polling import TERMINAL_ORDER_STATUSES
|
||||||
|
from app.repositories.order.models import Order
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class OrderData:
|
||||||
|
order_uuid: str
|
||||||
|
payment_url: str
|
||||||
|
price: int
|
||||||
|
tariff_code: int
|
||||||
|
account_email: str
|
||||||
|
payload: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class OrderRepository:
|
||||||
|
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
|
||||||
|
self._session_factory = session_factory
|
||||||
|
|
||||||
|
def session(self) -> AbstractAsyncContextManager[AsyncSession]:
|
||||||
|
return self._session_factory.begin()
|
||||||
|
|
||||||
|
async def create_order(self, session: AsyncSession, order_data: OrderData) -> Order:
|
||||||
|
order = Order(
|
||||||
|
order_uuid=order_data.order_uuid,
|
||||||
|
payment_url=order_data.payment_url,
|
||||||
|
price=order_data.price,
|
||||||
|
tariff_code=order_data.tariff_code,
|
||||||
|
account_email=order_data.account_email,
|
||||||
|
payload=order_data.payload,
|
||||||
|
)
|
||||||
|
session.add(order)
|
||||||
|
await session.flush()
|
||||||
|
await session.refresh(order)
|
||||||
|
return order
|
||||||
|
|
||||||
|
async def get_order_by_order_uuid(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
order_uuid: str,
|
||||||
|
) -> Order | None:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Order).where(Order.order_uuid == order_uuid)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def mark_payment_status(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
order_uuid: str,
|
||||||
|
status: str,
|
||||||
|
payment_id: int,
|
||||||
|
) -> Order | None:
|
||||||
|
order = await self.get_order_by_order_uuid(session, order_uuid)
|
||||||
|
if order is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
order.payment_status = status
|
||||||
|
order.tbank_payment_id = payment_id
|
||||||
|
await session.flush()
|
||||||
|
return order
|
||||||
|
|
||||||
|
async def mark_cdek_order_registered(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
order_uuid: 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.cdek_order_uuid = cdek_order_uuid
|
||||||
|
await session.flush()
|
||||||
|
return order
|
||||||
|
|
||||||
|
async def list_orders_pending_waybill(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
limit: int,
|
||||||
|
) -> Sequence[Order]:
|
||||||
|
statement = (
|
||||||
|
select(Order)
|
||||||
|
.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)
|
||||||
|
)
|
||||||
|
result = await session.execute(statement)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def record_order_poll(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
order_uuid: str,
|
||||||
|
order_status: str | None,
|
||||||
|
waybill_uuid: str | None,
|
||||||
|
polled_at: datetime,
|
||||||
|
) -> Order | None:
|
||||||
|
order = await self.get_order_by_order_uuid(session, order_uuid)
|
||||||
|
if order is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
async def record_waybill_poll(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
order_uuid: str,
|
||||||
|
waybill_url: str | None,
|
||||||
|
polled_at: datetime,
|
||||||
|
) -> Order | None:
|
||||||
|
order = await self.get_order_by_order_uuid(session, order_uuid)
|
||||||
|
if order is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
async def record_payment_email_sent(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
order_uuid: str,
|
||||||
|
sent_at: datetime,
|
||||||
|
) -> Order | None:
|
||||||
|
order = await self.get_order_by_order_uuid(session, order_uuid)
|
||||||
|
if order is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if order.payment_email_sent_at is None:
|
||||||
|
order.payment_email_sent_at = sent_at
|
||||||
|
await session.flush()
|
||||||
|
return order
|
||||||
|
|
||||||
|
async def list_orders_pending_waybill_email(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
limit: int,
|
||||||
|
) -> Sequence[Order]:
|
||||||
|
statement = (
|
||||||
|
select(Order)
|
||||||
|
.where(
|
||||||
|
Order.cdek_waybill_url.is_not(None),
|
||||||
|
Order.waybill_email_sent_at.is_(None),
|
||||||
|
)
|
||||||
|
.order_by(Order.created_at.asc())
|
||||||
|
.limit(limit)
|
||||||
|
.with_for_update(skip_locked=True)
|
||||||
|
)
|
||||||
|
result = await session.execute(statement)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def record_waybill_email_sent(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
order_uuid: str,
|
||||||
|
sent_at: datetime,
|
||||||
|
) -> Order | None:
|
||||||
|
order = await self.get_order_by_order_uuid(session, order_uuid)
|
||||||
|
if order is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if order.waybill_email_sent_at is None:
|
||||||
|
order.waybill_email_sent_at = sent_at
|
||||||
|
await session.flush()
|
||||||
|
return order
|
||||||
@@ -21,7 +21,7 @@ def configure_logging(
|
|||||||
foreign_pre_chain=list(_shared_processors()),
|
foreign_pre_chain=list(_shared_processors()),
|
||||||
processors=[
|
processors=[
|
||||||
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
||||||
structlog.processors.JSONRenderer(sort_keys=True),
|
structlog.processors.JSONRenderer(sort_keys=True, ensure_ascii=False),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
handler = logging.StreamHandler(resolved_stream)
|
handler = logging.StreamHandler(resolved_stream)
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
"""Schemas for CDEK order registration."""
|
|
||||||
|
|
||||||
from typing import Literal
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, model_validator
|
|
||||||
|
|
||||||
|
|
||||||
class OrderPhone(BaseModel):
|
|
||||||
number: str = Field(min_length=1)
|
|
||||||
|
|
||||||
|
|
||||||
class OrderParty(BaseModel):
|
|
||||||
name: str = Field(min_length=1)
|
|
||||||
email: str = Field(min_length=1)
|
|
||||||
phones: list[OrderPhone] = Field(min_length=1)
|
|
||||||
|
|
||||||
@model_validator(mode="before")
|
|
||||||
@classmethod
|
|
||||||
def reject_company_field(cls, value: object) -> object:
|
|
||||||
if isinstance(value, dict) and "company" in value:
|
|
||||||
raise ValueError("company is not allowed")
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
class OrderLocation(BaseModel):
|
|
||||||
address: str = Field(min_length=1)
|
|
||||||
city: str = Field(min_length=1)
|
|
||||||
country_code: str = Field(min_length=2, max_length=2)
|
|
||||||
|
|
||||||
|
|
||||||
class OrderService(BaseModel):
|
|
||||||
code: str = Field(min_length=1)
|
|
||||||
parameter: str = Field(min_length=1)
|
|
||||||
|
|
||||||
|
|
||||||
class OrderPackage(BaseModel):
|
|
||||||
number: str = Field(min_length=1)
|
|
||||||
weight: int = Field(gt=0)
|
|
||||||
length: int = Field(gt=0)
|
|
||||||
width: int = Field(gt=0)
|
|
||||||
height: int = Field(gt=0)
|
|
||||||
comment: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class OrderCreateRequest(BaseModel):
|
|
||||||
type: Literal[2]
|
|
||||||
tariff_code: Literal[535]
|
|
||||||
comment: str | None = None
|
|
||||||
sender: OrderParty
|
|
||||||
recipient: OrderParty
|
|
||||||
from_location: OrderLocation
|
|
||||||
to_location: OrderLocation
|
|
||||||
services: list[OrderService] = Field(min_length=1)
|
|
||||||
packages: list[OrderPackage] = Field(min_length=1)
|
|
||||||
|
|
||||||
|
|
||||||
class OrderCreateResponse(BaseModel):
|
|
||||||
provider: str = Field(min_length=1)
|
|
||||||
order_uuid: str = Field(min_length=1)
|
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
"""Schemas for delivery payment initialization."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal, InvalidOperation
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import (
|
||||||
|
BaseModel,
|
||||||
|
ConfigDict,
|
||||||
|
EmailStr,
|
||||||
|
Field,
|
||||||
|
field_validator,
|
||||||
|
model_validator,
|
||||||
|
)
|
||||||
|
from pydantic.alias_generators import to_camel
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_positive_decimal_string(value: str) -> str:
|
||||||
|
try:
|
||||||
|
parsed = Decimal(value)
|
||||||
|
except (InvalidOperation, TypeError, ValueError) as exc:
|
||||||
|
raise ValueError("must be a positive decimal number") from exc
|
||||||
|
if not parsed.is_finite() or parsed <= 0:
|
||||||
|
raise ValueError("must be a positive decimal number")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
class _CamelModel(BaseModel):
|
||||||
|
model_config = ConfigDict(
|
||||||
|
alias_generator=to_camel,
|
||||||
|
populate_by_name=False,
|
||||||
|
extra="forbid",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Address(_CamelModel):
|
||||||
|
city_id: int = Field(gt=0, strict=True)
|
||||||
|
city: str = Field(min_length=1)
|
||||||
|
street: str = Field(min_length=1)
|
||||||
|
house: str = Field(min_length=1)
|
||||||
|
apartment: str | None = None
|
||||||
|
zip: str = Field(min_length=1)
|
||||||
|
comment: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class Contact(_CamelModel):
|
||||||
|
full_name: str = Field(min_length=1)
|
||||||
|
email: str | None = None
|
||||||
|
phone: str = Field(min_length=1)
|
||||||
|
phone_ext: str | None = None
|
||||||
|
has_extra_phone: str | None = None
|
||||||
|
is_company: bool
|
||||||
|
company_name: str | None = None
|
||||||
|
inn: str | None = None
|
||||||
|
kpp: str | None = None
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _validate_company_requisites(self) -> "Contact":
|
||||||
|
if self.is_company:
|
||||||
|
missing = [
|
||||||
|
name
|
||||||
|
for name, value in (
|
||||||
|
("companyName", self.company_name),
|
||||||
|
("inn", self.inn),
|
||||||
|
("kpp", self.kpp),
|
||||||
|
)
|
||||||
|
if value is None or value == ""
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(
|
||||||
|
f"{', '.join(missing)} are required when isCompany is true"
|
||||||
|
)
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class Content(_CamelModel):
|
||||||
|
description: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SystemDataTariff(_CamelModel):
|
||||||
|
provider: str = Field(min_length=1)
|
||||||
|
service_name: str = Field(min_length=1)
|
||||||
|
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: int = Field(gt=0, strict=True)
|
||||||
|
|
||||||
|
|
||||||
|
class Dimensions(_CamelModel):
|
||||||
|
length: str = Field(min_length=1)
|
||||||
|
width: str = Field(min_length=1)
|
||||||
|
height: str = Field(min_length=1)
|
||||||
|
|
||||||
|
_validate_dimensions = field_validator("length", "width", "height")(
|
||||||
|
_validate_positive_decimal_string
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SystemData(_CamelModel):
|
||||||
|
tariff: SystemDataTariff
|
||||||
|
parcel_type: Literal["doc", "parcel"]
|
||||||
|
doc_packaging: Literal["envelope", "bag"] | None = None
|
||||||
|
weight: str = Field(min_length=1)
|
||||||
|
dimensions: Dimensions | None = None
|
||||||
|
|
||||||
|
_validate_weight = field_validator("weight")(_validate_positive_decimal_string)
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _validate_dimensions_for_parcel_type(self) -> "SystemData":
|
||||||
|
if self.parcel_type == "parcel" and self.dimensions is None:
|
||||||
|
raise ValueError("dimensions are required when parcelType is 'parcel'")
|
||||||
|
if self.parcel_type == "doc" and self.doc_packaging is None:
|
||||||
|
raise ValueError("docPackaging is required when parcelType is 'doc'")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class InitPaymentRequest(_CamelModel):
|
||||||
|
sender_address: Address
|
||||||
|
sender_contact: Contact
|
||||||
|
receiver_address: Address
|
||||||
|
receiver_contact: Contact
|
||||||
|
content: Content
|
||||||
|
pickup_date: datetime
|
||||||
|
delivery_date: datetime | None = None
|
||||||
|
account_email: EmailStr
|
||||||
|
system_data: SystemData
|
||||||
|
agree_privacy: bool
|
||||||
|
agree_terms: bool
|
||||||
|
|
||||||
|
|
||||||
|
class InitPaymentResponse(BaseModel):
|
||||||
|
payment_url: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
|
class TBankPaymentNotification(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="allow")
|
||||||
|
|
||||||
|
TerminalKey: str = Field(min_length=1)
|
||||||
|
OrderId: str = Field(min_length=1)
|
||||||
|
Success: bool
|
||||||
|
Status: str = Field(min_length=1)
|
||||||
|
PaymentId: int = Field(gt=0, strict=True)
|
||||||
|
ErrorCode: str = Field(min_length=1)
|
||||||
|
Amount: int
|
||||||
|
Token: str = Field(min_length=1)
|
||||||
@@ -12,6 +12,8 @@ class DeliveryPrice(BaseModel):
|
|||||||
currency: str = Field(min_length=3, max_length=3)
|
currency: str = Field(min_length=3, max_length=3)
|
||||||
delivery_days_min: int = Field(ge=0)
|
delivery_days_min: int = Field(ge=0)
|
||||||
delivery_days_max: int = Field(ge=0)
|
delivery_days_max: int = Field(ge=0)
|
||||||
|
tariff_code: int | None = Field(default=None, ge=0)
|
||||||
|
bypass_parcel_type_filter: bool = Field(default=False, exclude=True)
|
||||||
|
|
||||||
|
|
||||||
class AddressSuggestion(BaseModel):
|
class AddressSuggestion(BaseModel):
|
||||||
|
|||||||
+482
-29
@@ -3,27 +3,57 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from collections.abc import Iterable, Mapping, Sequence
|
from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||||
|
from contextlib import AbstractAsyncContextManager
|
||||||
|
from datetime import datetime, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
from app.adapters.address_suggestions.base import (
|
from app.adapters.address_suggestions.base import (
|
||||||
AddressSuggestionClientError,
|
AddressSuggestionClientError,
|
||||||
AddressSuggestionProvider,
|
AddressSuggestionProvider,
|
||||||
AddressSuggestionRequestError,
|
AddressSuggestionRequestError,
|
||||||
)
|
)
|
||||||
from app.adapters.delivery_providers.base import DeliveryProvider, ProviderRequestError
|
from app.adapters.delivery_providers.base import (
|
||||||
|
DeliveryProvider,
|
||||||
|
ProviderClientError,
|
||||||
|
ProviderRequestError,
|
||||||
|
)
|
||||||
|
from app.adapters.delivery_providers.cdek.order_mapper import (
|
||||||
|
CDEKOrderRegistrationResult,
|
||||||
|
)
|
||||||
|
from app.adapters.tbank.base import (
|
||||||
|
TBankPaymentAdapterError,
|
||||||
|
TBankPaymentNotificationTokenError,
|
||||||
|
TBankPaymentRequestError,
|
||||||
|
)
|
||||||
|
from app.domain.payment_notifications import (
|
||||||
|
TBankPaymentNotificationAction,
|
||||||
|
resolve_tbank_payment_notification_action,
|
||||||
|
)
|
||||||
from app.domain.price import (
|
from app.domain.price import (
|
||||||
DEFAULT_PROVIDER_PRICE_MULTIPLIER,
|
DEFAULT_PROVIDER_PRICE_MULTIPLIER,
|
||||||
DEFAULT_WEIGHT_ROUND_SCALE,
|
DEFAULT_WEIGHT_ROUND_SCALE,
|
||||||
NormalizedDeliveryRequest,
|
NormalizedDeliveryRequest,
|
||||||
|
calculate_expected_payment_amount_kopecks,
|
||||||
filter_and_sort_prices,
|
filter_and_sort_prices,
|
||||||
|
is_init_payment_price_valid,
|
||||||
normalize_delivery_request,
|
normalize_delivery_request,
|
||||||
)
|
)
|
||||||
from app.schemas.order import OrderCreateRequest, OrderCreateResponse
|
from app.repositories.order import OrderData
|
||||||
|
from app.schemas.payment import (
|
||||||
|
InitPaymentRequest,
|
||||||
|
InitPaymentResponse,
|
||||||
|
TBankPaymentNotification,
|
||||||
|
)
|
||||||
from app.schemas.request import AddressSuggestRequest, DeliveryCalculationRequest
|
from app.schemas.request import AddressSuggestRequest, DeliveryCalculationRequest
|
||||||
from app.schemas.response import AddressSuggestion, DeliveryPrice
|
from app.schemas.response import AddressSuggestion, DeliveryPrice
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class AggregatorServiceError(RuntimeError):
|
class AggregatorServiceError(RuntimeError):
|
||||||
"""Base exception for AggregatorService failures."""
|
"""Base exception for AggregatorService failures."""
|
||||||
@@ -45,12 +75,20 @@ class AddressSuggestionsUnavailableError(AggregatorServiceError):
|
|||||||
"""Raised when address suggestions cannot be completed."""
|
"""Raised when address suggestions cannot be completed."""
|
||||||
|
|
||||||
|
|
||||||
class InvalidOrderCreateRequestError(AggregatorServiceError):
|
class InvalidInitPaymentRequestError(AggregatorServiceError):
|
||||||
"""Raised when provider rejects order creation payload as invalid."""
|
"""Raised when provider rejects payment init payload as invalid."""
|
||||||
|
|
||||||
|
|
||||||
class OrderCreationUnavailableError(AggregatorServiceError):
|
class InitPaymentUnavailableError(AggregatorServiceError):
|
||||||
"""Raised when order creation cannot be completed."""
|
"""Raised when payment initialization cannot be completed."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidTBankPaymentNotificationError(AggregatorServiceError):
|
||||||
|
"""Raised when a TBank payment notification is invalid."""
|
||||||
|
|
||||||
|
|
||||||
|
class TBankPaymentNotificationProcessingError(AggregatorServiceError):
|
||||||
|
"""Raised when a TBank payment notification cannot be processed."""
|
||||||
|
|
||||||
|
|
||||||
class PriceCacheProtocol(Protocol):
|
class PriceCacheProtocol(Protocol):
|
||||||
@@ -59,6 +97,65 @@ class PriceCacheProtocol(Protocol):
|
|||||||
async def set(self, key: str, value: object, ttl: int | None = None) -> None: ...
|
async def set(self, key: str, value: object, ttl: int | None = None) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentAdapterProtocol(Protocol):
|
||||||
|
async def create_payment_link(self, order_uuid: str, amount_kopecks: int) -> str: ...
|
||||||
|
|
||||||
|
def verify_payment_notification(
|
||||||
|
self,
|
||||||
|
notification: TBankPaymentNotification,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class PaymentPriceValidationAdapterProtocol(Protocol):
|
||||||
|
async def get_payment_price(
|
||||||
|
self,
|
||||||
|
request: InitPaymentRequest,
|
||||||
|
) -> DeliveryPrice | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class OrderRegistrationAdapterProtocol(Protocol):
|
||||||
|
async def register_order(
|
||||||
|
self, request: InitPaymentRequest, order_uuid: str
|
||||||
|
) -> CDEKOrderRegistrationResult: ...
|
||||||
|
|
||||||
|
|
||||||
|
class OrderRepositoryProtocol(Protocol):
|
||||||
|
def session(self) -> AbstractAsyncContextManager[object]: ...
|
||||||
|
|
||||||
|
async def create_order(self, session: object, order_data: OrderData) -> object: ...
|
||||||
|
|
||||||
|
async def get_order_by_order_uuid(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
order_uuid: str,
|
||||||
|
) -> object | None: ...
|
||||||
|
|
||||||
|
async def mark_payment_status(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
order_uuid: str,
|
||||||
|
status: str,
|
||||||
|
payment_id: int,
|
||||||
|
) -> object | None: ...
|
||||||
|
|
||||||
|
async def mark_cdek_order_registered(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
order_uuid: str,
|
||||||
|
cdek_order_uuid: str,
|
||||||
|
) -> object | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSenderProtocol(Protocol):
|
||||||
|
async def send_email(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
to: str,
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
class FilterAndSortPricesFn(Protocol):
|
class FilterAndSortPricesFn(Protocol):
|
||||||
def __call__(
|
def __call__(
|
||||||
self,
|
self,
|
||||||
@@ -69,31 +166,37 @@ class FilterAndSortPricesFn(Protocol):
|
|||||||
) -> list[object]: ...
|
) -> list[object]: ...
|
||||||
|
|
||||||
|
|
||||||
class OrderRegistrationAdapterProtocol(Protocol):
|
|
||||||
async def register_order(
|
|
||||||
self, request: OrderCreateRequest
|
|
||||||
) -> OrderCreateResponse: ...
|
|
||||||
|
|
||||||
|
|
||||||
class AggregatorService:
|
class AggregatorService:
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
providers: Sequence[DeliveryProvider],
|
providers: Sequence[DeliveryProvider],
|
||||||
cache: PriceCacheProtocol | None = None,
|
cache: PriceCacheProtocol | None = None,
|
||||||
order_adapter: OrderRegistrationAdapterProtocol | None = None,
|
payment_adapter: PaymentAdapterProtocol | None = None,
|
||||||
|
payment_price_validation_adapter: (
|
||||||
|
PaymentPriceValidationAdapterProtocol | None
|
||||||
|
) = None,
|
||||||
|
order_repository: OrderRepositoryProtocol | None = None,
|
||||||
|
order_registration_adapter: OrderRegistrationAdapterProtocol | None = None,
|
||||||
|
email_sender: EmailSenderProtocol | None = None,
|
||||||
address_suggestion_providers: Sequence[AddressSuggestionProvider] = (),
|
address_suggestion_providers: Sequence[AddressSuggestionProvider] = (),
|
||||||
address_suggestion_country_to_provider: Mapping[str, str] | None = None,
|
address_suggestion_country_to_provider: Mapping[str, str] | None = None,
|
||||||
*,
|
*,
|
||||||
weight_round_scale: int = DEFAULT_WEIGHT_ROUND_SCALE,
|
weight_round_scale: int = DEFAULT_WEIGHT_ROUND_SCALE,
|
||||||
provider_price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER,
|
provider_price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER,
|
||||||
filter_and_sort_prices_fn: FilterAndSortPricesFn = filter_and_sort_prices,
|
filter_and_sort_prices_fn: FilterAndSortPricesFn = filter_and_sort_prices,
|
||||||
|
order_uuid_factory: Callable[[], str] = lambda: str(uuid4()),
|
||||||
) -> None:
|
) -> None:
|
||||||
self._providers = tuple(providers)
|
self._providers = tuple(providers)
|
||||||
self._cache = cache
|
self._cache = cache
|
||||||
self._order_adapter = order_adapter
|
self._payment_adapter = payment_adapter
|
||||||
|
self._payment_price_validation_adapter = payment_price_validation_adapter
|
||||||
|
self._order_repository = order_repository
|
||||||
|
self._order_registration_adapter = order_registration_adapter
|
||||||
|
self._email_sender = email_sender
|
||||||
self._weight_round_scale = weight_round_scale
|
self._weight_round_scale = weight_round_scale
|
||||||
self._provider_price_multiplier = provider_price_multiplier
|
self._provider_price_multiplier = provider_price_multiplier
|
||||||
self._filter_and_sort_prices = filter_and_sort_prices_fn
|
self._filter_and_sort_prices = filter_and_sort_prices_fn
|
||||||
|
self._order_uuid_factory = order_uuid_factory
|
||||||
self._address_suggestion_providers: dict[str, AddressSuggestionProvider] = {
|
self._address_suggestion_providers: dict[str, AddressSuggestionProvider] = {
|
||||||
provider.name: provider for provider in address_suggestion_providers
|
provider.name: provider for provider in address_suggestion_providers
|
||||||
}
|
}
|
||||||
@@ -168,27 +271,377 @@ class AggregatorService:
|
|||||||
|
|
||||||
return [self._coerce_address_suggestion(item) for item in suggestions]
|
return [self._coerce_address_suggestion(item) for item in suggestions]
|
||||||
|
|
||||||
async def create_order(self, request: OrderCreateRequest) -> OrderCreateResponse:
|
async def init_payment(self, request: InitPaymentRequest) -> InitPaymentResponse:
|
||||||
if self._order_adapter is None:
|
if self._payment_adapter is None:
|
||||||
raise OrderCreationUnavailableError(
|
raise InitPaymentUnavailableError("Payment adapter is not configured.")
|
||||||
"Order registration adapter is not configured."
|
|
||||||
|
order_uuid = self._order_uuid_factory()
|
||||||
|
|
||||||
|
await self._validate_init_payment_price(request, order_uuid)
|
||||||
|
|
||||||
|
try:
|
||||||
|
payment_url = await self._payment_adapter.create_payment_link(
|
||||||
|
order_uuid=order_uuid,
|
||||||
|
amount_kopecks=request.system_data.tariff.price,
|
||||||
|
)
|
||||||
|
except TBankPaymentRequestError as exc:
|
||||||
|
logger.exception(
|
||||||
|
"payment_init_rejected",
|
||||||
|
order_uuid=order_uuid,
|
||||||
|
provider_status_code=exc.status_code,
|
||||||
|
provider_error_code=exc.error_code,
|
||||||
|
provider_error_message=exc.provider_message,
|
||||||
|
provider_error_details=exc.details,
|
||||||
|
)
|
||||||
|
raise InvalidInitPaymentRequestError(
|
||||||
|
"Payment init request is invalid for the configured provider."
|
||||||
|
) from exc
|
||||||
|
except TBankPaymentAdapterError as exc:
|
||||||
|
raise InitPaymentUnavailableError(
|
||||||
|
"Payment initialization is temporarily unavailable."
|
||||||
|
) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
raise InitPaymentUnavailableError(
|
||||||
|
"Payment initialization is temporarily unavailable."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
await self._persist_order(
|
||||||
|
request=request, order_uuid=order_uuid, payment_url=payment_url
|
||||||
|
)
|
||||||
|
return InitPaymentResponse(payment_url=payment_url)
|
||||||
|
|
||||||
|
async def _validate_init_payment_price(
|
||||||
|
self,
|
||||||
|
request: InitPaymentRequest,
|
||||||
|
order_uuid: str,
|
||||||
|
) -> None:
|
||||||
|
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 self._payment_price_validation_adapter.get_payment_price(
|
||||||
|
request
|
||||||
|
)
|
||||||
|
except ProviderRequestError as exc:
|
||||||
|
logger.warning(
|
||||||
|
"init_payment_price_validation_request_rejected",
|
||||||
|
order_uuid=order_uuid,
|
||||||
|
tariff_code=tariff_code,
|
||||||
|
requested_price_kopecks=requested_price,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
raise InvalidInitPaymentRequestError(
|
||||||
|
"Payment init request is invalid for CDEK price validation."
|
||||||
|
) from exc
|
||||||
|
except ProviderClientError as exc:
|
||||||
|
logger.warning(
|
||||||
|
"init_payment_price_validation_unavailable",
|
||||||
|
order_uuid=order_uuid,
|
||||||
|
tariff_code=tariff_code,
|
||||||
|
requested_price_kopecks=requested_price,
|
||||||
|
error=str(exc),
|
||||||
|
)
|
||||||
|
raise InitPaymentUnavailableError(
|
||||||
|
"Payment price validation is temporarily unavailable."
|
||||||
|
) from exc
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception(
|
||||||
|
"init_payment_price_validation_unexpected_error",
|
||||||
|
order_uuid=order_uuid,
|
||||||
|
tariff_code=tariff_code,
|
||||||
|
requested_price_kopecks=requested_price,
|
||||||
|
)
|
||||||
|
raise InitPaymentUnavailableError(
|
||||||
|
"Payment price validation is temporarily unavailable."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
if provider_price is None:
|
||||||
|
logger.warning(
|
||||||
|
"init_payment_price_validation_tariff_not_found",
|
||||||
|
order_uuid=order_uuid,
|
||||||
|
tariff_code=tariff_code,
|
||||||
|
requested_price_kopecks=requested_price,
|
||||||
|
)
|
||||||
|
raise InvalidInitPaymentRequestError(
|
||||||
|
"CDEK did not return the requested tariff for payment validation."
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_amount_kopecks = calculate_expected_payment_amount_kopecks(
|
||||||
|
provider_price,
|
||||||
|
price_multiplier=self._provider_price_multiplier,
|
||||||
|
)
|
||||||
|
if not is_init_payment_price_valid(
|
||||||
|
requested_price,
|
||||||
|
provider_price,
|
||||||
|
price_multiplier=self._provider_price_multiplier,
|
||||||
|
):
|
||||||
|
logger.warning(
|
||||||
|
"init_payment_price_mismatch",
|
||||||
|
order_uuid=order_uuid,
|
||||||
|
tariff_code=tariff_code,
|
||||||
|
requested_price_kopecks=requested_price,
|
||||||
|
expected_price_kopecks=expected_amount_kopecks,
|
||||||
|
provider_currency=getattr(provider_price, "currency", None),
|
||||||
|
provider_price=str(getattr(provider_price, "price", None)),
|
||||||
|
)
|
||||||
|
raise InvalidInitPaymentRequestError(
|
||||||
|
"Payment amount does not match CDEK validated delivery price."
|
||||||
|
)
|
||||||
|
|
||||||
|
async def handle_tbank_payment_notification(
|
||||||
|
self,
|
||||||
|
notification: TBankPaymentNotification,
|
||||||
|
) -> str:
|
||||||
|
if self._payment_adapter is None:
|
||||||
|
raise TBankPaymentNotificationProcessingError(
|
||||||
|
"Payment adapter is not configured."
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
created_order = await self._order_adapter.register_order(request)
|
self._payment_adapter.verify_payment_notification(notification)
|
||||||
except ProviderRequestError as exc:
|
except TBankPaymentNotificationTokenError as exc:
|
||||||
raise InvalidOrderCreateRequestError(
|
raise InvalidTBankPaymentNotificationError(
|
||||||
"Order request is invalid for the configured provider."
|
"TBank payment notification token is invalid."
|
||||||
) from exc
|
) from exc
|
||||||
except Exception as exc:
|
except TBankPaymentAdapterError as exc:
|
||||||
raise OrderCreationUnavailableError(
|
raise TBankPaymentNotificationProcessingError(
|
||||||
"Order creation is temporarily unavailable."
|
"TBank payment notification verification failed."
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
return OrderCreateResponse.model_validate(
|
action = resolve_tbank_payment_notification_action(
|
||||||
created_order,
|
status=notification.Status,
|
||||||
from_attributes=True,
|
success=notification.Success,
|
||||||
|
error_code=notification.ErrorCode,
|
||||||
)
|
)
|
||||||
|
order = await self._load_order_and_mark_payment_status(notification)
|
||||||
|
|
||||||
|
if action is TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY:
|
||||||
|
return "OK"
|
||||||
|
|
||||||
|
existing_cdek_order_uuid = getattr(order, "cdek_order_uuid", None)
|
||||||
|
if existing_cdek_order_uuid:
|
||||||
|
return "OK"
|
||||||
|
|
||||||
|
registration_result = await self._register_cdek_order(
|
||||||
|
order, order_uuid=notification.OrderId
|
||||||
|
)
|
||||||
|
await self._save_cdek_order_uuid(
|
||||||
|
order_uuid=notification.OrderId,
|
||||||
|
cdek_order_uuid=registration_result.order_uuid,
|
||||||
|
)
|
||||||
|
await self._send_payment_confirmation_email(
|
||||||
|
order, order_uuid=notification.OrderId
|
||||||
|
)
|
||||||
|
return "OK"
|
||||||
|
|
||||||
|
async def _send_payment_confirmation_email(
|
||||||
|
self,
|
||||||
|
order: object,
|
||||||
|
*,
|
||||||
|
order_uuid: str,
|
||||||
|
) -> None:
|
||||||
|
if self._email_sender is None or self._order_repository is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
if getattr(order, "payment_email_sent_at", None) is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
account_email = getattr(order, "account_email", None)
|
||||||
|
if account_email is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._email_sender.send_email(
|
||||||
|
to=account_email,
|
||||||
|
subject=f"Оплата по заказу {order_uuid} принята",
|
||||||
|
body=(
|
||||||
|
f"Здравствуйте!\n\n"
|
||||||
|
f"Оплата по заказу {order_uuid} успешно принята.\n"
|
||||||
|
f"Накладная будет отправлена на этот адрес в ближайшее время.\n\n"
|
||||||
|
f"Спасибо за заказ!"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
async with self._order_repository.session() as session:
|
||||||
|
await self._order_repository.record_payment_email_sent(
|
||||||
|
session,
|
||||||
|
order_uuid=order_uuid,
|
||||||
|
sent_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"payment_confirmation_email_sent",
|
||||||
|
order_uuid=order_uuid,
|
||||||
|
account_email=account_email,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.warning(
|
||||||
|
"payment_confirmation_email_failed",
|
||||||
|
order_uuid=order_uuid,
|
||||||
|
account_email=account_email,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _load_order_and_mark_payment_status(
|
||||||
|
self,
|
||||||
|
notification: TBankPaymentNotification,
|
||||||
|
) -> object:
|
||||||
|
if self._order_repository is None:
|
||||||
|
raise TBankPaymentNotificationProcessingError(
|
||||||
|
"Order repository is not configured."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with self._order_repository.session() as session:
|
||||||
|
order = await self._order_repository.get_order_by_order_uuid(
|
||||||
|
session,
|
||||||
|
notification.OrderId,
|
||||||
|
)
|
||||||
|
if order is None:
|
||||||
|
logger.warning(
|
||||||
|
"tbank_payment_notification_order_not_found",
|
||||||
|
order_uuid=notification.OrderId,
|
||||||
|
)
|
||||||
|
raise TBankPaymentNotificationProcessingError(
|
||||||
|
"Order was not found for TBank payment notification."
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
raise TBankPaymentNotificationProcessingError(
|
||||||
|
"Order was not found for TBank payment notification."
|
||||||
|
)
|
||||||
|
return order
|
||||||
|
except TBankPaymentNotificationProcessingError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception(
|
||||||
|
"tbank_payment_notification_order_update_failed",
|
||||||
|
order_uuid=notification.OrderId,
|
||||||
|
payment_status=notification.Status,
|
||||||
|
tbank_payment_id=notification.PaymentId,
|
||||||
|
)
|
||||||
|
raise TBankPaymentNotificationProcessingError(
|
||||||
|
"TBank payment notification order update failed."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
async def _register_cdek_order(
|
||||||
|
self, order: object, *, order_uuid: str
|
||||||
|
) -> CDEKOrderRegistrationResult:
|
||||||
|
if self._order_registration_adapter is None:
|
||||||
|
raise TBankPaymentNotificationProcessingError(
|
||||||
|
"CDEK order registration adapter is not configured."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
request = self._to_init_payment_request_from_order(order)
|
||||||
|
return await self._order_registration_adapter.register_order(
|
||||||
|
request, order_uuid
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception(
|
||||||
|
"cdek_order_registration_failed",
|
||||||
|
order_uuid=getattr(order, "order_uuid", None),
|
||||||
|
)
|
||||||
|
raise TBankPaymentNotificationProcessingError(
|
||||||
|
"CDEK order registration failed."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
async def _save_cdek_order_uuid(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
order_uuid: str,
|
||||||
|
cdek_order_uuid: str,
|
||||||
|
) -> None:
|
||||||
|
if self._order_repository is None:
|
||||||
|
raise TBankPaymentNotificationProcessingError(
|
||||||
|
"Order repository is not configured."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with self._order_repository.session() as session:
|
||||||
|
order = await self._order_repository.mark_cdek_order_registered(
|
||||||
|
session,
|
||||||
|
order_uuid,
|
||||||
|
cdek_order_uuid,
|
||||||
|
)
|
||||||
|
if order is None:
|
||||||
|
logger.warning(
|
||||||
|
"cdek_order_uuid_order_not_found",
|
||||||
|
order_uuid=order_uuid,
|
||||||
|
cdek_order_uuid=cdek_order_uuid,
|
||||||
|
)
|
||||||
|
raise TBankPaymentNotificationProcessingError(
|
||||||
|
"Order was not found while saving CDEK order UUID."
|
||||||
|
)
|
||||||
|
except TBankPaymentNotificationProcessingError:
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception(
|
||||||
|
"cdek_order_uuid_persistence_failed",
|
||||||
|
order_uuid=order_uuid,
|
||||||
|
cdek_order_uuid=cdek_order_uuid,
|
||||||
|
)
|
||||||
|
raise TBankPaymentNotificationProcessingError(
|
||||||
|
"CDEK order UUID persistence failed."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
async def _persist_order(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
request: InitPaymentRequest,
|
||||||
|
order_uuid: str,
|
||||||
|
payment_url: str,
|
||||||
|
) -> None:
|
||||||
|
if self._order_repository is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with self._order_repository.session() as session:
|
||||||
|
await self._order_repository.create_order(
|
||||||
|
session,
|
||||||
|
self._to_order_data(
|
||||||
|
request=request,
|
||||||
|
order_uuid=order_uuid,
|
||||||
|
payment_url=payment_url,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception(
|
||||||
|
"order_persistence_failed",
|
||||||
|
order_uuid=order_uuid,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _to_order_data(
|
||||||
|
*,
|
||||||
|
request: InitPaymentRequest,
|
||||||
|
order_uuid: str,
|
||||||
|
payment_url: str,
|
||||||
|
) -> OrderData:
|
||||||
|
return OrderData(
|
||||||
|
order_uuid=order_uuid,
|
||||||
|
payment_url=payment_url,
|
||||||
|
price=request.system_data.tariff.price,
|
||||||
|
tariff_code=request.system_data.tariff.tariff_code,
|
||||||
|
account_email=request.account_email,
|
||||||
|
payload=request.model_dump(mode="json", by_alias=True),
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _to_init_payment_request_from_order(order: object) -> InitPaymentRequest:
|
||||||
|
payload = getattr(order, "payload")
|
||||||
|
return InitPaymentRequest.model_validate(payload)
|
||||||
|
|
||||||
async def _get_provider_prices(
|
async def _get_provider_prices(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
"""Background service that e-mails CDEK waybill PDFs to customers."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Callable, Sequence
|
||||||
|
from contextlib import AbstractAsyncContextManager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
_EMAIL_SUBJECT_TEMPLATE = "Накладная по заказу {order_uuid}"
|
||||||
|
_EMAIL_BODY_TEMPLATE = (
|
||||||
|
"Здравствуйте!\n\n"
|
||||||
|
"По вашему заказу {order_uuid} сформирована транспортная накладная CDEK.\n"
|
||||||
|
"PDF-файл накладной приложен к этому письму.\n"
|
||||||
|
"Также накладная доступна по ссылке: {waybill_url}\n"
|
||||||
|
)
|
||||||
|
_ATTACHMENT_FILENAME_TEMPLATE = "waybill_{order_uuid}.pdf"
|
||||||
|
|
||||||
|
|
||||||
|
class WaybillPDFDownloaderProtocol(Protocol):
|
||||||
|
async def download_waybill_pdf(self, url: str) -> bytes: ...
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSenderProtocol(Protocol):
|
||||||
|
async def send_email(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
to: str,
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
attachment_bytes: bytes,
|
||||||
|
attachment_filename: str,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class OrderRecord(Protocol):
|
||||||
|
order_uuid: str
|
||||||
|
account_email: str
|
||||||
|
cdek_waybill_url: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class WaybillEmailSenderRepositoryProtocol(Protocol):
|
||||||
|
def session(self) -> AbstractAsyncContextManager[object]: ...
|
||||||
|
|
||||||
|
async def list_orders_pending_waybill_email(
|
||||||
|
self, session: object, *, limit: int
|
||||||
|
) -> Sequence[OrderRecord]: ...
|
||||||
|
|
||||||
|
async def record_waybill_email_sent(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
order_uuid: str,
|
||||||
|
sent_at: datetime,
|
||||||
|
) -> object | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SendBatchSummary:
|
||||||
|
processed: int
|
||||||
|
succeeded: int
|
||||||
|
failed: int
|
||||||
|
|
||||||
|
|
||||||
|
class WaybillEmailSenderService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
order_repository: WaybillEmailSenderRepositoryProtocol,
|
||||||
|
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_downloader = waybill_downloader
|
||||||
|
self._email_sender = email_sender
|
||||||
|
self._batch_size = batch_size
|
||||||
|
self._datetime_now = datetime_now
|
||||||
|
|
||||||
|
async def poll_once(self) -> SendBatchSummary:
|
||||||
|
async with self._repository.session() as session:
|
||||||
|
orders = await self._repository.list_orders_pending_waybill_email(
|
||||||
|
session, limit=self._batch_size
|
||||||
|
)
|
||||||
|
succeeded = 0
|
||||||
|
failed = 0
|
||||||
|
for order in orders:
|
||||||
|
try:
|
||||||
|
await self._handle_order(session, order)
|
||||||
|
succeeded += 1
|
||||||
|
except Exception:
|
||||||
|
failed += 1
|
||||||
|
logger.exception(
|
||||||
|
"waybill_email_order_failed",
|
||||||
|
order_uuid=order.order_uuid,
|
||||||
|
account_email=order.account_email,
|
||||||
|
)
|
||||||
|
return SendBatchSummary(
|
||||||
|
processed=len(orders),
|
||||||
|
succeeded=succeeded,
|
||||||
|
failed=failed,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run_forever(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
interval_seconds: float,
|
||||||
|
stop_event: asyncio.Event,
|
||||||
|
) -> None:
|
||||||
|
while not stop_event.is_set():
|
||||||
|
try:
|
||||||
|
summary = await self.poll_once()
|
||||||
|
logger.info(
|
||||||
|
"waybill_email_tick",
|
||||||
|
processed=summary.processed,
|
||||||
|
succeeded=summary.succeeded,
|
||||||
|
failed=summary.failed,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("waybill_email_tick_failed")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
stop_event.wait(), timeout=interval_seconds
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
async def _handle_order(self, session: object, order: OrderRecord) -> None:
|
||||||
|
waybill_url = order.cdek_waybill_url
|
||||||
|
if waybill_url is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
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,
|
||||||
|
waybill_url=waybill_url,
|
||||||
|
)
|
||||||
|
filename = _ATTACHMENT_FILENAME_TEMPLATE.format(order_uuid=order.order_uuid)
|
||||||
|
|
||||||
|
await self._email_sender.send_email(
|
||||||
|
to=order.account_email,
|
||||||
|
subject=subject,
|
||||||
|
body=body,
|
||||||
|
attachment_bytes=pdf_bytes,
|
||||||
|
attachment_filename=filename,
|
||||||
|
)
|
||||||
|
|
||||||
|
sent_at = self._datetime_now()
|
||||||
|
await self._repository.record_waybill_email_sent(
|
||||||
|
session,
|
||||||
|
order_uuid=order.order_uuid,
|
||||||
|
sent_at=sent_at,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"waybill_email_sent",
|
||||||
|
order_uuid=order.order_uuid,
|
||||||
|
account_email=order.account_email,
|
||||||
|
sent_at=sent_at,
|
||||||
|
)
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
"""Background service that polls CDEK for waybill updates."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from collections.abc import Callable, Sequence
|
||||||
|
from contextlib import AbstractAsyncContextManager
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Protocol
|
||||||
|
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from app.adapters.delivery_providers.cdek.order_mapper import (
|
||||||
|
CDEKOrderInfo,
|
||||||
|
CDEKWaybillInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class CDEKOrderInfoAdapterProtocol(Protocol):
|
||||||
|
async def get_order(self, cdek_order_uuid: str) -> CDEKOrderInfo: ...
|
||||||
|
|
||||||
|
|
||||||
|
class CDEKWaybillInfoAdapterProtocol(Protocol):
|
||||||
|
async def get_waybill(self, cdek_waybill_uuid: str) -> CDEKWaybillInfo: ...
|
||||||
|
|
||||||
|
|
||||||
|
class OrderRecord(Protocol):
|
||||||
|
order_uuid: str
|
||||||
|
cdek_order_uuid: str | None
|
||||||
|
cdek_waybill_uuid: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class WaybillPollerRepositoryProtocol(Protocol):
|
||||||
|
def session(self) -> AbstractAsyncContextManager[object]: ...
|
||||||
|
|
||||||
|
async def list_orders_pending_waybill(
|
||||||
|
self, session: object, *, limit: int
|
||||||
|
) -> Sequence[OrderRecord]: ...
|
||||||
|
|
||||||
|
async def record_order_poll(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
order_uuid: str,
|
||||||
|
order_status: str | None,
|
||||||
|
waybill_uuid: str | None,
|
||||||
|
polled_at: datetime,
|
||||||
|
) -> object | None: ...
|
||||||
|
|
||||||
|
async def record_waybill_poll(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
order_uuid: str,
|
||||||
|
waybill_url: str | None,
|
||||||
|
polled_at: datetime,
|
||||||
|
) -> object | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PollBatchSummary:
|
||||||
|
processed: int
|
||||||
|
succeeded: int
|
||||||
|
failed: int
|
||||||
|
|
||||||
|
|
||||||
|
class WaybillPollerService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
order_repository: WaybillPollerRepositoryProtocol,
|
||||||
|
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_adapter = order_info_adapter
|
||||||
|
self._waybill_info_adapter = waybill_info_adapter
|
||||||
|
self._batch_size = batch_size
|
||||||
|
self._datetime_now = datetime_now
|
||||||
|
|
||||||
|
async def poll_once(self) -> PollBatchSummary:
|
||||||
|
async with self._repository.session() as session:
|
||||||
|
orders = await self._repository.list_orders_pending_waybill(
|
||||||
|
session, limit=self._batch_size
|
||||||
|
)
|
||||||
|
succeeded = 0
|
||||||
|
failed = 0
|
||||||
|
for order in orders:
|
||||||
|
try:
|
||||||
|
await self._handle_order(session, order)
|
||||||
|
succeeded += 1
|
||||||
|
except Exception:
|
||||||
|
failed += 1
|
||||||
|
logger.exception(
|
||||||
|
"waybill_poll_order_failed",
|
||||||
|
order_uuid=order.order_uuid,
|
||||||
|
cdek_order_uuid=order.cdek_order_uuid,
|
||||||
|
cdek_waybill_uuid=order.cdek_waybill_uuid,
|
||||||
|
)
|
||||||
|
return PollBatchSummary(
|
||||||
|
processed=len(orders),
|
||||||
|
succeeded=succeeded,
|
||||||
|
failed=failed,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def run_forever(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
interval_seconds: float,
|
||||||
|
stop_event: asyncio.Event,
|
||||||
|
) -> None:
|
||||||
|
while not stop_event.is_set():
|
||||||
|
try:
|
||||||
|
summary = await self.poll_once()
|
||||||
|
logger.info(
|
||||||
|
"waybill_poll_tick",
|
||||||
|
processed=summary.processed,
|
||||||
|
succeeded=summary.succeeded,
|
||||||
|
failed=summary.failed,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("waybill_poll_tick_failed")
|
||||||
|
|
||||||
|
try:
|
||||||
|
await asyncio.wait_for(
|
||||||
|
stop_event.wait(), timeout=interval_seconds
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
async def _handle_order(self, session: object, order: OrderRecord) -> None:
|
||||||
|
polled_at = self._datetime_now()
|
||||||
|
if order.cdek_waybill_uuid is None:
|
||||||
|
cdek_order_uuid = order.cdek_order_uuid
|
||||||
|
if cdek_order_uuid is None:
|
||||||
|
return
|
||||||
|
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=info.status_code,
|
||||||
|
waybill_uuid=info.waybill_uuid,
|
||||||
|
polled_at=polled_at,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"waybill_poll_order_result",
|
||||||
|
order_uuid=order.order_uuid,
|
||||||
|
cdek_order_uuid=cdek_order_uuid,
|
||||||
|
cdek_order_status=info.status_code,
|
||||||
|
cdek_waybill_uuid=info.waybill_uuid,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
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,
|
||||||
|
polled_at=polled_at,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"waybill_poll_waybill_result",
|
||||||
|
order_uuid=order.order_uuid,
|
||||||
|
cdek_waybill_uuid=order.cdek_waybill_uuid,
|
||||||
|
cdek_waybill_url=waybill.url,
|
||||||
|
)
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
"""Background worker that e-mails CDEK waybill PDFs."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import signal
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from app.adapters.delivery_providers.cdek.auth import CDEKAuthClient
|
||||||
|
from app.adapters.delivery_providers.cdek.client import CDEKClient
|
||||||
|
from app.adapters.email import SMTPEmailSender
|
||||||
|
from app.adapters.postgres.engine import (
|
||||||
|
create_postgres_engine,
|
||||||
|
create_postgres_session_factory,
|
||||||
|
)
|
||||||
|
from app.config import Settings, get_settings
|
||||||
|
from app.repositories.order import OrderRepository
|
||||||
|
from app.runtime.logging import configure_logging
|
||||||
|
from app.services.waybill_email_sender import WaybillEmailSenderService
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run(settings: Settings, stop_event: asyncio.Event) -> None:
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
email_sender = SMTPEmailSender(
|
||||||
|
smtp_host=settings.email.smtp_host,
|
||||||
|
smtp_port=settings.email.smtp_port,
|
||||||
|
username=settings.email.username,
|
||||||
|
password=settings.email.password,
|
||||||
|
from_address=settings.email.from_address,
|
||||||
|
use_tls=settings.email.use_tls,
|
||||||
|
timeout_seconds=settings.email.timeout_seconds,
|
||||||
|
)
|
||||||
|
engine = create_postgres_engine(settings.postgres)
|
||||||
|
session_factory = create_postgres_session_factory(engine)
|
||||||
|
repository = OrderRepository(session_factory=session_factory)
|
||||||
|
service = WaybillEmailSenderService(
|
||||||
|
order_repository=repository,
|
||||||
|
waybill_downloader=cdek_client,
|
||||||
|
email_sender=email_sender,
|
||||||
|
batch_size=settings.waybill_email_sender.batch_size,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"waybill_email_sender_started",
|
||||||
|
interval_seconds=settings.waybill_email_sender.interval_seconds,
|
||||||
|
batch_size=settings.waybill_email_sender.batch_size,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await service.run_forever(
|
||||||
|
interval_seconds=settings.waybill_email_sender.interval_seconds,
|
||||||
|
stop_event=stop_event,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await http_client.aclose()
|
||||||
|
await engine.dispose()
|
||||||
|
logger.info("waybill_email_sender_stopped")
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
configure_logging()
|
||||||
|
settings = get_settings()
|
||||||
|
stop_event = asyncio.Event()
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||||
|
try:
|
||||||
|
loop.add_signal_handler(sig, stop_event.set)
|
||||||
|
except NotImplementedError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await _run(settings, stop_event)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
"""Background worker that polls CDEK for waybill updates."""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import signal
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import structlog
|
||||||
|
|
||||||
|
from app.adapters.delivery_providers.cdek.auth import CDEKAuthClient
|
||||||
|
from app.adapters.delivery_providers.cdek.client import CDEKClient
|
||||||
|
from app.adapters.postgres.engine import (
|
||||||
|
create_postgres_engine,
|
||||||
|
create_postgres_session_factory,
|
||||||
|
)
|
||||||
|
from app.config import Settings, get_settings
|
||||||
|
from app.repositories.order import OrderRepository
|
||||||
|
from app.runtime.logging import configure_logging
|
||||||
|
from app.services.waybill_poller import WaybillPollerService
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def _run(settings: Settings, stop_event: asyncio.Event) -> None:
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
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_adapter=cdek_client,
|
||||||
|
waybill_info_adapter=cdek_client,
|
||||||
|
batch_size=settings.waybill_poller.batch_size,
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
"waybill_poller_started",
|
||||||
|
interval_seconds=settings.waybill_poller.interval_seconds,
|
||||||
|
batch_size=settings.waybill_poller.batch_size,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await service.run_forever(
|
||||||
|
interval_seconds=settings.waybill_poller.interval_seconds,
|
||||||
|
stop_event=stop_event,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
await http_client.aclose()
|
||||||
|
await engine.dispose()
|
||||||
|
logger.info("waybill_poller_stopped")
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
configure_logging()
|
||||||
|
settings = get_settings()
|
||||||
|
stop_event = asyncio.Event()
|
||||||
|
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||||
|
try:
|
||||||
|
loop.add_signal_handler(sig, stop_event.set)
|
||||||
|
except NotImplementedError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
await _run(settings, stop_event)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
controller:
|
|
||||||
api_prefix: "/api/v1"
|
|
||||||
request_id_header: "X-Request-ID"
|
|
||||||
|
|
||||||
service:
|
|
||||||
provider_timeout_seconds: 10.0
|
|
||||||
max_parallel_providers: 8
|
|
||||||
|
|
||||||
business_logic:
|
|
||||||
weight_round_scale: 2
|
|
||||||
provider_price_multiplier: 1.0
|
|
||||||
|
|
||||||
repository:
|
|
||||||
redis_dsn: "redis://localhost:6379/0"
|
|
||||||
price_cache_ttl_seconds: 900
|
|
||||||
|
|
||||||
adapter:
|
|
||||||
cdek_base_url: "https://api.cdek.ru/v2"
|
|
||||||
cdek_client_id: ""
|
|
||||||
cdek_client_secret: ""
|
|
||||||
cdek_retry_attempts: 2
|
|
||||||
cdek_retry_backoff_seconds: 0.2
|
|
||||||
cdek_timeout_seconds: 10.0
|
|
||||||
cdek_cache_ttl_seconds: 900
|
|
||||||
|
|
||||||
address_suggestions:
|
|
||||||
country_to_provider:
|
|
||||||
RU: "dadata"
|
|
||||||
BY: "dadata"
|
|
||||||
KZ: "dadata"
|
|
||||||
AM: "yandex_geosuggest"
|
|
||||||
AZ: "yandex_geosuggest"
|
|
||||||
KG: "yandex_geosuggest"
|
|
||||||
MD: "yandex_geosuggest"
|
|
||||||
TJ: "yandex_geosuggest"
|
|
||||||
TM: "yandex_geosuggest"
|
|
||||||
UZ: "yandex_geosuggest"
|
|
||||||
dadata:
|
|
||||||
url: "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
|
|
||||||
api_key: ""
|
|
||||||
timeout_seconds: 10.0
|
|
||||||
yandex_geosuggest:
|
|
||||||
url: "https://suggest-maps.yandex.ru/v1/suggest"
|
|
||||||
api_key: ""
|
|
||||||
timeout_seconds: 10.0
|
|
||||||
|
|
||||||
observability:
|
|
||||||
enabled: false
|
|
||||||
service_name: "g2s-aggregator"
|
|
||||||
otlp_endpoint: "http://localhost:4317"
|
|
||||||
otlp_insecure: true
|
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
controller:
|
||||||
|
api_prefix: "/api/v1"
|
||||||
|
request_id_header: "X-Request-ID"
|
||||||
|
|
||||||
|
service:
|
||||||
|
provider_timeout_seconds: 10.0
|
||||||
|
max_parallel_providers: 8
|
||||||
|
|
||||||
|
business_logic:
|
||||||
|
weight_round_scale: 2
|
||||||
|
provider_price_multiplier: 1.0
|
||||||
|
|
||||||
|
repository:
|
||||||
|
redis_dsn: "redis://localhost:6379/0"
|
||||||
|
price_cache_ttl_seconds: 900
|
||||||
|
|
||||||
|
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: "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}"
|
||||||
|
timeout_seconds: 10.0
|
||||||
|
retry_attempts: 2
|
||||||
|
retry_backoff_seconds: 0.2
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
dsn: "postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/g2s_aggregator"
|
||||||
|
|
||||||
|
address_suggestions:
|
||||||
|
country_to_provider:
|
||||||
|
RU: "dadata"
|
||||||
|
BY: "dadata"
|
||||||
|
KZ: "dadata"
|
||||||
|
AM: "yandex_geosuggest"
|
||||||
|
AZ: "yandex_geosuggest"
|
||||||
|
KG: "yandex_geosuggest"
|
||||||
|
MD: "yandex_geosuggest"
|
||||||
|
TJ: "yandex_geosuggest"
|
||||||
|
TM: "yandex_geosuggest"
|
||||||
|
UZ: "yandex_geosuggest"
|
||||||
|
AL: "tomtom"
|
||||||
|
AT: "tomtom"
|
||||||
|
BE: "tomtom"
|
||||||
|
BG: "tomtom"
|
||||||
|
CH: "tomtom"
|
||||||
|
CZ: "tomtom"
|
||||||
|
DE: "tomtom"
|
||||||
|
DK: "tomtom"
|
||||||
|
EE: "tomtom"
|
||||||
|
ES: "tomtom"
|
||||||
|
FI: "tomtom"
|
||||||
|
FR: "tomtom"
|
||||||
|
GB: "tomtom"
|
||||||
|
GR: "tomtom"
|
||||||
|
HR: "tomtom"
|
||||||
|
HU: "tomtom"
|
||||||
|
IE: "tomtom"
|
||||||
|
IT: "tomtom"
|
||||||
|
LT: "tomtom"
|
||||||
|
LV: "tomtom"
|
||||||
|
NL: "tomtom"
|
||||||
|
"NO": "tomtom"
|
||||||
|
PL: "tomtom"
|
||||||
|
PT: "tomtom"
|
||||||
|
RO: "tomtom"
|
||||||
|
RS: "tomtom"
|
||||||
|
SE: "tomtom"
|
||||||
|
SI: "tomtom"
|
||||||
|
SK: "tomtom"
|
||||||
|
UA: "tomtom"
|
||||||
|
dadata:
|
||||||
|
url: "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
|
||||||
|
api_key: "${DADATA_API_KEY}"
|
||||||
|
timeout_seconds: 7.5
|
||||||
|
yandex_geosuggest:
|
||||||
|
url: "https://suggest-maps.yandex.ru/v1/suggest"
|
||||||
|
api_key: "${YANDEX_GEOSUGGEST_API_KEY}"
|
||||||
|
timeout_seconds: 7.5
|
||||||
|
tomtom:
|
||||||
|
url: "https://api.tomtom.com/search/2/search"
|
||||||
|
api_key: "${TOMTOM_API_KEY}"
|
||||||
|
timeout_seconds: 7.5
|
||||||
|
|
||||||
|
waybill_poller:
|
||||||
|
interval_seconds: 10
|
||||||
|
batch_size: 50
|
||||||
|
|
||||||
|
email:
|
||||||
|
smtp_host: "smtp.yandex.ru"
|
||||||
|
smtp_port: 465
|
||||||
|
username: "${SMTP_USERNAME}"
|
||||||
|
password: "${SMTP_PASSWORD}"
|
||||||
|
from_address: "${SMTP_FROM_ADDRESS}"
|
||||||
|
use_tls: true
|
||||||
|
timeout_seconds: 10.0
|
||||||
|
|
||||||
|
waybill_email_sender:
|
||||||
|
interval_seconds: 10
|
||||||
|
batch_size: 50
|
||||||
|
|
||||||
|
observability:
|
||||||
|
enabled: true
|
||||||
|
service_name: "g2s-aggregator"
|
||||||
|
otlp_endpoint: "${OTLP_ENDPOINT}"
|
||||||
|
otlp_insecure: true
|
||||||
@@ -23,6 +23,20 @@ adapter:
|
|||||||
cdek_timeout_seconds: 10.0
|
cdek_timeout_seconds: 10.0
|
||||||
cdek_cache_ttl_seconds: 900
|
cdek_cache_ttl_seconds: 900
|
||||||
|
|
||||||
|
tbank_payment:
|
||||||
|
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||||
|
notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications"
|
||||||
|
success_url: "https://merchant.test/payment/success"
|
||||||
|
auth:
|
||||||
|
terminal_key: "test-terminal-key"
|
||||||
|
password: "test-password"
|
||||||
|
timeout_seconds: 10.0
|
||||||
|
retry_attempts: 2
|
||||||
|
retry_backoff_seconds: 0.2
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/g2s_aggregator_test"
|
||||||
|
|
||||||
address_suggestions:
|
address_suggestions:
|
||||||
country_to_provider:
|
country_to_provider:
|
||||||
RU: "dadata"
|
RU: "dadata"
|
||||||
@@ -83,3 +97,8 @@ observability:
|
|||||||
service_name: "g2s-aggregator-test"
|
service_name: "g2s-aggregator-test"
|
||||||
otlp_endpoint: "http://localhost:4317"
|
otlp_endpoint: "http://localhost:4317"
|
||||||
otlp_insecure: true
|
otlp_insecure: true
|
||||||
|
|
||||||
|
email:
|
||||||
|
smtp_host: "smtp.test"
|
||||||
|
smtp_port: 587
|
||||||
|
from_address: "no-reply@test"
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
services:
|
||||||
|
app:
|
||||||
|
image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.0.9
|
||||||
|
container_name: g2s-aggregator
|
||||||
|
ports:
|
||||||
|
- "8003:8000"
|
||||||
|
depends_on:
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
migrations:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
volumes:
|
||||||
|
- ./config.yaml:/config.yaml
|
||||||
|
restart: unless-stopped
|
||||||
|
logging:
|
||||||
|
driver: "json-file"
|
||||||
|
options:
|
||||||
|
tag: "g2s-aggregator"
|
||||||
|
redis:
|
||||||
|
image: redis:7-alpine
|
||||||
|
container_name: redis
|
||||||
|
command: ["redis-server", "--save", "", "--appendonly", "no"]
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
restart: unless-stopped
|
||||||
|
postgres:
|
||||||
|
image: postgres:16-alpine
|
||||||
|
container_name: postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: g2s_aggregator
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d g2s_aggregator"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
migrations:
|
||||||
|
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: ["poetry", "run", "alembic", "upgrade", "head"]
|
||||||
|
restart: "no"
|
||||||
|
waybill-poller:
|
||||||
|
image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.0.9
|
||||||
|
container_name: g2s-aggregator-waybill-poller
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
migrations:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
volumes:
|
||||||
|
- ./config.yaml:/config.yaml
|
||||||
|
command: ["poetry", "run", "python", "-m", "app.workers.waybill_poller"]
|
||||||
|
restart: unless-stopped
|
||||||
|
waybill-email-sender:
|
||||||
|
image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.0.9
|
||||||
|
container_name: g2s-aggregator-waybill-email-sender
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
migrations:
|
||||||
|
condition: service_completed_successfully
|
||||||
|
volumes:
|
||||||
|
- ./config.yaml:/config.yaml
|
||||||
|
command:
|
||||||
|
["poetry", "run", "python", "-m", "app.workers.waybill_email_sender"]
|
||||||
|
restart: unless-stopped
|
||||||
|
otel-collector:
|
||||||
|
image: otel/opentelemetry-collector-contrib:latest
|
||||||
|
container_name: otel-collector
|
||||||
|
user: "0"
|
||||||
|
volumes:
|
||||||
|
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
|
||||||
|
- /var/lib/docker/containers:/var/lib/docker/containers:ro
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
restart: unless-stopped
|
||||||
|
command: ["--config=/etc/otel-collector-config.yaml"]
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres:
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
services:
|
|
||||||
app:
|
|
||||||
image: yusupal1ev/g2s-aggregator:0.0.0
|
|
||||||
container_name: g2s-aggregator
|
|
||||||
ports:
|
|
||||||
- "8000:8000"
|
|
||||||
depends_on:
|
|
||||||
- redis
|
|
||||||
volumes:
|
|
||||||
- ./config.yaml:/config.yaml
|
|
||||||
redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
container_name: redis
|
|
||||||
command: ["redis-server", "--save", "", "--appendonly", "no"]
|
|
||||||
ports:
|
|
||||||
- "6379:6379"
|
|
||||||
+88
-43
@@ -12,66 +12,111 @@ grant_type=client_credentials&client_id={{client_id}}&client_secret={{client_sec
|
|||||||
GET {{base_url}}/v2/orders?cdek_number=10240410458
|
GET {{base_url}}/v2/orders?cdek_number=10240410458
|
||||||
Authorization: Bearer {{auth_token}}
|
Authorization: Bearer {{auth_token}}
|
||||||
|
|
||||||
### 3. Регистрация заказа (тип "доставка", до двери)
|
### 3. Инициализация оплаты доставки
|
||||||
POST {{base_url}}/v2/orders
|
POST http://localhost:8000/api/v1/delivery/order
|
||||||
Authorization: Bearer {{auth_token}}
|
|
||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
"type": 2,
|
"senderAddress": {
|
||||||
"tariff_code": 535,
|
"cityId": 1,
|
||||||
"comment": "Тестовый заказ",
|
"city": "Дубай",
|
||||||
"sender": {
|
"street": "Sheikh Zayed Road",
|
||||||
"name": "Петр Петров",
|
"house": "10",
|
||||||
|
"apartment": "201",
|
||||||
|
"zip": "12345",
|
||||||
|
"comment": "Домофон 12"
|
||||||
|
},
|
||||||
|
"senderContact": {
|
||||||
|
"fullName": "Петр Петров",
|
||||||
"email": "sender@example.com",
|
"email": "sender@example.com",
|
||||||
"phones": [
|
"phone": "+79009876543",
|
||||||
{
|
"phoneExt": null,
|
||||||
"number": "+79009876543"
|
"isCompany": false,
|
||||||
}
|
"companyName": null,
|
||||||
]
|
"inn": null,
|
||||||
|
"kpp": null
|
||||||
},
|
},
|
||||||
"recipient": {
|
"receiverAddress": {
|
||||||
"name": "Иван Иванов",
|
"cityId": 2,
|
||||||
|
"city": "Шарджа",
|
||||||
|
"street": "Al Wahda",
|
||||||
|
"house": "5",
|
||||||
|
"apartment": null,
|
||||||
|
"zip": "54321",
|
||||||
|
"comment": null
|
||||||
|
},
|
||||||
|
"receiverContact": {
|
||||||
|
"fullName": "Иван Иванов",
|
||||||
"email": "ivan@example.com",
|
"email": "ivan@example.com",
|
||||||
"phones": [
|
"phone": "+79001234567",
|
||||||
{
|
"phoneExt": "101",
|
||||||
"number": "+79001234567"
|
"isCompany": false,
|
||||||
}
|
"companyName": null,
|
||||||
]
|
"inn": null,
|
||||||
|
"kpp": null
|
||||||
},
|
},
|
||||||
"from_location": {
|
"content": {
|
||||||
"address": "ул. Ленина, 1",
|
"description": "Наушники"
|
||||||
"city": "Москва",
|
|
||||||
"country_code": "RU"
|
|
||||||
},
|
},
|
||||||
"to_location": {
|
"pickupDate": "2026-05-15T10:00:00.000Z",
|
||||||
"address": "ул. Пушкина, 10",
|
"deliveryDate": "2026-05-18T18:00:00.000Z",
|
||||||
"city": "Новосибирск",
|
"accountEmail": "client@example.com",
|
||||||
"country_code": "RU"
|
"systemData": {
|
||||||
|
"tariff": {
|
||||||
|
"provider": "СДЭК",
|
||||||
|
"serviceName": "Экспресс лайт",
|
||||||
|
"price": 125000,
|
||||||
|
"deliveryDaysMin": 1,
|
||||||
|
"deliveryDaysMax": 2,
|
||||||
|
"tariffCode": 535
|
||||||
},
|
},
|
||||||
"services": [
|
"parcelType": "parcel",
|
||||||
{
|
"docPackaging": null,
|
||||||
"code": "INSURANCE",
|
"weight": "1.0",
|
||||||
"parameter": "1000"
|
"dimensions": {
|
||||||
|
"length": "20",
|
||||||
|
"width": "15",
|
||||||
|
"height": "10"
|
||||||
}
|
}
|
||||||
],
|
|
||||||
"packages": [
|
|
||||||
{
|
|
||||||
"number": "1",
|
|
||||||
"weight": 1000,
|
|
||||||
"length": 20,
|
|
||||||
"width": 15,
|
|
||||||
"height": 10,
|
|
||||||
"comment": "Упаковка 1"
|
|
||||||
}
|
}
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
> {%
|
> {%
|
||||||
client.global.set("order_uuid", response.body.entity.uuid);
|
client.global.set("payment_url", response.body.payment_url);
|
||||||
%}
|
%}
|
||||||
|
|
||||||
### 5. Список доступных тарифов
|
### 5. Список доступных тарифов
|
||||||
GET {{base_url}}/v2/calculator/alltariffs
|
GET {{base_url}}/v2/calculator/alltariffs
|
||||||
Authorization: Bearer {{auth_token}}
|
Authorization: Bearer {{auth_token}}
|
||||||
X-User-Lang: rus
|
X-User-Lang: rus
|
||||||
|
|
||||||
|
### 6. Формирование накладной по заказу
|
||||||
|
POST {{base_url}}/v2/print/orders
|
||||||
|
Authorization: Bearer {{auth_token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
|
||||||
|
{
|
||||||
|
"orders": [
|
||||||
|
{
|
||||||
|
"order_uuid": "abd93f2d-7c5a-4820-9fdd-cab39aadbb1f"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"copy_count": 1
|
||||||
|
}
|
||||||
|
|
||||||
|
### 7. Получение накладной по UUID
|
||||||
|
GET {{base_url}}/v2/print/orders/31ff53f5-4c01-43aa-afa2-b7efdf0c06da
|
||||||
|
Authorization: Bearer {{auth_token}}
|
||||||
|
|
||||||
|
### 8. Просмотр заказа CDEK по UUID (с related_entities)
|
||||||
|
GET {{base_url}}/v2/orders/1e2282ba-529e-4ee7-9d82-2d7837145755
|
||||||
|
Authorization: Bearer {{auth_token}}
|
||||||
|
|
||||||
|
### 9. Скачивание готовой квитанции (PDF)
|
||||||
|
# Подставь WAYBILL_UUID из related_entities[type=waybill] (### 8).
|
||||||
|
# Файл сохраняется рядом с http-client.http как waybill.pdf.
|
||||||
|
GET {{base_url}}/v2/print/orders/31ff53f5-4c01-43aa-afa2-b7efdf0c06da.pdf
|
||||||
|
Authorization: Bearer {{auth_token}}
|
||||||
|
Accept: application/pdf
|
||||||
|
|
||||||
|
>>! waybill.pdf
|
||||||
|
|||||||
Generated
+527
-14
@@ -1,4 +1,4 @@
|
|||||||
# This file is automatically @generated by Poetry 1.8.5 and should not be changed by hand.
|
# This file is automatically @generated by Poetry 2.2.1 and should not be changed by hand.
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "aioredis"
|
name = "aioredis"
|
||||||
@@ -6,6 +6,7 @@ version = "2.0.1"
|
|||||||
description = "asyncio (PEP 3156) Redis support"
|
description = "asyncio (PEP 3156) Redis support"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.6"
|
python-versions = ">=3.6"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "aioredis-2.0.1-py3-none-any.whl", hash = "sha256:9ac0d0b3b485d293b8ca1987e6de8658d7dafcca1cddfcd1d506cae8cdebfdd6"},
|
{file = "aioredis-2.0.1-py3-none-any.whl", hash = "sha256:9ac0d0b3b485d293b8ca1987e6de8658d7dafcca1cddfcd1d506cae8cdebfdd6"},
|
||||||
{file = "aioredis-2.0.1.tar.gz", hash = "sha256:eaa51aaf993f2d71f54b70527c440437ba65340588afeb786cd87c55c89cd98e"},
|
{file = "aioredis-2.0.1.tar.gz", hash = "sha256:eaa51aaf993f2d71f54b70527c440437ba65340588afeb786cd87c55c89cd98e"},
|
||||||
@@ -16,7 +17,59 @@ async-timeout = "*"
|
|||||||
typing-extensions = "*"
|
typing-extensions = "*"
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
hiredis = ["hiredis (>=1.0)"]
|
hiredis = ["hiredis (>=1.0) ; implementation_name == \"cpython\""]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aiosmtplib"
|
||||||
|
version = "4.0.2"
|
||||||
|
description = "asyncio SMTP client"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
|
files = [
|
||||||
|
{file = "aiosmtplib-4.0.2-py3-none-any.whl", hash = "sha256:72491f96e6de035c28d29870186782eccb2f651db9c5f8a32c9db689327f5742"},
|
||||||
|
{file = "aiosmtplib-4.0.2.tar.gz", hash = "sha256:f0b4933e7270a8be2b588761e5b12b7334c11890ee91987c2fb057e72f566da6"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
docs = ["furo (>=2023.9.10)", "sphinx (>=7.0.0)", "sphinx-autodoc-typehints (>=1.24.0)", "sphinx-copybutton (>=0.5.0)"]
|
||||||
|
uvloop = ["uvloop (>=0.18)"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "aiosqlite"
|
||||||
|
version = "0.22.1"
|
||||||
|
description = "asyncio bridge to the standard sqlite3 module"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
|
files = [
|
||||||
|
{file = "aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb"},
|
||||||
|
{file = "aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
dev = ["attribution (==1.8.0)", "black (==25.11.0)", "build (>=1.2)", "coverage[toml] (==7.10.7)", "flake8 (==7.3.0)", "flake8-bugbear (==24.12.12)", "flit (==3.12.0)", "mypy (==1.19.0)", "ufmt (==2.8.0)", "usort (==1.0.8.post1)"]
|
||||||
|
docs = ["sphinx (==8.1.3)", "sphinx-mdinclude (==0.6.2)"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "alembic"
|
||||||
|
version = "1.18.4"
|
||||||
|
description = "A database migration tool for SQLAlchemy."
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.10"
|
||||||
|
groups = ["main"]
|
||||||
|
files = [
|
||||||
|
{file = "alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a"},
|
||||||
|
{file = "alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
Mako = "*"
|
||||||
|
SQLAlchemy = ">=1.4.23"
|
||||||
|
typing-extensions = ">=4.12"
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
tz = ["tzdata"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "annotated-doc"
|
name = "annotated-doc"
|
||||||
@@ -24,6 +77,7 @@ version = "0.0.4"
|
|||||||
description = "Document parameters, class attributes, return types, and variables inline, with Annotated."
|
description = "Document parameters, class attributes, return types, and variables inline, with Annotated."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8"
|
python-versions = ">=3.8"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320"},
|
{file = "annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320"},
|
||||||
{file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"},
|
{file = "annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4"},
|
||||||
@@ -35,6 +89,7 @@ version = "0.7.0"
|
|||||||
description = "Reusable constraint types to use with typing.Annotated"
|
description = "Reusable constraint types to use with typing.Annotated"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8"
|
python-versions = ">=3.8"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
|
{file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"},
|
||||||
{file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
|
{file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"},
|
||||||
@@ -46,6 +101,7 @@ version = "4.12.1"
|
|||||||
description = "High-level concurrency and networking framework on top of asyncio or Trio"
|
description = "High-level concurrency and networking framework on top of asyncio or Trio"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"},
|
{file = "anyio-4.12.1-py3-none-any.whl", hash = "sha256:d405828884fc140aa80a3c667b8beed277f1dfedec42ba031bd6ac3db606ab6c"},
|
||||||
{file = "anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703"},
|
{file = "anyio-4.12.1.tar.gz", hash = "sha256:41cfcc3a4c85d3f05c932da7c26d0201ac36f72abd4435ba90d0464a3ffed703"},
|
||||||
@@ -55,7 +111,7 @@ files = [
|
|||||||
idna = ">=2.8"
|
idna = ">=2.8"
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
trio = ["trio (>=0.31.0)", "trio (>=0.32.0)"]
|
trio = ["trio (>=0.31.0) ; python_version < \"3.10\"", "trio (>=0.32.0) ; python_version >= \"3.10\""]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "asgiref"
|
name = "asgiref"
|
||||||
@@ -63,6 +119,7 @@ version = "3.11.1"
|
|||||||
description = "ASGI specs, helper code, and adapters"
|
description = "ASGI specs, helper code, and adapters"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133"},
|
{file = "asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133"},
|
||||||
{file = "asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce"},
|
{file = "asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce"},
|
||||||
@@ -77,17 +134,89 @@ version = "5.0.1"
|
|||||||
description = "Timeout context manager for asyncio programs"
|
description = "Timeout context manager for asyncio programs"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8"
|
python-versions = ">=3.8"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"},
|
{file = "async_timeout-5.0.1-py3-none-any.whl", hash = "sha256:39e3809566ff85354557ec2398b55e096c8364bacac9405a7a1fa429e77fe76c"},
|
||||||
{file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"},
|
{file = "async_timeout-5.0.1.tar.gz", hash = "sha256:d9321a7a3d5a6a5e187e824d2fa0793ce379a202935782d555d6e9d2735677d3"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "asyncpg"
|
||||||
|
version = "0.31.0"
|
||||||
|
description = "An asyncio PostgreSQL driver"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.9.0"
|
||||||
|
groups = ["main"]
|
||||||
|
files = [
|
||||||
|
{file = "asyncpg-0.31.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:831712dd3cf117eec68575a9b50da711893fd63ebe277fc155ecae1c6c9f0f61"},
|
||||||
|
{file = "asyncpg-0.31.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0b17c89312c2f4ccea222a3a6571f7df65d4ba2c0e803339bfc7bed46a96d3be"},
|
||||||
|
{file = "asyncpg-0.31.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3faa62f997db0c9add34504a68ac2c342cfee4d57a0c3062fcf0d86c7f9cb1e8"},
|
||||||
|
{file = "asyncpg-0.31.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ea599d45c361dfbf398cb67da7fd052affa556a401482d3ff1ee99bd68808a1"},
|
||||||
|
{file = "asyncpg-0.31.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:795416369c3d284e1837461909f58418ad22b305f955e625a4b3a2521d80a5f3"},
|
||||||
|
{file = "asyncpg-0.31.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a8d758dac9d2e723e173d286ef5e574f0b350ec00e9186fce84d0fc5f6a8e6b8"},
|
||||||
|
{file = "asyncpg-0.31.0-cp310-cp310-win32.whl", hash = "sha256:2d076d42eb583601179efa246c5d7ae44614b4144bc1c7a683ad1222814ed095"},
|
||||||
|
{file = "asyncpg-0.31.0-cp310-cp310-win_amd64.whl", hash = "sha256:9ea33213ac044171f4cac23740bed9a3805abae10e7025314cfbd725ec670540"},
|
||||||
|
{file = "asyncpg-0.31.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eee690960e8ab85063ba93af2ce128c0f52fd655fdff9fdb1a28df01329f031d"},
|
||||||
|
{file = "asyncpg-0.31.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2657204552b75f8288de08ca60faf4a99a65deef3a71d1467454123205a88fab"},
|
||||||
|
{file = "asyncpg-0.31.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a429e842a3a4b4ea240ea52d7fe3f82d5149853249306f7ff166cb9948faa46c"},
|
||||||
|
{file = "asyncpg-0.31.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0807be46c32c963ae40d329b3a686356e417f674c976c07fa49f1b30303f109"},
|
||||||
|
{file = "asyncpg-0.31.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5d5098f63beeae93512ee513d4c0c53dc12e9aa2b7a1af5a81cddf93fe4e4da"},
|
||||||
|
{file = "asyncpg-0.31.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37fc6c00a814e18eef51833545d1891cac9aa69140598bb076b4cd29b3e010b9"},
|
||||||
|
{file = "asyncpg-0.31.0-cp311-cp311-win32.whl", hash = "sha256:5a4af56edf82a701aece93190cc4e094d2df7d33f6e915c222fb09efbb5afc24"},
|
||||||
|
{file = "asyncpg-0.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:480c4befbdf079c14c9ca43c8c5e1fe8b6296c96f1f927158d4f1e750aacc047"},
|
||||||
|
{file = "asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad"},
|
||||||
|
{file = "asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d"},
|
||||||
|
{file = "asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a"},
|
||||||
|
{file = "asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671"},
|
||||||
|
{file = "asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec"},
|
||||||
|
{file = "asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20"},
|
||||||
|
{file = "asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8"},
|
||||||
|
{file = "asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186"},
|
||||||
|
{file = "asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b"},
|
||||||
|
{file = "asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e"},
|
||||||
|
{file = "asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403"},
|
||||||
|
{file = "asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4"},
|
||||||
|
{file = "asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2"},
|
||||||
|
{file = "asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602"},
|
||||||
|
{file = "asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696"},
|
||||||
|
{file = "asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab"},
|
||||||
|
{file = "asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44"},
|
||||||
|
{file = "asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5"},
|
||||||
|
{file = "asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2"},
|
||||||
|
{file = "asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2"},
|
||||||
|
{file = "asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218"},
|
||||||
|
{file = "asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d"},
|
||||||
|
{file = "asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b"},
|
||||||
|
{file = "asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be"},
|
||||||
|
{file = "asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2"},
|
||||||
|
{file = "asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31"},
|
||||||
|
{file = "asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7"},
|
||||||
|
{file = "asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e"},
|
||||||
|
{file = "asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c"},
|
||||||
|
{file = "asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a"},
|
||||||
|
{file = "asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d"},
|
||||||
|
{file = "asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3"},
|
||||||
|
{file = "asyncpg-0.31.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:ebb3cde58321a1f89ce41812be3f2a98dddedc1e76d0838aba1d724f1e4e1a95"},
|
||||||
|
{file = "asyncpg-0.31.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e6974f36eb9a224d8fb428bcf66bd411aa12cf57c2967463178149e73d4de366"},
|
||||||
|
{file = "asyncpg-0.31.0-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc2b685f400ceae428f79f78b58110470d7b4466929a7f78d455964b17ad1008"},
|
||||||
|
{file = "asyncpg-0.31.0-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bb223567dea5f47c45d347f2bde5486be8d9f40339f27217adb3fb1c3be51298"},
|
||||||
|
{file = "asyncpg-0.31.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:22be6e02381bab3101cd502d9297ac71e2f966c86e20e78caead9934c98a8af6"},
|
||||||
|
{file = "asyncpg-0.31.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:37a58919cfef2448a920df00d1b2f821762d17194d0dbf355d6dde8d952c04f9"},
|
||||||
|
{file = "asyncpg-0.31.0-cp39-cp39-win32.whl", hash = "sha256:c1a9c5b71d2371a2290bc93336cd05ba4ec781683cab292adbddc084f89443c6"},
|
||||||
|
{file = "asyncpg-0.31.0-cp39-cp39-win_amd64.whl", hash = "sha256:c1e1ab5bc65373d92dd749d7308c5b26fb2dc0fbe5d3bf68a32b676aa3bcd24a"},
|
||||||
|
{file = "asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
gssauth = ["gssapi ; platform_system != \"Windows\"", "sspilib ; platform_system == \"Windows\""]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "certifi"
|
name = "certifi"
|
||||||
version = "2026.2.25"
|
version = "2026.2.25"
|
||||||
description = "Python package for providing Mozilla's CA Bundle."
|
description = "Python package for providing Mozilla's CA Bundle."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
python-versions = ">=3.7"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa"},
|
{file = "certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa"},
|
||||||
{file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"},
|
{file = "certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7"},
|
||||||
@@ -99,6 +228,7 @@ version = "3.4.5"
|
|||||||
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
|
description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
python-versions = ">=3.7"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "charset_normalizer-3.4.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4167a621a9a1a986c73777dbc15d4b5eac8ac5c10393374109a343d4013ec765"},
|
{file = "charset_normalizer-3.4.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4167a621a9a1a986c73777dbc15d4b5eac8ac5c10393374109a343d4013ec765"},
|
||||||
{file = "charset_normalizer-3.4.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f64c6bf8f32f9133b668c7f7a7cbdbc453412bc95ecdbd157f3b1e377a92990"},
|
{file = "charset_normalizer-3.4.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f64c6bf8f32f9133b668c7f7a7cbdbc453412bc95ecdbd157f3b1e377a92990"},
|
||||||
@@ -221,6 +351,7 @@ version = "8.3.1"
|
|||||||
description = "Composable command line interface toolkit"
|
description = "Composable command line interface toolkit"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.10"
|
python-versions = ">=3.10"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"},
|
{file = "click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6"},
|
||||||
{file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"},
|
{file = "click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a"},
|
||||||
@@ -235,17 +366,57 @@ version = "0.4.6"
|
|||||||
description = "Cross-platform colored terminal text."
|
description = "Cross-platform colored terminal text."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
|
||||||
|
groups = ["main"]
|
||||||
|
markers = "platform_system == \"Windows\" or sys_platform == \"win32\""
|
||||||
files = [
|
files = [
|
||||||
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
|
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
|
||||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "dnspython"
|
||||||
|
version = "2.8.0"
|
||||||
|
description = "DNS toolkit"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.10"
|
||||||
|
groups = ["main"]
|
||||||
|
files = [
|
||||||
|
{file = "dnspython-2.8.0-py3-none-any.whl", hash = "sha256:01d9bbc4a2d76bf0db7c1f729812ded6d912bd318d3b1cf81d30c0f845dbf3af"},
|
||||||
|
{file = "dnspython-2.8.0.tar.gz", hash = "sha256:181d3c6996452cb1189c4046c61599b84a5a86e099562ffde77d26984ff26d0f"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
dev = ["black (>=25.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn (>=0.17.0)", "mypy (>=1.17)", "pylint (>=3)", "pytest (>=8.4)", "pytest-cov (>=6.2.0)", "quart-trio (>=0.12.0)", "sphinx (>=8.2.0)", "sphinx-rtd-theme (>=3.0.0)", "twine (>=6.1.0)", "wheel (>=0.45.0)"]
|
||||||
|
dnssec = ["cryptography (>=45)"]
|
||||||
|
doh = ["h2 (>=4.2.0)", "httpcore (>=1.0.0)", "httpx (>=0.28.0)"]
|
||||||
|
doq = ["aioquic (>=1.2.0)"]
|
||||||
|
idna = ["idna (>=3.10)"]
|
||||||
|
trio = ["trio (>=0.30)"]
|
||||||
|
wmi = ["wmi (>=1.5.1) ; platform_system == \"Windows\""]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "email-validator"
|
||||||
|
version = "2.3.0"
|
||||||
|
description = "A robust email address syntax and deliverability validation library."
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.8"
|
||||||
|
groups = ["main"]
|
||||||
|
files = [
|
||||||
|
{file = "email_validator-2.3.0-py3-none-any.whl", hash = "sha256:80f13f623413e6b197ae73bb10bf4eb0908faf509ad8362c5edeb0be7fd450b4"},
|
||||||
|
{file = "email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
dnspython = ">=2.0.0"
|
||||||
|
idna = ">=2.0.0"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fastapi"
|
name = "fastapi"
|
||||||
version = "0.135.1"
|
version = "0.135.1"
|
||||||
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
|
description = "FastAPI framework, high performance, easy to learn, fast to code, ready for production"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.10"
|
python-versions = ">=3.10"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e"},
|
{file = "fastapi-0.135.1-py3-none-any.whl", hash = "sha256:46e2fc5745924b7c840f71ddd277382af29ce1cdb7d5eab5bf697e3fb9999c9e"},
|
||||||
{file = "fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd"},
|
{file = "fastapi-0.135.1.tar.gz", hash = "sha256:d04115b508d936d254cea545b7312ecaa58a7b3a0f84952535b4c9afae7668cd"},
|
||||||
@@ -269,6 +440,7 @@ version = "1.73.0"
|
|||||||
description = "Common protobufs used in Google APIs"
|
description = "Common protobufs used in Google APIs"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.7"
|
python-versions = ">=3.7"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "googleapis_common_protos-1.73.0-py3-none-any.whl", hash = "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8"},
|
{file = "googleapis_common_protos-1.73.0-py3-none-any.whl", hash = "sha256:dfdaaa2e860f242046be561e6d6cb5c5f1541ae02cfbcb034371aadb2942b4e8"},
|
||||||
{file = "googleapis_common_protos-1.73.0.tar.gz", hash = "sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a"},
|
{file = "googleapis_common_protos-1.73.0.tar.gz", hash = "sha256:778d07cd4fbeff84c6f7c72102f0daf98fa2bfd3fa8bea426edc545588da0b5a"},
|
||||||
@@ -280,12 +452,87 @@ protobuf = ">=3.20.2,<4.21.1 || >4.21.1,<4.21.2 || >4.21.2,<4.21.3 || >4.21.3,<4
|
|||||||
[package.extras]
|
[package.extras]
|
||||||
grpc = ["grpcio (>=1.44.0,<2.0.0)"]
|
grpc = ["grpcio (>=1.44.0,<2.0.0)"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "greenlet"
|
||||||
|
version = "3.4.0"
|
||||||
|
description = "Lightweight in-process concurrent programming"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.10"
|
||||||
|
groups = ["main"]
|
||||||
|
markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""
|
||||||
|
files = [
|
||||||
|
{file = "greenlet-3.4.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:d18eae9a7fb0f499efcd146b8c9750a2e1f6e0e93b5a382b3481875354a430e6"},
|
||||||
|
{file = "greenlet-3.4.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:636d2f95c309e35f650e421c23297d5011716be15d966e6328b367c9fc513a82"},
|
||||||
|
{file = "greenlet-3.4.0-cp310-cp310-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:234582c20af9742583c3b2ddfbdbb58a756cfff803763ffaae1ac7990a9fac31"},
|
||||||
|
{file = "greenlet-3.4.0-cp310-cp310-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ac6a5f618be581e1e0713aecec8e54093c235e5fa17d6d8eb7ffc487e2300508"},
|
||||||
|
{file = "greenlet-3.4.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:523677e69cd4711b5a014e37bc1fb3a29947c3e3a5bb6a527e1cc50312e5a398"},
|
||||||
|
{file = "greenlet-3.4.0-cp310-cp310-manylinux_2_39_riscv64.whl", hash = "sha256:d336d46878e486de7d9458653c722875547ac8d36a1cff9ffaf4a74a3c1f62eb"},
|
||||||
|
{file = "greenlet-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b45e45fe47a19051a396abb22e19e7836a59ee6c5a90f3be427343c37908d65b"},
|
||||||
|
{file = "greenlet-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5434271357be07f3ad0936c312645853b7e689e679e29310e2de09a9ea6c3adf"},
|
||||||
|
{file = "greenlet-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:a19093fbad824ed7c0f355b5ff4214bffda5f1a7f35f29b31fcaa240cc0135ab"},
|
||||||
|
{file = "greenlet-3.4.0-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:805bebb4945094acbab757d34d6e1098be6de8966009ab9ca54f06ff492def58"},
|
||||||
|
{file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:439fc2f12b9b512d9dfa681c5afe5f6b3232c708d13e6f02c845e0d9f4c2d8c6"},
|
||||||
|
{file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a70ed1cb0295bee1df57b63bf7f46b4e56a5c93709eea769c1fec1bb23a95875"},
|
||||||
|
{file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c5696c42e6bb5cfb7c6ff4453789081c66b9b91f061e5e9367fa15792644e76"},
|
||||||
|
{file = "greenlet-3.4.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c660bce1940a1acae5f51f0a064f1bc785d07ea16efcb4bc708090afc4d69e83"},
|
||||||
|
{file = "greenlet-3.4.0-cp311-cp311-manylinux_2_39_riscv64.whl", hash = "sha256:89995ce5ddcd2896d89615116dd39b9703bfa0c07b583b85b89bf1b5d6eddf81"},
|
||||||
|
{file = "greenlet-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:ee407d4d1ca9dc632265aee1c8732c4a2d60adff848057cdebfe5fe94eb2c8a2"},
|
||||||
|
{file = "greenlet-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:956215d5e355fffa7c021d168728321fd4d31fd730ac609b1653b450f6a4bc71"},
|
||||||
|
{file = "greenlet-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:5cb614ace7c27571270354e9c9f696554d073f8aa9319079dcba466bbdead711"},
|
||||||
|
{file = "greenlet-3.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:04403ac74fe295a361f650818de93be11b5038a78f49ccfb64d3b1be8fbf1267"},
|
||||||
|
{file = "greenlet-3.4.0-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1a54a921561dd9518d31d2d3db4d7f80e589083063ab4d3e2e950756ef809e1a"},
|
||||||
|
{file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:16dec271460a9a2b154e3b1c2fa1050ce6280878430320e85e08c166772e3f97"},
|
||||||
|
{file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:90036ce224ed6fe75508c1907a77e4540176dcf0744473627785dd519c6f9996"},
|
||||||
|
{file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6f0def07ec9a71d72315cf26c061aceee53b306c36ed38c35caba952ea1b319d"},
|
||||||
|
{file = "greenlet-3.4.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a1c4f6b453006efb8310affb2d132832e9bbb4fc01ce6df6b70d810d38f1f6dc"},
|
||||||
|
{file = "greenlet-3.4.0-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:0e1254cf0cbaa17b04320c3a78575f29f3c161ef38f59c977108f19ffddaf077"},
|
||||||
|
{file = "greenlet-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:9b2d9a138ffa0e306d0e2b72976d2fb10b97e690d40ab36a472acaab0838e2de"},
|
||||||
|
{file = "greenlet-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8424683caf46eb0eb6f626cb95e008e8cc30d0cb675bdfa48200925c79b38a08"},
|
||||||
|
{file = "greenlet-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0a53fb071531d003b075c444014ff8f8b1a9898d36bb88abd9ac7b3524648a2"},
|
||||||
|
{file = "greenlet-3.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:f38b81880ba28f232f1f675893a39cf7b6db25b31cc0a09bb50787ecf957e85e"},
|
||||||
|
{file = "greenlet-3.4.0-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:43748988b097f9c6f09364f260741aa73c80747f63389824435c7a50bfdfd5c1"},
|
||||||
|
{file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5566e4e2cd7a880e8c27618e3eab20f3494452d12fd5129edef7b2f7aa9a36d1"},
|
||||||
|
{file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1054c5a3c78e2ab599d452f23f7adafef55062a783a8e241d24f3b633ba6ff82"},
|
||||||
|
{file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:98eedd1803353daf1cd9ef23eef23eda5a4d22f99b1f998d273a8b78b70dd47f"},
|
||||||
|
{file = "greenlet-3.4.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f82cb6cddc27dd81c96b1506f4aa7def15070c3b2a67d4e46fd19016aacce6cf"},
|
||||||
|
{file = "greenlet-3.4.0-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:b7857e2202aae67bc5725e0c1f6403c20a8ff46094ece015e7d474f5f7020b55"},
|
||||||
|
{file = "greenlet-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:227a46251ecba4ff46ae742bc5ce95c91d5aceb4b02f885487aff269c127a729"},
|
||||||
|
{file = "greenlet-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5b99e87be7eba788dd5b75ba1cde5639edffdec5f91fe0d734a249535ec3408c"},
|
||||||
|
{file = "greenlet-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:849f8bc17acd6295fcb5de8e46d55cc0e52381c56eaf50a2afd258e97bc65940"},
|
||||||
|
{file = "greenlet-3.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:9390ad88b652b1903814eaabd629ca184db15e0eeb6fe8a390bbf8b9106ae15a"},
|
||||||
|
{file = "greenlet-3.4.0-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:10a07aca6babdd18c16a3f4f8880acfffc2b88dfe431ad6aa5f5740759d7d75e"},
|
||||||
|
{file = "greenlet-3.4.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:076e21040b3a917d3ce4ad68fb5c3c6b32f1405616c4a57aa83120979649bd3d"},
|
||||||
|
{file = "greenlet-3.4.0-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e82689eea4a237e530bb5cb41b180ef81fa2160e1f89422a67be7d90da67f615"},
|
||||||
|
{file = "greenlet-3.4.0-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:06c2d3b89e0c62ba50bd7adf491b14f39da9e7e701647cb7b9ff4c99bee04b19"},
|
||||||
|
{file = "greenlet-3.4.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4df3b0b2289ec686d3c821a5fee44259c05cfe824dd5e6e12c8e5f5df23085cf"},
|
||||||
|
{file = "greenlet-3.4.0-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:070b8bac2ff3b4d9e0ff36a0d19e42103331d9737e8504747cd1e659f76297bd"},
|
||||||
|
{file = "greenlet-3.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8bff29d586ea415688f4cec96a591fcc3bf762d046a796cdadc1fdb6e7f2d5bf"},
|
||||||
|
{file = "greenlet-3.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8a569c2fb840c53c13a2b8967c63621fafbd1a0e015b9c82f408c33d626a2fda"},
|
||||||
|
{file = "greenlet-3.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:207ba5b97ea8b0b60eb43ffcacf26969dd83726095161d676aac03ff913ee50d"},
|
||||||
|
{file = "greenlet-3.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:f8296d4e2b92af34ebde81085a01690f26a51eb9ac09a0fcadb331eb36dbc802"},
|
||||||
|
{file = "greenlet-3.4.0-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:d70012e51df2dbbccfaf63a40aaf9b40c8bed37c3e3a38751c926301ce538ece"},
|
||||||
|
{file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a58bec0751f43068cd40cff31bb3ca02ad6000b3a51ca81367af4eb5abc480c8"},
|
||||||
|
{file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:05fa0803561028f4b2e3b490ee41216a842eaee11aed004cc343a996d9523aa2"},
|
||||||
|
{file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c4cd56a9eb7a6444edbc19062f7b6fbc8f287c663b946e3171d899693b1c19fa"},
|
||||||
|
{file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e60d38719cb80b3ab5e85f9f1aed4960acfde09868af6762ccb27b260d68f4ed"},
|
||||||
|
{file = "greenlet-3.4.0-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:1f85f204c4d54134ae850d401fa435c89cd667d5ce9dc567571776b45941af72"},
|
||||||
|
{file = "greenlet-3.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7f50c804733b43eded05ae694691c9aa68bca7d0a867d67d4a3f514742a2d53f"},
|
||||||
|
{file = "greenlet-3.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:2d4f0635dc4aa638cda4b2f5a07ae9a2cff9280327b581a3fcb6f317b4fbc38a"},
|
||||||
|
{file = "greenlet-3.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:1a4a48f24681300c640f143ba7c404270e1ebbbcf34331d7104a4ff40f8ea705"},
|
||||||
|
{file = "greenlet-3.4.0.tar.gz", hash = "sha256:f50a96b64dafd6169e595a5c56c9146ef80333e67d4476a65a9c55f400fc22ff"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
docs = ["Sphinx", "furo"]
|
||||||
|
test = ["objgraph", "psutil", "setuptools"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "grpcio"
|
name = "grpcio"
|
||||||
version = "1.78.0"
|
version = "1.78.0"
|
||||||
description = "HTTP/2-based RPC framework"
|
description = "HTTP/2-based RPC framework"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "grpcio-1.78.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:7cc47943d524ee0096f973e1081cb8f4f17a4615f2116882a5f1416e4cfe92b5"},
|
{file = "grpcio-1.78.0-cp310-cp310-linux_armv7l.whl", hash = "sha256:7cc47943d524ee0096f973e1081cb8f4f17a4615f2116882a5f1416e4cfe92b5"},
|
||||||
{file = "grpcio-1.78.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c3f293fdc675ccba4db5a561048cca627b5e7bd1c8a6973ffedabe7d116e22e2"},
|
{file = "grpcio-1.78.0-cp310-cp310-macosx_11_0_universal2.whl", hash = "sha256:c3f293fdc675ccba4db5a561048cca627b5e7bd1c8a6973ffedabe7d116e22e2"},
|
||||||
@@ -362,6 +609,7 @@ version = "0.16.0"
|
|||||||
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
|
description = "A pure-Python, bring-your-own-I/O implementation of HTTP/1.1"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8"
|
python-versions = ">=3.8"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
|
{file = "h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86"},
|
||||||
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
|
{file = "h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1"},
|
||||||
@@ -373,6 +621,7 @@ version = "1.0.9"
|
|||||||
description = "A minimal low-level HTTP client."
|
description = "A minimal low-level HTTP client."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8"
|
python-versions = ">=3.8"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
|
{file = "httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55"},
|
||||||
{file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
|
{file = "httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8"},
|
||||||
@@ -394,6 +643,7 @@ version = "0.7.1"
|
|||||||
description = "A collection of framework independent HTTP protocol utils."
|
description = "A collection of framework independent HTTP protocol utils."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "httptools-0.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:11d01b0ff1fe02c4c32d60af61a4d613b74fad069e47e06e9067758c01e9ac78"},
|
{file = "httptools-0.7.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:11d01b0ff1fe02c4c32d60af61a4d613b74fad069e47e06e9067758c01e9ac78"},
|
||||||
{file = "httptools-0.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d86c1e5afdc479a6fdabf570be0d3eb791df0ae727e8dbc0259ed1249998d4"},
|
{file = "httptools-0.7.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:84d86c1e5afdc479a6fdabf570be0d3eb791df0ae727e8dbc0259ed1249998d4"},
|
||||||
@@ -446,6 +696,7 @@ version = "0.28.1"
|
|||||||
description = "The next generation HTTP client."
|
description = "The next generation HTTP client."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8"
|
python-versions = ">=3.8"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"},
|
{file = "httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad"},
|
||||||
{file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"},
|
{file = "httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc"},
|
||||||
@@ -458,7 +709,7 @@ httpcore = "==1.*"
|
|||||||
idna = "*"
|
idna = "*"
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
brotli = ["brotli", "brotlicffi"]
|
brotli = ["brotli ; platform_python_implementation == \"CPython\"", "brotlicffi ; platform_python_implementation != \"CPython\""]
|
||||||
cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
|
cli = ["click (==8.*)", "pygments (==2.*)", "rich (>=10,<14)"]
|
||||||
http2 = ["h2 (>=3,<5)"]
|
http2 = ["h2 (>=3,<5)"]
|
||||||
socks = ["socksio (==1.*)"]
|
socks = ["socksio (==1.*)"]
|
||||||
@@ -470,6 +721,7 @@ version = "3.11"
|
|||||||
description = "Internationalized Domain Names in Applications (IDNA)"
|
description = "Internationalized Domain Names in Applications (IDNA)"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8"
|
python-versions = ">=3.8"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"},
|
{file = "idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea"},
|
||||||
{file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"},
|
{file = "idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902"},
|
||||||
@@ -484,6 +736,7 @@ version = "8.7.1"
|
|||||||
description = "Read metadata from Python packages"
|
description = "Read metadata from Python packages"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"},
|
{file = "importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151"},
|
||||||
{file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"},
|
{file = "importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb"},
|
||||||
@@ -493,13 +746,13 @@ files = [
|
|||||||
zipp = ">=3.20"
|
zipp = ">=3.20"
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"]
|
check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""]
|
||||||
cover = ["pytest-cov"]
|
cover = ["pytest-cov"]
|
||||||
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
|
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
|
||||||
enabler = ["pytest-enabler (>=3.4)"]
|
enabler = ["pytest-enabler (>=3.4)"]
|
||||||
perf = ["ipython"]
|
perf = ["ipython"]
|
||||||
test = ["flufl.flake8", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"]
|
test = ["flufl.flake8", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"]
|
||||||
type = ["mypy (<1.19)", "pytest-mypy (>=1.0.1)"]
|
type = ["mypy (<1.19) ; platform_python_implementation == \"PyPy\"", "pytest-mypy (>=1.0.1)"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iniconfig"
|
name = "iniconfig"
|
||||||
@@ -507,17 +760,138 @@ version = "2.3.0"
|
|||||||
description = "brain-dead simple config-ini parsing"
|
description = "brain-dead simple config-ini parsing"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.10"
|
python-versions = ">=3.10"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
|
{file = "iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12"},
|
||||||
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
|
{file = "iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730"},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mako"
|
||||||
|
version = "1.3.10"
|
||||||
|
description = "A super-fast templating language that borrows the best ideas from the existing templating languages."
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.8"
|
||||||
|
groups = ["main"]
|
||||||
|
files = [
|
||||||
|
{file = "mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59"},
|
||||||
|
{file = "mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
MarkupSafe = ">=0.9.2"
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
babel = ["Babel"]
|
||||||
|
lingua = ["lingua"]
|
||||||
|
testing = ["pytest"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "markupsafe"
|
||||||
|
version = "3.0.3"
|
||||||
|
description = "Safely add untrusted strings to HTML/XML markup."
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
|
files = [
|
||||||
|
{file = "markupsafe-3.0.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:2f981d352f04553a7171b8e44369f2af4055f888dfb147d55e42d29e29e74559"},
|
||||||
|
{file = "markupsafe-3.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:e1c1493fb6e50ab01d20a22826e57520f1284df32f2d8601fdd90b6304601419"},
|
||||||
|
{file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ba88449deb3de88bd40044603fafffb7bc2b055d626a330323a9ed736661695"},
|
||||||
|
{file = "markupsafe-3.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f42d0984e947b8adf7dd6dde396e720934d12c506ce84eea8476409563607591"},
|
||||||
|
{file = "markupsafe-3.0.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c0c0b3ade1c0b13b936d7970b1d37a57acde9199dc2aecc4c336773e1d86049c"},
|
||||||
|
{file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:0303439a41979d9e74d18ff5e2dd8c43ed6c6001fd40e5bf2e43f7bd9bbc523f"},
|
||||||
|
{file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:d2ee202e79d8ed691ceebae8e0486bd9a2cd4794cec4824e1c99b6f5009502f6"},
|
||||||
|
{file = "markupsafe-3.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:177b5253b2834fe3678cb4a5f0059808258584c559193998be2601324fdeafb1"},
|
||||||
|
{file = "markupsafe-3.0.3-cp310-cp310-win32.whl", hash = "sha256:2a15a08b17dd94c53a1da0438822d70ebcd13f8c3a95abe3a9ef9f11a94830aa"},
|
||||||
|
{file = "markupsafe-3.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:c4ffb7ebf07cfe8931028e3e4c85f0357459a3f9f9490886198848f4fa002ec8"},
|
||||||
|
{file = "markupsafe-3.0.3-cp310-cp310-win_arm64.whl", hash = "sha256:e2103a929dfa2fcaf9bb4e7c091983a49c9ac3b19c9061b6d5427dd7d14d81a1"},
|
||||||
|
{file = "markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad"},
|
||||||
|
{file = "markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a"},
|
||||||
|
{file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50"},
|
||||||
|
{file = "markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf"},
|
||||||
|
{file = "markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f"},
|
||||||
|
{file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a"},
|
||||||
|
{file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115"},
|
||||||
|
{file = "markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a"},
|
||||||
|
{file = "markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19"},
|
||||||
|
{file = "markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01"},
|
||||||
|
{file = "markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c"},
|
||||||
|
{file = "markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e"},
|
||||||
|
{file = "markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce"},
|
||||||
|
{file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d"},
|
||||||
|
{file = "markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d"},
|
||||||
|
{file = "markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a"},
|
||||||
|
{file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b"},
|
||||||
|
{file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f"},
|
||||||
|
{file = "markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b"},
|
||||||
|
{file = "markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d"},
|
||||||
|
{file = "markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c"},
|
||||||
|
{file = "markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f"},
|
||||||
|
{file = "markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795"},
|
||||||
|
{file = "markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219"},
|
||||||
|
{file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6"},
|
||||||
|
{file = "markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676"},
|
||||||
|
{file = "markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9"},
|
||||||
|
{file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1"},
|
||||||
|
{file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc"},
|
||||||
|
{file = "markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12"},
|
||||||
|
{file = "markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed"},
|
||||||
|
{file = "markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5"},
|
||||||
|
{file = "markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485"},
|
||||||
|
{file = "markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73"},
|
||||||
|
{file = "markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37"},
|
||||||
|
{file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19"},
|
||||||
|
{file = "markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025"},
|
||||||
|
{file = "markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6"},
|
||||||
|
{file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f"},
|
||||||
|
{file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb"},
|
||||||
|
{file = "markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009"},
|
||||||
|
{file = "markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354"},
|
||||||
|
{file = "markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218"},
|
||||||
|
{file = "markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287"},
|
||||||
|
{file = "markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe"},
|
||||||
|
{file = "markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026"},
|
||||||
|
{file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737"},
|
||||||
|
{file = "markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97"},
|
||||||
|
{file = "markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d"},
|
||||||
|
{file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda"},
|
||||||
|
{file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf"},
|
||||||
|
{file = "markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe"},
|
||||||
|
{file = "markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9"},
|
||||||
|
{file = "markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581"},
|
||||||
|
{file = "markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4"},
|
||||||
|
{file = "markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab"},
|
||||||
|
{file = "markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175"},
|
||||||
|
{file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634"},
|
||||||
|
{file = "markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50"},
|
||||||
|
{file = "markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e"},
|
||||||
|
{file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5"},
|
||||||
|
{file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523"},
|
||||||
|
{file = "markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc"},
|
||||||
|
{file = "markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d"},
|
||||||
|
{file = "markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9"},
|
||||||
|
{file = "markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa"},
|
||||||
|
{file = "markupsafe-3.0.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15d939a21d546304880945ca1ecb8a039db6b4dc49b2c5a400387cdae6a62e26"},
|
||||||
|
{file = "markupsafe-3.0.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:f71a396b3bf33ecaa1626c255855702aca4d3d9fea5e051b41ac59a9c1c41edc"},
|
||||||
|
{file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f4b68347f8c5eab4a13419215bdfd7f8c9b19f2b25520968adfad23eb0ce60c"},
|
||||||
|
{file = "markupsafe-3.0.3-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8fc20152abba6b83724d7ff268c249fa196d8259ff481f3b1476383f8f24e42"},
|
||||||
|
{file = "markupsafe-3.0.3-cp39-cp39-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:949b8d66bc381ee8b007cd945914c721d9aba8e27f71959d750a46f7c282b20b"},
|
||||||
|
{file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:3537e01efc9d4dccdf77221fb1cb3b8e1a38d5428920e0657ce299b20324d758"},
|
||||||
|
{file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_riscv64.whl", hash = "sha256:591ae9f2a647529ca990bc681daebdd52c8791ff06c2bfa05b65163e28102ef2"},
|
||||||
|
{file = "markupsafe-3.0.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a320721ab5a1aba0a233739394eb907f8c8da5c98c9181d1161e77a0c8e36f2d"},
|
||||||
|
{file = "markupsafe-3.0.3-cp39-cp39-win32.whl", hash = "sha256:df2449253ef108a379b8b5d6b43f4b1a8e81a061d6537becd5582fba5f9196d7"},
|
||||||
|
{file = "markupsafe-3.0.3-cp39-cp39-win_amd64.whl", hash = "sha256:7c3fb7d25180895632e5d3148dbdc29ea38ccb7fd210aa27acbd1201a1902c6e"},
|
||||||
|
{file = "markupsafe-3.0.3-cp39-cp39-win_arm64.whl", hash = "sha256:38664109c14ffc9e7437e86b4dceb442b0096dfe3541d7864d9cbe1da4cf36c8"},
|
||||||
|
{file = "markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698"},
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "opentelemetry-api"
|
name = "opentelemetry-api"
|
||||||
version = "1.40.0"
|
version = "1.40.0"
|
||||||
description = "OpenTelemetry Python API"
|
description = "OpenTelemetry Python API"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9"},
|
{file = "opentelemetry_api-1.40.0-py3-none-any.whl", hash = "sha256:82dd69331ae74b06f6a874704be0cfaa49a1650e1537d4a813b86ecef7d0ecf9"},
|
||||||
{file = "opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f"},
|
{file = "opentelemetry_api-1.40.0.tar.gz", hash = "sha256:159be641c0b04d11e9ecd576906462773eb97ae1b657730f0ecf64d32071569f"},
|
||||||
@@ -533,6 +907,7 @@ version = "1.40.0"
|
|||||||
description = "OpenTelemetry Collector Exporters"
|
description = "OpenTelemetry Collector Exporters"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "opentelemetry_exporter_otlp-1.40.0-py3-none-any.whl", hash = "sha256:48c87e539ec9afb30dc443775a1334cc5487de2f72a770a4c00b1610bf6c697d"},
|
{file = "opentelemetry_exporter_otlp-1.40.0-py3-none-any.whl", hash = "sha256:48c87e539ec9afb30dc443775a1334cc5487de2f72a770a4c00b1610bf6c697d"},
|
||||||
{file = "opentelemetry_exporter_otlp-1.40.0.tar.gz", hash = "sha256:7caa0870b95e2fcb59d64e16e2b639ecffb07771b6cd0000b5d12e5e4fef765a"},
|
{file = "opentelemetry_exporter_otlp-1.40.0.tar.gz", hash = "sha256:7caa0870b95e2fcb59d64e16e2b639ecffb07771b6cd0000b5d12e5e4fef765a"},
|
||||||
@@ -548,6 +923,7 @@ version = "1.40.0"
|
|||||||
description = "OpenTelemetry Protobuf encoding"
|
description = "OpenTelemetry Protobuf encoding"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl", hash = "sha256:7081ff453835a82417bf38dccf122c827c3cbc94f2079b03bba02a3165f25149"},
|
{file = "opentelemetry_exporter_otlp_proto_common-1.40.0-py3-none-any.whl", hash = "sha256:7081ff453835a82417bf38dccf122c827c3cbc94f2079b03bba02a3165f25149"},
|
||||||
{file = "opentelemetry_exporter_otlp_proto_common-1.40.0.tar.gz", hash = "sha256:1cbee86a4064790b362a86601ee7934f368b81cd4cc2f2e163902a6e7818a0fa"},
|
{file = "opentelemetry_exporter_otlp_proto_common-1.40.0.tar.gz", hash = "sha256:1cbee86a4064790b362a86601ee7934f368b81cd4cc2f2e163902a6e7818a0fa"},
|
||||||
@@ -562,6 +938,7 @@ version = "1.40.0"
|
|||||||
description = "OpenTelemetry Collector Protobuf over gRPC Exporter"
|
description = "OpenTelemetry Collector Protobuf over gRPC Exporter"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "opentelemetry_exporter_otlp_proto_grpc-1.40.0-py3-none-any.whl", hash = "sha256:2aa0ca53483fe0cf6405087a7491472b70335bc5c7944378a0a8e72e86995c52"},
|
{file = "opentelemetry_exporter_otlp_proto_grpc-1.40.0-py3-none-any.whl", hash = "sha256:2aa0ca53483fe0cf6405087a7491472b70335bc5c7944378a0a8e72e86995c52"},
|
||||||
{file = "opentelemetry_exporter_otlp_proto_grpc-1.40.0.tar.gz", hash = "sha256:bd4015183e40b635b3dab8da528b27161ba83bf4ef545776b196f0fb4ec47740"},
|
{file = "opentelemetry_exporter_otlp_proto_grpc-1.40.0.tar.gz", hash = "sha256:bd4015183e40b635b3dab8da528b27161ba83bf4ef545776b196f0fb4ec47740"},
|
||||||
@@ -585,6 +962,7 @@ version = "1.40.0"
|
|||||||
description = "OpenTelemetry Collector Protobuf over HTTP Exporter"
|
description = "OpenTelemetry Collector Protobuf over HTTP Exporter"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "opentelemetry_exporter_otlp_proto_http-1.40.0-py3-none-any.whl", hash = "sha256:a8d1dab28f504c5d96577d6509f80a8150e44e8f45f82cdbe0e34c99ab040069"},
|
{file = "opentelemetry_exporter_otlp_proto_http-1.40.0-py3-none-any.whl", hash = "sha256:a8d1dab28f504c5d96577d6509f80a8150e44e8f45f82cdbe0e34c99ab040069"},
|
||||||
{file = "opentelemetry_exporter_otlp_proto_http-1.40.0.tar.gz", hash = "sha256:db48f5e0f33217588bbc00274a31517ba830da576e59503507c839b38fa0869c"},
|
{file = "opentelemetry_exporter_otlp_proto_http-1.40.0.tar.gz", hash = "sha256:db48f5e0f33217588bbc00274a31517ba830da576e59503507c839b38fa0869c"},
|
||||||
@@ -608,6 +986,7 @@ version = "0.61b0"
|
|||||||
description = "Instrumentation Tools & Auto Instrumentation for OpenTelemetry Python"
|
description = "Instrumentation Tools & Auto Instrumentation for OpenTelemetry Python"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "opentelemetry_instrumentation-0.61b0-py3-none-any.whl", hash = "sha256:92a93a280e69788e8f88391247cc530fd81f16f2b011979d4d6398f805cfbc63"},
|
{file = "opentelemetry_instrumentation-0.61b0-py3-none-any.whl", hash = "sha256:92a93a280e69788e8f88391247cc530fd81f16f2b011979d4d6398f805cfbc63"},
|
||||||
{file = "opentelemetry_instrumentation-0.61b0.tar.gz", hash = "sha256:cb21b48db738c9de196eba6b805b4ff9de3b7f187e4bbf9a466fa170514f1fc7"},
|
{file = "opentelemetry_instrumentation-0.61b0.tar.gz", hash = "sha256:cb21b48db738c9de196eba6b805b4ff9de3b7f187e4bbf9a466fa170514f1fc7"},
|
||||||
@@ -625,6 +1004,7 @@ version = "0.61b0"
|
|||||||
description = "ASGI instrumentation for OpenTelemetry"
|
description = "ASGI instrumentation for OpenTelemetry"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "opentelemetry_instrumentation_asgi-0.61b0-py3-none-any.whl", hash = "sha256:e4b3ce6b66074e525e717efff20745434e5efd5d9df6557710856fba356da7a4"},
|
{file = "opentelemetry_instrumentation_asgi-0.61b0-py3-none-any.whl", hash = "sha256:e4b3ce6b66074e525e717efff20745434e5efd5d9df6557710856fba356da7a4"},
|
||||||
{file = "opentelemetry_instrumentation_asgi-0.61b0.tar.gz", hash = "sha256:9d08e127244361dc33976d39dd4ca8f128b5aa5a7ae425208400a80a095019b5"},
|
{file = "opentelemetry_instrumentation_asgi-0.61b0.tar.gz", hash = "sha256:9d08e127244361dc33976d39dd4ca8f128b5aa5a7ae425208400a80a095019b5"},
|
||||||
@@ -646,6 +1026,7 @@ version = "0.61b0"
|
|||||||
description = "OpenTelemetry FastAPI Instrumentation"
|
description = "OpenTelemetry FastAPI Instrumentation"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "opentelemetry_instrumentation_fastapi-0.61b0-py3-none-any.whl", hash = "sha256:a1a844d846540d687d377516b2ff698b51d87c781b59f47c214359c4a241047c"},
|
{file = "opentelemetry_instrumentation_fastapi-0.61b0-py3-none-any.whl", hash = "sha256:a1a844d846540d687d377516b2ff698b51d87c781b59f47c214359c4a241047c"},
|
||||||
{file = "opentelemetry_instrumentation_fastapi-0.61b0.tar.gz", hash = "sha256:3a24f35b07c557ae1bbc483bf8412221f25d79a405f8b047de8b670722e2fa9f"},
|
{file = "opentelemetry_instrumentation_fastapi-0.61b0.tar.gz", hash = "sha256:3a24f35b07c557ae1bbc483bf8412221f25d79a405f8b047de8b670722e2fa9f"},
|
||||||
@@ -667,6 +1048,7 @@ version = "0.61b0"
|
|||||||
description = "OpenTelemetry HTTPX Instrumentation"
|
description = "OpenTelemetry HTTPX Instrumentation"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "opentelemetry_instrumentation_httpx-0.61b0-py3-none-any.whl", hash = "sha256:dee05c93a6593a5dc3ae5d9d5c01df8b4e2c5d02e49275e5558534ee46343d5e"},
|
{file = "opentelemetry_instrumentation_httpx-0.61b0-py3-none-any.whl", hash = "sha256:dee05c93a6593a5dc3ae5d9d5c01df8b4e2c5d02e49275e5558534ee46343d5e"},
|
||||||
{file = "opentelemetry_instrumentation_httpx-0.61b0.tar.gz", hash = "sha256:6569ec097946c5551c2a4252f74c98666addd1bf047c1dde6b4ef426719ff8dd"},
|
{file = "opentelemetry_instrumentation_httpx-0.61b0.tar.gz", hash = "sha256:6569ec097946c5551c2a4252f74c98666addd1bf047c1dde6b4ef426719ff8dd"},
|
||||||
@@ -688,6 +1070,7 @@ version = "0.61b0"
|
|||||||
description = "OpenTelemetry Redis instrumentation"
|
description = "OpenTelemetry Redis instrumentation"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "opentelemetry_instrumentation_redis-0.61b0-py3-none-any.whl", hash = "sha256:8d4e850bbb5f8eeafa44c0eac3a007990c7125de187bc9c3659e29ff7e091172"},
|
{file = "opentelemetry_instrumentation_redis-0.61b0-py3-none-any.whl", hash = "sha256:8d4e850bbb5f8eeafa44c0eac3a007990c7125de187bc9c3659e29ff7e091172"},
|
||||||
{file = "opentelemetry_instrumentation_redis-0.61b0.tar.gz", hash = "sha256:ae0fbb56be9a641e621d55b02a7d62977a2c77c5ee760addd79b9b266e46e523"},
|
{file = "opentelemetry_instrumentation_redis-0.61b0.tar.gz", hash = "sha256:ae0fbb56be9a641e621d55b02a7d62977a2c77c5ee760addd79b9b266e46e523"},
|
||||||
@@ -708,6 +1091,7 @@ version = "1.40.0"
|
|||||||
description = "OpenTelemetry Python Proto"
|
description = "OpenTelemetry Python Proto"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "opentelemetry_proto-1.40.0-py3-none-any.whl", hash = "sha256:266c4385d88923a23d63e353e9761af0f47a6ed0d486979777fe4de59dc9b25f"},
|
{file = "opentelemetry_proto-1.40.0-py3-none-any.whl", hash = "sha256:266c4385d88923a23d63e353e9761af0f47a6ed0d486979777fe4de59dc9b25f"},
|
||||||
{file = "opentelemetry_proto-1.40.0.tar.gz", hash = "sha256:03f639ca129ba513f5819810f5b1f42bcb371391405d99c168fe6937c62febcd"},
|
{file = "opentelemetry_proto-1.40.0.tar.gz", hash = "sha256:03f639ca129ba513f5819810f5b1f42bcb371391405d99c168fe6937c62febcd"},
|
||||||
@@ -722,6 +1106,7 @@ version = "1.40.0"
|
|||||||
description = "OpenTelemetry Python SDK"
|
description = "OpenTelemetry Python SDK"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "opentelemetry_sdk-1.40.0-py3-none-any.whl", hash = "sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1"},
|
{file = "opentelemetry_sdk-1.40.0-py3-none-any.whl", hash = "sha256:787d2154a71f4b3d81f20524a8ce061b7db667d24e46753f32a7bc48f1c1f3f1"},
|
||||||
{file = "opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2"},
|
{file = "opentelemetry_sdk-1.40.0.tar.gz", hash = "sha256:18e9f5ec20d859d268c7cb3c5198c8d105d073714db3de50b593b8c1345a48f2"},
|
||||||
@@ -738,6 +1123,7 @@ version = "0.61b0"
|
|||||||
description = "OpenTelemetry Semantic Conventions"
|
description = "OpenTelemetry Semantic Conventions"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2"},
|
{file = "opentelemetry_semantic_conventions-0.61b0-py3-none-any.whl", hash = "sha256:fa530a96be229795f8cef353739b618148b0fe2b4b3f005e60e262926c4d38e2"},
|
||||||
{file = "opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a"},
|
{file = "opentelemetry_semantic_conventions-0.61b0.tar.gz", hash = "sha256:072f65473c5d7c6dc0355b27d6c9d1a679d63b6d4b4b16a9773062cb7e31192a"},
|
||||||
@@ -753,6 +1139,7 @@ version = "0.61b0"
|
|||||||
description = "Web util for OpenTelemetry"
|
description = "Web util for OpenTelemetry"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "opentelemetry_util_http-0.61b0-py3-none-any.whl", hash = "sha256:8e715e848233e9527ea47e275659ea60a57a75edf5206a3b937e236a6da5fc33"},
|
{file = "opentelemetry_util_http-0.61b0-py3-none-any.whl", hash = "sha256:8e715e848233e9527ea47e275659ea60a57a75edf5206a3b937e236a6da5fc33"},
|
||||||
{file = "opentelemetry_util_http-0.61b0.tar.gz", hash = "sha256:1039cb891334ad2731affdf034d8fb8b48c239af9b6dd295e5fabd07f1c95572"},
|
{file = "opentelemetry_util_http-0.61b0.tar.gz", hash = "sha256:1039cb891334ad2731affdf034d8fb8b48c239af9b6dd295e5fabd07f1c95572"},
|
||||||
@@ -764,6 +1151,7 @@ version = "26.0"
|
|||||||
description = "Core utilities for Python packages"
|
description = "Core utilities for Python packages"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8"
|
python-versions = ">=3.8"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"},
|
{file = "packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529"},
|
||||||
{file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"},
|
{file = "packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4"},
|
||||||
@@ -775,6 +1163,7 @@ version = "1.6.0"
|
|||||||
description = "plugin and hook calling mechanisms for python"
|
description = "plugin and hook calling mechanisms for python"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
|
{file = "pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746"},
|
||||||
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
|
{file = "pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3"},
|
||||||
@@ -790,6 +1179,7 @@ version = "6.33.5"
|
|||||||
description = ""
|
description = ""
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b"},
|
{file = "protobuf-6.33.5-cp310-abi3-win32.whl", hash = "sha256:d71b040839446bac0f4d162e758bea99c8251161dae9d0983a3b88dee345153b"},
|
||||||
{file = "protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c"},
|
{file = "protobuf-6.33.5-cp310-abi3-win_amd64.whl", hash = "sha256:3093804752167bcab3998bec9f1048baae6e29505adaf1afd14a37bddede533c"},
|
||||||
@@ -809,6 +1199,7 @@ version = "2.12.5"
|
|||||||
description = "Data validation using Python type hints"
|
description = "Data validation using Python type hints"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"},
|
{file = "pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d"},
|
||||||
{file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"},
|
{file = "pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49"},
|
||||||
@@ -822,7 +1213,7 @@ typing-inspection = ">=0.4.2"
|
|||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
email = ["email-validator (>=2.0.0)"]
|
email = ["email-validator (>=2.0.0)"]
|
||||||
timezone = ["tzdata"]
|
timezone = ["tzdata ; python_version >= \"3.9\" and platform_system == \"Windows\""]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pydantic-core"
|
name = "pydantic-core"
|
||||||
@@ -830,6 +1221,7 @@ version = "2.41.5"
|
|||||||
description = "Core functionality for Pydantic validation and serialization"
|
description = "Core functionality for Pydantic validation and serialization"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"},
|
{file = "pydantic_core-2.41.5-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77b63866ca88d804225eaa4af3e664c5faf3568cea95360d21f4725ab6e07146"},
|
||||||
{file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"},
|
{file = "pydantic_core-2.41.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:dfa8a0c812ac681395907e71e1274819dec685fec28273a28905df579ef137e2"},
|
||||||
@@ -963,6 +1355,7 @@ version = "2.13.1"
|
|||||||
description = "Settings management using Pydantic"
|
description = "Settings management using Pydantic"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.10"
|
python-versions = ">=3.10"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237"},
|
{file = "pydantic_settings-2.13.1-py3-none-any.whl", hash = "sha256:d56fd801823dbeae7f0975e1f8c8e25c258eb75d278ea7abb5d9cebb01b56237"},
|
||||||
{file = "pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025"},
|
{file = "pydantic_settings-2.13.1.tar.gz", hash = "sha256:b4c11847b15237fb0171e1462bf540e294affb9b86db4d9aa5c01730bdbe4025"},
|
||||||
@@ -986,6 +1379,7 @@ version = "2.19.2"
|
|||||||
description = "Pygments is a syntax highlighting package written in Python."
|
description = "Pygments is a syntax highlighting package written in Python."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8"
|
python-versions = ">=3.8"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
|
{file = "pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b"},
|
||||||
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
|
{file = "pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887"},
|
||||||
@@ -1000,6 +1394,7 @@ version = "9.0.2"
|
|||||||
description = "pytest: simple powerful testing with Python"
|
description = "pytest: simple powerful testing with Python"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.10"
|
python-versions = ">=3.10"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"},
|
{file = "pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b"},
|
||||||
{file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"},
|
{file = "pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11"},
|
||||||
@@ -1021,6 +1416,7 @@ version = "1.2.2"
|
|||||||
description = "Read key-value pairs from a .env file and set them as environment variables"
|
description = "Read key-value pairs from a .env file and set them as environment variables"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.10"
|
python-versions = ">=3.10"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"},
|
{file = "python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a"},
|
||||||
{file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"},
|
{file = "python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3"},
|
||||||
@@ -1035,6 +1431,7 @@ version = "6.0.3"
|
|||||||
description = "YAML parser and emitter for Python"
|
description = "YAML parser and emitter for Python"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8"
|
python-versions = ">=3.8"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"},
|
{file = "PyYAML-6.0.3-cp38-cp38-macosx_10_13_x86_64.whl", hash = "sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f"},
|
||||||
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"},
|
{file = "PyYAML-6.0.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4"},
|
||||||
@@ -1117,6 +1514,7 @@ version = "7.3.0"
|
|||||||
description = "Python client for Redis database and key-value store"
|
description = "Python client for Redis database and key-value store"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.10"
|
python-versions = ">=3.10"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "redis-7.3.0-py3-none-any.whl", hash = "sha256:9d4fcb002a12a5e3c3fbe005d59c48a2cc231f87fbb2f6b70c2d89bb64fec364"},
|
{file = "redis-7.3.0-py3-none-any.whl", hash = "sha256:9d4fcb002a12a5e3c3fbe005d59c48a2cc231f87fbb2f6b70c2d89bb64fec364"},
|
||||||
{file = "redis-7.3.0.tar.gz", hash = "sha256:4d1b768aafcf41b01022410b3cc4f15a07d9b3d6fe0c66fc967da2c88e551034"},
|
{file = "redis-7.3.0.tar.gz", hash = "sha256:4d1b768aafcf41b01022410b3cc4f15a07d9b3d6fe0c66fc967da2c88e551034"},
|
||||||
@@ -1136,6 +1534,7 @@ version = "2.32.5"
|
|||||||
description = "Python HTTP for Humans."
|
description = "Python HTTP for Humans."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"},
|
{file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"},
|
||||||
{file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"},
|
{file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"},
|
||||||
@@ -1151,12 +1550,115 @@ urllib3 = ">=1.21.1,<3"
|
|||||||
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
|
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
|
||||||
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
|
use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sqlalchemy"
|
||||||
|
version = "2.0.49"
|
||||||
|
description = "Database Abstraction Library"
|
||||||
|
optional = false
|
||||||
|
python-versions = ">=3.7"
|
||||||
|
groups = ["main"]
|
||||||
|
files = [
|
||||||
|
{file = "sqlalchemy-2.0.49-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:42e8804962f9e6f4be2cbaedc0c3718f08f60a16910fa3d86da5a1e3b1bfe60f"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc992c6ed024c8c3c592c5fc9846a03dd68a425674900c70122c77ea16c5fb0b"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6eb188b84269f357669b62cb576b5b918de10fb7c728a005fa0ebb0b758adce1"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:62557958002b69699bdb7f5137c6714ca1133f045f97b3903964f47db97ea339"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:da9b91bca419dc9b9267ffadde24eae9b1a6bffcd09d0a207e5e3af99a03ce0d"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp310-cp310-win32.whl", hash = "sha256:5e61abbec255be7b122aa461021daa7c3f310f3e743411a67079f9b3cc91ece3"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp310-cp310-win_amd64.whl", hash = "sha256:0c98c59075b890df8abfcc6ad632879540f5791c68baebacb4f833713b510e75"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c5070135e1b7409c4161133aa525419b0062088ed77c92b1da95366ec5cbebbe"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ac7a3e245fd0310fd31495eb61af772e637bdf7d88ee81e7f10a3f271bff014"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4d4e5a0ceba319942fa6b585cf82539288a61e314ef006c1209f734551ab9536"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3ddcb27fb39171de36e207600116ac9dfd4ae46f86c82a9bf3934043e80ebb88"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:32fe6a41ad97302db2931f05bb91abbcc65b5ce4c675cd44b972428dd2947700"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp311-cp311-win32.whl", hash = "sha256:46d51518d53edfbe0563662c96954dc8fcace9832332b914375f45a99b77cc9a"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp311-cp311-win_amd64.whl", hash = "sha256:951d4a210744813be63019f3df343bf233b7432aadf0db54c75802247330d3af"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4bbccb45260e4ff1b7db0be80a9025bb1e6698bdb808b83fff0000f7a90b2c0b"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb37f15714ec2652d574f021d479e78cd4eb9d04396dca36568fdfffb3487982"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3bb9ec6436a820a4c006aad1ac351f12de2f2dbdaad171692ee457a02429b672"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8d6efc136f44a7e8bc8088507eaabbb8c2b55b3dbb63fe102c690da0ddebe55e"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e06e617e3d4fd9e51d385dfe45b077a41e9d1b033a7702551e3278ac597dc750"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp312-cp312-win32.whl", hash = "sha256:83101a6930332b87653886c01d1ee7e294b1fe46a07dd9a2d2b4f91bcc88eec0"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp312-cp312-win_amd64.whl", hash = "sha256:618a308215b6cececb6240b9abde545e3acdabac7ae3e1d4e666896bf5ba44b4"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:df2d441bacf97022e81ad047e1597552eb3f83ca8a8f1a1fdd43cd7fe3898120"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8e20e511dc15265fb433571391ba313e10dd8ea7e509d51686a51313b4ac01a2"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:47604cb2159f8bbd5a1ab48a714557156320f20871ee64d550d8bf2683d980d3"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:22d8798819f86720bc646ab015baff5ea4c971d68121cb36e2ebc2ee43ead2b7"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9b1c058c171b739e7c330760044803099c7fff11511e3ab3573e5327116a9c33"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp313-cp313-win32.whl", hash = "sha256:a143af2ea6672f2af3f44ed8f9cd020e9cc34c56f0e8db12019d5d9ecf41cb3b"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp313-cp313-win_amd64.whl", hash = "sha256:12b04d1db2663b421fe072d638a138460a51d5a862403295671c4f3987fb9148"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24bd94bb301ec672d8f0623eba9226cc90d775d25a0c92b5f8e4965d7f3a1518"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a51d3db74ba489266ef55c7a4534eb0b8db9a326553df481c11e5d7660c8364d"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:55250fe61d6ebfd6934a272ee16ef1244e0f16b7af6cd18ab5b1fc9f08631db0"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:46796877b47034b559a593d7e4b549aba151dae73f9e78212a3478161c12ab08"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp313-cp313t-win32.whl", hash = "sha256:9c4969a86e41454f2858256c39bdfb966a20961e9b58bf8749b65abf447e9a8d"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp313-cp313t-win_amd64.whl", hash = "sha256:b9870d15ef00e4d0559ae10ee5bc71b654d1f20076dbe8bc7ed19b4c0625ceba"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:233088b4b99ebcbc5258c755a097aa52fbf90727a03a5a80781c4b9c54347a2e"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:57ca426a48eb2c682dae8204cd89ea8ab7031e2675120a47924fabc7caacbc2a"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:685e93e9c8f399b0c96a624799820176312f5ceef958c0f88215af4013d29066"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9e0400fa22f79acc334d9a6b185dc00a44a8e6578aa7e12d0ddcd8434152b187"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a05977bffe9bffd2229f477fa75eabe3192b1b05f408961d1bebff8d1cd4d401"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp314-cp314-win32.whl", hash = "sha256:0f2fa354ba106eafff2c14b0cc51f22801d1e8b2e4149342023bd6f0955de5f5"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp314-cp314-win_amd64.whl", hash = "sha256:77641d299179c37b89cf2343ca9972c88bb6eef0d5fc504a2f86afd15cd5adf5"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c1dc3368794d522f43914e03312202523cc89692f5389c32bea0233924f8d977"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c821c47ecfe05cc32140dcf8dc6fd5d21971c86dbd56eabfe5ba07a64910c01"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9c04bff9a5335eb95c6ecf1c117576a0aa560def274876fd156cfe5510fccc61"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:7f605a456948c35260e7b2a39f8952a26f077fd25653c37740ed186b90aaa68a"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp314-cp314t-win32.whl", hash = "sha256:6270d717b11c5476b0cbb21eedc8d4dbb7d1a956fd6c15a23e96f197a6193158"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp314-cp314t-win_amd64.whl", hash = "sha256:275424295f4256fd301744b8f335cff367825d270f155d522b30c7bf49903ee7"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:8a97ac839c2c6672c4865e48f3cbad7152cee85f4233fb4ca6291d775b9b954a"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c338ec6ec01c0bc8e735c58b9f5d51e75bacb6ff23296658826d7cfdfdb8678a"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp38-cp38-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:566df36fd0e901625523a5a1835032f1ebdd7f7886c54584143fa6c668b4df3b"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:d99945830a6f3e9638d89a28ed130b1eb24c91255e4f24366fbe699b983f29e4"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:01146546d84185f12721a1d2ce0c6673451a7894d1460b592d378ca4871a0c72"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp38-cp38-win32.whl", hash = "sha256:69469ce8ce7a8df4d37620e3163b71238719e1e2e5048d114a1b6ce0fbf8c662"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp38-cp38-win_amd64.whl", hash = "sha256:b95b2f470c1b2683febd2e7eab1d3f0e078c91dbdd0b00e9c645d07a413bb99f"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:43d044780732d9e0381ac8d5316f95d7f02ef04d6e4ef6dc82379f09795d993f"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d6be30b2a75362325176c036d7fb8d19e8846c77e87683ffaa8177b35135613"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp39-cp39-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d898cc2c76c135ef65517f4ddd7a3512fb41f23087b0650efb3418b8389a3cd1"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:059d7151fff513c53a4638da8778be7fce81a0c4854c7348ebd0c4078ddf28fe"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:334edbcff10514ad1d66e3a70b339c0a29886394892490119dbb669627b17717"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp39-cp39-win32.whl", hash = "sha256:74ab4ee7794d7ed1b0c37e7333640e0f0a626fc7b398c07a7aef52f484fddde3"},
|
||||||
|
{file = "sqlalchemy-2.0.49-cp39-cp39-win_amd64.whl", hash = "sha256:88690f4e1f0fbf5339bedbb127e240fec1fd3070e9934c0b7bef83432f779d2f"},
|
||||||
|
{file = "sqlalchemy-2.0.49-py3-none-any.whl", hash = "sha256:ec44cfa7ef1a728e88ad41674de50f6db8cfdb3e2af84af86e0041aaf02d43d0"},
|
||||||
|
{file = "sqlalchemy-2.0.49.tar.gz", hash = "sha256:d15950a57a210e36dd4cec1aac22787e2a4d57ba9318233e2ef8b2daf9ff2d5f"},
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dependencies]
|
||||||
|
greenlet = {version = ">=1", markers = "platform_machine == \"aarch64\" or platform_machine == \"ppc64le\" or platform_machine == \"x86_64\" or platform_machine == \"amd64\" or platform_machine == \"AMD64\" or platform_machine == \"win32\" or platform_machine == \"WIN32\""}
|
||||||
|
typing-extensions = ">=4.6.0"
|
||||||
|
|
||||||
|
[package.extras]
|
||||||
|
aiomysql = ["aiomysql (>=0.2.0)", "greenlet (>=1)"]
|
||||||
|
aioodbc = ["aioodbc", "greenlet (>=1)"]
|
||||||
|
aiosqlite = ["aiosqlite", "greenlet (>=1)", "typing_extensions (!=3.10.0.1)"]
|
||||||
|
asyncio = ["greenlet (>=1)"]
|
||||||
|
asyncmy = ["asyncmy (>=0.2.3,!=0.2.4,!=0.2.6)", "greenlet (>=1)"]
|
||||||
|
mariadb-connector = ["mariadb (>=1.0.1,!=1.1.2,!=1.1.5,!=1.1.10)"]
|
||||||
|
mssql = ["pyodbc"]
|
||||||
|
mssql-pymssql = ["pymssql"]
|
||||||
|
mssql-pyodbc = ["pyodbc"]
|
||||||
|
mypy = ["mypy (>=0.910)"]
|
||||||
|
mysql = ["mysqlclient (>=1.4.0)"]
|
||||||
|
mysql-connector = ["mysql-connector-python"]
|
||||||
|
oracle = ["cx_oracle (>=8)"]
|
||||||
|
oracle-oracledb = ["oracledb (>=1.0.1)"]
|
||||||
|
postgresql = ["psycopg2 (>=2.7)"]
|
||||||
|
postgresql-asyncpg = ["asyncpg", "greenlet (>=1)"]
|
||||||
|
postgresql-pg8000 = ["pg8000 (>=1.29.1)"]
|
||||||
|
postgresql-psycopg = ["psycopg (>=3.0.7)"]
|
||||||
|
postgresql-psycopg2binary = ["psycopg2-binary"]
|
||||||
|
postgresql-psycopg2cffi = ["psycopg2cffi"]
|
||||||
|
postgresql-psycopgbinary = ["psycopg[binary] (>=3.0.7)"]
|
||||||
|
pymysql = ["pymysql"]
|
||||||
|
sqlcipher = ["sqlcipher3_binary"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "starlette"
|
name = "starlette"
|
||||||
version = "0.52.1"
|
version = "0.52.1"
|
||||||
description = "The little ASGI library that shines."
|
description = "The little ASGI library that shines."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.10"
|
python-versions = ">=3.10"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74"},
|
{file = "starlette-0.52.1-py3-none-any.whl", hash = "sha256:0029d43eb3d273bc4f83a08720b4912ea4b071087a3b48db01b7c839f7954d74"},
|
||||||
{file = "starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933"},
|
{file = "starlette-0.52.1.tar.gz", hash = "sha256:834edd1b0a23167694292e94f597773bc3f89f362be6effee198165a35d62933"},
|
||||||
@@ -1174,6 +1676,7 @@ version = "25.5.0"
|
|||||||
description = "Structured Logging for Python"
|
description = "Structured Logging for Python"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8"
|
python-versions = ">=3.8"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f"},
|
{file = "structlog-25.5.0-py3-none-any.whl", hash = "sha256:a8453e9b9e636ec59bd9e79bbd4a72f025981b3ba0f5837aebf48f02f37a7f9f"},
|
||||||
{file = "structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98"},
|
{file = "structlog-25.5.0.tar.gz", hash = "sha256:098522a3bebed9153d4570c6d0288abf80a031dfdb2048d59a49e9dc2190fc98"},
|
||||||
@@ -1185,6 +1688,7 @@ version = "4.15.0"
|
|||||||
description = "Backported and Experimental Type Hints for Python 3.9+"
|
description = "Backported and Experimental Type Hints for Python 3.9+"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
|
{file = "typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548"},
|
||||||
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
|
{file = "typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466"},
|
||||||
@@ -1196,6 +1700,7 @@ version = "0.4.2"
|
|||||||
description = "Runtime typing introspection tools"
|
description = "Runtime typing introspection tools"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"},
|
{file = "typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7"},
|
||||||
{file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"},
|
{file = "typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464"},
|
||||||
@@ -1210,16 +1715,17 @@ version = "2.6.3"
|
|||||||
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
description = "HTTP library with thread-safe connection pooling, file post, and more."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"},
|
{file = "urllib3-2.6.3-py3-none-any.whl", hash = "sha256:bf272323e553dfb2e87d9bfd225ca7b0f467b919d7bbd355436d3fd37cb0acd4"},
|
||||||
{file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"},
|
{file = "urllib3-2.6.3.tar.gz", hash = "sha256:1b62b6884944a57dbe321509ab94fd4d3b307075e0c2eae991ac71ee15ad38ed"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
brotli = ["brotli (>=1.2.0)", "brotlicffi (>=1.2.0.0)"]
|
brotli = ["brotli (>=1.2.0) ; platform_python_implementation == \"CPython\"", "brotlicffi (>=1.2.0.0) ; platform_python_implementation != \"CPython\""]
|
||||||
h2 = ["h2 (>=4,<5)"]
|
h2 = ["h2 (>=4,<5)"]
|
||||||
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
|
socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
|
||||||
zstd = ["backports-zstd (>=1.0.0)"]
|
zstd = ["backports-zstd (>=1.0.0) ; python_version < \"3.14\""]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uvicorn"
|
name = "uvicorn"
|
||||||
@@ -1227,6 +1733,7 @@ version = "0.41.0"
|
|||||||
description = "The lightning-fast ASGI server."
|
description = "The lightning-fast ASGI server."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.10"
|
python-versions = ">=3.10"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187"},
|
{file = "uvicorn-0.41.0-py3-none-any.whl", hash = "sha256:29e35b1d2c36a04b9e180d4007ede3bcb32a85fbdfd6c6aeb3f26839de088187"},
|
||||||
{file = "uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a"},
|
{file = "uvicorn-0.41.0.tar.gz", hash = "sha256:09d11cf7008da33113824ee5a1c6422d89fbc2ff476540d69a34c87fab8b571a"},
|
||||||
@@ -1239,12 +1746,12 @@ h11 = ">=0.8"
|
|||||||
httptools = {version = ">=0.6.3", optional = true, markers = "extra == \"standard\""}
|
httptools = {version = ">=0.6.3", optional = true, markers = "extra == \"standard\""}
|
||||||
python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
|
python-dotenv = {version = ">=0.13", optional = true, markers = "extra == \"standard\""}
|
||||||
pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""}
|
pyyaml = {version = ">=5.1", optional = true, markers = "extra == \"standard\""}
|
||||||
uvloop = {version = ">=0.15.1", optional = true, markers = "(sys_platform != \"win32\" and sys_platform != \"cygwin\") and platform_python_implementation != \"PyPy\" and extra == \"standard\""}
|
uvloop = {version = ">=0.15.1", optional = true, markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\" and extra == \"standard\""}
|
||||||
watchfiles = {version = ">=0.20", optional = true, markers = "extra == \"standard\""}
|
watchfiles = {version = ">=0.20", optional = true, markers = "extra == \"standard\""}
|
||||||
websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""}
|
websockets = {version = ">=10.4", optional = true, markers = "extra == \"standard\""}
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
standard = ["colorama (>=0.4)", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1)", "watchfiles (>=0.20)", "websockets (>=10.4)"]
|
standard = ["colorama (>=0.4) ; sys_platform == \"win32\"", "httptools (>=0.6.3)", "python-dotenv (>=0.13)", "pyyaml (>=5.1)", "uvloop (>=0.15.1) ; sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\"", "watchfiles (>=0.20)", "websockets (>=10.4)"]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uvloop"
|
name = "uvloop"
|
||||||
@@ -1252,6 +1759,8 @@ version = "0.22.1"
|
|||||||
description = "Fast implementation of asyncio event loop on top of libuv"
|
description = "Fast implementation of asyncio event loop on top of libuv"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8.1"
|
python-versions = ">=3.8.1"
|
||||||
|
groups = ["main"]
|
||||||
|
markers = "sys_platform != \"win32\" and sys_platform != \"cygwin\" and platform_python_implementation != \"PyPy\""
|
||||||
files = [
|
files = [
|
||||||
{file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c"},
|
{file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ef6f0d4cc8a9fa1f6a910230cd53545d9a14479311e87e3cb225495952eb672c"},
|
||||||
{file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792"},
|
{file = "uvloop-0.22.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:7cd375a12b71d33d46af85a3343b35d98e8116134ba404bd657b3b1d15988792"},
|
||||||
@@ -1315,6 +1824,7 @@ version = "1.1.1"
|
|||||||
description = "Simple, modern and high performance file watching and code reload in python."
|
description = "Simple, modern and high performance file watching and code reload in python."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c"},
|
{file = "watchfiles-1.1.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eef58232d32daf2ac67f42dea51a2c80f0d03379075d44a587051e63cc2e368c"},
|
||||||
{file = "watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43"},
|
{file = "watchfiles-1.1.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:03fa0f5237118a0c5e496185cafa92878568b652a2e9a9382a5151b1a0380a43"},
|
||||||
@@ -1436,6 +1946,7 @@ version = "16.0"
|
|||||||
description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
|
description = "An implementation of the WebSocket Protocol (RFC 6455 & 7692)"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.10"
|
python-versions = ">=3.10"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a"},
|
{file = "websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a"},
|
||||||
{file = "websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0"},
|
{file = "websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0"},
|
||||||
@@ -1506,6 +2017,7 @@ version = "1.17.3"
|
|||||||
description = "Module for decorators, wrappers and monkey patching."
|
description = "Module for decorators, wrappers and monkey patching."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.8"
|
python-versions = ">=3.8"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04"},
|
{file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04"},
|
||||||
{file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2"},
|
{file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2"},
|
||||||
@@ -1596,13 +2108,14 @@ version = "3.23.0"
|
|||||||
description = "Backport of pathlib-compatible object wrapper for zip files"
|
description = "Backport of pathlib-compatible object wrapper for zip files"
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.9"
|
||||||
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"},
|
{file = "zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e"},
|
||||||
{file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"},
|
{file = "zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"]
|
check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1) ; sys_platform != \"cygwin\""]
|
||||||
cover = ["pytest-cov"]
|
cover = ["pytest-cov"]
|
||||||
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
|
doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
|
||||||
enabler = ["pytest-enabler (>=2.2)"]
|
enabler = ["pytest-enabler (>=2.2)"]
|
||||||
@@ -1610,6 +2123,6 @@ test = ["big-O", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more_it
|
|||||||
type = ["pytest-mypy"]
|
type = ["pytest-mypy"]
|
||||||
|
|
||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.0"
|
lock-version = "2.1"
|
||||||
python-versions = "^3.14"
|
python-versions = "^3.14"
|
||||||
content-hash = "3156d28f769918f0fc547653c11a84e33728d9abed9e7be1a885aa7ae512d6dd"
|
content-hash = "37dfb9818087d72976a203956d4f1ef6df05377e66dc521b7884b718df44138e"
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ version = "0.1.0"
|
|||||||
description = ""
|
description = ""
|
||||||
authors = ["Your Name <you@example.com>"]
|
authors = ["Your Name <you@example.com>"]
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
|
package-mode = false
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.poetry.dependencies]
|
||||||
python = "^3.14"
|
python = "^3.14"
|
||||||
@@ -22,9 +23,16 @@ opentelemetry-instrumentation-httpx = "^0.61b0"
|
|||||||
opentelemetry-instrumentation-redis = "^0.61b0"
|
opentelemetry-instrumentation-redis = "^0.61b0"
|
||||||
pytest = "^9.0.2"
|
pytest = "^9.0.2"
|
||||||
redis = "^7.3.0"
|
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]
|
[tool.pytest.ini_options]
|
||||||
pythonpath = ["."]
|
pythonpath = ["."]
|
||||||
|
addopts = ["--import-mode=importlib"]
|
||||||
|
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
+12
-3
@@ -32,9 +32,18 @@
|
|||||||
| 023 | DONE | 2026-03-29 | Add Yandex Geosuggest address suggestion adapter and CIS routing | `spec/tasks/023_add_yandex_geosuggest_address_suggestion_adapter.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` |
|
| 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` |
|
| 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
|
## Summary
|
||||||
|
|
||||||
- Total: **26**
|
- Total: **35**
|
||||||
- TODO: **0**
|
- TODO: **2**
|
||||||
- DONE: **26**
|
- DONE: **33**
|
||||||
|
|||||||
+146
-38
@@ -17,8 +17,13 @@
|
|||||||
|
|
||||||
- Принимать запрос на расчёт стоимости доставки (идентификаторы городов отправления/назначения, вес, габариты)
|
- Принимать запрос на расчёт стоимости доставки (идентификаторы городов отправления/назначения, вес, габариты)
|
||||||
- Поддерживать необязательный параметр `parcel_type` в запросе расчёта стоимости доставки для фильтрации тарифов по типу отправления
|
- Поддерживать необязательный параметр `parcel_type` в запросе расчёта стоимости доставки для фильтрации тарифов по типу отправления
|
||||||
- Предоставлять отдельный endpoint подсказок адреса, чтобы frontend мог получить точное значение для `from_location.address` и `to_location.address` перед созданием заказа
|
- Предоставлять отдельный endpoint подсказок адреса, чтобы frontend мог получить точное значение для `from_location.address` и `to_location.address` перед инициализацией оплаты доставки
|
||||||
- Принимать запрос на создание заказа CDEK по контракту из `http-client.http` для сценария "доставка, до двери"
|
- Принимать запрос на инициализацию оплаты доставки через TBank и возвращать ссылку на оплату без регистрации заказа в CDEK
|
||||||
|
- Принимать сумму оплаты в поле `price` в копейках
|
||||||
|
- После успешного получения ссылки на оплату от TBank сохранять все данные заявки вместе со ссылкой на оплату в PostgreSQL; ошибка сохранения не блокирует возврат ссылки клиенту
|
||||||
|
- Принимать HTTP-уведомления TBank о статусах платежа на отдельный webhook endpoint
|
||||||
|
- При получении валидного уведомления TBank со статусом `CONFIRMED` находить сохранённую заявку в PostgreSQL и регистрировать заказ в CDEK
|
||||||
|
- Остальные валидные статусы платежа TBank подтверждать без регистрации заказа в CDEK
|
||||||
- Выбирать сервис подсказок адреса по `country_code` через маппинг стран в конфиге
|
- Выбирать сервис подсказок адреса по `country_code` через маппинг стран в конфиге
|
||||||
- Для `RU`, `BY` и `KZ`, сопоставленных с provider id `dadata`, использовать `dadata.ru`
|
- Для `RU`, `BY` и `KZ`, сопоставленных с provider id `dadata`, использовать `dadata.ru`
|
||||||
- Для `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM` и `UZ`, сопоставленных с provider id `yandex_geosuggest`, использовать Yandex Geosuggest
|
- Для `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM` и `UZ`, сопоставленных с provider id `yandex_geosuggest`, использовать Yandex Geosuggest
|
||||||
@@ -38,8 +43,11 @@
|
|||||||
### Фаза 1
|
### Фаза 1
|
||||||
- **CDEK** — https://apidoc.cdek.ru/
|
- **CDEK** — https://apidoc.cdek.ru/
|
||||||
- Аутентификация: OAuth2 (client credentials)
|
- Аутентификация: OAuth2 (client credentials)
|
||||||
- Операции: расчёт тарифа, регистрация заказа
|
- Операции: расчёт тарифа
|
||||||
- Cache TTL: 15 минут
|
- Cache TTL: 15 минут
|
||||||
|
- **TBank** — https://securepay.tinkoff.ru/v2/Init
|
||||||
|
- Аутентификация: `TerminalKey` и token на основе password
|
||||||
|
- Операции: инициализация платежа, получение payment URL, проверка подписи HTTP-уведомлений
|
||||||
|
|
||||||
### Фаза 2+
|
### Фаза 2+
|
||||||
- Дополнительные провайдеры (Boxberry, DHL и др.) — подключаются через интерфейс `DeliveryProvider` без изменений в логике агрегации
|
- Дополнительные провайдеры (Boxberry, DHL и др.) — подключаются через интерфейс `DeliveryProvider` без изменений в логике агрегации
|
||||||
@@ -51,10 +59,11 @@
|
|||||||
### Controller (`app/controllers/v1/delivery.py`)
|
### Controller (`app/controllers/v1/delivery.py`)
|
||||||
- `POST /api/v1/delivery/price` — принимает `DeliveryCalculationRequest`, возвращает `list[DeliveryPrice]`
|
- `POST /api/v1/delivery/price` — принимает `DeliveryCalculationRequest`, возвращает `list[DeliveryPrice]`
|
||||||
- `POST /api/v1/delivery/suggest-address` — принимает `AddressSuggestRequest`, возвращает `list[AddressSuggestion]`
|
- `POST /api/v1/delivery/suggest-address` — принимает `AddressSuggestRequest`, возвращает `list[AddressSuggestion]`
|
||||||
- `POST /api/v1/delivery/order` — принимает `OrderCreateRequest`, возвращает `OrderCreateResponse`
|
- `POST /api/v1/delivery/order` — принимает `InitPaymentRequest`, возвращает `InitPaymentResponse`
|
||||||
|
- `POST /api/v1/delivery/tbank/notifications` — принимает `TBankPaymentNotification`, возвращает plain text `OK` при успешной обработке
|
||||||
- Парсинг и валидация входных данных через Pydantic
|
- Парсинг и валидация входных данных через Pydantic
|
||||||
- Маппинг исключений сервиса в HTTP-ответы
|
- Маппинг исключений сервиса в HTTP-ответы
|
||||||
- Каждый endpoint вызывает ровно один метод Service: `AggregatorService.get_all_prices()`, `AggregatorService.suggest_addresses()` или `AggregatorService.create_order()`
|
- Каждый endpoint вызывает ровно один метод Service: `AggregatorService.get_all_prices()`, `AggregatorService.suggest_addresses()`, `AggregatorService.init_payment()` или `AggregatorService.handle_tbank_payment_notification()`
|
||||||
|
|
||||||
### Service (`app/services/aggregator.py`)
|
### Service (`app/services/aggregator.py`)
|
||||||
- `AggregatorService.get_all_prices(request: DeliveryCalculationRequest) -> list[DeliveryPrice]`
|
- `AggregatorService.get_all_prices(request: DeliveryCalculationRequest) -> list[DeliveryPrice]`
|
||||||
@@ -64,8 +73,12 @@
|
|||||||
- Сортирует тарифы по цене
|
- Сортирует тарифы по цене
|
||||||
- `AggregatorService.suggest_addresses(request: AddressSuggestRequest) -> list[AddressSuggestion]`
|
- `AggregatorService.suggest_addresses(request: AddressSuggestRequest) -> list[AddressSuggestion]`
|
||||||
- `AggregatorService.suggest_addresses()` выбирает address suggestion provider по `country_code` через injected config mapping и оркестрирует ровно один adapter call
|
- `AggregatorService.suggest_addresses()` выбирает address suggestion provider по `country_code` через injected config mapping и оркестрирует ровно один adapter call
|
||||||
- `AggregatorService.create_order(request: OrderCreateRequest) -> OrderCreateResponse`
|
- `AggregatorService.init_payment(request: InitPaymentRequest) -> InitPaymentResponse`
|
||||||
- `AggregatorService.create_order()` оркестрирует регистрацию заказа в CDEK через injected adapter dependency
|
- `AggregatorService.init_payment()` оркестрирует инициализацию платежа через injected TBank adapter dependency, затем сохраняет данные заявки вместе с `payment_url` через injected order repository; ошибка сохранения логируется, но не блокирует возврат `payment_url`
|
||||||
|
- `AggregatorService.handle_tbank_payment_notification(notification: TBankPaymentNotification) -> str`
|
||||||
|
- `AggregatorService.handle_tbank_payment_notification()` оркестрирует проверку подписи уведомления через injected TBank adapter, определение действия по статусу через Business Logic, чтение сохранённой заявки через injected order repository и регистрацию заказа через injected CDEK adapter при статусе `CONFIRMED`
|
||||||
|
- Повторное валидное уведомление `CONFIRMED` для заявки с уже сохранённым `cdek_order_uuid` не должно повторно создавать заказ в CDEK
|
||||||
|
- Валидные уведомления TBank со статусами, отличными от `CONFIRMED`, подтверждаются ответом `OK` без обращения к CDEK
|
||||||
- Service не содержит бизнес-логики и provider HTTP-деталей
|
- Service не содержит бизнес-логики и provider HTTP-деталей
|
||||||
|
|
||||||
### Business Logic (`app/domain/`)
|
### Business Logic (`app/domain/`)
|
||||||
@@ -74,6 +87,7 @@
|
|||||||
- Логика сравнения цен
|
- Логика сравнения цен
|
||||||
- Правила применения конфигурируемого мультипликатора к ценам провайдеров
|
- Правила применения конфигурируемого мультипликатора к ценам провайдеров
|
||||||
- Округление цены после применения мультипликатора до целого значения по детерминированному правилу
|
- Округление цены после применения мультипликатора до целого значения по детерминированному правилу
|
||||||
|
- Правило выбора действия для TBank payment notification по статусу платежа
|
||||||
- Нормализация входных данных (например, идентификаторов городов и правил округления веса)
|
- Нормализация входных данных (например, идентификаторов городов и правил округления веса)
|
||||||
- Чистые функции, без IO, без зависимостей от фреймворков
|
- Чистые функции, без IO, без зависимостей от фреймворков
|
||||||
|
|
||||||
@@ -82,6 +96,15 @@
|
|||||||
- Операции: `get(key)`, `set(key, value, ttl)`, `invalidate(key)`
|
- Операции: `get(key)`, `set(key, value, ttl)`, `invalidate(key)`
|
||||||
- Без бизнес-решений; формирование ключа — ответственность Adapter
|
- Без бизнес-решений; формирование ключа — ответственность Adapter
|
||||||
|
|
||||||
|
### Repository (`app/repositories/order/`)
|
||||||
|
- `OrderRepository` — сохранение данных заявки в PostgreSQL
|
||||||
|
- Операции: `create_order(session, order_data)` — сохраняет запись заявки с данными `InitPaymentRequest` и `payment_url`
|
||||||
|
- Операции: `get_order_by_order_uuid(session, order_uuid)` — получает сохранённую заявку для payment notification flow
|
||||||
|
- Операции: `mark_payment_status(session, order_uuid, status, payment_id)` — сохраняет последний статус платежа TBank
|
||||||
|
- Операции: `mark_cdek_order_registered(session, order_uuid, cdek_order_uuid)` — сохраняет UUID заказа CDEK после успешной регистрации
|
||||||
|
- SQLAlchemy ORM model таблицы `orders` в `models.py`
|
||||||
|
- Без бизнес-решений; только CRUD-примитивы
|
||||||
|
|
||||||
### Adapter (`app/adapters/delivery_providers`)
|
### Adapter (`app/adapters/delivery_providers`)
|
||||||
- `base.py` — абстрактный интерфейс `DeliveryProvider`:
|
- `base.py` — абстрактный интерфейс `DeliveryProvider`:
|
||||||
```python
|
```python
|
||||||
@@ -92,11 +115,33 @@
|
|||||||
- `cdek/client.py` — HTTP-клиент (httpx AsyncClient), аутентификация, ретраи, таймаут (10с)
|
- `cdek/client.py` — HTTP-клиент (httpx AsyncClient), аутентификация, ретраи, таймаут (10с)
|
||||||
- `cdek/auth.py` — управление OAuth2-токеном
|
- `cdek/auth.py` — управление OAuth2-токеном
|
||||||
- `cdek/mapper.py` — ответ CDEK → `list[DeliveryPrice]`
|
- `cdek/mapper.py` — ответ CDEK → `list[DeliveryPrice]`
|
||||||
- `cdek/order_mapper.py` — request/response mapping для регистрации заказа CDEK
|
- `cdek/order_mapper.py` — request/response mapping для CDEK order contract; публичный `init-payment` flow не вызывает регистрацию заказа в CDEK
|
||||||
- Каждый адаптер владеет своей конфигурацией; наружу экспонирует только service-facing methods, необходимые соответствующему use-case
|
- Каждый адаптер владеет своей конфигурацией; наружу экспонирует только service-facing methods, необходимые соответствующему use-case
|
||||||
- Для расчёта тарифа CDEK adapter принимает city identifiers из `DeliveryCalculationRequest`, находит запись в `cities_map`, берёт `cdek.code` и передаёт его в CDEK API
|
- Для расчёта тарифа CDEK adapter принимает city identifiers из `DeliveryCalculationRequest`, находит запись в `cities_map`, берёт `cdek.code` и передаёт его в CDEK API
|
||||||
|
|
||||||
Для сценария создания заказа CDEK adapter принимает валидированную order model, отправляет контракт `Регистрация заказа (тип "доставка", до двери)` из `http-client.http` и возвращает внутреннюю response model без утечки HTTP-деталей в Service.
|
Для CDEK order contract mapper принимает новый `InitPaymentRequest` и собирает provider payload:
|
||||||
|
- `number = orderUuid`, `type = 2`, `tariff_code = systemData.tariff.tariffCode`.
|
||||||
|
- `sender`/`recipient` маппятся из `senderContact`/`receiverContact`: `name = fullName`, `email`, `phones = [{number, additional: phoneExt}]`. При `isCompany=true` добавляются `contragent_type="LEGAL_ENTITY"`, `company`, `inn`, `kpp`.
|
||||||
|
- `from_location`/`to_location` собираются из `senderAddress`/`receiverAddress`: `code` берётся из `cities_map` по `cityId`, `address` склеивается строкой `"{city}, {street}, {house}, кв. {apartment}"` (хвост `кв.` опускается при пустом `apartment`), `postal_code = zip`.
|
||||||
|
- `packages[0]` содержит `number = orderUuid`, `weight = round(float(systemData.weight) * 1000)` в граммах; при `parcelType='parcel'` добавляются `length`, `width`, `height` из `systemData.dimensions`; при `parcelType='doc'` габариты не передаются.
|
||||||
|
- `packages[0].items[0]` пробрасывает `content.description` как `name`.
|
||||||
|
- `shipment_point.date = pickupDate.date()`, `delivery_point.date = deliveryDate.date()` (если задан).
|
||||||
|
- Поле верхнего уровня `comment` собирается из `content.description` (если есть).
|
||||||
|
- Возвращает `entity.uuid` строкой.
|
||||||
|
|
||||||
|
### Adapter (`app/adapters/tbank`)
|
||||||
|
- `base.py` — исключения TBank payment adapter
|
||||||
|
- `client.py` — `TBankAdapter`
|
||||||
|
- `TBankAdapter.create_payment_link(order_uuid: str, amount_kopecks: int) -> str` инициализирует платёж TBank и возвращает payment URL
|
||||||
|
- `TBankAdapter.verify_payment_notification(notification: TBankPaymentNotification) -> None` проверяет token HTTP-уведомления TBank по top-level scalar fields, исключая `Token` и вложенные объекты
|
||||||
|
- TBank adapter инкапсулирует HTTP-взаимодействие с TBank Init API, auth token, retries, timeout, serialization и error handling
|
||||||
|
- TBank adapter владеет собственной секцией конфигурации `tbank_payment` с полями `init_url`, `auth.terminal_key`, `auth.password`, `timeout_seconds`, `retry_attempts`, `retry_backoff_seconds`
|
||||||
|
|
||||||
|
### Adapter (`app/adapters/postgres`)
|
||||||
|
- Управление подключением к PostgreSQL: создание `AsyncEngine` и `async_sessionmaker` из конфигурации
|
||||||
|
- Инкапсулирует инфраструктурные детали SQLAlchemy async engine
|
||||||
|
- Владеет собственной секцией конфигурации `postgres` с обязательным полем `dsn`
|
||||||
|
- Без SQL-запросов, без бизнес-логики
|
||||||
|
|
||||||
### Adapter (`app/adapters/address_suggestions`)
|
### Adapter (`app/adapters/address_suggestions`)
|
||||||
- `base.py` — абстрактный интерфейс `AddressSuggestionProvider`:
|
- `base.py` — абстрактный интерфейс `AddressSuggestionProvider`:
|
||||||
@@ -165,39 +210,89 @@ flat: str | None
|
|||||||
postal_code: str | None
|
postal_code: str | None
|
||||||
```
|
```
|
||||||
|
|
||||||
### Входная: `OrderCreateRequest`
|
### Входная: `InitPaymentRequest`
|
||||||
|
Контракт ручки `/api/v1/delivery/order` использует camelCase в JSON; Pydantic-
|
||||||
|
модели хранят snake_case поля и принимают входной JSON через alias-generator.
|
||||||
```
|
```
|
||||||
type: Literal[2]
|
orderUuid: str
|
||||||
tariff_code: Literal[535]
|
senderAddress:
|
||||||
comment: str | None
|
cityId: int
|
||||||
sender:
|
|
||||||
name: str
|
|
||||||
email: str
|
|
||||||
phones: list[{number: str}]
|
|
||||||
recipient:
|
|
||||||
name: str
|
|
||||||
email: str
|
|
||||||
phones: list[{number: str}]
|
|
||||||
from_location:
|
|
||||||
address: str
|
|
||||||
city: str
|
city: str
|
||||||
country_code: str
|
street: str
|
||||||
to_location:
|
house: str
|
||||||
address: str
|
apartment: str | None
|
||||||
city: str
|
zip: str
|
||||||
country_code: str
|
comment: str | None
|
||||||
services: list[{code: str, parameter: str}]
|
senderContact:
|
||||||
packages: list[{number: str, weight: int, length: int, width: int, height: int, comment: str | None}]
|
fullName: str
|
||||||
|
email: str | None
|
||||||
|
phone: str
|
||||||
|
phoneExt: str | None
|
||||||
|
isCompany: bool
|
||||||
|
companyName: str | None # обязателен при isCompany=true
|
||||||
|
inn: str | None # обязателен при isCompany=true
|
||||||
|
kpp: str | None # обязателен при isCompany=true
|
||||||
|
receiverAddress: <структура senderAddress>
|
||||||
|
receiverContact: <структура senderContact>
|
||||||
|
content:
|
||||||
|
description: str | None
|
||||||
|
pickupDate: datetime # ISO 8601
|
||||||
|
deliveryDate: datetime | None # ISO 8601
|
||||||
|
accountEmail: str
|
||||||
|
systemData:
|
||||||
|
tariff:
|
||||||
|
provider: str
|
||||||
|
serviceName: str
|
||||||
|
price: int # копейки
|
||||||
|
deliveryDaysMin: int
|
||||||
|
deliveryDaysMax: int
|
||||||
|
tariffCode: int
|
||||||
|
parcelType: Literal[doc, parcel]
|
||||||
|
docPackaging: Literal[envelope, bag] | None # для doc
|
||||||
|
weight: str
|
||||||
|
dimensions: # null/отсутствует для doc
|
||||||
|
length: str
|
||||||
|
width: str
|
||||||
|
height: str
|
||||||
```
|
```
|
||||||
|
|
||||||
`from_location.address` и `to_location.address` должны содержать точные значения адреса, выбранные клиентом; order flow не выполняет address suggestion lookup.
|
`systemData.tariff.price` задаётся в копейках, является обязательным целым
|
||||||
|
числом и должен быть больше 0; backend использует его как сумму платежа
|
||||||
|
TBank без пересчёта.
|
||||||
|
`senderAddress.cityId` и `receiverAddress.cityId` используют общий справочник
|
||||||
|
`cities_map` (тот же идентификатор, что и в `DeliveryCalculationRequest`).
|
||||||
|
Для `parcelType='doc'` `systemData.dimensions` отсутствует или равен `null`,
|
||||||
|
для `parcelType='parcel'` — обязателен.
|
||||||
|
При `isCompany=true` поля `companyName`, `inn`, `kpp` обязательны.
|
||||||
|
`pickupDate` и `deliveryDate` принимаются и сохраняются как ISO datetime.
|
||||||
|
|
||||||
### Выходная: `OrderCreateResponse`
|
### Выходная: `InitPaymentResponse`
|
||||||
```
|
```
|
||||||
provider: str
|
payment_url: str
|
||||||
order_uuid: str
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Входная: `TBankPaymentNotification`
|
||||||
|
```
|
||||||
|
TerminalKey: str
|
||||||
|
OrderId: str
|
||||||
|
Success: bool
|
||||||
|
Status: str
|
||||||
|
PaymentId: int
|
||||||
|
ErrorCode: str
|
||||||
|
Amount: int
|
||||||
|
Token: str
|
||||||
|
```
|
||||||
|
|
||||||
|
`PaymentId` приходит из TBank как JSON-число (long integer) и валидируется как положительное целое.
|
||||||
|
Модель уведомления должна допускать дополнительные top-level поля TBank. Проверка token использует все top-level scalar fields кроме `Token`, добавляет `Password` из `tbank_payment.auth.password`, сортирует ключи по алфавиту, конкатенирует строковые представления значений (булевы — как lowercase `true`/`false`) и сравнивает SHA-256 hash с `Token`. Вложенные объекты, включая `Data` и `Receipt`, не участвуют в проверке token.
|
||||||
|
|
||||||
|
### Выходная: TBank notification response
|
||||||
|
```
|
||||||
|
OK
|
||||||
|
```
|
||||||
|
|
||||||
|
Успешно обработанное HTTP-уведомление TBank возвращает `HTTP 200` и plain text body `OK`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Технологический стек
|
## Технологический стек
|
||||||
@@ -208,6 +303,8 @@ order_uuid: str
|
|||||||
| HTTP-клиент | httpx (async) |
|
| HTTP-клиент | httpx (async) |
|
||||||
| Валидация | Pydantic v2 |
|
| Валидация | Pydantic v2 |
|
||||||
| Кеш | Redis |
|
| Кеш | Redis |
|
||||||
|
| База данных | PostgreSQL (asyncpg + SQLAlchemy 2.0 async) |
|
||||||
|
| Миграции | Alembic (async) |
|
||||||
| Конфигурация | pydantic-settings |
|
| Конфигурация | pydantic-settings |
|
||||||
| Сервер | Uvicorn |
|
| Сервер | Uvicorn |
|
||||||
|
|
||||||
@@ -241,15 +338,25 @@ app/
|
|||||||
│ ├── auth.py
|
│ ├── auth.py
|
||||||
│ ├── mapper.py
|
│ ├── mapper.py
|
||||||
│ └── order_mapper.py
|
│ └── order_mapper.py
|
||||||
|
│ └── tbank/
|
||||||
|
│ ├── base.py
|
||||||
|
│ └── client.py
|
||||||
|
│ └── postgres/
|
||||||
|
│ └── engine.py # AsyncEngine & session factory
|
||||||
├── repositories/
|
├── repositories/
|
||||||
│ └── cache/
|
│ ├── cache/
|
||||||
│ └── redis_cache.py # Repository
|
│ │ └── redis_cache.py # Repository (Redis)
|
||||||
|
│ └── order/
|
||||||
|
│ ├── models.py # SQLAlchemy ORM model
|
||||||
|
│ └── repository.py # Repository (PostgreSQL)
|
||||||
├── schemas/
|
├── schemas/
|
||||||
│ ├── address.py
|
│ ├── address.py
|
||||||
│ ├── request.py
|
│ ├── request.py
|
||||||
│ ├── response.py
|
│ ├── response.py
|
||||||
│ └── order.py
|
│ └── payment.py
|
||||||
└── config.py
|
├── config.py
|
||||||
|
alembic/ # Alembic migrations
|
||||||
|
alembic.ini
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -260,4 +367,5 @@ app/
|
|||||||
# docker-compose сервисы
|
# docker-compose сервисы
|
||||||
app # FastAPI-приложение
|
app # FastAPI-приложение
|
||||||
redis # Кеш тарифов
|
redis # Кеш тарифов
|
||||||
|
postgres # База данных заявок
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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`
|
||||||
@@ -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`
|
||||||
@@ -296,11 +296,27 @@ adapter:
|
|||||||
cdek_timeout_seconds: 7.5
|
cdek_timeout_seconds: 7.5
|
||||||
cdek_cache_ttl_seconds: 777
|
cdek_cache_ttl_seconds: 777
|
||||||
|
|
||||||
|
tbank_payment:
|
||||||
|
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||||
|
notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications"
|
||||||
|
success_url: "https://merchant.test/payment/success"
|
||||||
|
auth:
|
||||||
|
terminal_key: "test-terminal-key"
|
||||||
|
password: "test-password"
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/cdek_adapter_test"
|
||||||
|
|
||||||
observability:
|
observability:
|
||||||
enabled: false
|
enabled: false
|
||||||
service_name: "cdek-adapter-test-service"
|
service_name: "cdek-adapter-test-service"
|
||||||
otlp_endpoint: "http://collector:4317"
|
otlp_endpoint: "http://collector:4317"
|
||||||
otlp_insecure: true
|
otlp_insecure: true
|
||||||
|
|
||||||
|
email:
|
||||||
|
smtp_host: "smtp.test"
|
||||||
|
smtp_port: 587
|
||||||
|
from_address: "no-reply@test"
|
||||||
""".strip(),
|
""".strip(),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ def test_map_cdek_response_maps_all_tariffs_to_unified_model() -> None:
|
|||||||
"currency": "usd",
|
"currency": "usd",
|
||||||
"tariff_codes": [
|
"tariff_codes": [
|
||||||
{
|
{
|
||||||
|
"tariff_code": 7,
|
||||||
"tariff_name": "Express",
|
"tariff_name": "Express",
|
||||||
"delivery_sum": "1234.50",
|
"delivery_sum": "1234.50",
|
||||||
"currency": "rub",
|
"currency": "rub",
|
||||||
@@ -33,6 +34,25 @@ def test_map_cdek_response_maps_all_tariffs_to_unified_model() -> None:
|
|||||||
assert [price.currency for price in result] == ["RUB", "USD"]
|
assert [price.currency for price in result] == ["RUB", "USD"]
|
||||||
assert [price.delivery_days_min for price in result] == [2, 5]
|
assert [price.delivery_days_min for price in result] == [2, 5]
|
||||||
assert [price.delivery_days_max for price in result] == [4, 7]
|
assert [price.delivery_days_max for price in result] == [4, 7]
|
||||||
|
assert [price.tariff_code for price in result] == [7, 136]
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_cdek_response_returns_none_tariff_code_when_missing() -> None:
|
||||||
|
payload = {
|
||||||
|
"tariff_codes": [
|
||||||
|
{
|
||||||
|
"tariff_name": "Express",
|
||||||
|
"delivery_sum": "100.00",
|
||||||
|
"currency": "RUB",
|
||||||
|
"period_min": 1,
|
||||||
|
"period_max": 2,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
result = map_cdek_response(payload)
|
||||||
|
|
||||||
|
assert result[0].tariff_code is None
|
||||||
|
|
||||||
|
|
||||||
def test_map_cdek_response_raises_for_missing_tariff_codes() -> None:
|
def test_map_cdek_response_raises_for_missing_tariff_codes() -> None:
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ from app.adapters.delivery_providers.cdek.client import (
|
|||||||
CDEKProvider,
|
CDEKProvider,
|
||||||
CDEKRequestError,
|
CDEKRequestError,
|
||||||
)
|
)
|
||||||
from app.schemas.order import OrderCreateRequest
|
from app.adapters.delivery_providers.cdek.order_mapper import (
|
||||||
|
CDEKOrderRegistrationResult,
|
||||||
|
)
|
||||||
|
from tests.payment_fixtures import make_init_payment_request
|
||||||
|
|
||||||
|
|
||||||
class StubAuthClient:
|
class StubAuthClient:
|
||||||
@@ -51,113 +54,53 @@ class SequenceHTTPClient:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _make_order_request(**overrides: object) -> OrderCreateRequest:
|
def _make_provider(http_client: SequenceHTTPClient, **kwargs: Any) -> CDEKProvider:
|
||||||
payload: dict[str, object] = {
|
return CDEKProvider(
|
||||||
"type": 2,
|
|
||||||
"tariff_code": 535,
|
|
||||||
"comment": "Test order",
|
|
||||||
"sender": {
|
|
||||||
"name": "Petr Petrov",
|
|
||||||
"email": "sender@example.com",
|
|
||||||
"phones": [{"number": "+79009876543"}],
|
|
||||||
},
|
|
||||||
"recipient": {
|
|
||||||
"name": "Ivan Ivanov",
|
|
||||||
"email": "ivan@example.com",
|
|
||||||
"phones": [{"number": "+79001234567"}],
|
|
||||||
},
|
|
||||||
"from_location": {
|
|
||||||
"address": "Lenina 1",
|
|
||||||
"city": "Moscow",
|
|
||||||
"country_code": "RU",
|
|
||||||
},
|
|
||||||
"to_location": {
|
|
||||||
"address": "Pushkina 10",
|
|
||||||
"city": "Novosibirsk",
|
|
||||||
"country_code": "RU",
|
|
||||||
},
|
|
||||||
"services": [{"code": "INSURANCE", "parameter": "1000"}],
|
|
||||||
"packages": [
|
|
||||||
{
|
|
||||||
"number": "1",
|
|
||||||
"weight": 1000,
|
|
||||||
"length": 20,
|
|
||||||
"width": 15,
|
|
||||||
"height": 10,
|
|
||||||
"comment": "Package 1",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
payload.update(overrides)
|
|
||||||
return OrderCreateRequest(**payload)
|
|
||||||
|
|
||||||
|
|
||||||
def test_provider_register_order_posts_cdek_contract_payload_and_maps_response() -> None:
|
|
||||||
response = httpx.Response(
|
|
||||||
200,
|
|
||||||
json={"entity": {"uuid": "cdek-order-uuid"}},
|
|
||||||
request=httpx.Request("POST", "https://api.cdek.test/v2/orders"),
|
|
||||||
)
|
|
||||||
http_client = SequenceHTTPClient([response])
|
|
||||||
provider = CDEKProvider(
|
|
||||||
CDEKClient(
|
CDEKClient(
|
||||||
http_client=http_client, # type: ignore[arg-type]
|
http_client=http_client, # type: ignore[arg-type]
|
||||||
auth_client=StubAuthClient(), # type: ignore[arg-type]
|
auth_client=StubAuthClient(), # type: ignore[arg-type]
|
||||||
base_url="https://api.cdek.test/v2",
|
base_url="https://api.cdek.test/v2",
|
||||||
timeout_seconds=7.5,
|
timeout_seconds=7.5,
|
||||||
retry_attempts=0,
|
retry_attempts=0,
|
||||||
|
**kwargs,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
result = asyncio.run(provider.register_order(_make_order_request()))
|
|
||||||
|
|
||||||
assert result.provider == "cdek"
|
def test_provider_register_order_posts_payload_and_maps_response() -> None:
|
||||||
assert result.order_uuid == "cdek-order-uuid"
|
response = httpx.Response(
|
||||||
assert http_client.calls == [
|
200,
|
||||||
|
json={
|
||||||
|
"entity": {"uuid": "cdek-order-uuid"},
|
||||||
|
"related_entities": [
|
||||||
{
|
{
|
||||||
"method": "POST",
|
"type": "waybill",
|
||||||
"url": "https://api.cdek.test/v2/orders",
|
"uuid": "waybill-uuid-1",
|
||||||
"json": {
|
"url": "https://cdek.test/waybill/1.pdf",
|
||||||
"type": 2,
|
|
||||||
"tariff_code": 535,
|
|
||||||
"comment": "Test order",
|
|
||||||
"sender": {
|
|
||||||
"name": "Petr Petrov",
|
|
||||||
"email": "sender@example.com",
|
|
||||||
"phones": [{"number": "+79009876543"}],
|
|
||||||
},
|
|
||||||
"recipient": {
|
|
||||||
"name": "Ivan Ivanov",
|
|
||||||
"email": "ivan@example.com",
|
|
||||||
"phones": [{"number": "+79001234567"}],
|
|
||||||
},
|
|
||||||
"from_location": {
|
|
||||||
"address": "Lenina 1",
|
|
||||||
"city": "Moscow",
|
|
||||||
"country_code": "RU",
|
|
||||||
},
|
|
||||||
"to_location": {
|
|
||||||
"address": "Pushkina 10",
|
|
||||||
"city": "Novosibirsk",
|
|
||||||
"country_code": "RU",
|
|
||||||
},
|
|
||||||
"services": [{"code": "INSURANCE", "parameter": "1000"}],
|
|
||||||
"packages": [
|
|
||||||
{
|
|
||||||
"number": "1",
|
|
||||||
"weight": 1000,
|
|
||||||
"length": 20,
|
|
||||||
"width": 15,
|
|
||||||
"height": 10,
|
|
||||||
"comment": "Package 1",
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
"data": None,
|
request=httpx.Request("POST", "https://api.cdek.test/v2/orders"),
|
||||||
"headers": {"Authorization": "Bearer test-token"},
|
)
|
||||||
"timeout": 7.5,
|
http_client = SequenceHTTPClient([response])
|
||||||
}
|
provider = _make_provider(http_client)
|
||||||
]
|
|
||||||
|
result = asyncio.run(provider.register_order(make_init_payment_request(), "order-uuid-1"))
|
||||||
|
|
||||||
|
assert result == CDEKOrderRegistrationResult(
|
||||||
|
order_uuid="cdek-order-uuid",
|
||||||
|
waybill_uuid="waybill-uuid-1",
|
||||||
|
waybill_url="https://cdek.test/waybill/1.pdf",
|
||||||
|
)
|
||||||
|
assert http_client.calls[0]["url"] == "https://api.cdek.test/v2/orders"
|
||||||
|
payload = http_client.calls[0]["json"]
|
||||||
|
assert payload["number"] == "order-uuid-1"
|
||||||
|
assert payload["type"] == 2
|
||||||
|
assert payload["tariff_code"] == 535
|
||||||
|
assert payload["print"] == "WAYBILL"
|
||||||
|
assert payload["from_location"]["code"] == 7017
|
||||||
|
assert payload["to_location"]["code"] == 16454
|
||||||
|
assert payload["packages"][0]["weight"] == 1000
|
||||||
|
|
||||||
|
|
||||||
def test_cdek_client_register_order_maps_4xx_to_request_error() -> None:
|
def test_cdek_client_register_order_maps_4xx_to_request_error() -> None:
|
||||||
@@ -175,11 +118,47 @@ def test_cdek_client_register_order_maps_4xx_to_request_error() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(CDEKRequestError, match="status 422"):
|
with pytest.raises(CDEKRequestError, match="status 422"):
|
||||||
asyncio.run(client.register_order(_make_order_request()))
|
asyncio.run(client.register_order(make_init_payment_request(), "order-uuid-1"))
|
||||||
|
|
||||||
assert len(http_client.calls) == 1
|
assert len(http_client.calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_cdek_client_register_order_maps_duplicate_external_id_to_success() -> None:
|
||||||
|
duplicate_response = httpx.Response(
|
||||||
|
409,
|
||||||
|
json={
|
||||||
|
"entity": {"uuid": "existing-cdek-order-uuid"},
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"code": "v2_entity_already_exists",
|
||||||
|
"message": "Order with this number already exists",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
request=httpx.Request("POST", "https://api.cdek.test/v2/orders"),
|
||||||
|
)
|
||||||
|
http_client = SequenceHTTPClient([duplicate_response])
|
||||||
|
client = CDEKClient(
|
||||||
|
http_client=http_client, # type: ignore[arg-type]
|
||||||
|
auth_client=StubAuthClient(), # type: ignore[arg-type]
|
||||||
|
base_url="https://api.cdek.test/v2",
|
||||||
|
retry_attempts=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(client.register_order(make_init_payment_request(), "order-uuid-1"))
|
||||||
|
|
||||||
|
assert result == CDEKOrderRegistrationResult(
|
||||||
|
order_uuid="existing-cdek-order-uuid",
|
||||||
|
waybill_uuid=None,
|
||||||
|
waybill_url=None,
|
||||||
|
)
|
||||||
|
assert len(http_client.calls) == 1
|
||||||
|
|
||||||
|
|
||||||
def test_cdek_client_register_order_retries_5xx_and_raises_client_error() -> None:
|
def test_cdek_client_register_order_retries_5xx_and_raises_client_error() -> None:
|
||||||
first_response = httpx.Response(
|
first_response = httpx.Response(
|
||||||
503,
|
503,
|
||||||
@@ -207,7 +186,7 @@ def test_cdek_client_register_order_retries_5xx_and_raises_client_error() -> Non
|
|||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(CDEKClientError, match="retriable status 503"):
|
with pytest.raises(CDEKClientError, match="retriable status 503"):
|
||||||
asyncio.run(client.register_order(_make_order_request()))
|
asyncio.run(client.register_order(make_init_payment_request(), "order-uuid-1"))
|
||||||
|
|
||||||
assert len(http_client.calls) == 2
|
assert len(http_client.calls) == 2
|
||||||
assert sleep_calls == [0.25]
|
assert sleep_calls == [0.25]
|
||||||
@@ -228,4 +207,4 @@ def test_cdek_client_register_order_raises_client_error_for_invalid_success_payl
|
|||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(CDEKClientError, match="response payload is invalid"):
|
with pytest.raises(CDEKClientError, match="response payload is invalid"):
|
||||||
asyncio.run(client.register_order(_make_order_request()))
|
asyncio.run(client.register_order(make_init_payment_request(), "order-uuid-1"))
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.adapters.delivery_providers.cdek.client import (
|
||||||
|
CDEKClient,
|
||||||
|
CDEKClientError,
|
||||||
|
CDEKRequestError,
|
||||||
|
)
|
||||||
|
from app.adapters.delivery_providers.cdek.order_mapper import (
|
||||||
|
CDEKOrderInfo,
|
||||||
|
CDEKWaybillInfo,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StubAuthClient:
|
||||||
|
async def get_access_token(self) -> str:
|
||||||
|
return "test-token"
|
||||||
|
|
||||||
|
|
||||||
|
class SequenceHTTPClient:
|
||||||
|
def __init__(self, results: list[Any]) -> None:
|
||||||
|
self._results = results
|
||||||
|
self.calls: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def _next_result(self) -> Any:
|
||||||
|
return self._results[len(self.calls) - 1]
|
||||||
|
|
||||||
|
async def get(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
timeout: float | None = None,
|
||||||
|
) -> httpx.Response:
|
||||||
|
self.calls.append(
|
||||||
|
{"method": "GET", "url": url, "headers": headers, "timeout": timeout}
|
||||||
|
)
|
||||||
|
result = self._next_result()
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
raise result
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _make_client(http_client: SequenceHTTPClient, **kwargs: Any) -> CDEKClient:
|
||||||
|
kwargs.setdefault("retry_attempts", 0)
|
||||||
|
return CDEKClient(
|
||||||
|
http_client=http_client, # type: ignore[arg-type]
|
||||||
|
auth_client=StubAuthClient(), # type: ignore[arg-type]
|
||||||
|
base_url="https://api.cdek.test/v2",
|
||||||
|
timeout_seconds=7.5,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_order_parses_status_and_waybill_uuid() -> None:
|
||||||
|
response = httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"entity": {
|
||||||
|
"uuid": "cdek-order-uuid",
|
||||||
|
"statuses": [
|
||||||
|
{"code": "ACCEPTED", "date_time": "2026-05-24T10:00:00+0000"}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"related_entities": [
|
||||||
|
{"type": "waybill", "uuid": "waybill-uuid-1"}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
request=httpx.Request("GET", "https://api.cdek.test/v2/orders/cdek-order-uuid"),
|
||||||
|
)
|
||||||
|
http_client = SequenceHTTPClient([response])
|
||||||
|
client = _make_client(http_client)
|
||||||
|
|
||||||
|
result = asyncio.run(client.get_order("cdek-order-uuid"))
|
||||||
|
|
||||||
|
assert result == CDEKOrderInfo(
|
||||||
|
order_uuid="cdek-order-uuid",
|
||||||
|
status_code="ACCEPTED",
|
||||||
|
waybill_uuid="waybill-uuid-1",
|
||||||
|
)
|
||||||
|
assert http_client.calls[0]["url"] == (
|
||||||
|
"https://api.cdek.test/v2/orders/cdek-order-uuid"
|
||||||
|
)
|
||||||
|
assert http_client.calls[0]["headers"] == {"Authorization": "Bearer test-token"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_order_retries_on_5xx_and_succeeds() -> None:
|
||||||
|
flaky = httpx.Response(
|
||||||
|
503,
|
||||||
|
json={"message": "temporary"},
|
||||||
|
request=httpx.Request("GET", "https://api.cdek.test/v2/orders/x"),
|
||||||
|
)
|
||||||
|
success = httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"entity": {"uuid": "x", "statuses": []}},
|
||||||
|
request=httpx.Request("GET", "https://api.cdek.test/v2/orders/x"),
|
||||||
|
)
|
||||||
|
http_client = SequenceHTTPClient([flaky, success])
|
||||||
|
sleep_calls: list[float] = []
|
||||||
|
|
||||||
|
async def fake_sleep(seconds: float) -> None:
|
||||||
|
sleep_calls.append(seconds)
|
||||||
|
|
||||||
|
client = _make_client(
|
||||||
|
http_client,
|
||||||
|
retry_attempts=1,
|
||||||
|
retry_backoff_seconds=0.1,
|
||||||
|
sleep=fake_sleep,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(client.get_order("x"))
|
||||||
|
|
||||||
|
assert result.order_uuid == "x"
|
||||||
|
assert len(http_client.calls) == 2
|
||||||
|
assert sleep_calls == [0.1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_order_raises_request_error_on_4xx() -> None:
|
||||||
|
response = httpx.Response(
|
||||||
|
404,
|
||||||
|
json={"requests": [{"errors": [{"code": "v2_not_found"}]}]},
|
||||||
|
request=httpx.Request("GET", "https://api.cdek.test/v2/orders/missing"),
|
||||||
|
)
|
||||||
|
http_client = SequenceHTTPClient([response])
|
||||||
|
client = _make_client(http_client)
|
||||||
|
|
||||||
|
with pytest.raises(CDEKRequestError, match="status 404"):
|
||||||
|
asyncio.run(client.get_order("missing"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_waybill_returns_url_when_present() -> None:
|
||||||
|
response = httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"entity": {
|
||||||
|
"uuid": "waybill-uuid-1",
|
||||||
|
"url": "https://cdek.test/waybill/1.pdf",
|
||||||
|
}
|
||||||
|
},
|
||||||
|
request=httpx.Request(
|
||||||
|
"GET", "https://api.cdek.test/v2/print/orders/waybill-uuid-1"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
http_client = SequenceHTTPClient([response])
|
||||||
|
client = _make_client(http_client)
|
||||||
|
|
||||||
|
result = asyncio.run(client.get_waybill("waybill-uuid-1"))
|
||||||
|
|
||||||
|
assert result == CDEKWaybillInfo(
|
||||||
|
waybill_uuid="waybill-uuid-1",
|
||||||
|
url="https://cdek.test/waybill/1.pdf",
|
||||||
|
)
|
||||||
|
assert http_client.calls[0]["url"] == (
|
||||||
|
"https://api.cdek.test/v2/print/orders/waybill-uuid-1"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_waybill_raises_client_error_for_invalid_payload() -> None:
|
||||||
|
response = httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"entity": {}},
|
||||||
|
request=httpx.Request(
|
||||||
|
"GET", "https://api.cdek.test/v2/print/orders/waybill-uuid-1"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
http_client = SequenceHTTPClient([response])
|
||||||
|
client = _make_client(http_client)
|
||||||
|
|
||||||
|
with pytest.raises(CDEKClientError, match="response payload is invalid"):
|
||||||
|
asyncio.run(client.get_waybill("waybill-uuid-1"))
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
from app.adapters.delivery_providers.cdek.order_mapper import (
|
||||||
|
CDEKOrderInfo,
|
||||||
|
CDEKOrderMappingError,
|
||||||
|
CDEKOrderRegistrationResult,
|
||||||
|
CDEKWaybillInfo,
|
||||||
|
compose_address_line,
|
||||||
|
map_cdek_existing_order_response,
|
||||||
|
map_cdek_order_info_response,
|
||||||
|
map_cdek_order_request,
|
||||||
|
map_cdek_order_response,
|
||||||
|
map_cdek_waybill_info_response,
|
||||||
|
)
|
||||||
|
import pytest
|
||||||
|
from app.schemas.payment import Address
|
||||||
|
from tests.payment_fixtures import make_init_payment_request
|
||||||
|
|
||||||
|
|
||||||
|
def test_order_payload_uses_order_uuid_and_tariff_code_from_system_data() -> None:
|
||||||
|
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
|
||||||
|
|
||||||
|
assert payload["number"] == "order-uuid-1"
|
||||||
|
assert payload["type"] == 2
|
||||||
|
assert payload["tariff_code"] == 535
|
||||||
|
|
||||||
|
|
||||||
|
def test_order_payload_requests_waybill_print() -> None:
|
||||||
|
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
|
||||||
|
|
||||||
|
assert payload["print"] == "WAYBILL"
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_cdek_order_response_extracts_order_uuid_without_waybill() -> None:
|
||||||
|
result = map_cdek_order_response({"entity": {"uuid": "cdek-order-uuid"}})
|
||||||
|
|
||||||
|
assert result == CDEKOrderRegistrationResult(
|
||||||
|
order_uuid="cdek-order-uuid",
|
||||||
|
waybill_uuid=None,
|
||||||
|
waybill_url=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_existing_order_response_returns_waybill_for_duplicate() -> None:
|
||||||
|
result = map_cdek_existing_order_response(
|
||||||
|
{
|
||||||
|
"entity": {"uuid": "existing-uuid"},
|
||||||
|
"requests": [
|
||||||
|
{"errors": [{"code": "v2_entity_already_exists", "message": "dup"}]}
|
||||||
|
],
|
||||||
|
"related_entities": [
|
||||||
|
{
|
||||||
|
"type": "waybill",
|
||||||
|
"uuid": "waybill-existing",
|
||||||
|
"url": "https://cdek.test/waybill/existing.pdf",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == CDEKOrderRegistrationResult(
|
||||||
|
order_uuid="existing-uuid",
|
||||||
|
waybill_uuid="waybill-existing",
|
||||||
|
waybill_url="https://cdek.test/waybill/existing.pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_order_payload_maps_phones_and_phone_ext_to_additional() -> None:
|
||||||
|
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
|
||||||
|
|
||||||
|
assert payload["sender"]["phones"] == [{"number": "+79009876543"}]
|
||||||
|
assert payload["recipient"]["phones"] == [
|
||||||
|
{"number": "+79001234567", "additional": "101"}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_order_payload_maps_locations_with_address_and_postal_code() -> None:
|
||||||
|
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
|
||||||
|
|
||||||
|
assert payload["from_location"] == {
|
||||||
|
"code": 7017,
|
||||||
|
"address": "Дубай, Sheikh Zayed Road, 10, кв. 201",
|
||||||
|
"postal_code": "12345",
|
||||||
|
}
|
||||||
|
assert payload["to_location"] == {
|
||||||
|
"code": 16454,
|
||||||
|
"address": "Шарджа, Al Wahda, 5",
|
||||||
|
"postal_code": "54321",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_order_payload_for_parcel_includes_dimensions() -> None:
|
||||||
|
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
|
||||||
|
|
||||||
|
package = payload["packages"][0]
|
||||||
|
assert package["weight"] == 1000
|
||||||
|
assert package["length"] == 20
|
||||||
|
assert package["width"] == 15
|
||||||
|
assert package["height"] == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_order_payload_for_doc_omits_dimensions() -> None:
|
||||||
|
request = make_init_payment_request(
|
||||||
|
content={"description": "Docs"},
|
||||||
|
systemData={
|
||||||
|
"tariff": {
|
||||||
|
"provider": "СДЭК",
|
||||||
|
"serviceName": "Документы",
|
||||||
|
"price": 50000,
|
||||||
|
"deliveryDaysMin": 1,
|
||||||
|
"deliveryDaysMax": 2,
|
||||||
|
"tariffCode": 535,
|
||||||
|
},
|
||||||
|
"parcelType": "doc",
|
||||||
|
"docPackaging": "envelope",
|
||||||
|
"weight": "0.5",
|
||||||
|
"dimensions": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = map_cdek_order_request(request, "order-uuid-1")
|
||||||
|
package = payload["packages"][0]
|
||||||
|
|
||||||
|
assert package["weight"] == 500
|
||||||
|
assert "length" not in package
|
||||||
|
assert "width" not in package
|
||||||
|
assert "height" not in package
|
||||||
|
|
||||||
|
|
||||||
|
def test_order_payload_omits_shipment_and_delivery_point() -> None:
|
||||||
|
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
|
||||||
|
|
||||||
|
assert "shipment_point" not in payload
|
||||||
|
assert "delivery_point" not in payload
|
||||||
|
|
||||||
|
|
||||||
|
def test_order_payload_includes_company_requisites_for_legal_entity() -> None:
|
||||||
|
request = make_init_payment_request(
|
||||||
|
senderContact={
|
||||||
|
"fullName": "Romashka LLC",
|
||||||
|
"email": "buh@romashka.test",
|
||||||
|
"phone": "+74950000000",
|
||||||
|
"phoneExt": None,
|
||||||
|
"isCompany": True,
|
||||||
|
"companyName": "Romashka LLC",
|
||||||
|
"inn": "7707083893",
|
||||||
|
"kpp": "770701001",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
sender = map_cdek_order_request(request, "order-uuid-1")["sender"]
|
||||||
|
|
||||||
|
assert sender["contragent_type"] == "LEGAL_ENTITY"
|
||||||
|
assert sender["company"] == "Romashka LLC"
|
||||||
|
assert sender["inn"] == "7707083893"
|
||||||
|
assert sender["kpp"] == "770701001"
|
||||||
|
|
||||||
|
|
||||||
|
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"
|
||||||
|
assert payload["packages"][0]["comment"] == "Headphones"
|
||||||
|
assert "items" not in payload["packages"][0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_order_payload_falls_back_package_comment_to_order_uuid() -> None:
|
||||||
|
payload = map_cdek_order_request(
|
||||||
|
make_init_payment_request(content={"description": None}), "order-uuid-1"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert payload["packages"][0]["comment"] == "order-uuid-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_order_payload_includes_company_for_individual_sender_as_full_name() -> None:
|
||||||
|
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
|
||||||
|
|
||||||
|
assert payload["sender"]["company"] == "Petr Petrov"
|
||||||
|
assert payload["recipient"]["company"] == "Ivan Ivanov"
|
||||||
|
assert "contragent_type" not in payload["sender"]
|
||||||
|
assert "contragent_type" not in payload["recipient"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_compose_address_line_handles_empty_apartment() -> None:
|
||||||
|
address = Address(
|
||||||
|
cityId=1,
|
||||||
|
city="Москва",
|
||||||
|
street="Lenina",
|
||||||
|
house="1",
|
||||||
|
apartment=None,
|
||||||
|
zip="12345",
|
||||||
|
comment=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert compose_address_line(address) == "Москва, Lenina, 1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_cdek_order_info_response_picks_latest_status_by_date_time() -> None:
|
||||||
|
result = map_cdek_order_info_response(
|
||||||
|
{
|
||||||
|
"entity": {
|
||||||
|
"uuid": "cdek-order-uuid",
|
||||||
|
"statuses": [
|
||||||
|
{"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"}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == CDEKOrderInfo(
|
||||||
|
order_uuid="cdek-order-uuid",
|
||||||
|
status_code="INVALID",
|
||||||
|
waybill_uuid="waybill-uuid-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_cdek_order_info_response_returns_none_status_when_statuses_empty() -> None:
|
||||||
|
result = map_cdek_order_info_response(
|
||||||
|
{"entity": {"uuid": "cdek-order-uuid", "statuses": []}}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == CDEKOrderInfo(
|
||||||
|
order_uuid="cdek-order-uuid",
|
||||||
|
status_code=None,
|
||||||
|
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": []})
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_cdek_waybill_info_response_returns_url_when_present() -> None:
|
||||||
|
result = map_cdek_waybill_info_response(
|
||||||
|
{
|
||||||
|
"entity": {
|
||||||
|
"uuid": "waybill-uuid-1",
|
||||||
|
"url": "https://cdek.test/waybill/1.pdf",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == CDEKWaybillInfo(
|
||||||
|
waybill_uuid="waybill-uuid-1",
|
||||||
|
url="https://cdek.test/waybill/1.pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_cdek_waybill_info_response_returns_none_url_when_missing() -> None:
|
||||||
|
result = map_cdek_waybill_info_response(
|
||||||
|
{"entity": {"uuid": "waybill-uuid-1"}}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == CDEKWaybillInfo(waybill_uuid="waybill-uuid-1", url=None)
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_cdek_waybill_info_response_raises_for_missing_uuid() -> None:
|
||||||
|
with pytest.raises(CDEKOrderMappingError):
|
||||||
|
map_cdek_waybill_info_response({"entity": {}})
|
||||||
@@ -0,0 +1,220 @@
|
|||||||
|
import asyncio
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.adapters.delivery_providers.cdek.client import (
|
||||||
|
CDEKClient,
|
||||||
|
CDEKClientError,
|
||||||
|
CDEKProvider,
|
||||||
|
CDEKRequestError,
|
||||||
|
)
|
||||||
|
from tests.payment_fixtures import make_init_payment_request
|
||||||
|
|
||||||
|
|
||||||
|
class StubAuthClient:
|
||||||
|
async def get_access_token(self) -> str:
|
||||||
|
return "test-token"
|
||||||
|
|
||||||
|
|
||||||
|
class SequenceHTTPClient:
|
||||||
|
def __init__(self, results: list[Any]) -> None:
|
||||||
|
self._results = results
|
||||||
|
self.calls: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def _next_result(self) -> Any:
|
||||||
|
return self._results[len(self.calls) - 1]
|
||||||
|
|
||||||
|
async def post(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
json: dict[str, Any] | None = None,
|
||||||
|
data: dict[str, Any] | None = None,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
timeout: float | None = None,
|
||||||
|
) -> httpx.Response:
|
||||||
|
self.calls.append(
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"url": url,
|
||||||
|
"json": json,
|
||||||
|
"data": data,
|
||||||
|
"headers": headers,
|
||||||
|
"timeout": timeout,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result = self._next_result()
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
raise result
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_get_payment_price_posts_payload_from_camelcase_request() -> None:
|
||||||
|
response = httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"tariff_codes": [
|
||||||
|
{
|
||||||
|
"tariff_code": 535,
|
||||||
|
"tariff_name": "CDEK tariff",
|
||||||
|
"delivery_sum": "1250.00",
|
||||||
|
"currency": "RUB",
|
||||||
|
"period_min": 1,
|
||||||
|
"period_max": 2,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
|
||||||
|
)
|
||||||
|
http_client = SequenceHTTPClient([response])
|
||||||
|
provider = CDEKProvider(
|
||||||
|
CDEKClient(
|
||||||
|
http_client=http_client, # type: ignore[arg-type]
|
||||||
|
auth_client=StubAuthClient(), # type: ignore[arg-type]
|
||||||
|
base_url="https://api.cdek.test/v2",
|
||||||
|
timeout_seconds=7.5,
|
||||||
|
retry_attempts=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(provider.get_payment_price(make_init_payment_request()))
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result.price == Decimal("1250.00")
|
||||||
|
assert http_client.calls == [
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://api.cdek.test/v2/calculator/tarifflist",
|
||||||
|
"json": {
|
||||||
|
"type": 2,
|
||||||
|
"from_location": {"code": 7017},
|
||||||
|
"to_location": {"code": 16454},
|
||||||
|
"packages": [
|
||||||
|
{
|
||||||
|
"weight": 1000,
|
||||||
|
"length": 20,
|
||||||
|
"width": 15,
|
||||||
|
"height": 10,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"data": None,
|
||||||
|
"headers": {"Authorization": "Bearer test-token"},
|
||||||
|
"timeout": 7.5,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_get_payment_price_omits_dimensions_for_doc() -> None:
|
||||||
|
response = httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"tariff_codes": []},
|
||||||
|
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
|
||||||
|
)
|
||||||
|
http_client = SequenceHTTPClient([response])
|
||||||
|
provider = CDEKProvider(
|
||||||
|
CDEKClient(
|
||||||
|
http_client=http_client, # type: ignore[arg-type]
|
||||||
|
auth_client=StubAuthClient(), # type: ignore[arg-type]
|
||||||
|
base_url="https://api.cdek.test/v2",
|
||||||
|
retry_attempts=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
request = make_init_payment_request(
|
||||||
|
content={"description": "Docs"},
|
||||||
|
systemData={
|
||||||
|
"tariff": {
|
||||||
|
"provider": "СДЭК",
|
||||||
|
"serviceName": "Документы",
|
||||||
|
"price": 50000,
|
||||||
|
"deliveryDaysMin": 1,
|
||||||
|
"deliveryDaysMax": 2,
|
||||||
|
"tariffCode": 535,
|
||||||
|
},
|
||||||
|
"parcelType": "doc",
|
||||||
|
"docPackaging": "envelope",
|
||||||
|
"weight": "0.5",
|
||||||
|
"dimensions": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(provider.get_payment_price(request))
|
||||||
|
|
||||||
|
package = http_client.calls[0]["json"]["packages"][0]
|
||||||
|
assert package == {"weight": 500}
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_get_raw_payment_price_maps_4xx_to_request_error() -> None:
|
||||||
|
rejected_response = httpx.Response(
|
||||||
|
422,
|
||||||
|
json={"errors": [{"message": "bad request"}]},
|
||||||
|
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
|
||||||
|
)
|
||||||
|
http_client = SequenceHTTPClient([rejected_response])
|
||||||
|
client = CDEKClient(
|
||||||
|
http_client=http_client, # type: ignore[arg-type]
|
||||||
|
auth_client=StubAuthClient(), # type: ignore[arg-type]
|
||||||
|
base_url="https://api.cdek.test/v2",
|
||||||
|
retry_attempts=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(CDEKRequestError, match="status 422"):
|
||||||
|
asyncio.run(client.get_raw_payment_price(make_init_payment_request()))
|
||||||
|
|
||||||
|
assert len(http_client.calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_client_get_raw_payment_price_retries_5xx_and_raises_client_error() -> None:
|
||||||
|
first_response = httpx.Response(
|
||||||
|
503,
|
||||||
|
json={"message": "temporary failure"},
|
||||||
|
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
|
||||||
|
)
|
||||||
|
second_response = httpx.Response(
|
||||||
|
503,
|
||||||
|
json={"message": "temporary failure"},
|
||||||
|
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
|
||||||
|
)
|
||||||
|
http_client = SequenceHTTPClient([first_response, second_response])
|
||||||
|
sleep_calls: list[float] = []
|
||||||
|
|
||||||
|
async def fake_sleep(seconds: float) -> None:
|
||||||
|
sleep_calls.append(seconds)
|
||||||
|
|
||||||
|
client = CDEKClient(
|
||||||
|
http_client=http_client, # type: ignore[arg-type]
|
||||||
|
auth_client=StubAuthClient(), # type: ignore[arg-type]
|
||||||
|
base_url="https://api.cdek.test/v2",
|
||||||
|
retry_attempts=1,
|
||||||
|
retry_backoff_seconds=0.25,
|
||||||
|
sleep=fake_sleep,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(CDEKClientError, match="status 503"):
|
||||||
|
asyncio.run(client.get_raw_payment_price(make_init_payment_request()))
|
||||||
|
|
||||||
|
assert len(http_client.calls) == 2
|
||||||
|
assert sleep_calls == [0.25]
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_get_payment_price_maps_invalid_success_payload_to_client_error() -> None:
|
||||||
|
invalid_response = httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"tariff_codes": [{"tariff_code": 535}]},
|
||||||
|
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
|
||||||
|
)
|
||||||
|
http_client = SequenceHTTPClient([invalid_response])
|
||||||
|
provider = CDEKProvider(
|
||||||
|
CDEKClient(
|
||||||
|
http_client=http_client, # type: ignore[arg-type]
|
||||||
|
auth_client=StubAuthClient(), # type: ignore[arg-type]
|
||||||
|
base_url="https://api.cdek.test/v2",
|
||||||
|
retry_attempts=0,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(CDEKClientError, match="response payload is invalid"):
|
||||||
|
asyncio.run(provider.get_payment_price(make_init_payment_request()))
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import asyncio
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.adapters.delivery_providers.cdek.client import (
|
||||||
|
CDEKClient,
|
||||||
|
CDEKClientError,
|
||||||
|
CDEKRequestError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StubAuthClient:
|
||||||
|
async def get_access_token(self) -> str:
|
||||||
|
return "test-token"
|
||||||
|
|
||||||
|
|
||||||
|
class SequenceHTTPClient:
|
||||||
|
def __init__(self, results: list[Any]) -> None:
|
||||||
|
self._results = results
|
||||||
|
self.calls: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def get(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
timeout: float | None = None,
|
||||||
|
) -> httpx.Response:
|
||||||
|
self.calls.append(
|
||||||
|
{"method": "GET", "url": url, "headers": headers, "timeout": timeout}
|
||||||
|
)
|
||||||
|
result = self._results[len(self.calls) - 1]
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
raise result
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _make_client(http_client: SequenceHTTPClient, **kwargs: Any) -> CDEKClient:
|
||||||
|
kwargs.setdefault("retry_attempts", 0)
|
||||||
|
return CDEKClient(
|
||||||
|
http_client=http_client, # type: ignore[arg-type]
|
||||||
|
auth_client=StubAuthClient(), # type: ignore[arg-type]
|
||||||
|
base_url="https://api.cdek.test/v2",
|
||||||
|
timeout_seconds=7.5,
|
||||||
|
**kwargs,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_waybill_pdf_returns_response_bytes() -> None:
|
||||||
|
pdf_bytes = b"%PDF-1.4 mock content"
|
||||||
|
response = httpx.Response(
|
||||||
|
200,
|
||||||
|
content=pdf_bytes,
|
||||||
|
request=httpx.Request("GET", "https://cdek.test/waybill/1.pdf"),
|
||||||
|
)
|
||||||
|
http_client = SequenceHTTPClient([response])
|
||||||
|
client = _make_client(http_client)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
client.download_waybill_pdf("https://cdek.test/waybill/1.pdf")
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == pdf_bytes
|
||||||
|
assert http_client.calls[0]["url"] == "https://cdek.test/waybill/1.pdf"
|
||||||
|
assert http_client.calls[0]["headers"] == {"Authorization": "Bearer test-token"}
|
||||||
|
assert http_client.calls[0]["timeout"] == 7.5
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_waybill_pdf_retries_on_5xx_and_succeeds() -> None:
|
||||||
|
request = httpx.Request("GET", "https://cdek.test/x.pdf")
|
||||||
|
flaky = httpx.Response(503, content=b"oops", request=request)
|
||||||
|
success = httpx.Response(200, content=b"%PDF", request=request)
|
||||||
|
http_client = SequenceHTTPClient([flaky, success])
|
||||||
|
sleep_calls: list[float] = []
|
||||||
|
|
||||||
|
async def fake_sleep(seconds: float) -> None:
|
||||||
|
sleep_calls.append(seconds)
|
||||||
|
|
||||||
|
client = _make_client(
|
||||||
|
http_client,
|
||||||
|
retry_attempts=1,
|
||||||
|
retry_backoff_seconds=0.1,
|
||||||
|
sleep=fake_sleep,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(client.download_waybill_pdf("https://cdek.test/x.pdf"))
|
||||||
|
|
||||||
|
assert result == b"%PDF"
|
||||||
|
assert len(http_client.calls) == 2
|
||||||
|
assert sleep_calls == [0.1]
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_waybill_pdf_raises_request_error_on_4xx() -> None:
|
||||||
|
response = httpx.Response(
|
||||||
|
404,
|
||||||
|
content=b"not found",
|
||||||
|
request=httpx.Request("GET", "https://cdek.test/missing.pdf"),
|
||||||
|
)
|
||||||
|
http_client = SequenceHTTPClient([response])
|
||||||
|
client = _make_client(http_client)
|
||||||
|
|
||||||
|
with pytest.raises(CDEKRequestError, match="status 404"):
|
||||||
|
asyncio.run(client.download_waybill_pdf("https://cdek.test/missing.pdf"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_download_waybill_pdf_raises_client_error_after_retries_exhausted() -> None:
|
||||||
|
request = httpx.Request("GET", "https://cdek.test/x.pdf")
|
||||||
|
response = httpx.Response(500, content=b"oops", request=request)
|
||||||
|
http_client = SequenceHTTPClient([response])
|
||||||
|
client = _make_client(http_client, retry_attempts=0)
|
||||||
|
|
||||||
|
with pytest.raises(CDEKClientError, match="retriable status 500"):
|
||||||
|
asyncio.run(client.download_waybill_pdf("https://cdek.test/x.pdf"))
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
import asyncio
|
||||||
|
from email.message import EmailMessage
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.adapters.email import SMTPEmailSender, SMTPEmailSenderError
|
||||||
|
|
||||||
|
|
||||||
|
class StubSend:
|
||||||
|
def __init__(self, raise_exc: Exception | None = None) -> None:
|
||||||
|
self.raise_exc = raise_exc
|
||||||
|
self.calls: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
message: EmailMessage,
|
||||||
|
*,
|
||||||
|
hostname: str,
|
||||||
|
port: int,
|
||||||
|
username: str | None,
|
||||||
|
password: str | None,
|
||||||
|
use_tls: bool,
|
||||||
|
start_tls: bool,
|
||||||
|
timeout: float,
|
||||||
|
) -> object:
|
||||||
|
self.calls.append(
|
||||||
|
{
|
||||||
|
"message": message,
|
||||||
|
"hostname": hostname,
|
||||||
|
"port": port,
|
||||||
|
"username": username,
|
||||||
|
"password": password,
|
||||||
|
"use_tls": use_tls,
|
||||||
|
"start_tls": start_tls,
|
||||||
|
"timeout": timeout,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if self.raise_exc is not None:
|
||||||
|
raise self.raise_exc
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _make_sender(send: StubSend, **overrides: Any) -> SMTPEmailSender:
|
||||||
|
kwargs: dict[str, Any] = {
|
||||||
|
"smtp_host": "smtp.test",
|
||||||
|
"smtp_port": 587,
|
||||||
|
"username": "user",
|
||||||
|
"password": "pass",
|
||||||
|
"from_address": "no-reply@test",
|
||||||
|
"use_tls": True,
|
||||||
|
"timeout_seconds": 5.0,
|
||||||
|
"send": send,
|
||||||
|
}
|
||||||
|
kwargs.update(overrides)
|
||||||
|
return SMTPEmailSender(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_email_builds_multipart_message_with_pdf_attachment() -> None:
|
||||||
|
send = StubSend()
|
||||||
|
sender = _make_sender(send)
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
sender.send_email(
|
||||||
|
to="client@example.com",
|
||||||
|
subject="Накладная по заказу o-1",
|
||||||
|
body="hello",
|
||||||
|
attachment_bytes=b"%PDF",
|
||||||
|
attachment_filename="waybill_o-1.pdf",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(send.calls) == 1
|
||||||
|
call = send.calls[0]
|
||||||
|
assert call["hostname"] == "smtp.test"
|
||||||
|
assert call["port"] == 587
|
||||||
|
assert call["username"] == "user"
|
||||||
|
assert call["password"] == "pass"
|
||||||
|
assert call["use_tls"] is False
|
||||||
|
assert call["start_tls"] is True
|
||||||
|
assert call["timeout"] == 5.0
|
||||||
|
|
||||||
|
message: EmailMessage = call["message"]
|
||||||
|
assert message["From"] == "no-reply@test"
|
||||||
|
assert message["To"] == "client@example.com"
|
||||||
|
assert message["Subject"] == "Накладная по заказу o-1"
|
||||||
|
|
||||||
|
parts = list(message.iter_attachments())
|
||||||
|
assert len(parts) == 1
|
||||||
|
attachment = parts[0]
|
||||||
|
assert attachment.get_content_type() == "application/pdf"
|
||||||
|
assert attachment.get_filename() == "waybill_o-1.pdf"
|
||||||
|
assert attachment.get_payload(decode=True) == b"%PDF"
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_email_uses_tls_for_port_465() -> None:
|
||||||
|
send = StubSend()
|
||||||
|
sender = _make_sender(send, smtp_port=465)
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
sender.send_email(
|
||||||
|
to="x@example.com",
|
||||||
|
subject="s",
|
||||||
|
body="b",
|
||||||
|
attachment_bytes=b"",
|
||||||
|
attachment_filename="f.pdf",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
call = send.calls[0]
|
||||||
|
assert call["use_tls"] is True
|
||||||
|
assert call["start_tls"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_email_disables_tls_when_use_tls_false() -> None:
|
||||||
|
send = StubSend()
|
||||||
|
sender = _make_sender(send, use_tls=False)
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
sender.send_email(
|
||||||
|
to="x@example.com",
|
||||||
|
subject="s",
|
||||||
|
body="b",
|
||||||
|
attachment_bytes=b"",
|
||||||
|
attachment_filename="f.pdf",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
call = send.calls[0]
|
||||||
|
assert call["use_tls"] is False
|
||||||
|
assert call["start_tls"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_email_wraps_send_errors_in_sender_error() -> None:
|
||||||
|
send = StubSend(raise_exc=RuntimeError("connection refused"))
|
||||||
|
sender = _make_sender(send)
|
||||||
|
|
||||||
|
with pytest.raises(SMTPEmailSenderError, match="connection refused"):
|
||||||
|
asyncio.run(
|
||||||
|
sender.send_email(
|
||||||
|
to="x@example.com",
|
||||||
|
subject="s",
|
||||||
|
body="b",
|
||||||
|
attachment_bytes=b"",
|
||||||
|
attachment_filename="f.pdf",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_email_passes_none_when_credentials_blank() -> None:
|
||||||
|
send = StubSend()
|
||||||
|
sender = _make_sender(send, username="", password="")
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
sender.send_email(
|
||||||
|
to="x@example.com",
|
||||||
|
subject="s",
|
||||||
|
body="b",
|
||||||
|
attachment_bytes=b"",
|
||||||
|
attachment_filename="f.pdf",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
call = send.calls[0]
|
||||||
|
assert call["username"] is None
|
||||||
|
assert call["password"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_email_without_attachment_builds_plain_text_message() -> None:
|
||||||
|
send = StubSend()
|
||||||
|
sender = _make_sender(send)
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
sender.send_email(
|
||||||
|
to="client@example.com",
|
||||||
|
subject="Оплата принята",
|
||||||
|
body="Ваша оплата принята.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(send.calls) == 1
|
||||||
|
message: EmailMessage = send.calls[0]["message"]
|
||||||
|
assert message["From"] == "no-reply@test"
|
||||||
|
assert message["To"] == "client@example.com"
|
||||||
|
assert message["Subject"] == "Оплата принята"
|
||||||
|
assert "Ваша оплата принята." in message.get_content()
|
||||||
|
assert list(message.iter_attachments()) == []
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.adapters.tbank.client import TBankAdapter
|
||||||
|
from app.adapters.tbank.base import (
|
||||||
|
TBankPaymentAdapterError,
|
||||||
|
TBankPaymentRequestError,
|
||||||
|
)
|
||||||
|
from app.config import TBankPaymentAuthConfig, TBankPaymentConfig
|
||||||
|
|
||||||
|
|
||||||
|
class SequenceHTTPClient:
|
||||||
|
def __init__(self, results: list[Any]) -> None:
|
||||||
|
self._results = results
|
||||||
|
self.calls: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def _next_result(self) -> Any:
|
||||||
|
return self._results[len(self.calls) - 1]
|
||||||
|
|
||||||
|
async def post(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
json: dict[str, Any] | None = None,
|
||||||
|
data: dict[str, Any] | None = None,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
timeout: float | None = None,
|
||||||
|
) -> httpx.Response:
|
||||||
|
self.calls.append(
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"url": url,
|
||||||
|
"json": json,
|
||||||
|
"data": data,
|
||||||
|
"headers": headers,
|
||||||
|
"timeout": timeout,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
result = self._next_result()
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
raise result
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _make_adapter(
|
||||||
|
http_client: SequenceHTTPClient,
|
||||||
|
*,
|
||||||
|
notification_url: str = "https://example.test/api/v1/delivery/tbank/notifications",
|
||||||
|
success_url: str = "https://example.test/payment/success",
|
||||||
|
retry_attempts: int = 0,
|
||||||
|
retry_backoff_seconds: float = 0.2,
|
||||||
|
sleep_calls: list[float] | None = None,
|
||||||
|
) -> TBankAdapter:
|
||||||
|
async def fake_sleep(seconds: float) -> None:
|
||||||
|
if sleep_calls is not None:
|
||||||
|
sleep_calls.append(seconds)
|
||||||
|
|
||||||
|
return TBankAdapter(
|
||||||
|
http_client=http_client, # type: ignore[arg-type]
|
||||||
|
init_url="https://securepay.tinkoff.ru/v2/Init",
|
||||||
|
notification_url=notification_url,
|
||||||
|
success_url=success_url,
|
||||||
|
terminal_key="TBankTest",
|
||||||
|
password="test-password",
|
||||||
|
timeout_seconds=7.5,
|
||||||
|
retry_attempts=retry_attempts,
|
||||||
|
retry_backoff_seconds=retry_backoff_seconds,
|
||||||
|
sleep=fake_sleep,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_payment_link_posts_signed_payload_and_maps_payment_url() -> None:
|
||||||
|
response = httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"Success": True, "PaymentURL": "https://securepay.test/pay/1"},
|
||||||
|
request=httpx.Request("POST", "https://securepay.tinkoff.ru/v2/Init"),
|
||||||
|
)
|
||||||
|
http_client = SequenceHTTPClient([response])
|
||||||
|
adapter = _make_adapter(http_client)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
adapter.create_payment_link(
|
||||||
|
order_uuid="order-uuid-1",
|
||||||
|
amount_kopecks=125000,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_token = hashlib.sha256(
|
||||||
|
(
|
||||||
|
"125000"
|
||||||
|
"https://example.test/api/v1/delivery/tbank/notifications"
|
||||||
|
"order-uuid-1"
|
||||||
|
"test-password"
|
||||||
|
"https://example.test/payment/success/order-uuid-1"
|
||||||
|
"TBankTest"
|
||||||
|
).encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
assert result == "https://securepay.test/pay/1"
|
||||||
|
assert http_client.calls == [
|
||||||
|
{
|
||||||
|
"method": "POST",
|
||||||
|
"url": "https://securepay.tinkoff.ru/v2/Init",
|
||||||
|
"json": {
|
||||||
|
"TerminalKey": "TBankTest",
|
||||||
|
"Amount": 125000,
|
||||||
|
"OrderId": "order-uuid-1",
|
||||||
|
"NotificationURL": "https://example.test/api/v1/delivery/tbank/notifications",
|
||||||
|
"SuccessURL": "https://example.test/payment/success/order-uuid-1",
|
||||||
|
"Token": expected_token,
|
||||||
|
},
|
||||||
|
"data": None,
|
||||||
|
"headers": {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
"timeout": 7.5,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_from_config_posts_configured_urls_and_deterministic_token() -> None:
|
||||||
|
response = httpx.Response(
|
||||||
|
200,
|
||||||
|
json={"Success": True, "PaymentURL": "https://securepay.test/pay/2"},
|
||||||
|
request=httpx.Request("POST", "https://securepay.tinkoff.ru/v2/Init"),
|
||||||
|
)
|
||||||
|
http_client = SequenceHTTPClient([response])
|
||||||
|
config = TBankPaymentConfig(
|
||||||
|
init_url="https://securepay.tinkoff.ru/v2/Init",
|
||||||
|
notification_url="https://merchant.test/api/v1/delivery/tbank/notifications",
|
||||||
|
success_url="https://merchant.test/payment/success",
|
||||||
|
auth=TBankPaymentAuthConfig(
|
||||||
|
terminal_key="ConfigTerminal",
|
||||||
|
password="config-password",
|
||||||
|
),
|
||||||
|
timeout_seconds=6.25,
|
||||||
|
retry_attempts=0,
|
||||||
|
retry_backoff_seconds=0.1,
|
||||||
|
)
|
||||||
|
adapter = TBankAdapter.from_config(
|
||||||
|
http_client=http_client, # type: ignore[arg-type]
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
adapter.create_payment_link(
|
||||||
|
order_uuid="order-uuid-2",
|
||||||
|
amount_kopecks=9900,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
expected_token = hashlib.sha256(
|
||||||
|
(
|
||||||
|
"9900"
|
||||||
|
"https://merchant.test/api/v1/delivery/tbank/notifications"
|
||||||
|
"order-uuid-2"
|
||||||
|
"config-password"
|
||||||
|
"https://merchant.test/payment/success/order-uuid-2"
|
||||||
|
"ConfigTerminal"
|
||||||
|
).encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
assert result == "https://securepay.test/pay/2"
|
||||||
|
assert http_client.calls[0]["json"] == {
|
||||||
|
"TerminalKey": "ConfigTerminal",
|
||||||
|
"Amount": 9900,
|
||||||
|
"OrderId": "order-uuid-2",
|
||||||
|
"NotificationURL": "https://merchant.test/api/v1/delivery/tbank/notifications",
|
||||||
|
"SuccessURL": "https://merchant.test/payment/success/order-uuid-2",
|
||||||
|
"Token": expected_token,
|
||||||
|
}
|
||||||
|
assert http_client.calls[0]["timeout"] == 6.25
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_payment_link_maps_4xx_to_request_error() -> None:
|
||||||
|
response = httpx.Response(
|
||||||
|
400,
|
||||||
|
json={
|
||||||
|
"Success": False,
|
||||||
|
"ErrorCode": "101",
|
||||||
|
"Message": "bad request",
|
||||||
|
"Details": "invalid token",
|
||||||
|
},
|
||||||
|
request=httpx.Request("POST", "https://securepay.tinkoff.ru/v2/Init"),
|
||||||
|
)
|
||||||
|
http_client = SequenceHTTPClient([response])
|
||||||
|
adapter = _make_adapter(http_client)
|
||||||
|
|
||||||
|
with pytest.raises(TBankPaymentRequestError) as exc_info:
|
||||||
|
asyncio.run(adapter.create_payment_link("order-uuid-1", 125000))
|
||||||
|
|
||||||
|
assert exc_info.value.status_code == 400
|
||||||
|
assert exc_info.value.error_code == "101"
|
||||||
|
assert exc_info.value.provider_message == "bad request"
|
||||||
|
assert exc_info.value.details == "invalid token"
|
||||||
|
assert str(exc_info.value) == (
|
||||||
|
"TBank payment init request was rejected. "
|
||||||
|
"status_code=400 error_code=101 message=bad request details=invalid token"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_payment_link_maps_unsuccessful_payload_to_request_error() -> None:
|
||||||
|
response = httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"Success": False,
|
||||||
|
"ErrorCode": "102",
|
||||||
|
"Message": "duplicate order id",
|
||||||
|
"Details": "OrderId must be unique",
|
||||||
|
},
|
||||||
|
request=httpx.Request("POST", "https://securepay.tinkoff.ru/v2/Init"),
|
||||||
|
)
|
||||||
|
http_client = SequenceHTTPClient([response])
|
||||||
|
adapter = _make_adapter(http_client)
|
||||||
|
|
||||||
|
with pytest.raises(TBankPaymentRequestError) as exc_info:
|
||||||
|
asyncio.run(adapter.create_payment_link("order-uuid-1", 125000))
|
||||||
|
|
||||||
|
assert exc_info.value.status_code is None
|
||||||
|
assert exc_info.value.error_code == "102"
|
||||||
|
assert exc_info.value.provider_message == "duplicate order id"
|
||||||
|
assert exc_info.value.details == "OrderId must be unique"
|
||||||
|
assert str(exc_info.value) == (
|
||||||
|
"TBank payment init request was rejected. "
|
||||||
|
"error_code=102 message=duplicate order id details=OrderId must be unique"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_payment_link_maps_transport_errors_to_client_error() -> None:
|
||||||
|
request = httpx.Request("POST", "https://securepay.tinkoff.ru/v2/Init")
|
||||||
|
http_client = SequenceHTTPClient([httpx.ConnectError("down", request=request)])
|
||||||
|
adapter = _make_adapter(http_client)
|
||||||
|
|
||||||
|
with pytest.raises(TBankPaymentAdapterError, match="retry attempts"):
|
||||||
|
asyncio.run(adapter.create_payment_link("order-uuid-1", 125000))
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_payment_link_retries_5xx_and_raises_client_error() -> None:
|
||||||
|
first_response = httpx.Response(
|
||||||
|
503,
|
||||||
|
json={"Message": "temporary failure"},
|
||||||
|
request=httpx.Request("POST", "https://securepay.tinkoff.ru/v2/Init"),
|
||||||
|
)
|
||||||
|
second_response = httpx.Response(
|
||||||
|
503,
|
||||||
|
json={"Message": "temporary failure"},
|
||||||
|
request=httpx.Request("POST", "https://securepay.tinkoff.ru/v2/Init"),
|
||||||
|
)
|
||||||
|
http_client = SequenceHTTPClient([first_response, second_response])
|
||||||
|
sleep_calls: list[float] = []
|
||||||
|
adapter = _make_adapter(
|
||||||
|
http_client,
|
||||||
|
retry_attempts=1,
|
||||||
|
retry_backoff_seconds=0.25,
|
||||||
|
sleep_calls=sleep_calls,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(TBankPaymentAdapterError, match="retriable status 503"):
|
||||||
|
asyncio.run(adapter.create_payment_link("order-uuid-1", 125000))
|
||||||
|
|
||||||
|
assert len(http_client.calls) == 2
|
||||||
|
assert sleep_calls == [0.25]
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import hashlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.adapters.tbank.base import TBankPaymentNotificationTokenError
|
||||||
|
from app.adapters.tbank.client import TBankAdapter
|
||||||
|
from app.schemas.payment import TBankPaymentNotification
|
||||||
|
|
||||||
|
|
||||||
|
def _make_adapter() -> TBankAdapter:
|
||||||
|
return TBankAdapter(
|
||||||
|
http_client=object(), # type: ignore[arg-type]
|
||||||
|
init_url="https://securepay.tinkoff.ru/v2/Init",
|
||||||
|
notification_url="https://merchant.test/api/v1/delivery/tbank/notifications",
|
||||||
|
success_url="https://merchant.test/payment/success",
|
||||||
|
terminal_key="TestTerminal",
|
||||||
|
password="test-password",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _base_payload(**overrides: object) -> dict[str, object]:
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"TerminalKey": "TestTerminal",
|
||||||
|
"OrderId": "order-uuid-1",
|
||||||
|
"Success": True,
|
||||||
|
"Status": "CONFIRMED",
|
||||||
|
"PaymentId": 8347568144,
|
||||||
|
"ErrorCode": "0",
|
||||||
|
"Amount": 125000,
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def _build_notification_token(payload: dict[str, object]) -> str:
|
||||||
|
token_payload = {
|
||||||
|
key: value
|
||||||
|
for key, value in payload.items()
|
||||||
|
if key != "Token" and not isinstance(value, (dict, list))
|
||||||
|
}
|
||||||
|
token_payload["Password"] = "test-password"
|
||||||
|
token_source = "".join(
|
||||||
|
_stringify_token_value(token_payload[key]) for key in sorted(token_payload)
|
||||||
|
)
|
||||||
|
return hashlib.sha256(token_source.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _stringify_token_value(value: object) -> str:
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "true" if value else "false"
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _signed_notification(**overrides: object) -> TBankPaymentNotification:
|
||||||
|
payload = _base_payload(**overrides)
|
||||||
|
if "Token" not in payload:
|
||||||
|
payload["Token"] = _build_notification_token(payload)
|
||||||
|
return TBankPaymentNotification(**payload)
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_payment_notification_accepts_valid_token() -> None:
|
||||||
|
adapter = _make_adapter()
|
||||||
|
notification = _signed_notification()
|
||||||
|
|
||||||
|
adapter.verify_payment_notification(notification)
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_payment_notification_rejects_invalid_token() -> None:
|
||||||
|
adapter = _make_adapter()
|
||||||
|
notification = _signed_notification(Token="invalid-token")
|
||||||
|
|
||||||
|
with pytest.raises(TBankPaymentNotificationTokenError):
|
||||||
|
adapter.verify_payment_notification(notification)
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_payment_notification_excludes_token_from_hash_source() -> None:
|
||||||
|
adapter = _make_adapter()
|
||||||
|
payload = _base_payload()
|
||||||
|
payload["Token"] = _build_notification_token(payload)
|
||||||
|
notification = TBankPaymentNotification(**payload)
|
||||||
|
|
||||||
|
adapter.verify_payment_notification(notification)
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_payment_notification_excludes_nested_data_and_receipt() -> None:
|
||||||
|
adapter = _make_adapter()
|
||||||
|
notification = _signed_notification(
|
||||||
|
Data={"nested": "ignored"},
|
||||||
|
Receipt={"Items": [{"Name": "Box", "Price": 125000}]},
|
||||||
|
)
|
||||||
|
|
||||||
|
adapter.verify_payment_notification(notification)
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_payment_notification_includes_extra_scalar_fields() -> None:
|
||||||
|
adapter = _make_adapter()
|
||||||
|
notification = _signed_notification(CardId="card-id-1")
|
||||||
|
|
||||||
|
adapter.verify_payment_notification(notification)
|
||||||
|
|
||||||
|
payload_without_extra = notification.model_dump(mode="python")
|
||||||
|
del payload_without_extra["CardId"]
|
||||||
|
payload_without_extra["Token"] = _build_notification_token(payload_without_extra)
|
||||||
|
invalid_notification = TBankPaymentNotification(
|
||||||
|
**payload_without_extra,
|
||||||
|
CardId="card-id-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(TBankPaymentNotificationTokenError):
|
||||||
|
adapter.verify_payment_notification(invalid_notification)
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_payment_notification_serializes_booleans_as_lowercase() -> None:
|
||||||
|
adapter = _make_adapter()
|
||||||
|
notification = _signed_notification(Success=False)
|
||||||
|
|
||||||
|
adapter.verify_payment_notification(notification)
|
||||||
|
|
||||||
|
|
||||||
|
def test_verify_payment_notification_uses_deterministic_sha256_comparison() -> None:
|
||||||
|
payload = _base_payload()
|
||||||
|
expected_token = hashlib.sha256(
|
||||||
|
(
|
||||||
|
"125000"
|
||||||
|
"0"
|
||||||
|
"order-uuid-1"
|
||||||
|
"test-password"
|
||||||
|
"8347568144"
|
||||||
|
"CONFIRMED"
|
||||||
|
"true"
|
||||||
|
"TestTerminal"
|
||||||
|
).encode("utf-8")
|
||||||
|
).hexdigest()
|
||||||
|
|
||||||
|
assert _build_notification_token(payload) == expected_token
|
||||||
@@ -15,6 +15,17 @@ adapter:
|
|||||||
cdek_client_id: "yaml-id"
|
cdek_client_id: "yaml-id"
|
||||||
cdek_client_secret: "yaml-secret"
|
cdek_client_secret: "yaml-secret"
|
||||||
|
|
||||||
|
tbank_payment:
|
||||||
|
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||||
|
notification_url: "https://default.test/api/v1/delivery/tbank/notifications"
|
||||||
|
success_url: "https://default.test/payment/success"
|
||||||
|
auth:
|
||||||
|
terminal_key: "yaml-terminal-key"
|
||||||
|
password: "yaml-password"
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/default"
|
||||||
|
|
||||||
address_suggestions:
|
address_suggestions:
|
||||||
country_to_provider:
|
country_to_provider:
|
||||||
RU: "dadata"
|
RU: "dadata"
|
||||||
@@ -37,3 +48,8 @@ observability:
|
|||||||
service_name: "default-config-service"
|
service_name: "default-config-service"
|
||||||
otlp_endpoint: "http://default-collector:4317"
|
otlp_endpoint: "http://default-collector:4317"
|
||||||
otlp_insecure: true
|
otlp_insecure: true
|
||||||
|
|
||||||
|
email:
|
||||||
|
smtp_host: "smtp.test"
|
||||||
|
smtp_port: 587
|
||||||
|
from_address: "no-reply@test"
|
||||||
|
|||||||
@@ -23,6 +23,17 @@ adapter:
|
|||||||
cdek_timeout_seconds: 10.0
|
cdek_timeout_seconds: 10.0
|
||||||
cdek_cache_ttl_seconds: 900
|
cdek_cache_ttl_seconds: 900
|
||||||
|
|
||||||
|
tbank_payment:
|
||||||
|
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||||
|
notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications"
|
||||||
|
success_url: "https://merchant.test/payment/success"
|
||||||
|
auth:
|
||||||
|
terminal_key: "test-terminal-key"
|
||||||
|
password: "test-password"
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/invalid_price"
|
||||||
|
|
||||||
address_suggestions:
|
address_suggestions:
|
||||||
country_to_provider:
|
country_to_provider:
|
||||||
RU: "dadata"
|
RU: "dadata"
|
||||||
@@ -35,3 +46,8 @@ observability:
|
|||||||
service_name: "invalid-price-multiplier-service"
|
service_name: "invalid-price-multiplier-service"
|
||||||
otlp_endpoint: "http://collector:4317"
|
otlp_endpoint: "http://collector:4317"
|
||||||
otlp_insecure: true
|
otlp_insecure: true
|
||||||
|
|
||||||
|
email:
|
||||||
|
smtp_host: "smtp.test"
|
||||||
|
smtp_port: 587
|
||||||
|
from_address: "no-reply@test"
|
||||||
|
|||||||
@@ -12,6 +12,17 @@ repository:
|
|||||||
redis_dsn: "redis://localhost:6379/0"
|
redis_dsn: "redis://localhost:6379/0"
|
||||||
price_cache_ttl_seconds: 900
|
price_cache_ttl_seconds: 900
|
||||||
|
|
||||||
|
tbank_payment:
|
||||||
|
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||||
|
notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications"
|
||||||
|
success_url: "https://merchant.test/payment/success"
|
||||||
|
auth:
|
||||||
|
terminal_key: "test-terminal-key"
|
||||||
|
password: "test-password"
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/missing_adapter"
|
||||||
|
|
||||||
address_suggestions:
|
address_suggestions:
|
||||||
country_to_provider:
|
country_to_provider:
|
||||||
RU: "dadata"
|
RU: "dadata"
|
||||||
@@ -24,3 +35,8 @@ observability:
|
|||||||
service_name: "missing-adapter-service"
|
service_name: "missing-adapter-service"
|
||||||
otlp_endpoint: "http://collector:4317"
|
otlp_endpoint: "http://collector:4317"
|
||||||
otlp_insecure: true
|
otlp_insecure: true
|
||||||
|
|
||||||
|
email:
|
||||||
|
smtp_host: "smtp.test"
|
||||||
|
smtp_port: 587
|
||||||
|
from_address: "no-reply@test"
|
||||||
|
|||||||
@@ -23,8 +23,24 @@ adapter:
|
|||||||
cdek_timeout_seconds: 10.0
|
cdek_timeout_seconds: 10.0
|
||||||
cdek_cache_ttl_seconds: 900
|
cdek_cache_ttl_seconds: 900
|
||||||
|
|
||||||
|
tbank_payment:
|
||||||
|
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||||
|
notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications"
|
||||||
|
success_url: "https://merchant.test/payment/success"
|
||||||
|
auth:
|
||||||
|
terminal_key: "test-terminal-key"
|
||||||
|
password: "test-password"
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/missing_address"
|
||||||
|
|
||||||
observability:
|
observability:
|
||||||
enabled: false
|
enabled: false
|
||||||
service_name: "missing-address-suggestions-service"
|
service_name: "missing-address-suggestions-service"
|
||||||
otlp_endpoint: "http://collector:4317"
|
otlp_endpoint: "http://collector:4317"
|
||||||
otlp_insecure: true
|
otlp_insecure: true
|
||||||
|
|
||||||
|
email:
|
||||||
|
smtp_host: "smtp.test"
|
||||||
|
smtp_port: 587
|
||||||
|
from_address: "no-reply@test"
|
||||||
|
|||||||
@@ -23,9 +23,25 @@ adapter:
|
|||||||
cdek_timeout_seconds: 10.0
|
cdek_timeout_seconds: 10.0
|
||||||
cdek_cache_ttl_seconds: 900
|
cdek_cache_ttl_seconds: 900
|
||||||
|
|
||||||
|
tbank_payment:
|
||||||
|
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||||
|
notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications"
|
||||||
|
success_url: "https://merchant.test/payment/success"
|
||||||
|
auth:
|
||||||
|
terminal_key: "test-terminal-key"
|
||||||
|
password: "test-password"
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/missing_observability"
|
||||||
|
|
||||||
address_suggestions:
|
address_suggestions:
|
||||||
country_to_provider:
|
country_to_provider:
|
||||||
RU: "dadata"
|
RU: "dadata"
|
||||||
dadata:
|
dadata:
|
||||||
url: "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
|
url: "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
|
||||||
api_key: "missing-observability-dadata-key"
|
api_key: "missing-observability-dadata-key"
|
||||||
|
|
||||||
|
email:
|
||||||
|
smtp_host: "smtp.test"
|
||||||
|
smtp_port: 587
|
||||||
|
from_address: "no-reply@test"
|
||||||
|
|||||||
@@ -23,6 +23,17 @@ adapter:
|
|||||||
cdek_timeout_seconds: 10.0
|
cdek_timeout_seconds: 10.0
|
||||||
cdek_cache_ttl_seconds: 900
|
cdek_cache_ttl_seconds: 900
|
||||||
|
|
||||||
|
tbank_payment:
|
||||||
|
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||||
|
notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications"
|
||||||
|
success_url: "https://merchant.test/payment/success"
|
||||||
|
auth:
|
||||||
|
terminal_key: "test-terminal-key"
|
||||||
|
password: "test-password"
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/missing_observability_endpoint"
|
||||||
|
|
||||||
address_suggestions:
|
address_suggestions:
|
||||||
country_to_provider:
|
country_to_provider:
|
||||||
RU: "dadata"
|
RU: "dadata"
|
||||||
@@ -34,3 +45,8 @@ observability:
|
|||||||
enabled: true
|
enabled: true
|
||||||
service_name: "g2s-aggregator"
|
service_name: "g2s-aggregator"
|
||||||
otlp_insecure: true
|
otlp_insecure: true
|
||||||
|
|
||||||
|
email:
|
||||||
|
smtp_host: "smtp.test"
|
||||||
|
smtp_port: 587
|
||||||
|
from_address: "no-reply@test"
|
||||||
|
|||||||
@@ -22,6 +22,17 @@ adapter:
|
|||||||
cdek_timeout_seconds: 10.0
|
cdek_timeout_seconds: 10.0
|
||||||
cdek_cache_ttl_seconds: 900
|
cdek_cache_ttl_seconds: 900
|
||||||
|
|
||||||
|
tbank_payment:
|
||||||
|
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||||
|
notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications"
|
||||||
|
success_url: "https://merchant.test/payment/success"
|
||||||
|
auth:
|
||||||
|
terminal_key: "test-terminal-key"
|
||||||
|
password: "test-password"
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/missing_price"
|
||||||
|
|
||||||
address_suggestions:
|
address_suggestions:
|
||||||
country_to_provider:
|
country_to_provider:
|
||||||
RU: "dadata"
|
RU: "dadata"
|
||||||
@@ -34,3 +45,8 @@ observability:
|
|||||||
service_name: "missing-price-multiplier-service"
|
service_name: "missing-price-multiplier-service"
|
||||||
otlp_endpoint: "http://collector:4317"
|
otlp_endpoint: "http://collector:4317"
|
||||||
otlp_insecure: true
|
otlp_insecure: true
|
||||||
|
|
||||||
|
email:
|
||||||
|
smtp_host: "smtp.test"
|
||||||
|
smtp_port: 587
|
||||||
|
from_address: "no-reply@test"
|
||||||
|
|||||||
@@ -15,6 +15,20 @@ adapter:
|
|||||||
cdek_client_id: "test-id"
|
cdek_client_id: "test-id"
|
||||||
cdek_client_secret: "test-secret"
|
cdek_client_secret: "test-secret"
|
||||||
|
|
||||||
|
tbank_payment:
|
||||||
|
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||||
|
notification_url: "https://override.test/api/v1/delivery/tbank/notifications"
|
||||||
|
success_url: "https://override.test/payment/success"
|
||||||
|
auth:
|
||||||
|
terminal_key: "override-terminal-key"
|
||||||
|
password: "override-password"
|
||||||
|
timeout_seconds: 4.25
|
||||||
|
retry_attempts: 1
|
||||||
|
retry_backoff_seconds: 0.05
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/override"
|
||||||
|
|
||||||
address_suggestions:
|
address_suggestions:
|
||||||
country_to_provider:
|
country_to_provider:
|
||||||
RU: "dadata"
|
RU: "dadata"
|
||||||
@@ -38,3 +52,8 @@ observability:
|
|||||||
service_name: "override-config-service"
|
service_name: "override-config-service"
|
||||||
otlp_endpoint: "http://override-collector:4317"
|
otlp_endpoint: "http://override-collector:4317"
|
||||||
otlp_insecure: false
|
otlp_insecure: false
|
||||||
|
|
||||||
|
email:
|
||||||
|
smtp_host: "smtp.test"
|
||||||
|
smtp_port: 587
|
||||||
|
from_address: "no-reply@test"
|
||||||
|
|||||||
@@ -104,6 +104,61 @@ def _use_runtime_config_files(
|
|||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _write_tbank_url_validation_config(
|
||||||
|
tmp_path: Path,
|
||||||
|
*,
|
||||||
|
include_notification_url: bool = True,
|
||||||
|
include_success_url: bool = True,
|
||||||
|
) -> Path:
|
||||||
|
notification_url = (
|
||||||
|
' notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications"\n'
|
||||||
|
if include_notification_url
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
success_url = (
|
||||||
|
' success_url: "https://merchant.test/payment/success"\n'
|
||||||
|
if include_success_url
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
config_file = tmp_path / "config.yaml"
|
||||||
|
config_file.write_text(
|
||||||
|
f"""
|
||||||
|
controller: {{}}
|
||||||
|
|
||||||
|
service: {{}}
|
||||||
|
|
||||||
|
business_logic:
|
||||||
|
provider_price_multiplier: 1.0
|
||||||
|
|
||||||
|
repository: {{}}
|
||||||
|
|
||||||
|
adapter: {{}}
|
||||||
|
|
||||||
|
tbank_payment:
|
||||||
|
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||||
|
{notification_url}{success_url} auth:
|
||||||
|
terminal_key: "test-terminal-key"
|
||||||
|
password: "test-password"
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/config_test"
|
||||||
|
|
||||||
|
address_suggestions: {{}}
|
||||||
|
|
||||||
|
observability:
|
||||||
|
service_name: "config-test"
|
||||||
|
otlp_endpoint: "http://collector:4317"
|
||||||
|
|
||||||
|
email:
|
||||||
|
smtp_host: "smtp.test"
|
||||||
|
smtp_port: 587
|
||||||
|
from_address: "no-reply@test"
|
||||||
|
""".strip(),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
return config_file
|
||||||
|
|
||||||
|
|
||||||
def test_configuration_sections_are_loaded_from_yaml_file(
|
def test_configuration_sections_are_loaded_from_yaml_file(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
@@ -126,6 +181,21 @@ def test_configuration_sections_are_loaded_from_yaml_file(
|
|||||||
assert settings.adapter.cdek_retry_backoff_seconds == 0.2
|
assert settings.adapter.cdek_retry_backoff_seconds == 0.2
|
||||||
assert settings.adapter.cdek_timeout_seconds == 10.0
|
assert settings.adapter.cdek_timeout_seconds == 10.0
|
||||||
assert settings.adapter.cdek_cache_ttl_seconds == 900
|
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
|
||||||
|
== "https://merchant.test/api/v1/delivery/tbank/notifications"
|
||||||
|
)
|
||||||
|
assert settings.tbank_payment.success_url == "https://merchant.test/payment/success"
|
||||||
|
assert settings.tbank_payment.auth.terminal_key == "test-terminal-key"
|
||||||
|
assert settings.tbank_payment.auth.password == "test-password"
|
||||||
|
assert settings.tbank_payment.timeout_seconds == 10.0
|
||||||
|
assert settings.tbank_payment.retry_attempts == 2
|
||||||
|
assert settings.tbank_payment.retry_backoff_seconds == 0.2
|
||||||
|
assert (
|
||||||
|
settings.postgres.dsn
|
||||||
|
== "postgresql+asyncpg://postgres:postgres@localhost:5432/g2s_aggregator_test"
|
||||||
|
)
|
||||||
assert settings.address_suggestions.country_to_provider == _expected_country_mapping()
|
assert settings.address_suggestions.country_to_provider == _expected_country_mapping()
|
||||||
assert (
|
assert (
|
||||||
settings.address_suggestions.dadata.url
|
settings.address_suggestions.dadata.url
|
||||||
@@ -192,6 +262,12 @@ def test_get_settings_returns_cached_instance(monkeypatch: pytest.MonkeyPatch) -
|
|||||||
assert first is second
|
assert first is second
|
||||||
assert first.service.provider_timeout_seconds == 10.0
|
assert first.service.provider_timeout_seconds == 10.0
|
||||||
assert first.business_logic.provider_price_multiplier == Decimal("1.0")
|
assert first.business_logic.provider_price_multiplier == Decimal("1.0")
|
||||||
|
assert first.tbank_payment.auth.terminal_key == "test-terminal-key"
|
||||||
|
assert (
|
||||||
|
first.tbank_payment.notification_url
|
||||||
|
== "https://merchant.test/api/v1/delivery/tbank/notifications"
|
||||||
|
)
|
||||||
|
assert first.postgres.dsn.endswith("/g2s_aggregator_test")
|
||||||
assert first.address_suggestions.country_to_provider["RU"] == "dadata"
|
assert first.address_suggestions.country_to_provider["RU"] == "dadata"
|
||||||
assert first.observability.service_name == "g2s-aggregator-test"
|
assert first.observability.service_name == "g2s-aggregator-test"
|
||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
@@ -212,6 +288,20 @@ def test_get_settings_uses_config_test_yaml_in_pytest_environment(
|
|||||||
assert settings.controller.api_prefix == "/from-config-test-yaml"
|
assert settings.controller.api_prefix == "/from-config-test-yaml"
|
||||||
assert settings.business_logic.provider_price_multiplier == Decimal("1.25")
|
assert settings.business_logic.provider_price_multiplier == Decimal("1.25")
|
||||||
assert settings.adapter.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 (
|
||||||
|
settings.tbank_payment.notification_url
|
||||||
|
== "https://override.test/api/v1/delivery/tbank/notifications"
|
||||||
|
)
|
||||||
|
assert settings.tbank_payment.success_url == "https://override.test/payment/success"
|
||||||
|
assert settings.tbank_payment.timeout_seconds == 4.25
|
||||||
|
assert settings.tbank_payment.retry_attempts == 1
|
||||||
|
assert settings.tbank_payment.retry_backoff_seconds == 0.05
|
||||||
|
assert (
|
||||||
|
settings.postgres.dsn
|
||||||
|
== "postgresql+asyncpg://postgres:postgres@localhost:5432/override"
|
||||||
|
)
|
||||||
assert settings.address_suggestions.country_to_provider == {
|
assert settings.address_suggestions.country_to_provider == {
|
||||||
"RU": "dadata",
|
"RU": "dadata",
|
||||||
"AM": "yandex_geosuggest",
|
"AM": "yandex_geosuggest",
|
||||||
@@ -262,6 +352,35 @@ def test_get_settings_fails_when_provider_price_multiplier_is_missing(
|
|||||||
get_settings.cache_clear()
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("include_notification_url", "include_success_url", "expected_location"),
|
||||||
|
(
|
||||||
|
(False, True, ("tbank_payment", "notification_url")),
|
||||||
|
(True, False, ("tbank_payment", "success_url")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
def test_get_settings_fails_when_tbank_payment_url_is_missing(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
include_notification_url: bool,
|
||||||
|
include_success_url: bool,
|
||||||
|
expected_location: tuple[str, str],
|
||||||
|
) -> None:
|
||||||
|
config_file = _write_tbank_url_validation_config(
|
||||||
|
tmp_path,
|
||||||
|
include_notification_url=include_notification_url,
|
||||||
|
include_success_url=include_success_url,
|
||||||
|
)
|
||||||
|
_use_runtime_config_files(monkeypatch, test_config_file=config_file)
|
||||||
|
|
||||||
|
with pytest.raises(ValidationError) as error:
|
||||||
|
get_settings()
|
||||||
|
|
||||||
|
locations = {tuple(item["loc"]) for item in error.value.errors()}
|
||||||
|
assert expected_location in locations
|
||||||
|
get_settings.cache_clear()
|
||||||
|
|
||||||
|
|
||||||
def test_get_settings_fails_when_observability_section_is_missing(
|
def test_get_settings_fails_when_observability_section_is_missing(
|
||||||
monkeypatch: pytest.MonkeyPatch,
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ def test_post_delivery_price_uses_registered_provider_in_default_dependency(
|
|||||||
"currency": "RUB",
|
"currency": "RUB",
|
||||||
"delivery_days_min": 2,
|
"delivery_days_min": 2,
|
||||||
"delivery_days_max": 3,
|
"delivery_days_max": 3,
|
||||||
|
"tariff_code": None,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"provider": "stub-provider",
|
"provider": "stub-provider",
|
||||||
@@ -191,6 +192,7 @@ def test_post_delivery_price_uses_registered_provider_in_default_dependency(
|
|||||||
"currency": "RUB",
|
"currency": "RUB",
|
||||||
"delivery_days_min": 4,
|
"delivery_days_min": 4,
|
||||||
"delivery_days_max": 5,
|
"delivery_days_max": 5,
|
||||||
|
"tariff_code": None,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
assert second_response.json() == first_response.json()
|
assert second_response.json() == first_response.json()
|
||||||
@@ -308,9 +310,9 @@ def test_post_delivery_price_filters_response_by_optional_parcel_type(
|
|||||||
) -> None:
|
) -> None:
|
||||||
provider = StubPriceProvider(
|
provider = StubPriceProvider(
|
||||||
response=[
|
response=[
|
||||||
_make_price(service_name="Parcel locker", price="90.00"),
|
_make_price(service_name="Parcel locker", price="90.00", provider="other"),
|
||||||
_make_price(service_name="Срочный документ", price="150.00"),
|
_make_price(service_name="Срочный документ", price="150.00", provider="other"),
|
||||||
_make_price(service_name="DOCUMENT EXPRESS", price="200.00"),
|
_make_price(service_name="DOCUMENT EXPRESS", price="200.00", provider="other"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
service = AggregatorService(providers=[provider])
|
service = AggregatorService(providers=[provider])
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.controllers.v1.delivery import get_aggregator_service
|
||||||
|
from app.main import create_app
|
||||||
|
from app.schemas.payment import InitPaymentRequest, InitPaymentResponse
|
||||||
|
from app.services.aggregator import (
|
||||||
|
InitPaymentUnavailableError,
|
||||||
|
InvalidInitPaymentRequestError,
|
||||||
|
)
|
||||||
|
from tests.payment_fixtures import make_init_payment_payload
|
||||||
|
|
||||||
|
|
||||||
|
class StubAggregatorService:
|
||||||
|
def __init__(self, *, response: object, error: Exception | None = None) -> None:
|
||||||
|
self._response = response
|
||||||
|
self._error = error
|
||||||
|
self.calls: list[InitPaymentRequest] = []
|
||||||
|
|
||||||
|
async def init_payment(self, request: InitPaymentRequest) -> object:
|
||||||
|
self.calls.append(request)
|
||||||
|
if self._error is not None:
|
||||||
|
raise self._error
|
||||||
|
return self._response
|
||||||
|
|
||||||
|
|
||||||
|
def _install_service_override(app, service: StubAggregatorService) -> None:
|
||||||
|
async def override_service() -> StubAggregatorService:
|
||||||
|
return service
|
||||||
|
|
||||||
|
app.dependency_overrides[get_aggregator_service] = override_service
|
||||||
|
|
||||||
|
|
||||||
|
def _post(app, payload: dict[str, object]) -> httpx.Response:
|
||||||
|
async def run_request() -> httpx.Response:
|
||||||
|
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=transport,
|
||||||
|
base_url="http://testserver",
|
||||||
|
) as client:
|
||||||
|
return await client.post("/api/v1/delivery/order", json=payload)
|
||||||
|
|
||||||
|
return asyncio.run(run_request())
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_init_payment_returns_response_and_delegates_to_service() -> None:
|
||||||
|
expected_response = InitPaymentResponse(payment_url="https://pay.test/payment/1")
|
||||||
|
service = StubAggregatorService(response=expected_response)
|
||||||
|
app = create_app()
|
||||||
|
_install_service_override(app, service)
|
||||||
|
payload = make_init_payment_payload()
|
||||||
|
|
||||||
|
response = _post(app, payload)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.json() == expected_response.model_dump(mode="json")
|
||||||
|
assert service.calls == [InitPaymentRequest.model_validate(payload)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_init_payment_rejects_invalid_tariff_code() -> None:
|
||||||
|
service = StubAggregatorService(response=None)
|
||||||
|
app = create_app()
|
||||||
|
_install_service_override(app, service)
|
||||||
|
invalid_payload = make_init_payment_payload()
|
||||||
|
invalid_payload["systemData"]["tariff"]["tariffCode"] = "not-a-number"
|
||||||
|
|
||||||
|
response = _post(app, invalid_payload)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert service.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_init_payment_rejects_snake_case_top_level_field() -> None:
|
||||||
|
service = StubAggregatorService(response=None)
|
||||||
|
app = create_app()
|
||||||
|
_install_service_override(app, service)
|
||||||
|
invalid_payload = make_init_payment_payload()
|
||||||
|
invalid_payload["account_email"] = invalid_payload.pop("accountEmail")
|
||||||
|
|
||||||
|
response = _post(app, invalid_payload)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert service.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_init_payment_rejects_invalid_account_email() -> None:
|
||||||
|
service = StubAggregatorService(response=None)
|
||||||
|
app = create_app()
|
||||||
|
_install_service_override(app, service)
|
||||||
|
invalid_payload = make_init_payment_payload()
|
||||||
|
invalid_payload["accountEmail"] = "not-an-email"
|
||||||
|
|
||||||
|
response = _post(app, invalid_payload)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert service.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_init_payment_rejects_snake_case_nested_address_field() -> None:
|
||||||
|
service = StubAggregatorService(response=None)
|
||||||
|
app = create_app()
|
||||||
|
_install_service_override(app, service)
|
||||||
|
invalid_payload = make_init_payment_payload()
|
||||||
|
sender_address = invalid_payload["senderAddress"]
|
||||||
|
sender_address["city_id"] = sender_address.pop("cityId")
|
||||||
|
|
||||||
|
response = _post(app, invalid_payload)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert service.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_init_payment_rejects_snake_case_nested_tariff_field() -> None:
|
||||||
|
service = StubAggregatorService(response=None)
|
||||||
|
app = create_app()
|
||||||
|
_install_service_override(app, service)
|
||||||
|
invalid_payload = make_init_payment_payload()
|
||||||
|
tariff = invalid_payload["systemData"]["tariff"]
|
||||||
|
tariff["tariff_code"] = tariff.pop("tariffCode")
|
||||||
|
|
||||||
|
response = _post(app, invalid_payload)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert service.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_init_payment_rejects_missing_price() -> None:
|
||||||
|
service = StubAggregatorService(response=None)
|
||||||
|
app = create_app()
|
||||||
|
_install_service_override(app, service)
|
||||||
|
invalid_payload = make_init_payment_payload()
|
||||||
|
del invalid_payload["systemData"]["tariff"]["price"]
|
||||||
|
|
||||||
|
response = _post(app, invalid_payload)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert service.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_init_payment_rejects_non_positive_price() -> None:
|
||||||
|
service = StubAggregatorService(response=None)
|
||||||
|
app = create_app()
|
||||||
|
_install_service_override(app, service)
|
||||||
|
invalid_payload = make_init_payment_payload()
|
||||||
|
invalid_payload["systemData"]["tariff"]["price"] = 0
|
||||||
|
|
||||||
|
response = _post(app, invalid_payload)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert service.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_init_payment_rejects_non_integer_price() -> None:
|
||||||
|
service = StubAggregatorService(response=None)
|
||||||
|
app = create_app()
|
||||||
|
_install_service_override(app, service)
|
||||||
|
invalid_payload = make_init_payment_payload()
|
||||||
|
invalid_payload["systemData"]["tariff"]["price"] = 125000.5
|
||||||
|
|
||||||
|
response = _post(app, invalid_payload)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert service.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_init_payment_requires_company_fields_when_is_company_true() -> None:
|
||||||
|
service = StubAggregatorService(response=None)
|
||||||
|
app = create_app()
|
||||||
|
_install_service_override(app, service)
|
||||||
|
invalid_payload = make_init_payment_payload()
|
||||||
|
invalid_payload["senderContact"]["isCompany"] = True
|
||||||
|
|
||||||
|
response = _post(app, invalid_payload)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert service.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_init_payment_accepts_company_with_full_requisites() -> None:
|
||||||
|
expected_response = InitPaymentResponse(payment_url="https://pay.test/payment/1")
|
||||||
|
service = StubAggregatorService(response=expected_response)
|
||||||
|
app = create_app()
|
||||||
|
_install_service_override(app, service)
|
||||||
|
payload = make_init_payment_payload()
|
||||||
|
payload["senderContact"].update(
|
||||||
|
{
|
||||||
|
"isCompany": True,
|
||||||
|
"companyName": "Romashka LLC",
|
||||||
|
"inn": "7707083893",
|
||||||
|
"kpp": "770701001",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = _post(app, payload)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert service.calls == [InitPaymentRequest.model_validate(payload)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_init_payment_rejects_non_numeric_weight() -> None:
|
||||||
|
service = StubAggregatorService(response=None)
|
||||||
|
app = create_app()
|
||||||
|
_install_service_override(app, service)
|
||||||
|
invalid_payload = make_init_payment_payload()
|
||||||
|
invalid_payload["systemData"]["weight"] = "string"
|
||||||
|
|
||||||
|
response = _post(app, invalid_payload)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert service.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_init_payment_rejects_non_numeric_dimension() -> None:
|
||||||
|
service = StubAggregatorService(response=None)
|
||||||
|
app = create_app()
|
||||||
|
_install_service_override(app, service)
|
||||||
|
invalid_payload = make_init_payment_payload()
|
||||||
|
invalid_payload["systemData"]["dimensions"]["length"] = "string"
|
||||||
|
|
||||||
|
response = _post(app, invalid_payload)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert service.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_init_payment_rejects_parcel_without_dimensions() -> None:
|
||||||
|
service = StubAggregatorService(response=None)
|
||||||
|
app = create_app()
|
||||||
|
_install_service_override(app, service)
|
||||||
|
invalid_payload = make_init_payment_payload()
|
||||||
|
invalid_payload["systemData"]["dimensions"] = None
|
||||||
|
|
||||||
|
response = _post(app, invalid_payload)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert service.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_init_payment_accepts_doc_without_dimensions() -> None:
|
||||||
|
expected_response = InitPaymentResponse(payment_url="https://pay.test/payment/1")
|
||||||
|
service = StubAggregatorService(response=expected_response)
|
||||||
|
app = create_app()
|
||||||
|
_install_service_override(app, service)
|
||||||
|
payload = make_init_payment_payload()
|
||||||
|
payload["systemData"]["parcelType"] = "doc"
|
||||||
|
payload["systemData"]["docPackaging"] = "envelope"
|
||||||
|
payload["systemData"]["dimensions"] = None
|
||||||
|
|
||||||
|
response = _post(app, payload)
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert service.calls == [InitPaymentRequest.model_validate(payload)]
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_init_payment_maps_invalid_request_to_400() -> None:
|
||||||
|
service = StubAggregatorService(
|
||||||
|
response=None,
|
||||||
|
error=InvalidInitPaymentRequestError("invalid payload"),
|
||||||
|
)
|
||||||
|
app = create_app()
|
||||||
|
_install_service_override(app, service)
|
||||||
|
|
||||||
|
response = _post(app, make_init_payment_payload())
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json() == {
|
||||||
|
"detail": {
|
||||||
|
"code": "invalid_init_payment_request",
|
||||||
|
"message": "Payment request contains invalid or unsupported TBank data.",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_init_payment_maps_service_exception_to_503() -> None:
|
||||||
|
service = StubAggregatorService(
|
||||||
|
response=None,
|
||||||
|
error=InitPaymentUnavailableError("service unavailable"),
|
||||||
|
)
|
||||||
|
app = create_app()
|
||||||
|
_install_service_override(app, service)
|
||||||
|
|
||||||
|
response = _post(app, make_init_payment_payload())
|
||||||
|
|
||||||
|
assert response.status_code == 503
|
||||||
|
assert response.json() == {
|
||||||
|
"detail": {
|
||||||
|
"code": "init_payment_unavailable",
|
||||||
|
"message": "Payment initialization is temporarily unavailable.",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,214 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
from app.controllers.v1.delivery import get_aggregator_service
|
|
||||||
from app.main import create_app
|
|
||||||
from app.schemas.order import OrderCreateRequest, OrderCreateResponse
|
|
||||||
from app.services.aggregator import (
|
|
||||||
AggregatorServiceError,
|
|
||||||
InvalidOrderCreateRequestError,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class StubAggregatorService:
|
|
||||||
def __init__(self, *, response: object, error: Exception | None = None) -> None:
|
|
||||||
self._response = response
|
|
||||||
self._error = error
|
|
||||||
self.calls: list[OrderCreateRequest] = []
|
|
||||||
|
|
||||||
async def create_order(self, request: OrderCreateRequest) -> object:
|
|
||||||
self.calls.append(request)
|
|
||||||
if self._error is not None:
|
|
||||||
raise self._error
|
|
||||||
return self._response
|
|
||||||
|
|
||||||
|
|
||||||
def _install_service_override(app, service: StubAggregatorService) -> None:
|
|
||||||
async def override_service() -> StubAggregatorService:
|
|
||||||
return service
|
|
||||||
|
|
||||||
app.dependency_overrides[get_aggregator_service] = override_service
|
|
||||||
|
|
||||||
|
|
||||||
def _valid_payload() -> dict[str, object]:
|
|
||||||
return {
|
|
||||||
"type": 2,
|
|
||||||
"tariff_code": 535,
|
|
||||||
"comment": "Test order",
|
|
||||||
"sender": {
|
|
||||||
"name": "Petr Petrov",
|
|
||||||
"email": "sender@example.com",
|
|
||||||
"phones": [{"number": "+79009876543"}],
|
|
||||||
},
|
|
||||||
"recipient": {
|
|
||||||
"name": "Ivan Ivanov",
|
|
||||||
"email": "ivan@example.com",
|
|
||||||
"phones": [{"number": "+79001234567"}],
|
|
||||||
},
|
|
||||||
"from_location": {
|
|
||||||
"address": "Lenina 1",
|
|
||||||
"city": "Moscow",
|
|
||||||
"country_code": "RU",
|
|
||||||
},
|
|
||||||
"to_location": {
|
|
||||||
"address": "Pushkina 10",
|
|
||||||
"city": "Novosibirsk",
|
|
||||||
"country_code": "RU",
|
|
||||||
},
|
|
||||||
"services": [{"code": "INSURANCE", "parameter": "1000"}],
|
|
||||||
"packages": [
|
|
||||||
{
|
|
||||||
"number": "1",
|
|
||||||
"weight": 1000,
|
|
||||||
"length": 20,
|
|
||||||
"width": 15,
|
|
||||||
"height": 10,
|
|
||||||
"comment": "Package 1",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_post_delivery_order_returns_response_and_delegates_to_service() -> None:
|
|
||||||
expected_response = OrderCreateResponse(provider="cdek", order_uuid="order-uuid-1")
|
|
||||||
service = StubAggregatorService(response=expected_response)
|
|
||||||
app = create_app()
|
|
||||||
_install_service_override(app, service)
|
|
||||||
|
|
||||||
async def run_request() -> httpx.Response:
|
|
||||||
transport = httpx.ASGITransport(app=app)
|
|
||||||
async with httpx.AsyncClient(
|
|
||||||
transport=transport,
|
|
||||||
base_url="http://testserver",
|
|
||||||
) as client:
|
|
||||||
return await client.post("/api/v1/delivery/order", json=_valid_payload())
|
|
||||||
|
|
||||||
response = asyncio.run(run_request())
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
assert response.json() == expected_response.model_dump(mode="json")
|
|
||||||
assert service.calls == [OrderCreateRequest(**_valid_payload())]
|
|
||||||
|
|
||||||
|
|
||||||
def test_post_delivery_order_rejects_invalid_payload() -> None:
|
|
||||||
service = StubAggregatorService(response=None)
|
|
||||||
app = create_app()
|
|
||||||
_install_service_override(app, service)
|
|
||||||
invalid_payload = _valid_payload()
|
|
||||||
invalid_payload["tariff_code"] = 136
|
|
||||||
|
|
||||||
async def run_request() -> httpx.Response:
|
|
||||||
transport = httpx.ASGITransport(app=app)
|
|
||||||
async with httpx.AsyncClient(
|
|
||||||
transport=transport,
|
|
||||||
base_url="http://testserver",
|
|
||||||
) as client:
|
|
||||||
return await client.post("/api/v1/delivery/order", json=invalid_payload)
|
|
||||||
|
|
||||||
response = asyncio.run(run_request())
|
|
||||||
|
|
||||||
assert response.status_code == 422
|
|
||||||
assert service.calls == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_post_delivery_order_rejects_sender_company_field() -> None:
|
|
||||||
service = StubAggregatorService(response=None)
|
|
||||||
app = create_app()
|
|
||||||
_install_service_override(app, service)
|
|
||||||
invalid_payload = _valid_payload()
|
|
||||||
invalid_payload["sender"] = {
|
|
||||||
**invalid_payload["sender"], # type: ignore[arg-type]
|
|
||||||
"company": "Romashka LLC",
|
|
||||||
}
|
|
||||||
|
|
||||||
async def run_request() -> httpx.Response:
|
|
||||||
transport = httpx.ASGITransport(app=app)
|
|
||||||
async with httpx.AsyncClient(
|
|
||||||
transport=transport,
|
|
||||||
base_url="http://testserver",
|
|
||||||
) as client:
|
|
||||||
return await client.post("/api/v1/delivery/order", json=invalid_payload)
|
|
||||||
|
|
||||||
response = asyncio.run(run_request())
|
|
||||||
|
|
||||||
assert response.status_code == 422
|
|
||||||
assert service.calls == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_post_delivery_order_rejects_recipient_company_field() -> None:
|
|
||||||
service = StubAggregatorService(response=None)
|
|
||||||
app = create_app()
|
|
||||||
_install_service_override(app, service)
|
|
||||||
invalid_payload = _valid_payload()
|
|
||||||
invalid_payload["recipient"] = {
|
|
||||||
**invalid_payload["recipient"], # type: ignore[arg-type]
|
|
||||||
"company": "Romashka LLC",
|
|
||||||
}
|
|
||||||
|
|
||||||
async def run_request() -> httpx.Response:
|
|
||||||
transport = httpx.ASGITransport(app=app)
|
|
||||||
async with httpx.AsyncClient(
|
|
||||||
transport=transport,
|
|
||||||
base_url="http://testserver",
|
|
||||||
) as client:
|
|
||||||
return await client.post("/api/v1/delivery/order", json=invalid_payload)
|
|
||||||
|
|
||||||
response = asyncio.run(run_request())
|
|
||||||
|
|
||||||
assert response.status_code == 422
|
|
||||||
assert service.calls == []
|
|
||||||
|
|
||||||
|
|
||||||
def test_post_delivery_order_maps_invalid_request_to_400() -> None:
|
|
||||||
service = StubAggregatorService(
|
|
||||||
response=None,
|
|
||||||
error=InvalidOrderCreateRequestError("invalid payload"),
|
|
||||||
)
|
|
||||||
app = create_app()
|
|
||||||
_install_service_override(app, service)
|
|
||||||
|
|
||||||
async def run_request() -> httpx.Response:
|
|
||||||
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
|
||||||
async with httpx.AsyncClient(
|
|
||||||
transport=transport,
|
|
||||||
base_url="http://testserver",
|
|
||||||
) as client:
|
|
||||||
return await client.post("/api/v1/delivery/order", json=_valid_payload())
|
|
||||||
|
|
||||||
response = asyncio.run(run_request())
|
|
||||||
|
|
||||||
assert response.status_code == 400
|
|
||||||
assert response.json() == {
|
|
||||||
"detail": {
|
|
||||||
"code": "invalid_order_create_request",
|
|
||||||
"message": "Order request contains invalid or unsupported CDEK data.",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_post_delivery_order_maps_service_exception_to_503() -> None:
|
|
||||||
service = StubAggregatorService(
|
|
||||||
response=None,
|
|
||||||
error=AggregatorServiceError("service unavailable"),
|
|
||||||
)
|
|
||||||
app = create_app()
|
|
||||||
_install_service_override(app, service)
|
|
||||||
|
|
||||||
async def run_request() -> httpx.Response:
|
|
||||||
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
|
||||||
async with httpx.AsyncClient(
|
|
||||||
transport=transport,
|
|
||||||
base_url="http://testserver",
|
|
||||||
) as client:
|
|
||||||
return await client.post("/api/v1/delivery/order", json=_valid_payload())
|
|
||||||
|
|
||||||
response = asyncio.run(run_request())
|
|
||||||
|
|
||||||
assert response.status_code == 503
|
|
||||||
assert response.json() == {
|
|
||||||
"detail": {
|
|
||||||
"code": "order_creation_unavailable",
|
|
||||||
"message": "CDEK order creation is temporarily unavailable.",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.controllers.v1.delivery import get_aggregator_service
|
||||||
|
from app.main import create_app
|
||||||
|
from app.schemas.payment import TBankPaymentNotification
|
||||||
|
from app.services.aggregator import (
|
||||||
|
InvalidTBankPaymentNotificationError,
|
||||||
|
TBankPaymentNotificationProcessingError,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StubAggregatorService:
|
||||||
|
def __init__(self, *, response: str = "OK", error: Exception | None = None) -> None:
|
||||||
|
self._response = response
|
||||||
|
self._error = error
|
||||||
|
self.calls: list[TBankPaymentNotification] = []
|
||||||
|
|
||||||
|
async def handle_tbank_payment_notification(
|
||||||
|
self,
|
||||||
|
notification: TBankPaymentNotification,
|
||||||
|
) -> str:
|
||||||
|
self.calls.append(notification)
|
||||||
|
if self._error is not None:
|
||||||
|
raise self._error
|
||||||
|
return self._response
|
||||||
|
|
||||||
|
|
||||||
|
def _install_service_override(app, service: StubAggregatorService) -> None:
|
||||||
|
async def override_service() -> StubAggregatorService:
|
||||||
|
return service
|
||||||
|
|
||||||
|
app.dependency_overrides[get_aggregator_service] = override_service
|
||||||
|
|
||||||
|
|
||||||
|
def _valid_payload() -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"TerminalKey": "TestTerminal",
|
||||||
|
"OrderId": "order-uuid-1",
|
||||||
|
"Success": True,
|
||||||
|
"Status": "CONFIRMED",
|
||||||
|
"PaymentId": 8347568144,
|
||||||
|
"ErrorCode": "0",
|
||||||
|
"Amount": 125000,
|
||||||
|
"Token": "signed-token",
|
||||||
|
"Data": {"nested": "accepted by schema"},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _post_notification(app, payload: dict[str, object]) -> httpx.Response:
|
||||||
|
async def run_request() -> httpx.Response:
|
||||||
|
transport = httpx.ASGITransport(app=app)
|
||||||
|
async with httpx.AsyncClient(
|
||||||
|
transport=transport,
|
||||||
|
base_url="http://testserver",
|
||||||
|
) as client:
|
||||||
|
return await client.post(
|
||||||
|
"/api/v1/delivery/tbank/notifications",
|
||||||
|
json=payload,
|
||||||
|
)
|
||||||
|
|
||||||
|
return asyncio.run(run_request())
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_tbank_notification_returns_plain_text_ok_and_delegates_once() -> None:
|
||||||
|
service = StubAggregatorService(response="OK")
|
||||||
|
app = create_app()
|
||||||
|
_install_service_override(app, service)
|
||||||
|
|
||||||
|
response = _post_notification(app, _valid_payload())
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert response.text == "OK"
|
||||||
|
assert response.headers["content-type"].startswith("text/plain")
|
||||||
|
assert service.calls == [TBankPaymentNotification(**_valid_payload())]
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_tbank_notification_maps_invalid_token_to_400() -> None:
|
||||||
|
service = StubAggregatorService(
|
||||||
|
error=InvalidTBankPaymentNotificationError("invalid token")
|
||||||
|
)
|
||||||
|
app = create_app()
|
||||||
|
_install_service_override(app, service)
|
||||||
|
|
||||||
|
response = _post_notification(app, _valid_payload())
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.json()["detail"]["code"] == "invalid_tbank_payment_notification"
|
||||||
|
assert service.calls == [TBankPaymentNotification(**_valid_payload())]
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_tbank_notification_maps_processing_failure_to_503() -> None:
|
||||||
|
service = StubAggregatorService(
|
||||||
|
error=TBankPaymentNotificationProcessingError("temporary failure")
|
||||||
|
)
|
||||||
|
app = create_app()
|
||||||
|
_install_service_override(app, service)
|
||||||
|
|
||||||
|
response = _post_notification(app, _valid_payload())
|
||||||
|
|
||||||
|
assert response.status_code == 503
|
||||||
|
assert (
|
||||||
|
response.json()["detail"]["code"]
|
||||||
|
== "tbank_payment_notification_processing_unavailable"
|
||||||
|
)
|
||||||
|
assert service.calls == [TBankPaymentNotification(**_valid_payload())]
|
||||||
|
|
||||||
|
|
||||||
|
def test_post_tbank_notification_rejects_invalid_payload_without_service_call() -> None:
|
||||||
|
service = StubAggregatorService()
|
||||||
|
app = create_app()
|
||||||
|
_install_service_override(app, service)
|
||||||
|
payload = _valid_payload()
|
||||||
|
del payload["Token"]
|
||||||
|
|
||||||
|
response = _post_notification(app, payload)
|
||||||
|
|
||||||
|
assert response.status_code == 422
|
||||||
|
assert service.calls == []
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.domain.cdek_polling import TERMINAL_ORDER_STATUSES, is_terminal_order_status
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"code", sorted(TERMINAL_ORDER_STATUSES)
|
||||||
|
)
|
||||||
|
def test_terminal_statuses_are_terminal(code: str) -> None:
|
||||||
|
assert is_terminal_order_status(code) is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("code", ["ACCEPTED", "CREATED", "RECEIVED_AT_SHIPMENT_WAREHOUSE", ""])
|
||||||
|
def test_non_terminal_statuses_are_not_terminal(code: str) -> None:
|
||||||
|
assert is_terminal_order_status(code) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_none_status_is_not_terminal() -> None:
|
||||||
|
assert is_terminal_order_status(None) is False
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
from app.domain.payment_notifications import (
|
||||||
|
TBankPaymentNotificationAction,
|
||||||
|
resolve_tbank_payment_notification_action,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirmed_success_zero_error_code_registers_cdek_order() -> None:
|
||||||
|
result = resolve_tbank_payment_notification_action(
|
||||||
|
status="CONFIRMED",
|
||||||
|
success=True,
|
||||||
|
error_code="0",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is TBankPaymentNotificationAction.REGISTER_CDEK_ORDER
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirmed_without_success_acknowledges_only() -> None:
|
||||||
|
result = resolve_tbank_payment_notification_action(
|
||||||
|
status="CONFIRMED",
|
||||||
|
success=False,
|
||||||
|
error_code="0",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirmed_with_non_zero_error_code_acknowledges_only() -> None:
|
||||||
|
result = resolve_tbank_payment_notification_action(
|
||||||
|
status="CONFIRMED",
|
||||||
|
success=True,
|
||||||
|
error_code="101",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY
|
||||||
|
|
||||||
|
|
||||||
|
def test_known_non_confirmed_statuses_acknowledge_only() -> None:
|
||||||
|
for status in ("AUTHORIZED", "REJECTED", "CANCELED", "DEADLINE_EXPIRED"):
|
||||||
|
result = resolve_tbank_payment_notification_action(
|
||||||
|
status=status,
|
||||||
|
success=True,
|
||||||
|
error_code="0",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_status_acknowledges_only() -> None:
|
||||||
|
result = resolve_tbank_payment_notification_action(
|
||||||
|
status="UNKNOWN_STATUS",
|
||||||
|
success=True,
|
||||||
|
error_code="0",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result is TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from app.domain.price import (
|
||||||
|
calculate_expected_payment_amount_kopecks,
|
||||||
|
is_init_payment_price_valid,
|
||||||
|
)
|
||||||
|
from app.schemas.response import DeliveryPrice
|
||||||
|
|
||||||
|
|
||||||
|
def _make_price(**overrides: object) -> DeliveryPrice:
|
||||||
|
payload = {
|
||||||
|
"provider": "cdek",
|
||||||
|
"service_name": "CDEK tariff",
|
||||||
|
"price": Decimal("1250.00"),
|
||||||
|
"currency": "RUB",
|
||||||
|
"delivery_days_min": 1,
|
||||||
|
"delivery_days_max": 2,
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
return DeliveryPrice.model_construct(**payload)
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_payment_price_validation_accepts_exact_match() -> None:
|
||||||
|
provider_price = _make_price(price=Decimal("1250.00"))
|
||||||
|
|
||||||
|
assert is_init_payment_price_valid(125000, provider_price)
|
||||||
|
assert calculate_expected_payment_amount_kopecks(provider_price) == 125000
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_payment_price_validation_rejects_mismatch() -> None:
|
||||||
|
provider_price = _make_price(price=Decimal("1250.00"))
|
||||||
|
|
||||||
|
assert not is_init_payment_price_valid(124999, provider_price)
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_payment_price_validation_applies_multiplier_and_rounds_half_up() -> None:
|
||||||
|
provider_price = _make_price(price=Decimal("100.50"))
|
||||||
|
|
||||||
|
expected_amount = calculate_expected_payment_amount_kopecks(
|
||||||
|
provider_price,
|
||||||
|
price_multiplier=Decimal("1.1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert expected_amount == 11100
|
||||||
|
assert is_init_payment_price_valid(
|
||||||
|
11100,
|
||||||
|
provider_price,
|
||||||
|
price_multiplier=Decimal("1.1"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_payment_price_validation_converts_rub_to_kopecks() -> None:
|
||||||
|
provider_price = _make_price(price=Decimal("899.00"))
|
||||||
|
|
||||||
|
assert calculate_expected_payment_amount_kopecks(provider_price) == 89900
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_payment_price_validation_rejects_non_rub_currency() -> None:
|
||||||
|
provider_price = _make_price(price=Decimal("899.00"), currency="USD")
|
||||||
|
|
||||||
|
assert calculate_expected_payment_amount_kopecks(provider_price) is None
|
||||||
|
assert not is_init_payment_price_valid(89900, provider_price)
|
||||||
@@ -106,6 +106,41 @@ def test_filter_prices_by_parcel_type_returns_non_document_tariffs_for_parcel()
|
|||||||
assert [price.service_name for price in result] == ["Economy parcel", "Express"]
|
assert [price.service_name for price in result] == ["Economy parcel", "Express"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_filter_prices_by_parcel_type_keeps_prices_with_bypass_flag_regardless_of_type() -> None:
|
||||||
|
bypass_document = _make_price(
|
||||||
|
provider="cdek",
|
||||||
|
service_name="Документ курьером",
|
||||||
|
bypass_parcel_type_filter=True,
|
||||||
|
)
|
||||||
|
bypass_parcel = _make_price(
|
||||||
|
provider="cdek",
|
||||||
|
service_name="Посылка склад-склад",
|
||||||
|
bypass_parcel_type_filter=True,
|
||||||
|
)
|
||||||
|
other_document = _make_price(provider="other", service_name="Document")
|
||||||
|
other_parcel = _make_price(provider="other", service_name="Parcel")
|
||||||
|
|
||||||
|
doc_result = filter_prices_by_parcel_type(
|
||||||
|
[bypass_document, bypass_parcel, other_document, other_parcel],
|
||||||
|
parcel_type=ParcelType.DOC,
|
||||||
|
)
|
||||||
|
parcel_result = filter_prices_by_parcel_type(
|
||||||
|
[bypass_document, bypass_parcel, other_document, other_parcel],
|
||||||
|
parcel_type=ParcelType.PARCEL,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [price.service_name for price in doc_result] == [
|
||||||
|
"Документ курьером",
|
||||||
|
"Посылка склад-склад",
|
||||||
|
"Document",
|
||||||
|
]
|
||||||
|
assert [price.service_name for price in parcel_result] == [
|
||||||
|
"Документ курьером",
|
||||||
|
"Посылка склад-склад",
|
||||||
|
"Parcel",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_filter_prices_by_parcel_type_returns_all_prices_when_type_is_missing() -> None:
|
def test_filter_prices_by_parcel_type_returns_all_prices_when_type_is_missing() -> None:
|
||||||
prices = [
|
prices = [
|
||||||
_make_price(service_name="Документ"),
|
_make_price(service_name="Документ"),
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
"""Shared fixtures for the camelCase init-payment contract."""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.schemas.payment import InitPaymentRequest
|
||||||
|
|
||||||
|
|
||||||
|
def make_init_payment_payload(**overrides: Any) -> dict[str, Any]:
|
||||||
|
"""Return a valid camelCase JSON payload for /api/v1/delivery/order."""
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"senderAddress": {
|
||||||
|
"cityId": 1,
|
||||||
|
"city": "Дубай",
|
||||||
|
"street": "Sheikh Zayed Road",
|
||||||
|
"house": "10",
|
||||||
|
"apartment": "201",
|
||||||
|
"zip": "12345",
|
||||||
|
"comment": "Domofon 12",
|
||||||
|
},
|
||||||
|
"senderContact": {
|
||||||
|
"fullName": "Petr Petrov",
|
||||||
|
"email": "sender@example.com",
|
||||||
|
"phone": "+79009876543",
|
||||||
|
"phoneExt": None,
|
||||||
|
"isCompany": False,
|
||||||
|
"companyName": None,
|
||||||
|
"inn": None,
|
||||||
|
"kpp": None,
|
||||||
|
},
|
||||||
|
"receiverAddress": {
|
||||||
|
"cityId": 2,
|
||||||
|
"city": "Шарджа",
|
||||||
|
"street": "Al Wahda",
|
||||||
|
"house": "5",
|
||||||
|
"apartment": None,
|
||||||
|
"zip": "54321",
|
||||||
|
"comment": None,
|
||||||
|
},
|
||||||
|
"receiverContact": {
|
||||||
|
"fullName": "Ivan Ivanov",
|
||||||
|
"email": "ivan@example.com",
|
||||||
|
"phone": "+79001234567",
|
||||||
|
"phoneExt": "101",
|
||||||
|
"isCompany": False,
|
||||||
|
"companyName": None,
|
||||||
|
"inn": None,
|
||||||
|
"kpp": None,
|
||||||
|
},
|
||||||
|
"content": {
|
||||||
|
"description": "Headphones",
|
||||||
|
},
|
||||||
|
"pickupDate": "2026-05-15T10:00:00.000Z",
|
||||||
|
"deliveryDate": "2026-05-18T18:00:00.000Z",
|
||||||
|
"accountEmail": "client@example.com",
|
||||||
|
"agreePrivacy": True,
|
||||||
|
"agreeTerms": True,
|
||||||
|
"systemData": {
|
||||||
|
"tariff": {
|
||||||
|
"provider": "СДЭК",
|
||||||
|
"serviceName": "Экспресс лайт",
|
||||||
|
"price": 125000,
|
||||||
|
"deliveryDaysMin": 1,
|
||||||
|
"deliveryDaysMax": 2,
|
||||||
|
"tariffCode": 535,
|
||||||
|
},
|
||||||
|
"parcelType": "parcel",
|
||||||
|
"docPackaging": None,
|
||||||
|
"weight": "1.0",
|
||||||
|
"dimensions": {
|
||||||
|
"length": "20",
|
||||||
|
"width": "15",
|
||||||
|
"height": "10",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
def make_init_payment_request(**overrides: Any) -> InitPaymentRequest:
|
||||||
|
return InitPaymentRequest.model_validate(make_init_payment_payload(**overrides))
|
||||||
+16
@@ -179,11 +179,27 @@ adapter:
|
|||||||
cdek_timeout_seconds: 10.0
|
cdek_timeout_seconds: 10.0
|
||||||
cdek_cache_ttl_seconds: 900
|
cdek_cache_ttl_seconds: 900
|
||||||
|
|
||||||
|
tbank_payment:
|
||||||
|
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||||
|
notification_url: "https://merchant.test/api/v1/delivery/tbank/notifications"
|
||||||
|
success_url: "https://merchant.test/payment/success"
|
||||||
|
auth:
|
||||||
|
terminal_key: "test-terminal-key"
|
||||||
|
password: "test-password"
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
dsn: "postgresql+asyncpg://postgres:postgres@localhost:5432/repository_test"
|
||||||
|
|
||||||
observability:
|
observability:
|
||||||
enabled: false
|
enabled: false
|
||||||
service_name: "repository-test-service"
|
service_name: "repository-test-service"
|
||||||
otlp_endpoint: "http://collector:4317"
|
otlp_endpoint: "http://collector:4317"
|
||||||
otlp_insecure: true
|
otlp_insecure: true
|
||||||
|
|
||||||
|
email:
|
||||||
|
smtp_host: "smtp.test"
|
||||||
|
smtp_port: 587
|
||||||
|
from_address: "no-reply@test"
|
||||||
""".strip(),
|
""".strip(),
|
||||||
encoding="utf-8",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,598 @@
|
|||||||
|
import asyncio
|
||||||
|
from collections.abc import Awaitable, Callable
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
from sqlalchemy.ext.asyncio import create_async_engine
|
||||||
|
|
||||||
|
from app.repositories.order import OrderData, OrderRepository
|
||||||
|
from app.repositories.order.models import Base, Order
|
||||||
|
from tests.payment_fixtures import make_init_payment_payload
|
||||||
|
|
||||||
|
|
||||||
|
def _make_order_data(**overrides: object) -> OrderData:
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"order_uuid": "order-uuid-1",
|
||||||
|
"payment_url": "https://pay.test/payment/1",
|
||||||
|
"price": 125000,
|
||||||
|
"tariff_code": 535,
|
||||||
|
"account_email": "client@example.com",
|
||||||
|
"payload": make_init_payment_payload(),
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
return OrderData(**payload)
|
||||||
|
|
||||||
|
|
||||||
|
async def _with_repository(
|
||||||
|
test_fn: Callable[
|
||||||
|
[OrderRepository, async_sessionmaker[AsyncSession]],
|
||||||
|
Awaitable[None],
|
||||||
|
],
|
||||||
|
) -> None:
|
||||||
|
engine = create_async_engine("sqlite+aiosqlite:///:memory:")
|
||||||
|
try:
|
||||||
|
async with engine.begin() as connection:
|
||||||
|
await connection.run_sync(Base.metadata.create_all)
|
||||||
|
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
await test_fn(OrderRepository(session_factory=session_factory), session_factory)
|
||||||
|
finally:
|
||||||
|
await engine.dispose()
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_order_persists_all_required_fields() -> None:
|
||||||
|
async def run(
|
||||||
|
repository: OrderRepository,
|
||||||
|
session_factory: async_sessionmaker[AsyncSession],
|
||||||
|
) -> None:
|
||||||
|
order_data = _make_order_data()
|
||||||
|
|
||||||
|
async with repository.session() as session:
|
||||||
|
order = await repository.create_order(session, order_data)
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Order).where(Order.order_uuid == "order-uuid-1")
|
||||||
|
)
|
||||||
|
persisted_order = result.scalar_one()
|
||||||
|
|
||||||
|
assert order.id == persisted_order.id
|
||||||
|
assert persisted_order.order_uuid == "order-uuid-1"
|
||||||
|
assert persisted_order.payment_url == "https://pay.test/payment/1"
|
||||||
|
assert persisted_order.price == 125000
|
||||||
|
assert persisted_order.tariff_code == 535
|
||||||
|
assert persisted_order.account_email == "client@example.com"
|
||||||
|
assert persisted_order.payload == order_data.payload
|
||||||
|
assert persisted_order.payment_status is None
|
||||||
|
assert persisted_order.tbank_payment_id is None
|
||||||
|
assert persisted_order.cdek_order_uuid is None
|
||||||
|
assert persisted_order.cdek_waybill_uuid is None
|
||||||
|
assert persisted_order.cdek_waybill_url is None
|
||||||
|
assert persisted_order.created_at is not None
|
||||||
|
assert persisted_order.updated_at is not None
|
||||||
|
|
||||||
|
asyncio.run(_with_repository(run))
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_order_rejects_duplicate_order_uuid() -> None:
|
||||||
|
async def run(
|
||||||
|
repository: OrderRepository,
|
||||||
|
_session_factory: async_sessionmaker[AsyncSession],
|
||||||
|
) -> None:
|
||||||
|
order_data = _make_order_data()
|
||||||
|
|
||||||
|
async with repository.session() as session:
|
||||||
|
await repository.create_order(session, order_data)
|
||||||
|
|
||||||
|
with pytest.raises(IntegrityError):
|
||||||
|
async with repository.session() as session:
|
||||||
|
await repository.create_order(session, order_data)
|
||||||
|
|
||||||
|
asyncio.run(_with_repository(run))
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_order_by_order_uuid_returns_persisted_order() -> None:
|
||||||
|
async def run(
|
||||||
|
repository: OrderRepository,
|
||||||
|
_session_factory: async_sessionmaker[AsyncSession],
|
||||||
|
) -> None:
|
||||||
|
order_data = _make_order_data()
|
||||||
|
|
||||||
|
async with repository.session() as session:
|
||||||
|
await repository.create_order(session, order_data)
|
||||||
|
|
||||||
|
async with repository.session() as session:
|
||||||
|
order = await repository.get_order_by_order_uuid(session, "order-uuid-1")
|
||||||
|
|
||||||
|
assert order is not None
|
||||||
|
assert order.order_uuid == "order-uuid-1"
|
||||||
|
assert order.payment_url == "https://pay.test/payment/1"
|
||||||
|
|
||||||
|
asyncio.run(_with_repository(run))
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_order_by_order_uuid_returns_none_for_missing_order() -> None:
|
||||||
|
async def run(
|
||||||
|
repository: OrderRepository,
|
||||||
|
_session_factory: async_sessionmaker[AsyncSession],
|
||||||
|
) -> None:
|
||||||
|
async with repository.session() as session:
|
||||||
|
order = await repository.get_order_by_order_uuid(session, "missing-order")
|
||||||
|
|
||||||
|
assert order is None
|
||||||
|
|
||||||
|
asyncio.run(_with_repository(run))
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_payment_status_persists_status_and_payment_id() -> None:
|
||||||
|
async def run(
|
||||||
|
repository: OrderRepository,
|
||||||
|
session_factory: async_sessionmaker[AsyncSession],
|
||||||
|
) -> None:
|
||||||
|
async with repository.session() as session:
|
||||||
|
await repository.create_order(session, _make_order_data())
|
||||||
|
|
||||||
|
async with repository.session() as session:
|
||||||
|
order = await repository.mark_payment_status(
|
||||||
|
session,
|
||||||
|
"order-uuid-1",
|
||||||
|
"CONFIRMED",
|
||||||
|
8347568144,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Order).where(Order.order_uuid == "order-uuid-1")
|
||||||
|
)
|
||||||
|
persisted_order = result.scalar_one()
|
||||||
|
|
||||||
|
assert order is not None
|
||||||
|
assert persisted_order.payment_status == "CONFIRMED"
|
||||||
|
assert persisted_order.tbank_payment_id == 8347568144
|
||||||
|
|
||||||
|
asyncio.run(_with_repository(run))
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_payment_status_returns_none_for_missing_order() -> None:
|
||||||
|
async def run(
|
||||||
|
repository: OrderRepository,
|
||||||
|
_session_factory: async_sessionmaker[AsyncSession],
|
||||||
|
) -> None:
|
||||||
|
async with repository.session() as session:
|
||||||
|
order = await repository.mark_payment_status(
|
||||||
|
session,
|
||||||
|
"missing-order",
|
||||||
|
"CONFIRMED",
|
||||||
|
8347568144,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert order is None
|
||||||
|
|
||||||
|
asyncio.run(_with_repository(run))
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_cdek_order_registered_persists_cdek_order_uuid_only() -> None:
|
||||||
|
async def run(
|
||||||
|
repository: OrderRepository,
|
||||||
|
session_factory: async_sessionmaker[AsyncSession],
|
||||||
|
) -> None:
|
||||||
|
async with repository.session() as session:
|
||||||
|
await repository.create_order(session, _make_order_data())
|
||||||
|
|
||||||
|
async with repository.session() as session:
|
||||||
|
order = await repository.mark_cdek_order_registered(
|
||||||
|
session,
|
||||||
|
"order-uuid-1",
|
||||||
|
"cdek-order-uuid-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
result = await session.execute(
|
||||||
|
select(Order).where(Order.order_uuid == "order-uuid-1")
|
||||||
|
)
|
||||||
|
persisted_order = result.scalar_one()
|
||||||
|
|
||||||
|
assert order is not None
|
||||||
|
assert persisted_order.cdek_order_uuid == "cdek-order-uuid-1"
|
||||||
|
assert persisted_order.cdek_waybill_uuid is None
|
||||||
|
assert persisted_order.cdek_waybill_url is None
|
||||||
|
|
||||||
|
asyncio.run(_with_repository(run))
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_cdek_order_registered_is_idempotent_for_same_uuid() -> None:
|
||||||
|
async def run(
|
||||||
|
repository: OrderRepository,
|
||||||
|
session_factory: async_sessionmaker[AsyncSession],
|
||||||
|
) -> None:
|
||||||
|
async with repository.session() as session:
|
||||||
|
await repository.create_order(session, _make_order_data())
|
||||||
|
|
||||||
|
async with repository.session() as session:
|
||||||
|
await repository.mark_cdek_order_registered(
|
||||||
|
session,
|
||||||
|
"order-uuid-1",
|
||||||
|
"cdek-order-uuid-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
async with repository.session() as session:
|
||||||
|
await repository.mark_cdek_order_registered(
|
||||||
|
session,
|
||||||
|
"order-uuid-1",
|
||||||
|
"cdek-order-uuid-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
result = await session.execute(select(Order))
|
||||||
|
orders = result.scalars().all()
|
||||||
|
|
||||||
|
assert len(orders) == 1
|
||||||
|
assert orders[0].cdek_order_uuid == "cdek-order-uuid-1"
|
||||||
|
|
||||||
|
asyncio.run(_with_repository(run))
|
||||||
|
|
||||||
|
|
||||||
|
def test_mark_cdek_order_registered_returns_none_for_missing_order() -> None:
|
||||||
|
async def run(
|
||||||
|
repository: OrderRepository,
|
||||||
|
_session_factory: async_sessionmaker[AsyncSession],
|
||||||
|
) -> None:
|
||||||
|
async with repository.session() as session:
|
||||||
|
order = await repository.mark_cdek_order_registered(
|
||||||
|
session,
|
||||||
|
"missing-order",
|
||||||
|
"cdek-order-uuid-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert order is None
|
||||||
|
|
||||||
|
asyncio.run(_with_repository(run))
|
||||||
|
|
||||||
|
|
||||||
|
async def _seed_order(
|
||||||
|
repository: OrderRepository,
|
||||||
|
*,
|
||||||
|
order_uuid: str,
|
||||||
|
cdek_order_uuid: str | None,
|
||||||
|
cdek_order_status: str | None = None,
|
||||||
|
cdek_waybill_uuid: str | None = None,
|
||||||
|
cdek_waybill_url: str | None = None,
|
||||||
|
cdek_polled_at: datetime | None = None,
|
||||||
|
) -> None:
|
||||||
|
async with repository.session() as session:
|
||||||
|
await repository.create_order(
|
||||||
|
session, _make_order_data(order_uuid=order_uuid)
|
||||||
|
)
|
||||||
|
if cdek_order_uuid is not None:
|
||||||
|
await repository.mark_cdek_order_registered(
|
||||||
|
session, order_uuid, cdek_order_uuid
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
cdek_order_status is not None
|
||||||
|
or cdek_waybill_uuid is not None
|
||||||
|
or cdek_polled_at is not None
|
||||||
|
):
|
||||||
|
order = await repository.get_order_by_order_uuid(session, order_uuid)
|
||||||
|
assert order is not None
|
||||||
|
if cdek_order_status is not None:
|
||||||
|
order.cdek_order_status = cdek_order_status
|
||||||
|
if cdek_waybill_uuid is not None:
|
||||||
|
order.cdek_waybill_uuid = cdek_waybill_uuid
|
||||||
|
if cdek_polled_at is not None:
|
||||||
|
order.cdek_polled_at = cdek_polled_at
|
||||||
|
if cdek_waybill_url is not None:
|
||||||
|
order = await repository.get_order_by_order_uuid(session, order_uuid)
|
||||||
|
assert order is not None
|
||||||
|
order.cdek_waybill_url = cdek_waybill_url
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_orders_pending_waybill_returns_orders_without_url() -> None:
|
||||||
|
async def run(
|
||||||
|
repository: OrderRepository,
|
||||||
|
_session_factory: async_sessionmaker[AsyncSession],
|
||||||
|
) -> None:
|
||||||
|
await _seed_order(repository, order_uuid="pending", cdek_order_uuid="o1")
|
||||||
|
await _seed_order(
|
||||||
|
repository,
|
||||||
|
order_uuid="done",
|
||||||
|
cdek_order_uuid="o2",
|
||||||
|
cdek_waybill_uuid="w2",
|
||||||
|
cdek_waybill_url="https://cdek.test/2.pdf",
|
||||||
|
)
|
||||||
|
await _seed_order(
|
||||||
|
repository,
|
||||||
|
order_uuid="invalid",
|
||||||
|
cdek_order_uuid="o3",
|
||||||
|
cdek_order_status="INVALID",
|
||||||
|
)
|
||||||
|
await _seed_order(repository, order_uuid="no-cdek", cdek_order_uuid=None)
|
||||||
|
|
||||||
|
async with repository.session() as session:
|
||||||
|
orders = await repository.list_orders_pending_waybill(session, limit=10)
|
||||||
|
|
||||||
|
assert [order.order_uuid for order in orders] == ["pending"]
|
||||||
|
|
||||||
|
asyncio.run(_with_repository(run))
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_orders_pending_waybill_orders_polled_at_nulls_first() -> None:
|
||||||
|
earlier = datetime(2026, 5, 24, 10, 0, tzinfo=timezone.utc)
|
||||||
|
later = datetime(2026, 5, 24, 11, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
async def run(
|
||||||
|
repository: OrderRepository,
|
||||||
|
_session_factory: async_sessionmaker[AsyncSession],
|
||||||
|
) -> None:
|
||||||
|
await _seed_order(
|
||||||
|
repository,
|
||||||
|
order_uuid="late",
|
||||||
|
cdek_order_uuid="o-late",
|
||||||
|
cdek_polled_at=later,
|
||||||
|
)
|
||||||
|
await _seed_order(
|
||||||
|
repository,
|
||||||
|
order_uuid="early",
|
||||||
|
cdek_order_uuid="o-early",
|
||||||
|
cdek_polled_at=earlier,
|
||||||
|
)
|
||||||
|
await _seed_order(repository, order_uuid="never", cdek_order_uuid="o-never")
|
||||||
|
|
||||||
|
async with repository.session() as session:
|
||||||
|
orders = await repository.list_orders_pending_waybill(session, limit=10)
|
||||||
|
|
||||||
|
assert [order.order_uuid for order in orders] == ["never", "early", "late"]
|
||||||
|
|
||||||
|
asyncio.run(_with_repository(run))
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_order_poll_sets_status_and_waybill_uuid() -> None:
|
||||||
|
polled = datetime(2026, 5, 24, 12, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
async def run(
|
||||||
|
repository: OrderRepository,
|
||||||
|
_session_factory: async_sessionmaker[AsyncSession],
|
||||||
|
) -> None:
|
||||||
|
await _seed_order(repository, order_uuid="o", cdek_order_uuid="cdek-o")
|
||||||
|
|
||||||
|
async with repository.session() as session:
|
||||||
|
order = await repository.record_order_poll(
|
||||||
|
session,
|
||||||
|
order_uuid="o",
|
||||||
|
order_status="ACCEPTED",
|
||||||
|
waybill_uuid="waybill-1",
|
||||||
|
polled_at=polled,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert order is not None
|
||||||
|
assert order.cdek_order_status == "ACCEPTED"
|
||||||
|
assert order.cdek_waybill_uuid == "waybill-1"
|
||||||
|
assert order.cdek_polled_at == polled
|
||||||
|
|
||||||
|
asyncio.run(_with_repository(run))
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_order_poll_does_not_overwrite_existing_waybill_uuid() -> None:
|
||||||
|
polled = datetime(2026, 5, 24, 12, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
async def run(
|
||||||
|
repository: OrderRepository,
|
||||||
|
_session_factory: async_sessionmaker[AsyncSession],
|
||||||
|
) -> None:
|
||||||
|
await _seed_order(
|
||||||
|
repository,
|
||||||
|
order_uuid="o",
|
||||||
|
cdek_order_uuid="cdek-o",
|
||||||
|
cdek_waybill_uuid="existing-waybill",
|
||||||
|
)
|
||||||
|
|
||||||
|
async with repository.session() as session:
|
||||||
|
order = await repository.record_order_poll(
|
||||||
|
session,
|
||||||
|
order_uuid="o",
|
||||||
|
order_status="ACCEPTED",
|
||||||
|
waybill_uuid="new-waybill",
|
||||||
|
polled_at=polled,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert order is not None
|
||||||
|
assert order.cdek_waybill_uuid == "existing-waybill"
|
||||||
|
|
||||||
|
asyncio.run(_with_repository(run))
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_waybill_poll_sets_url_only_when_previously_null() -> None:
|
||||||
|
polled = datetime(2026, 5, 24, 13, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
async def run(
|
||||||
|
repository: OrderRepository,
|
||||||
|
_session_factory: async_sessionmaker[AsyncSession],
|
||||||
|
) -> None:
|
||||||
|
await _seed_order(
|
||||||
|
repository,
|
||||||
|
order_uuid="o",
|
||||||
|
cdek_order_uuid="cdek-o",
|
||||||
|
cdek_waybill_uuid="waybill-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
async with repository.session() as session:
|
||||||
|
order = await repository.record_waybill_poll(
|
||||||
|
session,
|
||||||
|
order_uuid="o",
|
||||||
|
waybill_url="https://cdek.test/1.pdf",
|
||||||
|
polled_at=polled,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert order is not None
|
||||||
|
assert order.cdek_waybill_url == "https://cdek.test/1.pdf"
|
||||||
|
assert order.cdek_polled_at == polled
|
||||||
|
|
||||||
|
async with repository.session() as session:
|
||||||
|
order = await repository.record_waybill_poll(
|
||||||
|
session,
|
||||||
|
order_uuid="o",
|
||||||
|
waybill_url="https://cdek.test/REPLACED.pdf",
|
||||||
|
polled_at=polled,
|
||||||
|
)
|
||||||
|
assert order is not None
|
||||||
|
assert order.cdek_waybill_url == "https://cdek.test/1.pdf"
|
||||||
|
|
||||||
|
asyncio.run(_with_repository(run))
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_orders_pending_waybill_email_returns_orders_with_url_and_no_sent_at() -> None:
|
||||||
|
async def run(
|
||||||
|
repository: OrderRepository,
|
||||||
|
_session_factory: async_sessionmaker[AsyncSession],
|
||||||
|
) -> None:
|
||||||
|
await _seed_order(
|
||||||
|
repository,
|
||||||
|
order_uuid="ready",
|
||||||
|
cdek_order_uuid="o1",
|
||||||
|
cdek_waybill_uuid="w1",
|
||||||
|
cdek_waybill_url="https://cdek.test/1.pdf",
|
||||||
|
)
|
||||||
|
await _seed_order(
|
||||||
|
repository,
|
||||||
|
order_uuid="no-url",
|
||||||
|
cdek_order_uuid="o2",
|
||||||
|
cdek_waybill_uuid="w2",
|
||||||
|
)
|
||||||
|
await _seed_order(
|
||||||
|
repository,
|
||||||
|
order_uuid="already-sent",
|
||||||
|
cdek_order_uuid="o3",
|
||||||
|
cdek_waybill_uuid="w3",
|
||||||
|
cdek_waybill_url="https://cdek.test/3.pdf",
|
||||||
|
)
|
||||||
|
async with repository.session() as session:
|
||||||
|
sent = await repository.get_order_by_order_uuid(session, "already-sent")
|
||||||
|
assert sent is not None
|
||||||
|
sent.waybill_email_sent_at = datetime(
|
||||||
|
2026, 5, 24, 12, 0, tzinfo=timezone.utc
|
||||||
|
)
|
||||||
|
|
||||||
|
async with repository.session() as session:
|
||||||
|
orders = await repository.list_orders_pending_waybill_email(
|
||||||
|
session, limit=10
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [order.order_uuid for order in orders] == ["ready"]
|
||||||
|
|
||||||
|
asyncio.run(_with_repository(run))
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_orders_pending_waybill_email_orders_by_created_at_asc() -> None:
|
||||||
|
async def run(
|
||||||
|
repository: OrderRepository,
|
||||||
|
_session_factory: async_sessionmaker[AsyncSession],
|
||||||
|
) -> None:
|
||||||
|
await _seed_order(
|
||||||
|
repository,
|
||||||
|
order_uuid="first",
|
||||||
|
cdek_order_uuid="o1",
|
||||||
|
cdek_waybill_uuid="w1",
|
||||||
|
cdek_waybill_url="https://cdek.test/1.pdf",
|
||||||
|
)
|
||||||
|
await _seed_order(
|
||||||
|
repository,
|
||||||
|
order_uuid="second",
|
||||||
|
cdek_order_uuid="o2",
|
||||||
|
cdek_waybill_uuid="w2",
|
||||||
|
cdek_waybill_url="https://cdek.test/2.pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
async with repository.session() as session:
|
||||||
|
orders = await repository.list_orders_pending_waybill_email(
|
||||||
|
session, limit=10
|
||||||
|
)
|
||||||
|
|
||||||
|
assert [order.order_uuid for order in orders] == ["first", "second"]
|
||||||
|
|
||||||
|
asyncio.run(_with_repository(run))
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_waybill_email_sent_sets_timestamp_once() -> None:
|
||||||
|
first_sent = datetime(2026, 5, 24, 12, 0, tzinfo=timezone.utc)
|
||||||
|
second_sent = datetime(2026, 5, 24, 13, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
async def run(
|
||||||
|
repository: OrderRepository,
|
||||||
|
_session_factory: async_sessionmaker[AsyncSession],
|
||||||
|
) -> None:
|
||||||
|
await _seed_order(
|
||||||
|
repository,
|
||||||
|
order_uuid="o",
|
||||||
|
cdek_order_uuid="cdek-o",
|
||||||
|
cdek_waybill_uuid="w",
|
||||||
|
cdek_waybill_url="https://cdek.test/1.pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
async with repository.session() as session:
|
||||||
|
order = await repository.record_waybill_email_sent(
|
||||||
|
session, order_uuid="o", sent_at=first_sent
|
||||||
|
)
|
||||||
|
|
||||||
|
assert order is not None
|
||||||
|
assert order.waybill_email_sent_at == first_sent
|
||||||
|
|
||||||
|
async with repository.session() as session:
|
||||||
|
order = await repository.record_waybill_email_sent(
|
||||||
|
session, order_uuid="o", sent_at=second_sent
|
||||||
|
)
|
||||||
|
|
||||||
|
assert order is not None
|
||||||
|
assert order.waybill_email_sent_at is not None
|
||||||
|
assert order.waybill_email_sent_at.replace(
|
||||||
|
tzinfo=None
|
||||||
|
) == first_sent.replace(tzinfo=None)
|
||||||
|
|
||||||
|
asyncio.run(_with_repository(run))
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_waybill_email_sent_returns_none_for_missing_order() -> None:
|
||||||
|
async def run(
|
||||||
|
repository: OrderRepository,
|
||||||
|
_session_factory: async_sessionmaker[AsyncSession],
|
||||||
|
) -> None:
|
||||||
|
async with repository.session() as session:
|
||||||
|
order = await repository.record_waybill_email_sent(
|
||||||
|
session,
|
||||||
|
order_uuid="missing",
|
||||||
|
sent_at=datetime(2026, 5, 24, 12, 0, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert order is None
|
||||||
|
|
||||||
|
asyncio.run(_with_repository(run))
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_waybill_poll_updates_polled_at_when_url_is_none() -> None:
|
||||||
|
polled = datetime(2026, 5, 24, 13, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
async def run(
|
||||||
|
repository: OrderRepository,
|
||||||
|
_session_factory: async_sessionmaker[AsyncSession],
|
||||||
|
) -> None:
|
||||||
|
await _seed_order(
|
||||||
|
repository,
|
||||||
|
order_uuid="o",
|
||||||
|
cdek_order_uuid="cdek-o",
|
||||||
|
cdek_waybill_uuid="waybill-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
async with repository.session() as session:
|
||||||
|
order = await repository.record_waybill_poll(
|
||||||
|
session,
|
||||||
|
order_uuid="o",
|
||||||
|
waybill_url=None,
|
||||||
|
polled_at=polled,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert order is not None
|
||||||
|
assert order.cdek_waybill_url is None
|
||||||
|
assert order.cdek_polled_at == polled
|
||||||
|
|
||||||
|
asyncio.run(_with_repository(run))
|
||||||
@@ -218,12 +218,12 @@ def test_get_all_prices_cache_hit_skips_provider_call() -> None:
|
|||||||
StubCache(
|
StubCache(
|
||||||
forced_get_value=[
|
forced_get_value=[
|
||||||
_make_price(
|
_make_price(
|
||||||
"cdek",
|
"other",
|
||||||
"100.40",
|
"100.40",
|
||||||
service_name="DOCUMENT EXPRESS",
|
service_name="DOCUMENT EXPRESS",
|
||||||
).model_dump(mode="json"),
|
).model_dump(mode="json"),
|
||||||
_make_price(
|
_make_price(
|
||||||
"cdek",
|
"other",
|
||||||
"200.40",
|
"200.40",
|
||||||
service_name="Economy parcel",
|
service_name="Economy parcel",
|
||||||
).model_dump(mode="json"),
|
).model_dump(mode="json"),
|
||||||
@@ -238,10 +238,10 @@ def test_get_all_prices_applies_same_parcel_type_filter_for_fresh_and_cached_res
|
|||||||
expected_provider_calls: int,
|
expected_provider_calls: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
provider = StubProvider(
|
provider = StubProvider(
|
||||||
name="cdek",
|
name="other",
|
||||||
response=[
|
response=[
|
||||||
_make_price("cdek", "100.40", service_name="DOCUMENT EXPRESS"),
|
_make_price("other", "100.40", service_name="DOCUMENT EXPRESS"),
|
||||||
_make_price("cdek", "200.40", service_name="Economy parcel"),
|
_make_price("other", "200.40", service_name="Economy parcel"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
service = AggregatorService(
|
service = AggregatorService(
|
||||||
|
|||||||
@@ -0,0 +1,282 @@
|
|||||||
|
import asyncio
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.adapters.delivery_providers.base import ProviderClientError, ProviderRequestError
|
||||||
|
from app.adapters.tbank.base import (
|
||||||
|
TBankPaymentAdapterError,
|
||||||
|
TBankPaymentRequestError,
|
||||||
|
)
|
||||||
|
from app.repositories.order import OrderData
|
||||||
|
from app.schemas.payment import InitPaymentRequest, InitPaymentResponse
|
||||||
|
from app.schemas.response import DeliveryPrice
|
||||||
|
from app.services.aggregator import (
|
||||||
|
AggregatorService,
|
||||||
|
InitPaymentUnavailableError,
|
||||||
|
InvalidInitPaymentRequestError,
|
||||||
|
)
|
||||||
|
from tests.payment_fixtures import make_init_payment_payload, make_init_payment_request
|
||||||
|
|
||||||
|
|
||||||
|
class StubPaymentAdapter:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
response: str | None = None,
|
||||||
|
error: Exception | None = None,
|
||||||
|
events: list[str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._response = response
|
||||||
|
self._error = error
|
||||||
|
self._events = events
|
||||||
|
self.calls: list[tuple[str, int]] = []
|
||||||
|
|
||||||
|
async def create_payment_link(self, order_uuid: str, amount_kopecks: int) -> str:
|
||||||
|
self.calls.append((order_uuid, amount_kopecks))
|
||||||
|
if self._events is not None:
|
||||||
|
self._events.append("tbank")
|
||||||
|
if self._error is not None:
|
||||||
|
raise self._error
|
||||||
|
if self._response is None:
|
||||||
|
raise RuntimeError("Stub payment adapter has no response configured.")
|
||||||
|
return self._response
|
||||||
|
|
||||||
|
|
||||||
|
class StubPaymentPriceValidationAdapter:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
response: DeliveryPrice | None = None,
|
||||||
|
error: Exception | None = None,
|
||||||
|
events: list[str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._response = response
|
||||||
|
self._error = error
|
||||||
|
self._events = events
|
||||||
|
self.calls: list[InitPaymentRequest] = []
|
||||||
|
|
||||||
|
async def get_payment_price(
|
||||||
|
self,
|
||||||
|
request: InitPaymentRequest,
|
||||||
|
) -> DeliveryPrice | None:
|
||||||
|
self.calls.append(request)
|
||||||
|
if self._events is not None:
|
||||||
|
self._events.append("cdek")
|
||||||
|
if self._error is not None:
|
||||||
|
raise self._error
|
||||||
|
return self._response
|
||||||
|
|
||||||
|
|
||||||
|
class StubOrderSessionContext:
|
||||||
|
def __init__(self, session: object) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def __aenter__(self) -> object:
|
||||||
|
return self._session
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, traceback) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class StubOrderRepository:
|
||||||
|
def __init__(self, *, error: Exception | None = None) -> None:
|
||||||
|
self._error = error
|
||||||
|
self.session_value = object()
|
||||||
|
self.calls: list[tuple[object, OrderData]] = []
|
||||||
|
|
||||||
|
def session(self) -> StubOrderSessionContext:
|
||||||
|
return StubOrderSessionContext(self.session_value)
|
||||||
|
|
||||||
|
async def create_order(self, session: object, order_data: OrderData) -> object:
|
||||||
|
self.calls.append((session, order_data))
|
||||||
|
if self._error is not None:
|
||||||
|
raise self._error
|
||||||
|
return object()
|
||||||
|
|
||||||
|
|
||||||
|
def _make_cdek_price(**overrides: object) -> DeliveryPrice:
|
||||||
|
payload = {
|
||||||
|
"provider": "cdek",
|
||||||
|
"service_name": "CDEK tariff",
|
||||||
|
"price": Decimal("1250.00"),
|
||||||
|
"currency": "RUB",
|
||||||
|
"delivery_days_min": 1,
|
||||||
|
"delivery_days_max": 2,
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
return DeliveryPrice.model_construct(**payload)
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_payment_validates_cdek_price_before_tbank_and_returns_payment_url() -> None:
|
||||||
|
request = make_init_payment_request()
|
||||||
|
events: list[str] = []
|
||||||
|
validation_adapter = StubPaymentPriceValidationAdapter(
|
||||||
|
response=_make_cdek_price(),
|
||||||
|
events=events,
|
||||||
|
)
|
||||||
|
adapter = StubPaymentAdapter(response="https://pay.test/payment/1", events=events)
|
||||||
|
service = AggregatorService(
|
||||||
|
providers=[],
|
||||||
|
payment_adapter=adapter,
|
||||||
|
payment_price_validation_adapter=validation_adapter,
|
||||||
|
order_uuid_factory=lambda: "order-uuid-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(service.init_payment(request))
|
||||||
|
|
||||||
|
assert result == InitPaymentResponse(payment_url="https://pay.test/payment/1")
|
||||||
|
assert validation_adapter.calls == [request]
|
||||||
|
assert adapter.calls == [("order-uuid-1", 125000)]
|
||||||
|
assert events == ["cdek", "tbank"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_payment_persists_order_payload_after_successful_payment_link() -> None:
|
||||||
|
payload = make_init_payment_payload()
|
||||||
|
request = InitPaymentRequest.model_validate(payload)
|
||||||
|
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
|
||||||
|
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
|
||||||
|
order_repository = StubOrderRepository()
|
||||||
|
service = AggregatorService(
|
||||||
|
providers=[],
|
||||||
|
payment_adapter=adapter,
|
||||||
|
payment_price_validation_adapter=validation_adapter,
|
||||||
|
order_repository=order_repository,
|
||||||
|
order_uuid_factory=lambda: "order-uuid-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(service.init_payment(request))
|
||||||
|
|
||||||
|
assert len(order_repository.calls) == 1
|
||||||
|
session, order_data = order_repository.calls[0]
|
||||||
|
assert session is order_repository.session_value
|
||||||
|
assert order_data.order_uuid == "order-uuid-1"
|
||||||
|
assert order_data.payment_url == "https://pay.test/payment/1"
|
||||||
|
assert order_data.price == 125000
|
||||||
|
assert order_data.tariff_code == 535
|
||||||
|
assert order_data.account_email == "client@example.com"
|
||||||
|
assert order_data.payload == request.model_dump(mode="json", by_alias=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_payment_returns_payment_url_when_order_persistence_fails() -> None:
|
||||||
|
request = make_init_payment_request()
|
||||||
|
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
|
||||||
|
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
|
||||||
|
order_repository = StubOrderRepository(error=RuntimeError("database down"))
|
||||||
|
service = AggregatorService(
|
||||||
|
providers=[],
|
||||||
|
payment_adapter=adapter,
|
||||||
|
payment_price_validation_adapter=validation_adapter,
|
||||||
|
order_repository=order_repository,
|
||||||
|
order_uuid_factory=lambda: "order-uuid-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(service.init_payment(request))
|
||||||
|
|
||||||
|
assert result == InitPaymentResponse(payment_url="https://pay.test/payment/1")
|
||||||
|
assert adapter.calls == [("order-uuid-1", 125000)]
|
||||||
|
assert len(order_repository.calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_payment_maps_provider_request_errors_to_invalid_payment_error() -> None:
|
||||||
|
request = make_init_payment_request()
|
||||||
|
adapter = StubPaymentAdapter(error=TBankPaymentRequestError("bad payload"))
|
||||||
|
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
|
||||||
|
service = AggregatorService(
|
||||||
|
providers=[],
|
||||||
|
payment_adapter=adapter,
|
||||||
|
payment_price_validation_adapter=validation_adapter,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(InvalidInitPaymentRequestError):
|
||||||
|
asyncio.run(service.init_payment(request))
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_payment_maps_client_failures_to_unavailable_error() -> None:
|
||||||
|
request = make_init_payment_request()
|
||||||
|
adapter = StubPaymentAdapter(error=TBankPaymentAdapterError("transport down"))
|
||||||
|
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
|
||||||
|
service = AggregatorService(
|
||||||
|
providers=[],
|
||||||
|
payment_adapter=adapter,
|
||||||
|
payment_price_validation_adapter=validation_adapter,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(InitPaymentUnavailableError):
|
||||||
|
asyncio.run(service.init_payment(request))
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_payment_without_configured_adapter_raises_unavailable_error() -> None:
|
||||||
|
service = AggregatorService(providers=[])
|
||||||
|
|
||||||
|
with pytest.raises(InitPaymentUnavailableError):
|
||||||
|
asyncio.run(service.init_payment(make_init_payment_request()))
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_payment_rejects_cdek_price_mismatch_without_tbank_or_repository_calls() -> None:
|
||||||
|
payload = make_init_payment_payload()
|
||||||
|
payload["systemData"]["tariff"]["price"] = 124999
|
||||||
|
request = InitPaymentRequest.model_validate(payload)
|
||||||
|
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
|
||||||
|
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
|
||||||
|
order_repository = StubOrderRepository()
|
||||||
|
service = AggregatorService(
|
||||||
|
providers=[],
|
||||||
|
payment_adapter=adapter,
|
||||||
|
payment_price_validation_adapter=validation_adapter,
|
||||||
|
order_repository=order_repository,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(InvalidInitPaymentRequestError):
|
||||||
|
asyncio.run(service.init_payment(request))
|
||||||
|
|
||||||
|
assert adapter.calls == []
|
||||||
|
assert order_repository.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_payment_rejects_when_cdek_does_not_return_requested_tariff() -> None:
|
||||||
|
request = make_init_payment_request()
|
||||||
|
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
|
||||||
|
validation_adapter = StubPaymentPriceValidationAdapter(response=None)
|
||||||
|
service = AggregatorService(
|
||||||
|
providers=[],
|
||||||
|
payment_adapter=adapter,
|
||||||
|
payment_price_validation_adapter=validation_adapter,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(InvalidInitPaymentRequestError):
|
||||||
|
asyncio.run(service.init_payment(request))
|
||||||
|
|
||||||
|
assert adapter.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_payment_maps_cdek_request_error_to_invalid_payment_error() -> None:
|
||||||
|
request = make_init_payment_request()
|
||||||
|
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
|
||||||
|
validation_adapter = StubPaymentPriceValidationAdapter(
|
||||||
|
error=ProviderRequestError("bad CDEK payload")
|
||||||
|
)
|
||||||
|
service = AggregatorService(
|
||||||
|
providers=[],
|
||||||
|
payment_adapter=adapter,
|
||||||
|
payment_price_validation_adapter=validation_adapter,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(InvalidInitPaymentRequestError):
|
||||||
|
asyncio.run(service.init_payment(request))
|
||||||
|
|
||||||
|
|
||||||
|
def test_init_payment_maps_cdek_client_error_to_unavailable_error() -> None:
|
||||||
|
request = make_init_payment_request()
|
||||||
|
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
|
||||||
|
validation_adapter = StubPaymentPriceValidationAdapter(
|
||||||
|
error=ProviderClientError("CDEK unavailable")
|
||||||
|
)
|
||||||
|
service = AggregatorService(
|
||||||
|
providers=[],
|
||||||
|
payment_adapter=adapter,
|
||||||
|
payment_price_validation_adapter=validation_adapter,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(InitPaymentUnavailableError):
|
||||||
|
asyncio.run(service.init_payment(request))
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import asyncio
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.adapters.delivery_providers.base import ProviderRequestError
|
|
||||||
from app.schemas.order import OrderCreateRequest, OrderCreateResponse
|
|
||||||
from app.services.aggregator import (
|
|
||||||
AggregatorService,
|
|
||||||
InvalidOrderCreateRequestError,
|
|
||||||
OrderCreationUnavailableError,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class StubOrderAdapter:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
response: OrderCreateResponse | None = None,
|
|
||||||
error: Exception | None = None,
|
|
||||||
) -> None:
|
|
||||||
self._response = response
|
|
||||||
self._error = error
|
|
||||||
self.calls: list[OrderCreateRequest] = []
|
|
||||||
|
|
||||||
async def register_order(self, request: OrderCreateRequest) -> OrderCreateResponse:
|
|
||||||
self.calls.append(request)
|
|
||||||
if self._error is not None:
|
|
||||||
raise self._error
|
|
||||||
if self._response is None:
|
|
||||||
raise RuntimeError("Stub order adapter has no response configured.")
|
|
||||||
return self._response
|
|
||||||
|
|
||||||
|
|
||||||
def _make_order_request(**overrides: object) -> OrderCreateRequest:
|
|
||||||
payload: dict[str, object] = {
|
|
||||||
"type": 2,
|
|
||||||
"tariff_code": 535,
|
|
||||||
"comment": "Test order",
|
|
||||||
"sender": {
|
|
||||||
"name": "Petr Petrov",
|
|
||||||
"email": "sender@example.com",
|
|
||||||
"phones": [{"number": "+79009876543"}],
|
|
||||||
},
|
|
||||||
"recipient": {
|
|
||||||
"name": "Ivan Ivanov",
|
|
||||||
"email": "ivan@example.com",
|
|
||||||
"phones": [{"number": "+79001234567"}],
|
|
||||||
},
|
|
||||||
"from_location": {
|
|
||||||
"address": "Lenina 1",
|
|
||||||
"city": "Moscow",
|
|
||||||
"country_code": "RU",
|
|
||||||
},
|
|
||||||
"to_location": {
|
|
||||||
"address": "Pushkina 10",
|
|
||||||
"city": "Novosibirsk",
|
|
||||||
"country_code": "RU",
|
|
||||||
},
|
|
||||||
"services": [{"code": "INSURANCE", "parameter": "1000"}],
|
|
||||||
"packages": [
|
|
||||||
{
|
|
||||||
"number": "1",
|
|
||||||
"weight": 1000,
|
|
||||||
"length": 20,
|
|
||||||
"width": 15,
|
|
||||||
"height": 10,
|
|
||||||
"comment": "Package 1",
|
|
||||||
}
|
|
||||||
],
|
|
||||||
}
|
|
||||||
payload.update(overrides)
|
|
||||||
return OrderCreateRequest(**payload)
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_order_delegates_to_adapter_and_returns_created_order() -> None:
|
|
||||||
request = _make_order_request()
|
|
||||||
adapter = StubOrderAdapter(
|
|
||||||
response=OrderCreateResponse(provider="cdek", order_uuid="order-uuid-1")
|
|
||||||
)
|
|
||||||
service = AggregatorService(providers=[], order_adapter=adapter)
|
|
||||||
|
|
||||||
result = asyncio.run(service.create_order(request))
|
|
||||||
|
|
||||||
assert result == OrderCreateResponse(provider="cdek", order_uuid="order-uuid-1")
|
|
||||||
assert adapter.calls == [request]
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_order_maps_provider_request_errors_to_invalid_order_error() -> None:
|
|
||||||
request = _make_order_request()
|
|
||||||
adapter = StubOrderAdapter(error=ProviderRequestError("bad payload"))
|
|
||||||
service = AggregatorService(providers=[], order_adapter=adapter)
|
|
||||||
|
|
||||||
with pytest.raises(InvalidOrderCreateRequestError):
|
|
||||||
asyncio.run(service.create_order(request))
|
|
||||||
|
|
||||||
assert adapter.calls == [request]
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_order_maps_transport_failures_to_unavailable_error() -> None:
|
|
||||||
request = _make_order_request()
|
|
||||||
adapter = StubOrderAdapter(error=RuntimeError("transport down"))
|
|
||||||
service = AggregatorService(providers=[], order_adapter=adapter)
|
|
||||||
|
|
||||||
with pytest.raises(OrderCreationUnavailableError):
|
|
||||||
asyncio.run(service.create_order(request))
|
|
||||||
|
|
||||||
assert adapter.calls == [request]
|
|
||||||
|
|
||||||
|
|
||||||
def test_create_order_without_configured_adapter_raises_unavailable_error() -> None:
|
|
||||||
service = AggregatorService(providers=[])
|
|
||||||
|
|
||||||
with pytest.raises(OrderCreationUnavailableError):
|
|
||||||
asyncio.run(service.create_order(_make_order_request()))
|
|
||||||
@@ -0,0 +1,453 @@
|
|||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.adapters.delivery_providers.cdek.order_mapper import (
|
||||||
|
CDEKOrderRegistrationResult,
|
||||||
|
)
|
||||||
|
from app.adapters.tbank.base import TBankPaymentNotificationTokenError
|
||||||
|
from app.schemas.payment import InitPaymentRequest, TBankPaymentNotification
|
||||||
|
from app.services.aggregator import (
|
||||||
|
AggregatorService,
|
||||||
|
InvalidTBankPaymentNotificationError,
|
||||||
|
TBankPaymentNotificationProcessingError,
|
||||||
|
)
|
||||||
|
from tests.payment_fixtures import make_init_payment_payload
|
||||||
|
|
||||||
|
|
||||||
|
def _default_payload() -> dict[str, Any]:
|
||||||
|
return make_init_payment_payload()
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StoredOrder:
|
||||||
|
order_uuid: str = "order-uuid-1"
|
||||||
|
payment_url: str = "https://pay.test/payment/1"
|
||||||
|
price: int = 125000
|
||||||
|
tariff_code: int = 535
|
||||||
|
account_email: str = "client@example.com"
|
||||||
|
payload: dict[str, Any] = field(default_factory=_default_payload)
|
||||||
|
payment_status: str | None = None
|
||||||
|
tbank_payment_id: int | None = None
|
||||||
|
cdek_order_uuid: str | None = None
|
||||||
|
cdek_waybill_uuid: str | None = None
|
||||||
|
cdek_waybill_url: str | None = None
|
||||||
|
payment_email_sent_at: object | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class StubPaymentAdapter:
|
||||||
|
def __init__(self, *, verify_error: Exception | None = None) -> None:
|
||||||
|
self._verify_error = verify_error
|
||||||
|
self.notifications: list[TBankPaymentNotification] = []
|
||||||
|
|
||||||
|
async def create_payment_link(self, order_uuid: str, amount_kopecks: int) -> str:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def verify_payment_notification(
|
||||||
|
self,
|
||||||
|
notification: TBankPaymentNotification,
|
||||||
|
) -> None:
|
||||||
|
self.notifications.append(notification)
|
||||||
|
if self._verify_error is not None:
|
||||||
|
raise self._verify_error
|
||||||
|
|
||||||
|
|
||||||
|
class StubOrderSessionContext:
|
||||||
|
def __init__(self, session: object) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def __aenter__(self) -> object:
|
||||||
|
return self._session
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, traceback) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class StubOrderRepository:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
orders: list[StoredOrder] | None = None,
|
||||||
|
mark_cdek_errors: list[Exception | None] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._orders = {order.order_uuid: order for order in orders or []}
|
||||||
|
self._mark_cdek_errors = mark_cdek_errors or []
|
||||||
|
self.session_value = object()
|
||||||
|
self.calls: list[tuple[str, tuple[object, ...]]] = []
|
||||||
|
|
||||||
|
def session(self) -> StubOrderSessionContext:
|
||||||
|
self.calls.append(("session", ()))
|
||||||
|
return StubOrderSessionContext(self.session_value)
|
||||||
|
|
||||||
|
async def create_order(self, session: object, order_data: object) -> object:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
async def get_order_by_order_uuid(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
order_uuid: str,
|
||||||
|
) -> StoredOrder | None:
|
||||||
|
self.calls.append(("get_order_by_order_uuid", (session, order_uuid)))
|
||||||
|
return self._orders.get(order_uuid)
|
||||||
|
|
||||||
|
async def mark_payment_status(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
order_uuid: str,
|
||||||
|
status: str,
|
||||||
|
payment_id: int,
|
||||||
|
) -> StoredOrder | None:
|
||||||
|
self.calls.append(
|
||||||
|
("mark_payment_status", (session, order_uuid, status, payment_id))
|
||||||
|
)
|
||||||
|
order = self._orders.get(order_uuid)
|
||||||
|
if order is None:
|
||||||
|
return None
|
||||||
|
order.payment_status = status
|
||||||
|
order.tbank_payment_id = payment_id
|
||||||
|
return order
|
||||||
|
|
||||||
|
async def mark_cdek_order_registered(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
order_uuid: str,
|
||||||
|
cdek_order_uuid: str,
|
||||||
|
) -> StoredOrder | None:
|
||||||
|
self.calls.append(
|
||||||
|
("mark_cdek_order_registered", (session, order_uuid, cdek_order_uuid))
|
||||||
|
)
|
||||||
|
if self._mark_cdek_errors:
|
||||||
|
error = self._mark_cdek_errors.pop(0)
|
||||||
|
if error is not None:
|
||||||
|
raise error
|
||||||
|
|
||||||
|
order = self._orders.get(order_uuid)
|
||||||
|
if order is None:
|
||||||
|
return None
|
||||||
|
order.cdek_order_uuid = cdek_order_uuid
|
||||||
|
return order
|
||||||
|
|
||||||
|
async def record_payment_email_sent(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
order_uuid: str,
|
||||||
|
sent_at: object,
|
||||||
|
) -> StoredOrder | None:
|
||||||
|
self.calls.append(
|
||||||
|
("record_payment_email_sent", (session, order_uuid, sent_at))
|
||||||
|
)
|
||||||
|
order = self._orders.get(order_uuid)
|
||||||
|
if order is not None and order.payment_email_sent_at is None:
|
||||||
|
order.payment_email_sent_at = sent_at
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
class StubEmailSender:
|
||||||
|
def __init__(self, *, error: Exception | None = None) -> None:
|
||||||
|
self._error = error
|
||||||
|
self.calls: list[dict[str, str]] = []
|
||||||
|
|
||||||
|
async def send_email(
|
||||||
|
self, *, to: str, subject: str, body: str
|
||||||
|
) -> None:
|
||||||
|
self.calls.append({"to": to, "subject": subject, "body": body})
|
||||||
|
if self._error is not None:
|
||||||
|
raise self._error
|
||||||
|
|
||||||
|
|
||||||
|
class StubCDEKOrderAdapter:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
responses: list[CDEKOrderRegistrationResult] | None = None,
|
||||||
|
error: Exception | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._responses = responses or [
|
||||||
|
CDEKOrderRegistrationResult(
|
||||||
|
order_uuid="cdek-order-uuid-1",
|
||||||
|
waybill_uuid=None,
|
||||||
|
waybill_url=None,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
self._error = error
|
||||||
|
self.calls: list[tuple[InitPaymentRequest, str]] = []
|
||||||
|
|
||||||
|
async def register_order(
|
||||||
|
self, request: InitPaymentRequest, order_uuid: str
|
||||||
|
) -> CDEKOrderRegistrationResult:
|
||||||
|
self.calls.append((request, order_uuid))
|
||||||
|
if self._error is not None:
|
||||||
|
raise self._error
|
||||||
|
return self._responses.pop(0)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_notification(**overrides: object) -> TBankPaymentNotification:
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"TerminalKey": "TestTerminal",
|
||||||
|
"OrderId": "order-uuid-1",
|
||||||
|
"Success": True,
|
||||||
|
"Status": "CONFIRMED",
|
||||||
|
"PaymentId": 8347568144,
|
||||||
|
"ErrorCode": "0",
|
||||||
|
"Amount": 125000,
|
||||||
|
"Token": "signed-token",
|
||||||
|
}
|
||||||
|
payload.update(overrides)
|
||||||
|
return TBankPaymentNotification(**payload)
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirmed_notification_registers_cdek_order_and_saves_uuid() -> None:
|
||||||
|
order = StoredOrder()
|
||||||
|
payment_adapter = StubPaymentAdapter()
|
||||||
|
order_repository = StubOrderRepository(orders=[order])
|
||||||
|
cdek_adapter = StubCDEKOrderAdapter(
|
||||||
|
responses=[
|
||||||
|
CDEKOrderRegistrationResult(
|
||||||
|
order_uuid="cdek-order-uuid-1",
|
||||||
|
waybill_uuid="waybill-uuid-1",
|
||||||
|
waybill_url="https://cdek.test/waybill/1.pdf",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
|
service = AggregatorService(
|
||||||
|
providers=[],
|
||||||
|
payment_adapter=payment_adapter,
|
||||||
|
order_repository=order_repository,
|
||||||
|
order_registration_adapter=cdek_adapter,
|
||||||
|
)
|
||||||
|
notification = _make_notification()
|
||||||
|
|
||||||
|
result = asyncio.run(service.handle_tbank_payment_notification(notification))
|
||||||
|
|
||||||
|
assert result == "OK"
|
||||||
|
assert payment_adapter.notifications == [notification]
|
||||||
|
assert order.payment_status == "CONFIRMED"
|
||||||
|
assert order.tbank_payment_id == 8347568144
|
||||||
|
assert order.cdek_order_uuid == "cdek-order-uuid-1"
|
||||||
|
assert order.cdek_waybill_uuid is None
|
||||||
|
assert order.cdek_waybill_url is None
|
||||||
|
assert len(cdek_adapter.calls) == 1
|
||||||
|
assert cdek_adapter.calls[0][1] == "order-uuid-1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_confirmed_notification_does_not_call_cdek() -> None:
|
||||||
|
order = StoredOrder(cdek_order_uuid="existing-cdek-order-uuid")
|
||||||
|
cdek_adapter = StubCDEKOrderAdapter()
|
||||||
|
service = AggregatorService(
|
||||||
|
providers=[],
|
||||||
|
payment_adapter=StubPaymentAdapter(),
|
||||||
|
order_repository=StubOrderRepository(orders=[order]),
|
||||||
|
order_registration_adapter=cdek_adapter,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
service.handle_tbank_payment_notification(_make_notification())
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == "OK"
|
||||||
|
assert cdek_adapter.calls == []
|
||||||
|
assert order.cdek_order_uuid == "existing-cdek-order-uuid"
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_confirmed_notification_acknowledges_without_cdek() -> None:
|
||||||
|
order = StoredOrder()
|
||||||
|
cdek_adapter = StubCDEKOrderAdapter()
|
||||||
|
service = AggregatorService(
|
||||||
|
providers=[],
|
||||||
|
payment_adapter=StubPaymentAdapter(),
|
||||||
|
order_repository=StubOrderRepository(orders=[order]),
|
||||||
|
order_registration_adapter=cdek_adapter,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
service.handle_tbank_payment_notification(
|
||||||
|
_make_notification(Status="AUTHORIZED")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == "OK"
|
||||||
|
assert order.payment_status == "AUTHORIZED"
|
||||||
|
assert cdek_adapter.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_token_does_not_access_repository_or_cdek() -> None:
|
||||||
|
order_repository = StubOrderRepository(orders=[StoredOrder()])
|
||||||
|
cdek_adapter = StubCDEKOrderAdapter()
|
||||||
|
service = AggregatorService(
|
||||||
|
providers=[],
|
||||||
|
payment_adapter=StubPaymentAdapter(
|
||||||
|
verify_error=TBankPaymentNotificationTokenError("bad token")
|
||||||
|
),
|
||||||
|
order_repository=order_repository,
|
||||||
|
order_registration_adapter=cdek_adapter,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(InvalidTBankPaymentNotificationError):
|
||||||
|
asyncio.run(service.handle_tbank_payment_notification(_make_notification()))
|
||||||
|
|
||||||
|
assert order_repository.calls == []
|
||||||
|
assert cdek_adapter.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_order_returns_processing_error_without_cdek() -> None:
|
||||||
|
cdek_adapter = StubCDEKOrderAdapter()
|
||||||
|
service = AggregatorService(
|
||||||
|
providers=[],
|
||||||
|
payment_adapter=StubPaymentAdapter(),
|
||||||
|
order_repository=StubOrderRepository(),
|
||||||
|
order_registration_adapter=cdek_adapter,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(TBankPaymentNotificationProcessingError):
|
||||||
|
asyncio.run(service.handle_tbank_payment_notification(_make_notification()))
|
||||||
|
|
||||||
|
assert cdek_adapter.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_cdek_registration_failure_returns_processing_error() -> None:
|
||||||
|
cdek_adapter = StubCDEKOrderAdapter(error=RuntimeError("cdek down"))
|
||||||
|
service = AggregatorService(
|
||||||
|
providers=[],
|
||||||
|
payment_adapter=StubPaymentAdapter(),
|
||||||
|
order_repository=StubOrderRepository(orders=[StoredOrder()]),
|
||||||
|
order_registration_adapter=cdek_adapter,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(TBankPaymentNotificationProcessingError):
|
||||||
|
asyncio.run(service.handle_tbank_payment_notification(_make_notification()))
|
||||||
|
|
||||||
|
assert len(cdek_adapter.calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_repeated_confirmed_after_cdek_uuid_save_failure_uses_same_external_id() -> None:
|
||||||
|
order = StoredOrder()
|
||||||
|
order_repository = StubOrderRepository(
|
||||||
|
orders=[order],
|
||||||
|
mark_cdek_errors=[RuntimeError("db down"), None],
|
||||||
|
)
|
||||||
|
cdek_adapter = StubCDEKOrderAdapter(
|
||||||
|
responses=[
|
||||||
|
CDEKOrderRegistrationResult(
|
||||||
|
order_uuid="same-cdek-order-uuid",
|
||||||
|
waybill_uuid=None,
|
||||||
|
waybill_url=None,
|
||||||
|
),
|
||||||
|
CDEKOrderRegistrationResult(
|
||||||
|
order_uuid="same-cdek-order-uuid",
|
||||||
|
waybill_uuid=None,
|
||||||
|
waybill_url=None,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
service = AggregatorService(
|
||||||
|
providers=[],
|
||||||
|
payment_adapter=StubPaymentAdapter(),
|
||||||
|
order_repository=order_repository,
|
||||||
|
order_registration_adapter=cdek_adapter,
|
||||||
|
)
|
||||||
|
notification = _make_notification()
|
||||||
|
|
||||||
|
with pytest.raises(TBankPaymentNotificationProcessingError):
|
||||||
|
asyncio.run(service.handle_tbank_payment_notification(notification))
|
||||||
|
|
||||||
|
assert order.cdek_order_uuid is None
|
||||||
|
|
||||||
|
result = asyncio.run(service.handle_tbank_payment_notification(notification))
|
||||||
|
|
||||||
|
assert result == "OK"
|
||||||
|
assert order.cdek_order_uuid == "same-cdek-order-uuid"
|
||||||
|
assert [order_uuid for _, order_uuid in cdek_adapter.calls] == [
|
||||||
|
"order-uuid-1",
|
||||||
|
"order-uuid-1",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirmed_notification_sends_payment_confirmation_email() -> None:
|
||||||
|
order = StoredOrder()
|
||||||
|
email_sender = StubEmailSender()
|
||||||
|
service = AggregatorService(
|
||||||
|
providers=[],
|
||||||
|
payment_adapter=StubPaymentAdapter(),
|
||||||
|
order_repository=StubOrderRepository(orders=[order]),
|
||||||
|
order_registration_adapter=StubCDEKOrderAdapter(),
|
||||||
|
email_sender=email_sender,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
service.handle_tbank_payment_notification(_make_notification())
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == "OK"
|
||||||
|
assert len(email_sender.calls) == 1
|
||||||
|
assert email_sender.calls[0]["to"] == "client@example.com"
|
||||||
|
assert "order-uuid-1" in email_sender.calls[0]["subject"]
|
||||||
|
assert order.payment_email_sent_at is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_confirmed_notification_does_not_send_email() -> None:
|
||||||
|
order = StoredOrder()
|
||||||
|
email_sender = StubEmailSender()
|
||||||
|
service = AggregatorService(
|
||||||
|
providers=[],
|
||||||
|
payment_adapter=StubPaymentAdapter(),
|
||||||
|
order_repository=StubOrderRepository(orders=[order]),
|
||||||
|
order_registration_adapter=StubCDEKOrderAdapter(),
|
||||||
|
email_sender=email_sender,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
service.handle_tbank_payment_notification(
|
||||||
|
_make_notification(Status="AUTHORIZED")
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == "OK"
|
||||||
|
assert email_sender.calls == []
|
||||||
|
assert order.payment_email_sent_at is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_duplicate_notification_does_not_resend_payment_email() -> None:
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
order = StoredOrder(
|
||||||
|
cdek_order_uuid="existing-cdek-order-uuid",
|
||||||
|
payment_email_sent_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
||||||
|
)
|
||||||
|
email_sender = StubEmailSender()
|
||||||
|
service = AggregatorService(
|
||||||
|
providers=[],
|
||||||
|
payment_adapter=StubPaymentAdapter(),
|
||||||
|
order_repository=StubOrderRepository(orders=[order]),
|
||||||
|
order_registration_adapter=StubCDEKOrderAdapter(),
|
||||||
|
email_sender=email_sender,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
service.handle_tbank_payment_notification(_make_notification())
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == "OK"
|
||||||
|
assert email_sender.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_email_failure_does_not_break_notification_handling() -> None:
|
||||||
|
order = StoredOrder()
|
||||||
|
email_sender = StubEmailSender(error=RuntimeError("smtp down"))
|
||||||
|
service = AggregatorService(
|
||||||
|
providers=[],
|
||||||
|
payment_adapter=StubPaymentAdapter(),
|
||||||
|
order_repository=StubOrderRepository(orders=[order]),
|
||||||
|
order_registration_adapter=StubCDEKOrderAdapter(),
|
||||||
|
email_sender=email_sender,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
service.handle_tbank_payment_notification(_make_notification())
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == "OK"
|
||||||
|
assert len(email_sender.calls) == 1
|
||||||
|
assert order.cdek_order_uuid == "cdek-order-uuid-1"
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.services.waybill_email_sender import WaybillEmailSenderService
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StoredOrder:
|
||||||
|
order_uuid: str
|
||||||
|
account_email: str
|
||||||
|
cdek_waybill_url: str | None = None
|
||||||
|
waybill_email_sent_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class StubSessionContext:
|
||||||
|
def __init__(self, session: object) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def __aenter__(self) -> object:
|
||||||
|
return self._session
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, traceback) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class StubRepository:
|
||||||
|
def __init__(self, orders: list[StoredOrder]) -> None:
|
||||||
|
self._orders = {order.order_uuid: order for order in orders}
|
||||||
|
self.session_value = object()
|
||||||
|
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
|
||||||
|
def session(self) -> StubSessionContext:
|
||||||
|
return StubSessionContext(self.session_value)
|
||||||
|
|
||||||
|
async def list_orders_pending_waybill_email(
|
||||||
|
self, session: object, *, limit: int
|
||||||
|
) -> list[StoredOrder]:
|
||||||
|
self.calls.append(("list", {"limit": limit}))
|
||||||
|
return [
|
||||||
|
order
|
||||||
|
for order in self._orders.values()
|
||||||
|
if order.cdek_waybill_url is not None
|
||||||
|
and order.waybill_email_sent_at is None
|
||||||
|
]
|
||||||
|
|
||||||
|
async def record_waybill_email_sent(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
order_uuid: str,
|
||||||
|
sent_at: datetime,
|
||||||
|
) -> StoredOrder | None:
|
||||||
|
self.calls.append(
|
||||||
|
("record", {"order_uuid": order_uuid, "sent_at": sent_at})
|
||||||
|
)
|
||||||
|
order = self._orders.get(order_uuid)
|
||||||
|
if order is None:
|
||||||
|
return None
|
||||||
|
if order.waybill_email_sent_at is None:
|
||||||
|
order.waybill_email_sent_at = sent_at
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
class StubDownloader:
|
||||||
|
def __init__(self, results: dict[str, bytes | Exception]) -> None:
|
||||||
|
self._results = results
|
||||||
|
self.calls: list[str] = []
|
||||||
|
|
||||||
|
async def download_waybill_pdf(self, url: str) -> bytes:
|
||||||
|
self.calls.append(url)
|
||||||
|
result = self._results[url]
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
raise result
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class StubEmailSender:
|
||||||
|
def __init__(self, raise_for: set[str] | None = None) -> None:
|
||||||
|
self.raise_for = raise_for or set()
|
||||||
|
self.calls: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def send_email(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
to: str,
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
attachment_bytes: bytes,
|
||||||
|
attachment_filename: str,
|
||||||
|
) -> None:
|
||||||
|
self.calls.append(
|
||||||
|
{
|
||||||
|
"to": to,
|
||||||
|
"subject": subject,
|
||||||
|
"body": body,
|
||||||
|
"attachment_bytes": attachment_bytes,
|
||||||
|
"attachment_filename": attachment_filename,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if to in self.raise_for:
|
||||||
|
raise RuntimeError(f"smtp blew up for {to}")
|
||||||
|
|
||||||
|
|
||||||
|
_SENT_AT = datetime(2026, 5, 24, 12, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_service(
|
||||||
|
*,
|
||||||
|
repository: StubRepository,
|
||||||
|
downloader: StubDownloader | None = None,
|
||||||
|
email_sender: StubEmailSender | None = None,
|
||||||
|
) -> WaybillEmailSenderService:
|
||||||
|
return WaybillEmailSenderService(
|
||||||
|
order_repository=repository,
|
||||||
|
waybill_downloader=downloader or StubDownloader({}),
|
||||||
|
email_sender=email_sender or StubEmailSender(),
|
||||||
|
batch_size=10,
|
||||||
|
datetime_now=lambda: _SENT_AT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_once_downloads_pdf_sends_email_and_marks_sent() -> None:
|
||||||
|
order = StoredOrder(
|
||||||
|
order_uuid="o-1",
|
||||||
|
account_email="client@example.com",
|
||||||
|
cdek_waybill_url="https://cdek.test/1.pdf",
|
||||||
|
)
|
||||||
|
repo = StubRepository([order])
|
||||||
|
downloader = StubDownloader({"https://cdek.test/1.pdf": b"%PDF"})
|
||||||
|
email_sender = StubEmailSender()
|
||||||
|
service = _make_service(
|
||||||
|
repository=repo, downloader=downloader, email_sender=email_sender
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = asyncio.run(service.poll_once())
|
||||||
|
|
||||||
|
assert summary.processed == 1
|
||||||
|
assert summary.succeeded == 1
|
||||||
|
assert summary.failed == 0
|
||||||
|
assert downloader.calls == ["https://cdek.test/1.pdf"]
|
||||||
|
assert len(email_sender.calls) == 1
|
||||||
|
call = email_sender.calls[0]
|
||||||
|
assert call["to"] == "client@example.com"
|
||||||
|
assert call["subject"] == "Накладная по заказу o-1"
|
||||||
|
assert "o-1" in call["body"]
|
||||||
|
assert "https://cdek.test/1.pdf" in call["body"]
|
||||||
|
assert call["attachment_bytes"] == b"%PDF"
|
||||||
|
assert call["attachment_filename"] == "waybill_o-1.pdf"
|
||||||
|
assert order.waybill_email_sent_at == _SENT_AT
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_once_download_error_keeps_order_pending_and_skips_send() -> None:
|
||||||
|
order = StoredOrder(
|
||||||
|
order_uuid="o-1",
|
||||||
|
account_email="client@example.com",
|
||||||
|
cdek_waybill_url="https://cdek.test/1.pdf",
|
||||||
|
)
|
||||||
|
repo = StubRepository([order])
|
||||||
|
downloader = StubDownloader({"https://cdek.test/1.pdf": RuntimeError("cdek 500")})
|
||||||
|
email_sender = StubEmailSender()
|
||||||
|
service = _make_service(
|
||||||
|
repository=repo, downloader=downloader, email_sender=email_sender
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = asyncio.run(service.poll_once())
|
||||||
|
|
||||||
|
assert summary.processed == 1
|
||||||
|
assert summary.succeeded == 0
|
||||||
|
assert summary.failed == 1
|
||||||
|
assert email_sender.calls == []
|
||||||
|
assert order.waybill_email_sent_at is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_once_smtp_error_keeps_order_pending() -> None:
|
||||||
|
order = StoredOrder(
|
||||||
|
order_uuid="o-1",
|
||||||
|
account_email="bad@example.com",
|
||||||
|
cdek_waybill_url="https://cdek.test/1.pdf",
|
||||||
|
)
|
||||||
|
repo = StubRepository([order])
|
||||||
|
downloader = StubDownloader({"https://cdek.test/1.pdf": b"%PDF"})
|
||||||
|
email_sender = StubEmailSender(raise_for={"bad@example.com"})
|
||||||
|
service = _make_service(
|
||||||
|
repository=repo, downloader=downloader, email_sender=email_sender
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = asyncio.run(service.poll_once())
|
||||||
|
|
||||||
|
assert summary.failed == 1
|
||||||
|
assert order.waybill_email_sent_at is None
|
||||||
|
assert len(email_sender.calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_once_failure_on_one_order_does_not_break_batch() -> None:
|
||||||
|
bad = StoredOrder(
|
||||||
|
order_uuid="bad",
|
||||||
|
account_email="bad@example.com",
|
||||||
|
cdek_waybill_url="https://cdek.test/bad.pdf",
|
||||||
|
)
|
||||||
|
good = StoredOrder(
|
||||||
|
order_uuid="good",
|
||||||
|
account_email="good@example.com",
|
||||||
|
cdek_waybill_url="https://cdek.test/good.pdf",
|
||||||
|
)
|
||||||
|
repo = StubRepository([bad, good])
|
||||||
|
downloader = StubDownloader(
|
||||||
|
{
|
||||||
|
"https://cdek.test/bad.pdf": RuntimeError("cdek 500"),
|
||||||
|
"https://cdek.test/good.pdf": b"%PDF",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
email_sender = StubEmailSender()
|
||||||
|
service = _make_service(
|
||||||
|
repository=repo, downloader=downloader, email_sender=email_sender
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = asyncio.run(service.poll_once())
|
||||||
|
|
||||||
|
assert summary.processed == 2
|
||||||
|
assert summary.succeeded == 1
|
||||||
|
assert summary.failed == 1
|
||||||
|
assert good.waybill_email_sent_at == _SENT_AT
|
||||||
|
assert bad.waybill_email_sent_at is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_forever_exits_when_stop_event_is_set() -> None:
|
||||||
|
repo = StubRepository([])
|
||||||
|
service = _make_service(repository=repo)
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
stop_event = asyncio.Event()
|
||||||
|
stop_event.set()
|
||||||
|
await asyncio.wait_for(
|
||||||
|
service.run_forever(interval_seconds=0.01, stop_event=stop_event),
|
||||||
|
timeout=1.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from app.adapters.delivery_providers.cdek.order_mapper import (
|
||||||
|
CDEKOrderInfo,
|
||||||
|
CDEKWaybillInfo,
|
||||||
|
)
|
||||||
|
from app.services.waybill_poller import WaybillPollerService
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StoredOrder:
|
||||||
|
order_uuid: str
|
||||||
|
cdek_order_uuid: str | None = None
|
||||||
|
cdek_order_status: str | None = None
|
||||||
|
cdek_waybill_uuid: str | None = None
|
||||||
|
cdek_waybill_url: str | None = None
|
||||||
|
cdek_polled_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class StubSessionContext:
|
||||||
|
def __init__(self, session: object) -> None:
|
||||||
|
self._session = session
|
||||||
|
|
||||||
|
async def __aenter__(self) -> object:
|
||||||
|
return self._session
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc, traceback) -> None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class StubRepository:
|
||||||
|
def __init__(self, orders: list[StoredOrder]) -> None:
|
||||||
|
self._orders = {order.order_uuid: order for order in orders}
|
||||||
|
self.session_value = object()
|
||||||
|
self.calls: list[tuple[str, dict[str, Any]]] = []
|
||||||
|
|
||||||
|
def session(self) -> StubSessionContext:
|
||||||
|
return StubSessionContext(self.session_value)
|
||||||
|
|
||||||
|
async def list_orders_pending_waybill(
|
||||||
|
self, session: object, *, limit: int
|
||||||
|
) -> list[StoredOrder]:
|
||||||
|
self.calls.append(("list", {"session": session, "limit": limit}))
|
||||||
|
return list(self._orders.values())
|
||||||
|
|
||||||
|
async def record_order_poll(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
order_uuid: str,
|
||||||
|
order_status: str | None,
|
||||||
|
waybill_uuid: str | None,
|
||||||
|
polled_at: datetime,
|
||||||
|
) -> StoredOrder | None:
|
||||||
|
self.calls.append(
|
||||||
|
(
|
||||||
|
"record_order_poll",
|
||||||
|
{
|
||||||
|
"order_uuid": order_uuid,
|
||||||
|
"order_status": order_status,
|
||||||
|
"waybill_uuid": waybill_uuid,
|
||||||
|
"polled_at": polled_at,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
order = self._orders.get(order_uuid)
|
||||||
|
if order is None:
|
||||||
|
return None
|
||||||
|
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
|
||||||
|
return order
|
||||||
|
|
||||||
|
async def record_waybill_poll(
|
||||||
|
self,
|
||||||
|
session: object,
|
||||||
|
*,
|
||||||
|
order_uuid: str,
|
||||||
|
waybill_url: str | None,
|
||||||
|
polled_at: datetime,
|
||||||
|
) -> StoredOrder | None:
|
||||||
|
self.calls.append(
|
||||||
|
(
|
||||||
|
"record_waybill_poll",
|
||||||
|
{
|
||||||
|
"order_uuid": order_uuid,
|
||||||
|
"waybill_url": waybill_url,
|
||||||
|
"polled_at": polled_at,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
order = self._orders.get(order_uuid)
|
||||||
|
if order is None:
|
||||||
|
return None
|
||||||
|
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
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
class StubOrderInfoAdapter:
|
||||||
|
def __init__(self, results: dict[str, CDEKOrderInfo | Exception]) -> None:
|
||||||
|
self._results = results
|
||||||
|
self.calls: list[str] = []
|
||||||
|
|
||||||
|
async def get_order(self, cdek_order_uuid: str) -> CDEKOrderInfo:
|
||||||
|
self.calls.append(cdek_order_uuid)
|
||||||
|
result = self._results[cdek_order_uuid]
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
raise result
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class StubWaybillInfoAdapter:
|
||||||
|
def __init__(self, results: dict[str, CDEKWaybillInfo | Exception]) -> None:
|
||||||
|
self._results = results
|
||||||
|
self.calls: list[str] = []
|
||||||
|
|
||||||
|
async def get_waybill(self, cdek_waybill_uuid: str) -> CDEKWaybillInfo:
|
||||||
|
self.calls.append(cdek_waybill_uuid)
|
||||||
|
result = self._results[cdek_waybill_uuid]
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
raise result
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
_POLLED_AT = datetime(2026, 5, 24, 12, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
def _make_service(
|
||||||
|
*,
|
||||||
|
repository: StubRepository,
|
||||||
|
order_info: StubOrderInfoAdapter | None = None,
|
||||||
|
waybill_info: StubWaybillInfoAdapter | None = None,
|
||||||
|
) -> WaybillPollerService:
|
||||||
|
return WaybillPollerService(
|
||||||
|
order_repository=repository,
|
||||||
|
order_info_adapter=order_info or StubOrderInfoAdapter({}),
|
||||||
|
waybill_info_adapter=waybill_info or StubWaybillInfoAdapter({}),
|
||||||
|
batch_size=10,
|
||||||
|
datetime_now=lambda: _POLLED_AT,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_once_fetches_order_info_when_waybill_uuid_is_missing() -> None:
|
||||||
|
order = StoredOrder(order_uuid="o", cdek_order_uuid="cdek-o")
|
||||||
|
repo = StubRepository([order])
|
||||||
|
order_info = StubOrderInfoAdapter(
|
||||||
|
{
|
||||||
|
"cdek-o": CDEKOrderInfo(
|
||||||
|
order_uuid="cdek-o",
|
||||||
|
status_code="ACCEPTED",
|
||||||
|
waybill_uuid="waybill-1",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
service = _make_service(repository=repo, order_info=order_info)
|
||||||
|
|
||||||
|
summary = asyncio.run(service.poll_once())
|
||||||
|
|
||||||
|
assert summary.processed == 1 and summary.succeeded == 1 and summary.failed == 0
|
||||||
|
assert order_info.calls == ["cdek-o"]
|
||||||
|
assert order.cdek_order_status == "ACCEPTED"
|
||||||
|
assert order.cdek_waybill_uuid == "waybill-1"
|
||||||
|
assert order.cdek_polled_at == _POLLED_AT
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_once_fetches_waybill_info_when_waybill_uuid_is_present() -> None:
|
||||||
|
order = StoredOrder(
|
||||||
|
order_uuid="o",
|
||||||
|
cdek_order_uuid="cdek-o",
|
||||||
|
cdek_waybill_uuid="waybill-1",
|
||||||
|
)
|
||||||
|
repo = StubRepository([order])
|
||||||
|
waybill_info = StubWaybillInfoAdapter(
|
||||||
|
{
|
||||||
|
"waybill-1": CDEKWaybillInfo(
|
||||||
|
waybill_uuid="waybill-1",
|
||||||
|
url="https://cdek.test/1.pdf",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
service = _make_service(repository=repo, waybill_info=waybill_info)
|
||||||
|
|
||||||
|
summary = asyncio.run(service.poll_once())
|
||||||
|
|
||||||
|
assert summary.processed == 1 and summary.succeeded == 1 and summary.failed == 0
|
||||||
|
assert waybill_info.calls == ["waybill-1"]
|
||||||
|
assert order.cdek_waybill_url == "https://cdek.test/1.pdf"
|
||||||
|
assert order.cdek_polled_at == _POLLED_AT
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_once_records_terminal_status_without_waybill() -> None:
|
||||||
|
order = StoredOrder(order_uuid="o", cdek_order_uuid="cdek-o")
|
||||||
|
repo = StubRepository([order])
|
||||||
|
order_info = StubOrderInfoAdapter(
|
||||||
|
{
|
||||||
|
"cdek-o": CDEKOrderInfo(
|
||||||
|
order_uuid="cdek-o",
|
||||||
|
status_code="INVALID",
|
||||||
|
waybill_uuid=None,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
service = _make_service(repository=repo, order_info=order_info)
|
||||||
|
|
||||||
|
summary = asyncio.run(service.poll_once())
|
||||||
|
|
||||||
|
assert summary.succeeded == 1
|
||||||
|
assert order.cdek_order_status == "INVALID"
|
||||||
|
assert order.cdek_waybill_uuid is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_poll_once_failure_on_one_order_does_not_break_batch() -> None:
|
||||||
|
failing = StoredOrder(order_uuid="bad", cdek_order_uuid="cdek-bad")
|
||||||
|
good = StoredOrder(
|
||||||
|
order_uuid="good",
|
||||||
|
cdek_order_uuid="cdek-good",
|
||||||
|
cdek_waybill_uuid="waybill-good",
|
||||||
|
)
|
||||||
|
repo = StubRepository([failing, good])
|
||||||
|
order_info = StubOrderInfoAdapter({"cdek-bad": RuntimeError("cdek down")})
|
||||||
|
waybill_info = StubWaybillInfoAdapter(
|
||||||
|
{
|
||||||
|
"waybill-good": CDEKWaybillInfo(
|
||||||
|
waybill_uuid="waybill-good",
|
||||||
|
url="https://cdek.test/good.pdf",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
service = _make_service(
|
||||||
|
repository=repo, order_info=order_info, waybill_info=waybill_info
|
||||||
|
)
|
||||||
|
|
||||||
|
summary = asyncio.run(service.poll_once())
|
||||||
|
|
||||||
|
assert summary.processed == 2
|
||||||
|
assert summary.succeeded == 1
|
||||||
|
assert summary.failed == 1
|
||||||
|
assert good.cdek_waybill_url == "https://cdek.test/good.pdf"
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_forever_exits_when_stop_event_is_set() -> None:
|
||||||
|
repo = StubRepository([])
|
||||||
|
service = _make_service(repository=repo)
|
||||||
|
|
||||||
|
async def run() -> None:
|
||||||
|
stop_event = asyncio.Event()
|
||||||
|
stop_event.set()
|
||||||
|
await asyncio.wait_for(
|
||||||
|
service.run_forever(interval_seconds=0.01, stop_event=stop_event),
|
||||||
|
timeout=1.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
asyncio.run(run())
|
||||||
@@ -35,6 +35,9 @@ def test_app_import_smoke(monkeypatch) -> None:
|
|||||||
|
|
||||||
assert isinstance(main_module.app, FastAPI)
|
assert isinstance(main_module.app, FastAPI)
|
||||||
assert isinstance(main_module.app.state.settings, Settings)
|
assert isinstance(main_module.app.state.settings, Settings)
|
||||||
|
assert "/api/v1/delivery/tbank/notifications" in {
|
||||||
|
route.path for route in main_module.app.routes
|
||||||
|
}
|
||||||
assert logging_bootstrap_calls == [None]
|
assert logging_bootstrap_calls == [None]
|
||||||
assert tracing_bootstrap_calls == [
|
assert tracing_bootstrap_calls == [
|
||||||
(main_module.app, main_module.app.state.settings.observability)
|
(main_module.app, main_module.app.state.settings.observability)
|
||||||
|
|||||||
@@ -18,7 +18,8 @@ def test_compose_defines_required_services() -> None:
|
|||||||
compose = _load_compose()
|
compose = _load_compose()
|
||||||
services = compose.get("services")
|
services = compose.get("services")
|
||||||
assert isinstance(services, dict)
|
assert isinstance(services, dict)
|
||||||
assert {"app", "redis"}.issubset(services.keys())
|
assert {"app", "redis", "postgres"}.issubset(services.keys())
|
||||||
|
assert "postgres" in services["app"]["depends_on"]
|
||||||
|
|
||||||
|
|
||||||
def test_smoke_command_sequence_is_documented() -> None:
|
def test_smoke_command_sequence_is_documented() -> None:
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user