Compare commits

..

34 Commits

Author SHA1 Message Date
Раис Юсупалиев c2728ae268 КОСТЫЛЬ перетираем дату доставки
Deploy / deploy (push) Successful in 53s
2026-06-27 22:52:19 +03:00
Раис Юсупалиев c942885abe fix pickup date
Deploy / deploy (push) Successful in 1m2s
2026-06-27 22:41:10 +03:00
Раис Юсупалиев 7287a1398e еще логи
Deploy / deploy (push) Successful in 56s
2026-06-27 18:42:50 +03:00
Раис Юсупалиев a2d9a243f7 add cse logs
Deploy / deploy (push) Successful in 58s
2026-06-27 18:10:20 +03:00
Раис Юсупалиев 4262b8a200 fix ксе tariffs
Deploy / deploy (push) Successful in 57s
2026-06-27 17:34:06 +03:00
Раис Юсупалиев e4d9b581a6 fix ксе prices
Deploy / deploy (push) Successful in 55s
2026-06-27 16:45:58 +03:00
Раис Юсупалиев facdde00c9 добавлено получение накладной в cse
Deploy / deploy (push) Successful in 2m33s
2026-06-27 07:10:31 +03:00
Раис Юсупалиев 2d8f31d3d9 оценочная стоимость отправляется в cdek
Deploy / deploy (push) Successful in 1m1s
2026-06-26 20:59:27 +03:00
Раис Юсупалиев 6906db7739 убрал packages из cdek payload
Deploy / deploy (push) Successful in 52s
2026-06-26 20:46:21 +03:00
Раис Юсупалиев 843175f12e Рефактор
Deploy / deploy (push) Successful in 18m5s
2026-06-26 20:16:04 +03:00
Раис Юсупалиев c6c37640fd добавлен declaredValue
Deploy / deploy (push) Successful in 53s
2026-06-21 22:55:29 +03:00
Раис Юсупалиев 6a3da82ddc span_id и trace_id
Deploy / deploy (push) Successful in 51s
2026-06-20 19:56:48 +03:00
Раис Юсупалиев 798b6e38bd пофикшен redis и логи redis
Deploy / deploy (push) Successful in 52s
2026-06-20 19:35:23 +03:00
Раис Юсупалиев 02f5ef93b0 улучшены логи, otel collector добавлен в git
Deploy / deploy (push) Successful in 50s
2026-06-20 05:24:17 +03:00
Раис Юсупалиев aeee641c6c поправлена гонка статусов от tabnk
Deploy / deploy (push) Successful in 48s
2026-06-20 01:12:16 +03:00
Раис Юсупалиев cbcd9ca1bc логи для нотификации tbank
Deploy / deploy (push) Successful in 56s
2026-06-20 00:26:48 +03:00
Раис Юсупалиев 65c07f1da3 фикс поллинга накладной
Deploy / deploy (push) Successful in 9m37s
2026-06-19 23:52:46 +03:00
Раис Юсупалиев f82a7c7606 fix image tag
Deploy / deploy (push) Successful in 2m59s
2026-06-19 08:22:17 +03:00
Раис Юсупалиев b58fd11966 fix build
Deploy / deploy (push) Successful in 3m23s
2026-06-19 07:18:30 +03:00
Раис Юсупалиев ed33357af2 убран SDD, добавлен uv, фикс билда
Deploy / deploy (push) Failing after 2m28s
2026-06-19 06:07:30 +03:00
Раис Юсупалиев 5f85006e1d добавлен stage
Deploy / deploy (push) Failing after 40s
2026-06-19 05:32:19 +03:00
Раис Юсупалиев c2757d24fc Убрали ПВЗ для КСЕ, и поправили формирование тарифа КСЕ
Deploy / deploy (push) Successful in 1m8s
2026-05-31 21:34:00 +03:00
Раис Юсупалиев 6c0f97adf6 Добавлен провайдер доставки CSE: SOAP-адаптер (Calc + SaveDocuments), маршрутизация init-payment по провайдеру, обобщение tariff_code до строки, география CSE в cities_map
Deploy / deploy (push) Failing after 52s
2026-05-31 20:13:52 +03:00
Раис Юсупалиев 6a2bf05ba5 Перевел /suggest-address на id города и убрал country_code
Deploy / deploy (push) Successful in 59s
2026-05-29 04:01:09 +03:00
Раис Юсупалиев 6caea9e111 Добавлен CI, добавлена отправка письма о получении оплаты, добавлен order_id в success_url
Deploy / deploy (push) Successful in 1m0s
2026-05-29 03:10:07 +03:00
Раис Юсупалиев 50124fb2c9 Добавлена отправка накладных на почту 2026-05-24 00:55:09 +03:00
Раис Юсупалиев 5526f90cb3 Добавлен поллер накладной 2026-05-23 20:24:23 +03:00
Раис Юсупалиев c494d50566 Добавлен заказ накладной 2026-05-23 19:27:32 +03:00
Раис Юсупалиев 39cd5ddc7a Добавлена валидация цены 2026-05-13 16:35:22 +03:00
Раис Юсупалиев 6b66af5eb3 Добавлена обработка уведомлений через ручку 2026-04-18 21:27:15 +03:00
Раис Юсупалиев 78e9ad4fa9 Добавлена отправка NotificationURL и SuccessURL 2026-04-18 00:58:13 +03:00
Раис Юсупалиев bddac60965 Добавлено сохранение заказов в postgres 2026-04-18 00:33:45 +03:00
Раис Юсупалиев 2b201a08be Добавлен адаптер к tbank и формирование ссылки на оплату 2026-04-12 18:56:41 +03:00
Раис Юсупалиев ab0b66e1c2 fix order form 2026-04-11 00:36:37 +03:00
149 changed files with 27831 additions and 8971 deletions
+130
View File
@@ -0,0 +1,130 @@
name: Deploy
on:
push:
branches:
- master
- stage
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Resolve environment
run: |
set -euo pipefail
# Тег образа зависит только от коммита (одинаков для stage и prod)
echo "IMAGE_TAG=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
if [[ "${{ gitea.ref_name }}" == "stage" ]]; then
{
echo "ENV_NAME=stage"
echo "CONTAINER_SUFFIX=-stage"
echo "APP_PORT=8004"
echo "POSTGRES_PORT=5433"
echo "REDIS_PORT=6380"
echo 'METRICS_FILTER_EXPRESSION=resource.attributes["container.name"] != nil and not IsMatch(resource.attributes["container.name"], "-stage$")'
echo "COMPOSE_PROJECT=g2s-aggregator-stage"
echo "DEPLOY_DIR=/home/deploy/g2s-aggregator-stage"
} >> "$GITHUB_ENV"
else
{
echo "ENV_NAME=prod"
echo "CONTAINER_SUFFIX="
echo "APP_PORT=8003"
echo "POSTGRES_PORT=5432"
echo "REDIS_PORT=6379"
echo 'METRICS_FILTER_EXPRESSION=resource.attributes["container.name"] != nil and IsMatch(resource.attributes["container.name"], "-stage$")'
echo "COMPOSE_PROJECT=g2s-aggregator"
echo "DEPLOY_DIR=/home/deploy/g2s-aggregator"
} >> "$GITHUB_ENV"
fi
- name: Build and push image
run: |
docker login gitea.p4r4dls.ru \
-u ${{ secrets.REGISTRY_USER }} \
-p ${{ secrets.REGISTRY_TOKEN }}
IMG=gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:${IMAGE_TAG}
if docker manifest inspect "$IMG" >/dev/null 2>&1; then
echo "Образ $IMG уже есть в реестре — сборка пропущена"
else
docker buildx rm multibuilder 2>/dev/null || true
docker buildx create --use --name multibuilder --driver-opt network=host
docker buildx build \
--platform linux/amd64 \
--load \
-t "$IMG" \
.
docker push "$IMG"
fi
- 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
env:
ALL_SECRETS: ${{ toJSON(secrets) }}
run: |
set -euo pipefail
# Экспортируем все секреты как env-переменные
while IFS= read -r -d '' entry; do
key="${entry%%=*}"
value="${entry#*=}"
export "$key=$value"
done < <(echo "$ALL_SECRETS" | jq -j 'to_entries[] | "\(.key)=\(.value)\u0000"')
# Для stage подменяем базовые имена секретов на STAGE_-версии:
# STAGE_FOO -> FOO
if [[ "$ENV_NAME" == "stage" ]]; then
while IFS= read -r -d '' entry; do
key="${entry%%=*}"
value="${entry#*=}"
if [[ "$key" == STAGE_* ]]; then
export "${key#STAGE_}=$value"
fi
done < <(echo "$ALL_SECRETS" | jq -j 'to_entries[] | "\(.key)=\(.value)\u0000"')
fi
render() {
local src=$1 dst=$2
vars=$(grep -oP '\$\{\K[^}]+' "$src")
for var in $vars; do
if [[ -z "${!var+x}" ]]; then
echo "Error: $var не задан в Gitea Secrets" >&2
exit 1
fi
done
perl -pe 's/\$\{(\w+)\}/exists $ENV{$1} ? $ENV{$1} : ""/ge' "$src" > "$dst"
}
render config.template.yaml config.rendered.yaml
render docker-compose.template.yml docker-compose.rendered.yml
render otel-collector-config.template.yaml otel-collector-config.rendered.yaml
scp -i ~/.ssh/deploy_key config.rendered.yaml \
deploy@194.58.121.203:$DEPLOY_DIR/config.yaml
scp -i ~/.ssh/deploy_key docker-compose.rendered.yml \
deploy@194.58.121.203:$DEPLOY_DIR/docker-compose.yml
scp -i ~/.ssh/deploy_key otel-collector-config.rendered.yaml \
deploy@194.58.121.203:$DEPLOY_DIR/otel-collector-config.yaml
- name: Deploy
run: |
ssh -i ~/.ssh/deploy_key deploy@194.58.121.203 "
cd $DEPLOY_DIR &&
docker login gitea.p4r4dls.ru \
-u ${{ secrets.REGISTRY_USER }} \
-p ${{ secrets.REGISTRY_TOKEN }} &&
docker compose -p $COMPOSE_PROJECT pull &&
docker compose -p $COMPOSE_PROJECT up -d --force-recreate otel-collector &&
docker compose -p $COMPOSE_PROJECT up -d
"
+4
View File
@@ -1,7 +1,11 @@
.idea .idea
*.iml *.iml
/config.yaml /config.yaml
/docker-compose.yml
/otel-collector-config.yaml
__pycache__ __pycache__
http-client.private.env.json http-client.private.env.json
scripts scripts
.codex .codex
cse-api.pdf
cse_geography*.tsv
+12 -8
View File
@@ -1,16 +1,20 @@
FROM python:3.14-slim FROM python:3.14-slim
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1 \
UV_COMPILE_BYTECODE=1 \
UV_LINK_MODE=copy \
UV_FROZEN=1 \
PATH="/app/.venv/bin:$PATH"
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
WORKDIR /app WORKDIR /app
RUN pip install --no-cache-dir poetry COPY pyproject.toml uv.lock ./
RUN uv sync --frozen --no-install-project --no-dev
COPY pyproject.toml poetry.lock ./
RUN poetry config virtualenvs.create false \
&& 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 ["uv", "run", "--no-sync", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
-85
View File
@@ -1,85 +0,0 @@
You are Implementer.
You implement EXACTLY ONE next uncompleted task from spec/tasks/.
Before starting, you MUST read:
- spec/overview.md
- the assigned task file in spec/tasks/
You MUST NOT modify any files in spec/.
---
## Your responsibilities
- Implement the task exactly as specified
- Place business rules in Business Logic
- Place orchestration in Service
- Respect all constraints from the task
- Add or update required tests
- Keep changes strictly within task scope
---
## Mandatory verification steps (BEFORE finishing)
You MUST:
1. Verify all Definition of Done items from the task
2. Ensure Business Logic is fully unit-tested WITHOUT mocks
3. Ensure Service tests (if any) may use mocks/stubs
4. Run all tests specified in the task
5. Run all linters / type checks specified in the task
6. Verify no unrelated files were modified
7. Stage all intended changes:
- run: git add -A
- DO NOT commit
If ANY item fails, you MUST report it explicitly.
---
## Output format (MANDATORY)
Output EXACTLY the following sections.
## Task
- Task ID and title
## Changes made
- List of changed files
- Short description per file
- Explicit layer for each file (Controller / Service / Business Logic / Repository / Adapter / Tests)
## Definition of Done verification
- Checklist copied from the task
- Each item marked as PASSED or FAILED
## Tests
- Tests added or updated
- Test type:
- unit (no mocks)
- integration
- other
- Results
## Commands executed
- Exact commands run
- Result (success/failure)
## Git staging
- Confirm: git add -A executed
- Confirm: no commits created
## Notes
- Edge cases, trade-offs, or limitations
- If none, write "None"
---
## Hard rules
- No scope expansion
- No speculative refactoring
- No changes outside task scope
- No spec changes
- No business logic in Service or Adapter
-99
View File
@@ -1,99 +0,0 @@
You are Planner.
You analyze a programming task or specification and produce planning artifacts.
You do NOT write production code.
You MUST always read:
- spec/overview.md
- spec/index.md
- existing files in spec/tasks/
You are the ONLY role allowed to create or modify files in spec/tasks/.
Write tasks in russian except headings, frontmatter and terms
Follow AGENTS.md strictly.
---
## Your responsibilities
Depending on the input, you may:
- create one or more new task files in spec/tasks/
- update existing task files in spec/tasks/
For every change in spec/tasks/, you MUST:
- use the minimal task format defined below
- update spec/index.md via the generator workflow
You MUST NOT:
- modify any source code
- change files outside spec/
- introduce architectural decisions not requested
---
## Minimal task format (MANDATORY)
Every task file in spec/tasks/ MUST follow this structure.
### Front matter (required)
---
id: <numeric, zero-padded, unique>
title: <short, precise, action-oriented>
status: TODO
created: <YYYY-MM-DD>
---
### Body (required sections, minimal)
## Context
Why this task exists. Short and factual.
## Goal
What must be implemented or changed.
## Constraints
Hard limits:
- architectural
- scope
- forbidden changes
## Acceptance criteria
- Bullet list
- Objective and testable
## Definition of Done
Checklist. Task is DONE only if ALL items are satisfied.
## Tests
What tests must exist or be updated.
## Commands
Exact commands to verify completion
(e.g. pytest, ruff, mypy, docker compose).
---
## Planner output rules
When creating or updating tasks, output MUST include:
### Tasks created
- List of new spec/tasks/*.md files with full content
### Tasks updated
- List of modified spec/tasks/*.md files with changes
### Index update
- Explicit instruction to run:
python3 spec/gen_spec_index.py
If no tasks are created or updated, explicitly state:
- "No task changes required"
Rules:
- No code blocks outside task files
- No speculative tasks
- No refactoring-only tasks
- No vague goals
-93
View File
@@ -1,93 +0,0 @@
You are Reviewer.
You review code changes produced by Implementer.
Before reviewing, you MUST read:
- spec/overview.md
- the assigned task file in spec/tasks/
You MUST review ONLY staged changes:
- git diff --staged
You MUST NOT modify source code.
---
## Review scope (MANDATORY)
You must verify:
1. Task compliance
- Implementation matches task requirements
- No scope expansion
2. Architecture compliance
- Correct layer separation
- Business Logic purity
- Proper Service orchestration
- No business decisions in Repository or Adapter
3. Testing strategy
- Business Logic has unit tests WITHOUT mocks
- Tests cover task Definition of Done
- No missing critical tests
4. Project compliance
- Matches constraints and invariants from spec/overview.md
---
## Output format (MANDATORY)
Comments must be in russian language.
Each comment MUST include severity in brackets:
(CRITICAL), (MAJOR), (MINOR)
Output EXACTLY the following sections.
## Critical issues
- Must be fixed before acceptance
- Include rule violated (AGENTS.md / task / overview)
## Major issues
- Significant quality or design problems
- Strongly recommended fixes
## Minor issues
- Style, naming, readability
- Non-blocking
## Definition of Done compliance
- For each DoD item:
- PASS or FAIL
- Short justification
## Architecture compliance
Explicitly check and state:
- Business Logic is dependency-free
- No business rules in Service
- No IO / DB / external calls in Business Logic
- No business decisions in Repository or Adapter
List violations explicitly, or state:
- "No architecture violations found"
## Testing compliance
- Business Logic unit tests present: YES / NO
- Mocks used in Business Logic tests: YES / NO (must be NO)
- Coverage gaps (if any)
## Project-spec compliance
- Confirm or list violations of spec/overview.md
## Review input
- Confirm you reviewed: git diff --staged
---
## Hard rules
- No code changes
- No new requirements
- No architectural redesign
- No speculative improvements
+41
View File
@@ -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
+66
View File
@@ -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())
+20
View File
@@ -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")
@@ -0,0 +1,34 @@
"""Change orders.tariff_code to string."""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "20260530_037"
down_revision: str | None = "20260527_036"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.alter_column(
"orders",
"tariff_code",
existing_type=sa.Integer(),
type_=sa.String(length=128),
existing_nullable=False,
postgresql_using="tariff_code::varchar",
)
def downgrade() -> None:
op.alter_column(
"orders",
"tariff_code",
existing_type=sa.String(length=128),
type_=sa.Integer(),
existing_nullable=False,
postgresql_using="tariff_code::integer",
)
@@ -0,0 +1,33 @@
"""Add provider and CSE order number columns to orders table."""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "20260530_038"
down_revision: str | None = "20260530_037"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"orders",
sa.Column(
"provider",
sa.String(length=32),
nullable=False,
server_default="cdek",
),
)
op.add_column(
"orders",
sa.Column("cse_order_number", sa.String(length=128), nullable=True),
)
def downgrade() -> None:
op.drop_column("orders", "cse_order_number")
op.drop_column("orders", "provider")
@@ -0,0 +1,115 @@
"""Generalize provider order state columns."""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "20260626_039"
down_revision: str | None = "20260530_038"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"orders",
sa.Column("provider_order_id", sa.String(length=128), nullable=True),
)
op.add_column(
"orders",
sa.Column("provider_order_status", sa.String(length=64), nullable=True),
)
op.add_column(
"orders",
sa.Column("provider_waybill_id", sa.String(length=128), nullable=True),
)
op.add_column(
"orders",
sa.Column("provider_waybill_url", sa.String(length=2048), nullable=True),
)
op.add_column(
"orders",
sa.Column("provider_polled_at", sa.DateTime(timezone=True), nullable=True),
)
op.execute(
"""
UPDATE orders
SET
provider_order_id = cdek_order_uuid,
provider_order_status = cdek_order_status,
provider_waybill_id = cdek_waybill_uuid,
provider_waybill_url = cdek_waybill_url,
provider_polled_at = cdek_polled_at
WHERE provider = 'cdek'
"""
)
op.execute(
"""
UPDATE orders
SET provider_order_id = cse_order_number
WHERE provider = 'cse'
"""
)
op.drop_column("orders", "cse_order_number")
op.drop_column("orders", "cdek_polled_at")
op.drop_column("orders", "cdek_order_status")
op.drop_column("orders", "cdek_waybill_url")
op.drop_column("orders", "cdek_waybill_uuid")
op.drop_column("orders", "cdek_order_uuid")
def downgrade() -> None:
op.add_column(
"orders",
sa.Column("cdek_order_uuid", sa.String(length=128), nullable=True),
)
op.add_column(
"orders",
sa.Column("cdek_waybill_uuid", sa.String(length=128), nullable=True),
)
op.add_column(
"orders",
sa.Column("cdek_waybill_url", sa.String(length=2048), nullable=True),
)
op.add_column(
"orders",
sa.Column("cdek_order_status", sa.String(length=64), nullable=True),
)
op.add_column(
"orders",
sa.Column("cdek_polled_at", sa.DateTime(timezone=True), nullable=True),
)
op.add_column(
"orders",
sa.Column("cse_order_number", sa.String(length=128), nullable=True),
)
op.execute(
"""
UPDATE orders
SET
cdek_order_uuid = provider_order_id,
cdek_order_status = provider_order_status,
cdek_waybill_uuid = provider_waybill_id,
cdek_waybill_url = provider_waybill_url,
cdek_polled_at = provider_polled_at
WHERE provider = 'cdek'
"""
)
op.execute(
"""
UPDATE orders
SET cse_order_number = provider_order_id
WHERE provider = 'cse'
"""
)
op.drop_column("orders", "provider_polled_at")
op.drop_column("orders", "provider_waybill_url")
op.drop_column("orders", "provider_waybill_id")
op.drop_column("orders", "provider_order_status")
op.drop_column("orders", "provider_order_id")
+27
View File
@@ -1,7 +1,9 @@
"""Base interface for delivery providers.""" """Base interface for delivery providers."""
from abc import ABC, abstractmethod from abc import ABC, abstractmethod
from typing import Protocol, runtime_checkable
from app.schemas.payment import InitPaymentRequest
from app.schemas.request import DeliveryCalculationRequest from app.schemas.request import DeliveryCalculationRequest
from app.schemas.response import DeliveryPrice from app.schemas.response import DeliveryPrice
@@ -18,5 +20,30 @@ class DeliveryProvider(ABC):
raise NotImplementedError raise NotImplementedError
@runtime_checkable
class PaymentPriceValidationProvider(Protocol):
name: str
async def get_payment_price(
self,
request: InitPaymentRequest,
) -> DeliveryPrice | None: ...
@runtime_checkable
class OrderRegistrationProvider(Protocol):
name: str
async def register_order(
self,
request: InitPaymentRequest,
order_uuid: str,
) -> object: ...
class ProviderClientError(RuntimeError):
"""Raised when a provider call fails for temporary or provider-side reasons."""
class ProviderRequestError(RuntimeError): class ProviderRequestError(RuntimeError):
"""Raised when a provider rejects input request data.""" """Raised when a provider rejects input request data."""
+324 -22
View File
@@ -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 CDEKDeliveryProviderConfig
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,141 @@ 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",
)
requests_with_errors = _extract_requests_with_errors(raw_payload)
if requests_with_errors:
log.warning(
"cdek_order_request_errors",
cdek_order_uuid=cdek_order_uuid,
requests=requests_with_errors,
)
try:
return map_cdek_order_info_response(raw_payload)
except CDEKOrderMappingError as exc:
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 +388,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))
@@ -212,28 +452,28 @@ class CDEKProvider(DeliveryProvider):
self.cache_ttl_seconds = cache_ttl_seconds self.cache_ttl_seconds = cache_ttl_seconds
@classmethod @classmethod
def from_adapter_config( def from_config(
cls, cls,
*, *,
http_client: httpx.AsyncClient, http_client: httpx.AsyncClient,
adapter_config: AdapterConfig, config: CDEKDeliveryProviderConfig,
) -> "CDEKProvider": ) -> "CDEKProvider":
auth_client = CDEKAuthClient( auth_client = CDEKAuthClient(
http_client=http_client, http_client=http_client,
base_url=adapter_config.cdek_base_url, base_url=config.base_url,
client_id=adapter_config.cdek_client_id, client_id=config.client_id,
client_secret=adapter_config.cdek_client_secret, client_secret=config.client_secret,
timeout_seconds=adapter_config.cdek_timeout_seconds, timeout_seconds=config.timeout_seconds,
) )
client = CDEKClient( client = CDEKClient(
http_client=http_client, http_client=http_client,
auth_client=auth_client, auth_client=auth_client,
base_url=adapter_config.cdek_base_url, base_url=config.base_url,
timeout_seconds=adapter_config.cdek_timeout_seconds, timeout_seconds=config.timeout_seconds,
retry_attempts=adapter_config.cdek_retry_attempts, retry_attempts=config.retry_attempts,
retry_backoff_seconds=adapter_config.cdek_retry_backoff_seconds, retry_backoff_seconds=config.retry_backoff_seconds,
) )
return cls(client=client, cache_ttl_seconds=adapter_config.cdek_cache_ttl_seconds) return cls(client=client, cache_ttl_seconds=config.cache_ttl_seconds)
async def get_prices( async def get_prices(
self, request: DeliveryCalculationRequest self, request: DeliveryCalculationRequest
@@ -241,5 +481,67 @@ 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
def _extract_requests_with_errors(
payload: dict[str, Any],
) -> list[dict[str, object]]:
requests = payload.get("requests")
if not isinstance(requests, list):
return []
requests_with_errors: list[dict[str, object]] = []
for request in requests:
if not isinstance(request, dict):
continue
errors = request.get("errors")
if not isinstance(errors, list) or not errors:
continue
requests_with_errors.append(
{
"request_uuid": request.get("request_uuid"),
"type": request.get("type"),
"state": request.get("state"),
"errors": errors,
}
)
return requests_with_errors
@@ -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: str,
) -> 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,19 @@ 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: str) -> bool:
extracted = _extract_tariff_code(value)
if extracted is None:
return False
return extracted == expected_tariff_code
def _extract_tariff_code(value: object) -> str | None:
if isinstance(value, bool) or value is None:
return None
try:
return str(int(value))
except (TypeError, ValueError):
return None
@@ -1,19 +1,80 @@
"""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"
_CDEK_INSURANCE_SERVICE_CODE = "INSURANCE"
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": _to_cdek_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)],
}
services = _map_services(request)
if services:
payload["services"] = services
if request.content.description:
payload["comment"] = request.content.description
return payload
def _to_cdek_tariff_code(tariff_code: str) -> int:
try:
return int(tariff_code)
except (TypeError, ValueError) as exc:
raise CDEKOrderMappingError(
f"CDEK tariff_code must be numeric: {tariff_code!r}."
) from exc
def map_cdek_order_response(payload: dict[str, Any]) -> CDEKOrderRegistrationResult:
entity = payload.get("entity") 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 +83,301 @@ 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 = _invalid_create_request_status(
payload.get("requests")
) or _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 _invalid_create_request_status(requests: object) -> str | None:
if not isinstance(requests, list):
return None
for entry in requests:
if not isinstance(entry, dict):
continue
if entry.get("type") != "CREATE" or entry.get("state") != "INVALID":
continue
errors = entry.get("errors")
if isinstance(errors, list) and errors:
return "INVALID"
return None
def _extract_waybill(payload: dict[str, Any]) -> tuple[str | None, str | None]:
entity = payload.get("entity")
related_entities = (
entity.get("related_entities")
if isinstance(entity, dict)
else None
)
if not isinstance(related_entities, list):
related_entities = payload.get("related_entities")
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_services(request: InitPaymentRequest) -> list[dict[str, Any]]:
declared_value = request.content.declared_value
if declared_value <= 0:
return []
return [
{
"code": _CDEK_INSURANCE_SERVICE_CODE,
"parameter": declared_value,
}
]
def _map_dimensions(dimensions: Dimensions) -> dict[str, int]:
return {
"length": centimeters_string_to_int(dimensions.length, "length"),
"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,15 @@
"""CSE delivery provider adapter."""
from app.adapters.delivery_providers.cse.client import (
CSEClient,
CSEClientError,
CSEProvider,
CSERequestError,
)
__all__ = (
"CSEClient",
"CSEClientError",
"CSEProvider",
"CSERequestError",
)
@@ -0,0 +1,389 @@
"""CSE SOAP client and provider adapter."""
import asyncio
from collections.abc import Awaitable, Callable
import httpx
import structlog
from app.adapters.delivery_providers.base import DeliveryProvider
from app.adapters.delivery_providers.cse.constants import (
CSE_PROVIDER_NAME,
delivery_type_requires_pvz,
)
from app.adapters.delivery_providers.cse.errors import (
CSEClientError,
CSEMappingError,
CSERequestError,
)
from app.adapters.delivery_providers.cse.mapper import (
map_cse_calc_response,
map_cse_calc_response_for_tariff_code,
)
from app.adapters.delivery_providers.cse.order_mapper import (
CSEOrderInfo,
CSEOrderRegistrationParams,
CSEOrderRegistrationResult,
build_calc_body_for_calculation,
build_calc_body_for_payment,
build_tracking_body_for_order,
build_waybill_print_form_body,
map_cse_tracking_response,
map_cse_save_order_request,
map_cse_save_order_response,
map_cse_waybill_print_form_response,
split_tariff_code,
)
from app.adapters.delivery_providers.cse.soap import (
Element,
build_envelope,
make_field,
parse_response,
)
from app.config import CSEDeliveryProviderConfig
from app.schemas.payment import InitPaymentRequest
from app.schemas.request import DeliveryCalculationRequest
from app.schemas.response import DeliveryPrice
log = structlog.get_logger(__name__)
_SOAP_HEADERS = {"Content-Type": "application/soap+xml; charset=utf-8"}
class CSEClient:
def __init__(
self,
http_client: httpx.AsyncClient,
*,
base_url: str,
login: str,
password: str,
registration_params: CSEOrderRegistrationParams,
timeout_seconds: float = 10.0,
retry_attempts: int = 2,
retry_backoff_seconds: float = 0.2,
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
) -> None:
self._http_client = http_client
self._url = base_url
self._login = login
self._password = password
self._registration_params = registration_params
self._timeout_seconds = timeout_seconds
self._retry_attempts = retry_attempts
self._retry_backoff_seconds = retry_backoff_seconds
self._sleep = sleep
async def calc(self, body: dict[str, Element]) -> Element:
return await self._post(
"Calc",
body,
request_error_message="CSE calc request was rejected with status",
)
async def get_delivery_types(self) -> list[tuple[str, str]]:
"""Return available delivery schemes as (name, human label) pairs."""
body = {
"parameters": Element(
key="parameters",
items=[make_field("Reference", "DeliveryType")],
)
}
root = await self._post(
"GetReferenceData",
body,
request_error_message="CSE delivery types request was rejected with status",
)
result: list[tuple[str, str]] = []
for entry in root.items:
name = entry.value
if name:
result.append((name, entry.field_value("Information") or name))
return result
async def save_order(
self, request: InitPaymentRequest, order_uuid: str
) -> CSEOrderRegistrationResult:
body = map_cse_save_order_request(
request, order_uuid, self._registration_params
)
root = await self._post(
"SaveDocuments",
body,
request_error_message="CSE SaveDocuments request was rejected with status",
)
try:
return map_cse_save_order_response(root)
except CSEMappingError as exc:
raise CSEClientError(str(exc)) from exc
async def get_order(self, order_number: str) -> CSEOrderInfo:
root = await self._post(
"Tracking",
build_tracking_body_for_order(order_number),
request_error_message="CSE Tracking request was rejected with status",
)
try:
return map_cse_tracking_response(root)
except CSEMappingError as exc:
raise CSEClientError(str(exc)) from exc
async def download_waybill_pdf(self, waybill_number: str) -> bytes:
root = await self._post(
"GetFormsForDocuments",
build_waybill_print_form_body(waybill_number),
request_error_message=(
"CSE GetFormsForDocuments request was rejected with status"
),
)
try:
return map_cse_waybill_print_form_response(root)
except CSEMappingError as exc:
raise CSEClientError(str(exc)) from exc
async def _post(
self,
operation: str,
body: dict[str, Element],
*,
request_error_message: str,
) -> Element:
envelope = build_envelope(
operation,
login=self._login,
password=self._password,
body=body,
)
for attempt in range(self._retry_attempts + 1):
try:
response = await self._http_client.post(
self._url,
content=envelope,
headers=_SOAP_HEADERS,
timeout=self._timeout_seconds,
)
except (httpx.TimeoutException, httpx.TransportError) as exc:
if attempt < self._retry_attempts:
await self._sleep(self._retry_delay(attempt))
continue
raise CSEClientError(
f"CSE {operation} request failed after retry attempts."
) from exc
if self._should_retry(response.status_code):
if attempt < self._retry_attempts:
await self._sleep(self._retry_delay(attempt))
continue
log.warning(
"cse_request_server_error",
operation=operation,
status_code=response.status_code,
response_excerpt=_response_excerpt(response.text),
)
raise CSEClientError(
f"CSE {operation} request failed with status "
f"{response.status_code}."
)
if 400 <= response.status_code < 500:
log.warning(
"cse_request_rejected",
operation=operation,
status_code=response.status_code,
)
raise CSERequestError(
f"{request_error_message} {response.status_code}."
)
try:
root = parse_response(response.text, operation)
except ValueError as exc:
raise CSEClientError(
f"CSE {operation} response payload is invalid."
) from exc
self._raise_for_response_error(root, operation, request_error_message)
return root
raise CSEClientError(f"CSE {operation} request failed unexpectedly.")
@staticmethod
def _raise_for_response_error(
root: Element,
operation: str,
request_error_message: str,
) -> None:
error_codes = _response_error_codes(root)
if error_codes:
log.warning(
"cse_response_error",
operation=operation,
error_codes=error_codes,
)
raise CSERequestError(
f"{request_error_message} application error {error_codes}."
)
@staticmethod
def _should_retry(status_code: int) -> bool:
return status_code == 429 or 500 <= status_code < 600
def _retry_delay(self, attempt: int) -> float:
return self._retry_backoff_seconds * (2**attempt)
def _response_excerpt(text: str, *, limit: int = 1000) -> str:
return " ".join(text.split())[:limit]
def _response_error_codes(root: Element) -> list[str]:
codes: list[str] = []
_collect_response_error_codes(root, codes)
return codes
def _collect_response_error_codes(element: Element, codes: list[str]) -> None:
for prop in element.properties:
if prop.key == "Error":
codes.extend(item.value for item in prop.items if item.value)
for child in (*element.items, *element.tables):
_collect_response_error_codes(child, codes)
class CSEProvider(DeliveryProvider):
name = CSE_PROVIDER_NAME
def __init__(
self,
client: CSEClient,
*,
cache_ttl_seconds: int = 900,
delivery_service_guids: tuple[str, ...] = (),
) -> None:
self._client = client
self.cache_ttl_seconds = cache_ttl_seconds
self._delivery_service_guids = delivery_service_guids
self._delivery_types: list[tuple[str, str]] | None = None
@classmethod
def from_config(
cls,
*,
http_client: httpx.AsyncClient,
config: CSEDeliveryProviderConfig,
) -> "CSEProvider":
client = CSEClient(
http_client=http_client,
base_url=config.base_url,
login=config.login,
password=config.password,
registration_params=CSEOrderRegistrationParams(
payer=config.payer,
payment_method=config.payment_method,
shipping_method=config.shipping_method,
),
timeout_seconds=config.timeout_seconds,
retry_attempts=config.retry_attempts,
retry_backoff_seconds=config.retry_backoff_seconds,
)
return cls(
client=client,
cache_ttl_seconds=config.cache_ttl_seconds,
delivery_service_guids=tuple(config.delivery_service_guids),
)
async def get_prices(
self, request: DeliveryCalculationRequest
) -> list[DeliveryPrice]:
delivery_types = await self._resolve_delivery_types()
results = await asyncio.gather(
*(
self._prices_for_delivery_type(request, name, label, service_guid)
for name, label in delivery_types
for service_guid in self._service_guids_for_price_calculation()
),
return_exceptions=True,
)
prices: list[DeliveryPrice] = []
errors: list[BaseException] = []
for result in results:
if isinstance(result, BaseException):
errors.append(result)
else:
prices.extend(result)
if not prices and errors:
raise errors[0]
return prices
async def _prices_for_delivery_type(
self,
request: DeliveryCalculationRequest,
delivery_type: str,
delivery_type_label: str,
service_guid: str | None,
) -> list[DeliveryPrice]:
body = build_calc_body_for_calculation(request, delivery_type, service_guid)
root = await self._client.calc(body)
try:
return map_cse_calc_response(
root,
delivery_type=delivery_type,
delivery_type_label=delivery_type_label,
service_guid=service_guid,
)
except CSEMappingError as exc:
raise CSEClientError("CSE calc response payload is invalid.") from exc
def _service_guids_for_price_calculation(self) -> tuple[str, ...]:
if not self._delivery_service_guids:
raise CSERequestError("CSE delivery service GUIDs are not configured.")
return self._delivery_service_guids
async def _resolve_delivery_types(self) -> list[tuple[str, str]]:
if self._delivery_types is None:
self._delivery_types = await self._client.get_delivery_types()
# Exclude schemes that require a pickup point (PVZ) at registration,
# since PVZ is not yet supported (only door-to-door is fulfillable).
usable = [
(name, label)
for name, label in self._delivery_types
if not delivery_type_requires_pvz(name, label)
]
# Fall back to the contract-default scheme (empty delivery_type) so
# calculation still works if no usable schemes are returned.
return usable or [("", "")]
async def get_payment_price(
self,
request: InitPaymentRequest,
) -> DeliveryPrice | None:
tariff_code = request.system_data.tariff.tariff_code
try:
delivery_type, service_guid, _ = split_tariff_code(tariff_code)
body = build_calc_body_for_payment(request, delivery_type, service_guid)
root = await self._client.calc(body)
return map_cse_calc_response_for_tariff_code(
root,
tariff_code=tariff_code,
)
except CSERequestError:
raise
except CSEMappingError as exc:
raise CSEClientError(
"CSE payment price validation response payload is invalid."
) from exc
async def register_order(
self, request: InitPaymentRequest, order_uuid: str
) -> CSEOrderRegistrationResult:
return await self._client.save_order(request, order_uuid)
async def get_order(self, order_number: str) -> CSEOrderInfo:
return await self._client.get_order(order_number)
async def download_waybill_pdf(self, waybill_number: str) -> bytes:
return await self._client.download_waybill_pdf(waybill_number)
@@ -0,0 +1,82 @@
"""CSE reference constants and geography resolution.
CSE identifies geography, cargo type and currency by GUID. Geography GUIDs per
city live in ``cities_map`` under the ``cse`` block (see task 037). Cargo-type
GUIDs come from ``GetReferenceData: TypesOfCargo`` and must be configured with
real values before production use.
"""
from app.adapters.delivery_providers.cse.errors import CSERequestError
from app.cities import cities_map
# Provider name surfaced in unified DeliveryPrice/SystemDataTariff.
CSE_PROVIDER_NAME = "cse"
# Keywords in a DeliveryType name/label that imply a pickup point (PVZ) is
# required at registration (SenderPVZ/RecipientPVZ). PVZ is not yet supported,
# so such schemes are excluded from price calculation.
_PVZ_SCHEME_KEYWORDS = ("склад", "самовывоз", "почтов", "пвз")
def delivery_type_requires_pvz(name: str, label: str) -> bool:
haystack = f"{name} {label}".lower()
return any(keyword in haystack for keyword in _PVZ_SCHEME_KEYWORDS)
# CSE returns currency short names (e.g. "RUR"); normalize to ISO-4217.
_CURRENCY_NAME_TO_CODE = {
"RUR": "RUB",
"RUB": "RUB",
"USD": "USD",
"EUR": "EUR",
}
# TypeOfCargo GUIDs by parcel type (GetReferenceData: TypesOfCargo).
TYPE_OF_CARGO_BY_PARCEL_TYPE: dict[str, str] = {
"doc": "81dd8a13-8235-494f-84fd-9c04c51d50ec", # Документы
"parcel": "4aab1fc6-fc2b-473a-8728-58bcd4ff79ba", # Груз
}
# Cargo type used when the parcel type is not known (price calculation flow).
DEFAULT_TYPE_OF_CARGO: str = "4aab1fc6-fc2b-473a-8728-58bcd4ff79ba" # Груз
def normalize_currency(currency_name: str | None) -> str:
if not currency_name:
return "RUB"
return _CURRENCY_NAME_TO_CODE.get(currency_name.strip().upper(), "RUB")
def resolve_type_of_cargo(parcel_type: str | None) -> str:
cargo_guid = (
TYPE_OF_CARGO_BY_PARCEL_TYPE.get(parcel_type)
if parcel_type is not None
else DEFAULT_TYPE_OF_CARGO
)
if not cargo_guid:
raise CSERequestError(
f"CSE cargo type is not configured for parcel type {parcel_type!r}."
)
return cargo_guid
def resolve_cse_geography(city_id: int) -> str:
"""Resolve CSE geography GUID from cities_map by city identifier."""
city_entry = cities_map.get(str(city_id))
if not isinstance(city_entry, dict):
raise CSERequestError(
f"CSE geography is not configured for city id {city_id}."
)
cse_data = city_entry.get("cse")
if not isinstance(cse_data, dict):
raise CSERequestError(
f"CSE geography is not configured for city id {city_id}."
)
geography_uid = cse_data.get("geography_uid")
if not isinstance(geography_uid, str) or not geography_uid:
raise CSERequestError(
f"CSE geography is not configured for city id {city_id}."
)
return geography_uid
@@ -0,0 +1,18 @@
"""CSE adapter error hierarchy."""
from app.adapters.delivery_providers.base import (
ProviderClientError,
ProviderRequestError,
)
class CSEClientError(ProviderClientError):
"""Raised when a CSE request fails for temporary or provider-side reasons."""
class CSERequestError(CSEClientError, ProviderRequestError):
"""Raised when CSE rejects request data as invalid."""
class CSEMappingError(ValueError):
"""Raised when a CSE SOAP response cannot be mapped."""
@@ -0,0 +1,150 @@
"""CSE Calc response mapper to the unified delivery schema."""
from decimal import Decimal, InvalidOperation
from app.adapters.delivery_providers.cse.constants import (
CSE_PROVIDER_NAME,
normalize_currency,
)
from app.adapters.delivery_providers.cse.errors import CSEMappingError
from app.adapters.delivery_providers.cse.soap import Element
from app.schemas.response import DeliveryPrice
_TARIFF_KEY = "Tariff"
def map_cse_calc_response(
root: Element,
*,
delivery_type: str = "",
delivery_type_label: str = "",
service_guid: str | None = None,
) -> list[DeliveryPrice]:
"""Map a parsed ``Calc`` ``return`` Element into unified delivery prices."""
prices: list[DeliveryPrice] = []
for tariff in _iter_tariffs(root):
if service_guid is not None and tariff.value != service_guid:
continue
price = _map_tariff(tariff, delivery_type, delivery_type_label)
if price is not None:
prices.append(price)
return prices
def map_cse_calc_response_for_tariff_code(
root: Element,
*,
tariff_code: str,
delivery_type_label: str = "",
) -> DeliveryPrice | None:
"""Return tariff matching ``"<DeliveryType>|<ServiceGuid>|<Urgency>"``."""
delivery_type, tariff_guid, urgency = _split_tariff_code(tariff_code)
for tariff in _iter_tariffs(root):
if _tariff_matches(tariff, tariff_guid, urgency):
price = _map_tariff(tariff, delivery_type, delivery_type_label)
if price is not None:
return price
return None
def _tariff_urgency(tariff: Element) -> str | None:
"""Urgency GUID of a Calc tariff (fallback: the tariff record GUID)."""
return tariff.field_value("Urgency") or tariff.value
def _tariff_matches(tariff: Element, tariff_guid: str, urgency: str) -> bool:
if _tariff_urgency(tariff) != urgency:
return False
return tariff.value == tariff_guid
def _split_tariff_code(tariff_code: str) -> tuple[str, str, str]:
parts = tariff_code.split("|")
if len(parts) != 3 or not parts[1] or not parts[2]:
raise CSEMappingError("CSE tariff_code has invalid format.")
return parts[0], parts[1], parts[2]
def _iter_tariffs(root: Element):
for destination in root.items:
for tariff in destination.items:
if tariff.key == _TARIFF_KEY:
yield tariff
def _map_tariff(
tariff: Element,
delivery_type: str,
delivery_type_label: str,
) -> DeliveryPrice | None:
if _is_additional_service_tariff(tariff):
return None
urgency = _tariff_urgency(tariff)
if not urgency:
return None
if not tariff.value:
return None
# Unified tariff_code carries the delivery scheme, CSE service GUID and
# urgency so payment validation can recalculate the exact selected service.
tariff_code = f"{delivery_type}|{tariff.value}|{urgency}"
raw_total = tariff.field_value("Total")
if raw_total is None:
raise CSEMappingError("CSE tariff is missing Total.")
try:
price = Decimal(raw_total)
except (InvalidOperation, TypeError, ValueError) as exc:
raise CSEMappingError("CSE tariff Total is not a valid decimal.") from exc
if not price.is_finite() or price <= 0:
return None
base_name = tariff.field_value("Service") or tariff.field_value("UrgencyName")
if not base_name:
base_name = urgency
service_name = (
f"{base_name}{delivery_type_label}" if delivery_type_label else base_name
)
currency = normalize_currency(tariff.field_value("CurrencyName"))
min_days = _to_int(tariff.field_value("MinPeriod"))
max_days = _to_int(tariff.field_value("MaxPeriod"))
if min_days is None:
min_days = 0
if max_days is None or max_days < min_days:
max_days = min_days
try:
return DeliveryPrice(
provider=CSE_PROVIDER_NAME,
service_name=str(service_name),
price=price,
currency=currency,
delivery_days_min=min_days,
delivery_days_max=max_days,
tariff_code=tariff_code,
bypass_parcel_type_filter=True,
)
except (ArithmeticError, TypeError, ValueError) as exc:
raise CSEMappingError("CSE tariff fields have invalid values.") from exc
def _is_additional_service_tariff(tariff: Element) -> bool:
value = tariff.field_value("AdditionalService")
if value is None:
return False
return value.strip().lower() == "true"
def _to_int(value: str | None) -> int | None:
if value is None or value == "":
return None
try:
return int(Decimal(value))
except (InvalidOperation, TypeError, ValueError):
return None
@@ -0,0 +1,302 @@
"""CSE Calc, SaveDocuments and waybill payload mappers."""
import base64
import binascii
from dataclasses import dataclass
from datetime import timedelta
from app.adapters.delivery_providers.cse.constants import (
resolve_cse_geography,
resolve_type_of_cargo,
)
from app.adapters.delivery_providers.cse.errors import CSEMappingError, CSERequestError
from app.adapters.delivery_providers.cse.soap import Element, make_field
from app.schemas.payment import Address, InitPaymentRequest
from app.schemas.request import DeliveryCalculationRequest
_DATETIME_FORMAT = "%Y-%m-%dT%H:%M:%S"
_DELIVERY_DATE_OFFSET_DAYS = 7
@dataclass(frozen=True)
class CSEOrderRegistrationParams:
"""Contract-specific required parameters for SaveDocuments.
``Urgency`` is not here: it comes from the tariff the client selected
(``system_data.tariff.tariff_code`` holds the urgency GUID).
"""
payer: str
payment_method: str
shipping_method: str
@dataclass(frozen=True)
class CSEOrderRegistrationResult:
order_number: str
@dataclass(frozen=True)
class CSEOrderInfo:
order_number: str
status_code: str | None
waybill_number: str | None
def build_calc_body_for_calculation(
request: DeliveryCalculationRequest,
delivery_type: str = "",
service_guid: str | None = None,
) -> dict[str, Element]:
fields = [
make_field("SenderGeography", resolve_cse_geography(request.from_city)),
make_field("RecipientGeography", resolve_cse_geography(request.to_city)),
make_field("TypeOfCargo", resolve_type_of_cargo(_parcel_type(request))),
make_field("Weight", _format_decimal(request.weight_kg), "float"),
make_field("Qty", "1", "int"),
]
_append_delivery_type(fields, delivery_type)
if service_guid:
fields.append(make_field("Service", service_guid))
return _calc_body(Element(key="Destination", fields=fields))
def build_calc_body_for_payment(
request: InitPaymentRequest,
delivery_type: str = "",
service_guid: str | None = None,
) -> dict[str, Element]:
system_data = request.system_data
fields = [
make_field(
"SenderGeography",
resolve_cse_geography(request.sender_address.city_id),
),
make_field(
"RecipientGeography",
resolve_cse_geography(request.receiver_address.city_id),
),
make_field("TypeOfCargo", resolve_type_of_cargo(system_data.parcel_type)),
make_field("Weight", system_data.weight, "float"),
make_field("Qty", "1", "int"),
]
_append_delivery_type(fields, delivery_type)
if service_guid:
fields.append(make_field("Service", service_guid))
return _calc_body(Element(key="Destination", fields=fields))
def _append_delivery_type(fields: list[Element], delivery_type: str) -> None:
if delivery_type:
fields.append(make_field("DeliveryType", delivery_type))
def split_tariff_code(tariff_code: str) -> tuple[str, str, str]:
"""Split the CSE ``tariff_code`` into delivery type, service GUID, urgency.
The expected format is ``"<DeliveryType>|<ServiceGuid>|<Urgency>"``.
``DeliveryType`` may be empty when CSE contract defaults are used.
"""
parts = tariff_code.split("|")
if len(parts) != 3 or not parts[1] or not parts[2]:
raise CSERequestError("CSE tariff_code has invalid format.")
return parts[0], parts[1], parts[2]
def _calc_body(destination: Element) -> dict[str, Element]:
return {
"data": Element(key="Destinations", items=[destination]),
"parameters": Element(
key="parameters",
items=[make_field("ipaddress", "0.0.0.0")],
),
}
def map_cse_save_order_request(
request: InitPaymentRequest,
order_uuid: str,
params: CSEOrderRegistrationParams,
) -> dict[str, Element]:
system_data = request.system_data
take_date = request.pickup_date.strftime(_DATETIME_FORMAT)
delivery_date = (
request.pickup_date + timedelta(days=_DELIVERY_DATE_OFFSET_DAYS)
).strftime(_DATETIME_FORMAT)
delivery_type, _, urgency = split_tariff_code(system_data.tariff.tariff_code)
fields = [
make_field("TakeDate", take_date, "dateTime"),
make_field("TakeDateOf", take_date, "dateTime"),
make_field("Sender", request.sender_contact.full_name),
make_field(
"SenderGeography",
resolve_cse_geography(request.sender_address.city_id),
),
make_field("SenderAddress", _compose_address(request.sender_address)),
make_field("SenderPhone", request.sender_contact.phone),
make_field("Recipient", request.receiver_contact.full_name),
make_field(
"RecipientGeography",
resolve_cse_geography(request.receiver_address.city_id),
),
make_field("RecipientAddress", _compose_address(request.receiver_address)),
make_field("RecipientPhone", request.receiver_contact.phone),
make_field("Urgency", urgency),
make_field("Payer", params.payer, "float"),
make_field("PaymentMethod", params.payment_method, "float"),
make_field("ShippingMethod", params.shipping_method),
make_field("TypeOfCargo", resolve_type_of_cargo(system_data.parcel_type)),
make_field("Weight", system_data.weight, "float"),
make_field("CargoPackageQty", "1", "float"),
make_field("CargoCost", str(request.content.declared_value), "float"),
]
if delivery_type:
fields.append(make_field("DeliveryOfCargo", delivery_type))
fields.append(make_field("DeliveryDate", delivery_date, "dateTime"))
if request.content.description:
fields.append(make_field("CargoDescription", request.content.description))
fields.append(make_field("Comment", request.content.description))
if request.sender_contact.email:
fields.append(make_field("SenderEMail", request.sender_contact.email))
if request.receiver_contact.email:
fields.append(make_field("RecipientEMail", request.receiver_contact.email))
if system_data.parcel_type == "parcel" and system_data.dimensions is not None:
dimensions = system_data.dimensions
fields.append(make_field("Length", dimensions.length, "float"))
fields.append(make_field("Width", dimensions.width, "float"))
fields.append(make_field("Height", dimensions.height, "float"))
order = Element(
key="Order",
fields=fields,
properties=[make_field("ClientNumber", order_uuid)],
)
return {
"data": Element(key="Documents", items=[order]),
"parameters": Element(
key="parameters",
items=[make_field("DocumentType", "Order")],
),
}
def map_cse_save_order_response(root: Element) -> CSEOrderRegistrationResult:
for document in root.items:
number = document.property_value("Number")
if number:
return CSEOrderRegistrationResult(order_number=number)
raise CSEMappingError("CSE SaveDocuments response is missing document Number.")
def build_tracking_body_for_order(order_number: str) -> dict[str, Element]:
return {
"documents": Element(
key="Documents",
items=[Element(key=order_number)],
),
"parameters": Element(
key="parameters",
items=[
make_field("DocumentType", "Order"),
make_field("OnlySelectedType", True, "boolean"),
],
),
}
def map_cse_tracking_response(root: Element) -> CSEOrderInfo:
for document in root.items:
order_number = document.property_value("Number") or document.key
if not order_number:
continue
return CSEOrderInfo(
order_number=order_number,
status_code=_latest_tracking_status(document),
waybill_number=_extract_waybill_number(document),
)
raise CSEMappingError("CSE Tracking response is missing document data.")
def build_waybill_print_form_body(waybill_number: str) -> dict[str, Element]:
return {
"documents": Element(
key="Documents",
items=[Element(key=waybill_number)],
),
"parameters": Element(
key="parameters",
items=[
make_field("DocumentType", "waybill"),
make_field("Type", "print"),
make_field(
"Name",
"Универсальная печатная "
"форма документа НАКЛАДНАЯ",
),
make_field("Format", "pdf"),
make_field("OnlySelectedType", True, "boolean"),
],
),
}
def map_cse_waybill_print_form_response(root: Element) -> bytes:
for document in root.items:
bdata = document.bdata
if not bdata:
continue
try:
normalized_bdata = "".join(bdata.split())
return base64.b64decode(normalized_bdata, validate=True)
except (binascii.Error, ValueError) as exc:
raise CSEMappingError(
"CSE GetFormsForDocuments response contains invalid BData."
) from exc
raise CSEMappingError("CSE GetFormsForDocuments response is missing BData.")
def _parcel_type(request: DeliveryCalculationRequest) -> str | None:
return request.parcel_type.value if request.parcel_type is not None else None
def _compose_address(address: Address) -> str:
parts = [address.city, address.street, address.house]
line = ", ".join(part for part in parts if part)
if address.apartment:
line = f"{line}, кв. {address.apartment}"
return line
def _format_decimal(value: float) -> str:
return format(value, "g")
def _latest_tracking_status(document: Element) -> str | None:
dated: list[tuple[str, str]] = []
for state in document.items:
if not state.key:
continue
date_time = state.property_value("DateTime") or ""
dated.append((date_time, state.key))
if not dated:
return None
dated.sort(key=lambda item: item[0])
return dated[-1][1]
def _extract_waybill_number(document: Element) -> str | None:
for table in document.tables:
if table.key != "Waybills":
continue
for waybill in table.items:
document_type = waybill.property_value("DocumentType")
if document_type is not None and document_type.lower() != "waybill":
continue
number = waybill.property_value("Number") or waybill.key
if number:
return number
return None
+182
View File
@@ -0,0 +1,182 @@
"""SOAP (cargo3) Element serialization and parsing for CSE.
CSE exposes a SOAP/1C web service ("Карго") that transfers data through nested
universal ``Element`` structures (``Key``/``Value``/``ValueType``/
``Properties``/``Fields``/``List``/``Tables``). This module builds request
envelopes and parses responses into a plain Python representation that mappers
can navigate.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from xml.etree import ElementTree as ET
CARGO_NS = "http://www.cargo3.ru"
SOAP_NS = "http://www.w3.org/2003/05/soap-envelope"
_SCALAR_TAGS = ("Key", "Value", "ValueType")
_BINARY_TAG = "BData"
@dataclass
class Element:
"""In-memory representation of a cargo3 ``Element`` structure."""
key: str | None = None
value: str | None = None
value_type: str | None = None
bdata: str | None = None
fields: list["Element"] = field(default_factory=list)
items: list["Element"] = field(default_factory=list) # <List>
tables: list["Element"] = field(default_factory=list)
properties: list["Element"] = field(default_factory=list)
def field_value(self, key: str) -> str | None:
"""Return the ``Value`` of the ``Fields`` entry with the given ``Key``."""
return _find_value(self.fields, key)
def property_value(self, key: str) -> str | None:
"""Return the ``Value`` of the ``Properties`` entry with the given ``Key``."""
return _find_value(self.properties, key)
def _find_value(elements: list["Element"], key: str) -> str | None:
for element in elements:
if element.key == key:
return element.value
return None
def make_field(key: str, value: Any, value_type: str = "string") -> Element:
"""Build a leaf ``Element`` for a ``Fields`` entry."""
return Element(key=key, value=_stringify(value), value_type=value_type)
def _stringify(value: Any) -> str:
if isinstance(value, bool):
return "true" if value else "false"
return str(value)
def build_envelope(
operation: str,
*,
login: str,
password: str,
body: dict[str, Element],
) -> bytes:
"""Build a SOAP 1.2 request envelope for the given CSE operation.
``body`` maps the operation parameter tag (e.g. ``data``/``parameters``) to
the root ``Element`` placed under that tag.
"""
envelope = ET.Element(f"{{{SOAP_NS}}}Envelope")
soap_body = ET.SubElement(envelope, f"{{{SOAP_NS}}}Body")
op_node = ET.SubElement(soap_body, f"{{{CARGO_NS}}}{operation}")
login_node = ET.SubElement(op_node, f"{{{CARGO_NS}}}login")
login_node.text = login
password_node = ET.SubElement(op_node, f"{{{CARGO_NS}}}password")
password_node.text = password
for tag, element in body.items():
_append_element(op_node, tag, element)
ET.register_namespace("soap", SOAP_NS)
ET.register_namespace("m", CARGO_NS)
return ET.tostring(envelope, encoding="utf-8", xml_declaration=True)
def _append_element(parent: ET.Element, tag: str, element: Element) -> None:
node = ET.SubElement(parent, f"{{{CARGO_NS}}}{tag}")
if element.key is not None:
_scalar(node, "Key", element.key)
if element.value is not None:
_scalar(node, "Value", element.value)
if element.value_type is not None:
_scalar(node, "ValueType", element.value_type)
for child in element.properties:
_append_element(node, "Properties", child)
for child in element.fields:
_append_element(node, "Fields", child)
for child in element.items:
_append_element(node, "List", child)
for child in element.tables:
_append_element(node, "Tables", child)
def _scalar(parent: ET.Element, tag: str, text: str) -> None:
child = ET.SubElement(parent, f"{{{CARGO_NS}}}{tag}")
child.text = text
def parse_response(xml_payload: str | bytes, operation: str) -> Element:
"""Parse a CSE SOAP response and return the ``return`` Element.
Raises ``ValueError`` when the envelope is malformed or the expected
``<operation>Response`` / ``return`` nodes are missing.
"""
try:
root = ET.fromstring(xml_payload)
except ET.ParseError as exc:
raise ValueError("CSE response is not valid XML.") from exc
response_tag = f"{{{CARGO_NS}}}{operation}Response"
response_node = _find_descendant(root, response_tag)
if response_node is None:
raise ValueError(f"CSE response is missing <{operation}Response>.")
return_node = _find_child(response_node, "return")
if return_node is None:
raise ValueError("CSE response is missing <return>.")
return _parse_element(return_node)
def _parse_element(node: ET.Element) -> Element:
element = Element()
for child in node:
local = _localname(child.tag)
if local in _SCALAR_TAGS:
text = (child.text or "").strip()
if local == "Key":
element.key = text
elif local == "Value":
element.value = text
else:
element.value_type = text
elif local == "Fields":
element.fields.append(_parse_element(child))
elif local == "List":
element.items.append(_parse_element(child))
elif local == "Tables":
element.tables.append(_parse_element(child))
elif local == "Properties":
element.properties.append(_parse_element(child))
elif local == _BINARY_TAG:
element.bdata = (child.text or "").strip()
return element
def _find_descendant(root: ET.Element, tag: str) -> ET.Element | None:
if root.tag == tag:
return root
return root.find(f".//{tag}")
def _find_child(node: ET.Element, local: str) -> ET.Element | None:
for child in node:
if _localname(child.tag) == local:
return child
return None
def _localname(tag: str) -> str:
return tag.rsplit("}", 1)[-1]
@@ -0,0 +1,67 @@
"""Delivery provider registry and wiring."""
from dataclasses import dataclass
from typing import Mapping, Sequence
import httpx
from app.adapters.delivery_providers.base import (
DeliveryProvider,
OrderRegistrationProvider,
PaymentPriceValidationProvider,
)
from app.adapters.delivery_providers.cdek import CDEKProvider
from app.adapters.delivery_providers.cse import CSEProvider
from app.config import DeliveryProvidersConfig
@dataclass(frozen=True, slots=True)
class DeliveryProviderRegistry:
providers: Sequence[DeliveryProvider]
payment_price_validation_adapters: Mapping[str, PaymentPriceValidationProvider]
order_registration_adapters: Mapping[str, OrderRegistrationProvider]
def resolve_delivery_provider_timeout_seconds(
config: DeliveryProvidersConfig,
) -> float:
timeouts = [
provider_config.timeout_seconds
for provider_config in (config.cdek, config.cse)
if provider_config.enabled
]
return max(timeouts, default=10.0)
def build_delivery_provider_registry(
*,
http_client: httpx.AsyncClient,
config: DeliveryProvidersConfig,
) -> DeliveryProviderRegistry:
providers: list[DeliveryProvider] = []
payment_price_validation_adapters: dict[str, PaymentPriceValidationProvider] = {}
order_registration_adapters: dict[str, OrderRegistrationProvider] = {}
if config.cdek.enabled:
cdek_provider = CDEKProvider.from_config(
http_client=http_client,
config=config.cdek,
)
providers.append(cdek_provider)
payment_price_validation_adapters[cdek_provider.name] = cdek_provider
order_registration_adapters[cdek_provider.name] = cdek_provider
if config.cse.enabled:
cse_provider = CSEProvider.from_config(
http_client=http_client,
config=config.cse,
)
providers.append(cse_provider)
payment_price_validation_adapters[cse_provider.name] = cse_provider
order_registration_adapters[cse_provider.name] = cse_provider
return DeliveryProviderRegistry(
providers=tuple(providers),
payment_price_validation_adapters=payment_price_validation_adapters,
order_registration_adapters=order_registration_adapters,
)
+5
View File
@@ -0,0 +1,5 @@
"""E-mail adapters."""
from app.adapters.email.smtp_client import SMTPEmailSender, SMTPEmailSenderError
__all__ = ["SMTPEmailSender", "SMTPEmailSenderError"]
+136
View File
@@ -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,
)
View File
+16
View File
@@ -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)
+5
View File
@@ -0,0 +1,5 @@
"""TBank payment adapter package."""
from app.adapters.tbank.client import TBankAdapter
__all__ = ["TBankAdapter"]
+28
View File
@@ -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."""
+257
View File
@@ -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)}"
+14399 -4706
View File
File diff suppressed because it is too large Load Diff
+87 -10
View File
@@ -38,14 +38,59 @@ class RepositoryConfig(BaseModel):
price_cache_ttl_seconds: int = 900 price_cache_ttl_seconds: int = 900
class AdapterConfig(BaseModel): class DeliveryProviderBaseConfig(BaseModel):
cdek_base_url: str = "https://api.cdek.ru/v2" enabled: bool = True
cdek_client_id: str = "" timeout_seconds: float = Field(default=10.0, gt=0)
cdek_client_secret: str = "" retry_attempts: int = Field(default=2, ge=0)
cdek_retry_attempts: int = Field(default=2, ge=0) retry_backoff_seconds: float = Field(default=0.2, ge=0)
cdek_retry_backoff_seconds: float = Field(default=0.2, ge=0) cache_ttl_seconds: int = Field(default=900, gt=0)
cdek_timeout_seconds: float = Field(default=10.0, gt=0)
cdek_cache_ttl_seconds: int = Field(default=900, gt=0)
class CDEKDeliveryProviderConfig(DeliveryProviderBaseConfig):
base_url: str = "https://api.cdek.ru/v2"
client_id: str = ""
client_secret: str = ""
class CSEDeliveryProviderConfig(DeliveryProviderBaseConfig):
base_url: str = "https://web.cse.ru/1c/ws/Web1C.1cws"
login: str = ""
password: str = ""
# CSE service GUIDs from GetReferenceData: Services that are allowed to be
# shown as delivery tariffs. Additional/non-delivery services must not be
# included here.
delivery_service_guids: list[str] = Field(default_factory=list)
# Contract-specific required parameters for SaveDocuments (order registration).
# Urgency is not here: it comes from the tariff selected by the client.
payer: str = ""
payment_method: str = ""
shipping_method: str = ""
class DeliveryProvidersConfig(BaseModel):
cdek: CDEKDeliveryProviderConfig = Field(
default_factory=CDEKDeliveryProviderConfig
)
cse: CSEDeliveryProviderConfig = Field(default_factory=CSEDeliveryProviderConfig)
class 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):
@@ -97,6 +142,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",
@@ -106,11 +171,20 @@ class Settings(BaseSettings):
service: ServiceConfig = Field(default_factory=ServiceConfig) service: ServiceConfig = Field(default_factory=ServiceConfig)
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) delivery_providers: DeliveryProvidersConfig = Field(
default_factory=DeliveryProvidersConfig
)
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(
@@ -137,9 +211,12 @@ class _RequiredYamlSections(BaseModel):
service: dict[str, Any] service: dict[str, Any]
business_logic: dict[str, Any] business_logic: dict[str, Any]
repository: dict[str, Any] repository: dict[str, Any]
adapter: dict[str, Any] delivery_providers: 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:
+144 -22
View File
@@ -1,18 +1,32 @@
"""Delivery API controller skeleton.""" """Delivery API controller skeleton."""
import structlog
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.registry import (
build_delivery_provider_registry,
resolve_delivery_provider_timeout_seconds,
)
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.request import AddressSuggestRequest, DeliveryCalculationRequest from app.runtime.metrics import get_cache_metrics
from app.schemas.payment import (
InitPaymentRequest,
InitPaymentResponse,
TBankPaymentNotification,
)
from app.schemas.request import DeliveryCalculationRequest, SuggestAddressRequest
from app.schemas.response import AddressSuggestion, DeliveryPrice from app.schemas.response import AddressSuggestion, DeliveryPrice
from app.services.aggregator import ( from app.services.aggregator import (
AddressSuggestionsUnavailableError, AddressSuggestionsUnavailableError,
@@ -20,10 +34,15 @@ from app.services.aggregator import (
AggregatorServiceError, AggregatorServiceError,
InvalidAddressSuggestRequestError, InvalidAddressSuggestRequestError,
InvalidDeliveryRequestError, InvalidDeliveryRequestError,
InvalidOrderCreateRequestError, InvalidInitPaymentRequestError,
InvalidTBankPaymentNotificationError,
InitPaymentUnavailableError,
TBankPaymentNotificationProcessingError,
UnsupportedAddressSuggestionCountryError, UnsupportedAddressSuggestionCountryError,
) )
logger = structlog.get_logger(__name__)
router = APIRouter(prefix="/delivery", tags=["delivery"]) router = APIRouter(prefix="/delivery", tags=["delivery"])
@@ -32,11 +51,17 @@ _AGGREGATOR_SERVICE_STATE_KEY = "aggregator_service"
def _build_aggregator_service(settings: Settings) -> AggregatorService: def _build_aggregator_service(settings: Settings) -> AggregatorService:
http_client = build_controller_http_client( http_client = build_controller_http_client(
timeout_seconds=settings.adapter.cdek_timeout_seconds timeout_seconds=resolve_delivery_provider_timeout_seconds(
settings.delivery_providers
) )
cdek_provider = CDEKProvider.from_adapter_config( )
delivery_provider_registry = build_delivery_provider_registry(
http_client=http_client, http_client=http_client,
adapter_config=settings.adapter, config=settings.delivery_providers,
)
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,
@@ -50,12 +75,34 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
http_client=http_client, http_client=http_client,
config=settings.address_suggestions.tomtom, config=settings.address_suggestions.tomtom,
) )
providers = (cdek_provider,) cache = PriceCache.from_repository_config(
cache = PriceCache.from_repository_config(settings.repository) settings.repository,
metrics=get_cache_metrics(),
)
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=delivery_provider_registry.providers,
cache=cache, cache=cache,
order_adapter=cdek_provider, payment_adapter=payment_adapter,
payment_price_validation_adapters=(
delivery_provider_registry.payment_price_validation_adapters
),
order_repository=order_repository,
order_registration_adapters=(
delivery_provider_registry.order_registration_adapters
),
email_sender=email_sender,
address_suggestion_providers=( address_suggestion_providers=(
dadata_provider, dadata_provider,
yandex_geosuggest_provider, yandex_geosuggest_provider,
@@ -114,9 +161,15 @@ async def get_delivery_price(
response_model=list[AddressSuggestion], response_model=list[AddressSuggestion],
) )
async def suggest_addresses( async def suggest_addresses(
address_request: AddressSuggestRequest, address_request: SuggestAddressRequest,
service: AggregatorService = Depends(get_aggregator_service), service: AggregatorService = Depends(get_aggregator_service),
) -> list[AddressSuggestion]: ) -> list[AddressSuggestion]:
logger.info(
"suggest_address_requested",
city=address_request.city,
query=address_request.query,
limit=address_request.limit,
)
try: try:
return await service.suggest_addresses(address_request) return await service.suggest_addresses(address_request)
except UnsupportedAddressSuggestionCountryError as exc: except UnsupportedAddressSuggestionCountryError as exc:
@@ -147,27 +200,96 @@ 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:
logger.info(
"tbank_notification_received",
order_id=notification.OrderId,
status=notification.Status,
success=notification.Success,
payment_id=notification.PaymentId,
error_code=notification.ErrorCode,
amount=notification.Amount,
)
try:
response_body = await service.handle_tbank_payment_notification(notification)
except InvalidTBankPaymentNotificationError as exc:
logger.warning(
"tbank_notification_rejected",
order_id=notification.OrderId,
reason="invalid_token",
)
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail={
"code": "invalid_tbank_payment_notification",
"message": "TBank payment notification token is invalid.",
},
) from exc
except TBankPaymentNotificationProcessingError as exc:
logger.error(
"tbank_notification_processing_failed",
order_id=notification.OrderId,
exc_info=True,
)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={
"code": "tbank_payment_notification_processing_unavailable",
"message": "TBank payment notification processing is temporarily unavailable.",
},
) from exc
except AggregatorServiceError as exc:
logger.error(
"tbank_notification_processing_failed",
order_id=notification.OrderId,
exc_info=True,
)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={
"code": "tbank_payment_notification_processing_unavailable",
"message": "TBank payment notification processing is temporarily unavailable.",
},
) from exc
return PlainTextResponse(response_body)
+11
View File
@@ -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
+56
View File
@@ -0,0 +1,56 @@
"""Pure rules for TBank payment notification handling."""
from enum import Enum
class TBankPaymentNotificationAction(str, Enum):
ACKNOWLEDGE_ONLY = "acknowledge_only"
REGISTER_PROVIDER_ORDER = "register_provider_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_PROVIDER_ORDER
return TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY
# Монотонный приоритет статусов платежа TBank по жизненному циклу.
# Используется, чтобы внеочередное уведомление не понижало уже записанный статус
# (например, AUTHORIZED, пришедший после CONFIRMED, не должен затирать CONFIRMED).
# Неизвестные статусы получают ранг 0 и не перезаписывают известный статус.
_TBANK_PAYMENT_STATUS_RANK: dict[str, int] = {
"NEW": 10,
"FORM_SHOWED": 20,
"AUTHORIZING": 30,
"3DS_CHECKING": 30,
"3DS_CHECKED": 30,
"REJECTED": 40,
"AUTH_FAIL": 40,
"DEADLINE_EXPIRED": 40,
"AUTHORIZED": 40,
"CONFIRMING": 50,
"CONFIRMED": 60,
"REVERSING": 70,
"PARTIAL_REVERSED": 70,
"REVERSED": 70,
"REFUNDING": 70,
"PARTIAL_REFUNDED": 70,
"REFUNDED": 70,
"CANCELED": 70,
}
def tbank_payment_status_rank(status: str) -> int:
return _TBANK_PAYMENT_STATUS_RANK.get(status, 0)
def should_apply_tbank_payment_status(current: str | None, new: str) -> bool:
"""Применять новый статус, только если он не понижает текущий по жизненному циклу."""
if current is None:
return True
return tbank_payment_status_rank(new) >= tbank_payment_status_rank(current)
+63 -1
View File
@@ -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: str | 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_str(_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:
@@ -216,6 +271,13 @@ def _normalize_text(value: object) -> str:
return str(value).strip() return str(value).strip()
def _try_to_str(value: object) -> str | None:
if value is _MISSING or value is None:
return None
text = str(value).strip()
return text or None
def _normalize_city_id(value: object) -> int: def _normalize_city_id(value: object) -> int:
city_id = _try_to_int(value) city_id = _try_to_int(value)
if city_id is None: if city_id is None:
+2
View File
@@ -5,12 +5,14 @@ from fastapi import FastAPI
from app.config import Settings, get_settings from app.config import Settings, get_settings
from app.controllers.v1.delivery import router as delivery_router from app.controllers.v1.delivery import router as delivery_router
from app.runtime.logging import configure_logging from app.runtime.logging import configure_logging
from app.runtime.metrics import configure_metrics
from app.runtime.tracing import configure_tracing from app.runtime.tracing import configure_tracing
def create_app(settings: Settings | None = None) -> FastAPI: def create_app(settings: Settings | None = None) -> FastAPI:
configure_logging() configure_logging()
resolved_settings = settings or get_settings() resolved_settings = settings or get_settings()
configure_metrics(resolved_settings.observability)
application = FastAPI(title="G2S Aggregator", version="0.1.0") application = FastAPI(title="G2S Aggregator", version="0.1.0")
application.state.settings = resolved_settings application.state.settings = resolved_settings
+52 -2
View File
@@ -7,10 +7,14 @@ import json
from typing import Any, Callable, Protocol from typing import Any, Callable, Protocol
from pydantic import BaseModel from pydantic import BaseModel
import structlog
from app.config import RepositoryConfig from app.config import RepositoryConfig
logger = structlog.get_logger(__name__)
class RedisClientProtocol(Protocol): class RedisClientProtocol(Protocol):
async def get(self, key: str) -> bytes | str | None: ... async def get(self, key: str) -> bytes | str | None: ...
@@ -19,6 +23,14 @@ class RedisClientProtocol(Protocol):
async def delete(self, key: str) -> Any: ... async def delete(self, key: str) -> Any: ...
class CacheMetricsProtocol(Protocol):
def record_request(self, *, operation: str, outcome: str) -> None: ...
def record_error(self, *, operation: str, error_type: str) -> None: ...
def record_hit(self, *, hit: bool) -> None: ...
class PriceCacheRepositoryError(RuntimeError): class PriceCacheRepositoryError(RuntimeError):
"""Deterministic repository error raised on Redis operation failures.""" """Deterministic repository error raised on Redis operation failures."""
@@ -26,9 +38,16 @@ class PriceCacheRepositoryError(RuntimeError):
class PriceCache: class PriceCache:
"""Repository for cached provider payloads.""" """Repository for cached provider payloads."""
def __init__(self, redis_client: RedisClientProtocol, *, ttl_seconds: int) -> None: def __init__(
self,
redis_client: RedisClientProtocol,
*,
ttl_seconds: int,
metrics: CacheMetricsProtocol | None = None,
) -> None:
self._redis_client = redis_client self._redis_client = redis_client
self._ttl_seconds = ttl_seconds self._ttl_seconds = ttl_seconds
self._metrics = metrics
@classmethod @classmethod
def from_repository_config( def from_repository_config(
@@ -36,20 +55,25 @@ class PriceCache:
repository_config: RepositoryConfig, repository_config: RepositoryConfig,
*, *,
client_factory: Callable[[str], RedisClientProtocol] | None = None, client_factory: Callable[[str], RedisClientProtocol] | None = None,
metrics: CacheMetricsProtocol | None = None,
) -> "PriceCache": ) -> "PriceCache":
resolved_factory = client_factory or _default_redis_client_factory resolved_factory = client_factory or _default_redis_client_factory
redis_client = resolved_factory(repository_config.redis_dsn) redis_client = resolved_factory(repository_config.redis_dsn)
return cls( return cls(
redis_client=redis_client, redis_client=redis_client,
ttl_seconds=repository_config.price_cache_ttl_seconds, ttl_seconds=repository_config.price_cache_ttl_seconds,
metrics=metrics,
) )
async def get(self, key: str) -> Any | None: async def get(self, key: str) -> Any | None:
try: try:
payload = await self._redis_client.get(key) payload = await self._redis_client.get(key)
except Exception as exc: except Exception as exc:
self._record_failure(operation="get", error=exc)
raise PriceCacheRepositoryError("Redis cache read failed.") from exc raise PriceCacheRepositoryError("Redis cache read failed.") from exc
if payload is None: if payload is None:
self._record_request(operation="get", outcome="miss")
self._record_hit(hit=False)
return None return None
if isinstance(payload, bytes): if isinstance(payload, bytes):
@@ -59,7 +83,10 @@ class PriceCache:
else: else:
raise TypeError("Redis cache payload must be bytes or str.") raise TypeError("Redis cache payload must be bytes or str.")
return json.loads(serialized_payload) result = json.loads(serialized_payload)
self._record_request(operation="get", outcome="hit")
self._record_hit(hit=True)
return result
async def set(self, key: str, value: Any, ttl: int | None = None) -> None: async def set(self, key: str, value: Any, ttl: int | None = None) -> None:
serialized_payload = _serialize(value) serialized_payload = _serialize(value)
@@ -67,13 +94,36 @@ class PriceCache:
try: try:
await self._redis_client.set(key, serialized_payload, ex=resolved_ttl) await self._redis_client.set(key, serialized_payload, ex=resolved_ttl)
except Exception as exc: except Exception as exc:
self._record_failure(operation="set", error=exc)
raise PriceCacheRepositoryError("Redis cache write failed.") from exc raise PriceCacheRepositoryError("Redis cache write failed.") from exc
self._record_request(operation="set", outcome="success")
async def invalidate(self, key: str) -> None: async def invalidate(self, key: str) -> None:
try: try:
await self._redis_client.delete(key) await self._redis_client.delete(key)
except Exception as exc: except Exception as exc:
self._record_failure(operation="invalidate", error=exc)
raise PriceCacheRepositoryError("Redis cache invalidate failed.") from exc raise PriceCacheRepositoryError("Redis cache invalidate failed.") from exc
self._record_request(operation="invalidate", outcome="success")
def _record_failure(self, *, operation: str, error: Exception) -> None:
error_type = type(error).__name__
logger.warning(
"cache.operation_failed",
operation=operation,
error_type=error_type,
)
if self._metrics is not None:
self._metrics.record_request(operation=operation, outcome="error")
self._metrics.record_error(operation=operation, error_type=error_type)
def _record_request(self, *, operation: str, outcome: str) -> None:
if self._metrics is not None:
self._metrics.record_request(operation=operation, outcome=outcome)
def _record_hit(self, *, hit: bool) -> None:
if self._metrics is not None:
self._metrics.record_hit(hit=hit)
def _default_redis_client_factory(redis_dsn: str) -> RedisClientProtocol: def _default_redis_client_factory(redis_dsn: str) -> RedisClientProtocol:
+5
View File
@@ -0,0 +1,5 @@
"""Order repository exports."""
from app.repositories.order.repository import OrderData, OrderRepository
__all__ = ("OrderData", "OrderRepository")
+76
View File
@@ -0,0 +1,76 @@
"""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[str] = mapped_column(String(128), nullable=False)
provider: Mapped[str] = mapped_column(
String(32),
nullable=False,
server_default="cdek",
)
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)
provider_order_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
provider_order_status: Mapped[str | None] = mapped_column(String(64), nullable=True)
provider_waybill_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
provider_waybill_url: Mapped[str | None] = mapped_column(
String(2048), nullable=True
)
provider_polled_at: Mapped[datetime | None] = mapped_column(
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,
)
+217
View File
@@ -0,0 +1,217 @@
"""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 and_, or_, 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: str
provider: str
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,
provider=order_data.provider,
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,
*,
for_update: bool = False,
) -> Order | None:
statement = select(Order).where(Order.order_uuid == order_uuid)
if for_update:
statement = statement.with_for_update()
result = await session.execute(statement)
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_provider_order_registered(
self,
session: AsyncSession,
order_uuid: str,
provider_order_id: str,
) -> Order | None:
order = await self.get_order_by_order_uuid(session, order_uuid)
if order is None:
return None
order.provider_order_id = provider_order_id
await session.flush()
return order
async def list_orders_pending_waybill(
self,
session: AsyncSession,
*,
limit: int,
) -> Sequence[Order]:
cdek_pending = and_(
Order.provider == "cdek",
Order.provider_order_id.is_not(None),
Order.provider_waybill_url.is_(None),
(
Order.provider_order_status.is_(None)
| Order.provider_order_status.not_in(TERMINAL_ORDER_STATUSES)
),
)
cse_pending = and_(
Order.provider == "cse",
Order.provider_order_id.is_not(None),
Order.provider_waybill_id.is_(None),
)
statement = (
select(Order)
.where(or_(cdek_pending, cse_pending))
.order_by(Order.provider_polled_at.asc().nulls_first())
.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.provider_order_status = order_status
if waybill_uuid is not None and order.provider_waybill_id is None:
order.provider_waybill_id = waybill_uuid
order.provider_polled_at = polled_at
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.provider_waybill_url is None:
order.provider_waybill_url = waybill_url
order.provider_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]:
cdek_ready = and_(
Order.provider == "cdek",
Order.provider_waybill_url.is_not(None),
)
cse_ready = and_(
Order.provider == "cse",
Order.provider_waybill_id.is_not(None),
)
statement = (
select(Order)
.where(
or_(cdek_ready, cse_ready),
Order.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
+58 -4
View File
@@ -1,11 +1,13 @@
"""Centralized runtime logging bootstrap.""" """Centralized runtime logging bootstrap."""
import json
import logging import logging
import sys import sys
from collections.abc import Iterable from collections.abc import Iterable
from typing import TextIO from typing import Any, TextIO
import structlog import structlog
from opentelemetry import trace
_UVICORN_LOGGER_NAMES = ("uvicorn", "uvicorn.error", "uvicorn.access") _UVICORN_LOGGER_NAMES = ("uvicorn", "uvicorn.error", "uvicorn.access")
@@ -18,10 +20,14 @@ def configure_logging(
) -> None: ) -> None:
resolved_stream = sys.stdout if stream is None else stream resolved_stream = sys.stdout if stream is None else stream
formatter = structlog.stdlib.ProcessorFormatter( formatter = structlog.stdlib.ProcessorFormatter(
foreign_pre_chain=list(_shared_processors()), foreign_pre_chain=[
_add_trace_context,
_render_context_in_event,
*_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)
@@ -31,8 +37,10 @@ def configure_logging(
structlog.reset_defaults() structlog.reset_defaults()
structlog.configure( structlog.configure(
processors=[ processors=[
*_shared_processors(),
structlog.stdlib.PositionalArgumentsFormatter(), structlog.stdlib.PositionalArgumentsFormatter(),
_add_trace_context,
_render_context_in_event,
*_shared_processors(),
structlog.processors.StackInfoRenderer(), structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info, structlog.processors.format_exc_info,
structlog.stdlib.ProcessorFormatter.wrap_for_formatter, structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
@@ -60,6 +68,52 @@ def _shared_processors() -> tuple[structlog.types.Processor, ...]:
) )
def _add_trace_context(
_logger: Any,
_method_name: str,
event_dict: structlog.types.EventDict,
) -> structlog.types.EventDict:
span_context = trace.get_current_span().get_span_context()
if not span_context.is_valid:
return event_dict
event_dict["trace_id"] = format(span_context.trace_id, "032x")
event_dict["span_id"] = format(span_context.span_id, "016x")
return event_dict
def _render_context_in_event(
_logger: Any,
_method_name: str,
event_dict: structlog.types.EventDict,
) -> structlog.types.EventDict:
event = str(event_dict.get("event", ""))
context = " ".join(
f"{key}={_serialize_log_value(value)}"
for key, value in sorted(event_dict.items())
if key
not in {
"event",
"exc_info",
"stack_info",
"_record",
"_from_structlog",
}
)
event_dict["event"] = f"{event} {context}" if context else event
return event_dict
def _serialize_log_value(value: object) -> str:
return json.dumps(
value,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
default=str,
)
def _configure_logger( def _configure_logger(
logger: logging.Logger, logger: logging.Logger,
*, *,
+77
View File
@@ -0,0 +1,77 @@
"""OpenTelemetry metrics bootstrap and application instruments."""
from opentelemetry import metrics
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.sdk.resources import Resource
from app.config import ObservabilityConfig
_METER_PROVIDER: MeterProvider | None = None
_CACHE_METRICS: "CacheMetrics | None" = None
class CacheMetrics:
"""Metrics emitted by the Redis-backed price cache."""
def __init__(self) -> None:
meter = metrics.get_meter("app.repositories.cache")
self._requests = meter.create_counter(
"cache_requests_total",
unit="{request}",
description="Number of cache operations by operation and outcome.",
)
self._errors = meter.create_counter(
"cache_errors_total",
unit="{error}",
description="Number of failed cache operations by error type.",
)
self._hit_ratio = meter.create_histogram(
"cache_hit_ratio",
unit="1",
description="Cache lookup result: 1 for a hit and 0 for a miss.",
)
def record_request(self, *, operation: str, outcome: str) -> None:
self._requests.add(1, {"operation": operation, "outcome": outcome})
def record_error(self, *, operation: str, error_type: str) -> None:
self._errors.add(1, {"operation": operation, "error.type": error_type})
def record_hit(self, *, hit: bool) -> None:
self._hit_ratio.record(1.0 if hit else 0.0)
def configure_metrics(observability: ObservabilityConfig) -> None:
"""Configure the process-wide OTLP metrics exporter once."""
global _METER_PROVIDER
if not observability.enabled or _METER_PROVIDER is not None:
return
meter_provider = _build_meter_provider(observability)
metrics.set_meter_provider(meter_provider)
_METER_PROVIDER = meter_provider
def get_cache_metrics() -> CacheMetrics:
"""Return the process-wide cache instruments."""
global _CACHE_METRICS
if _CACHE_METRICS is None:
_CACHE_METRICS = CacheMetrics()
return _CACHE_METRICS
def _build_meter_provider(observability: ObservabilityConfig) -> MeterProvider:
exporter = OTLPMetricExporter(
endpoint=observability.otlp_endpoint,
insecure=observability.otlp_insecure,
)
reader = PeriodicExportingMetricReader(exporter)
resource = Resource.create({"service.name": observability.service_name})
return MeterProvider(resource=resource, metric_readers=[reader])
-59
View File
@@ -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)
+146
View File
@@ -0,0 +1,146 @@
"""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
declared_value: int = Field(alias="declared_value", strict=True)
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: str = Field(min_length=1)
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)
+14
View File
@@ -26,7 +26,21 @@ class DeliveryCalculationRequest(BaseModel):
parcel_type: ParcelType | None = None parcel_type: ParcelType | None = None
class SuggestAddressRequest(BaseModel):
"""API request for the /suggest-address endpoint.
``city`` is a city identifier (key in ``cities_map``); the city name and
country code are resolved from it by the service layer.
"""
city: str = Field(min_length=1)
query: str = Field(min_length=1)
limit: int | None = Field(default=None, gt=0)
class AddressSuggestRequest(BaseModel): class AddressSuggestRequest(BaseModel):
"""Provider-facing address suggestion request."""
country_code: str = Field(min_length=2, max_length=2) country_code: str = Field(min_length=2, max_length=2)
city: str = Field(min_length=1) city: str = Field(min_length=1)
query: str = Field(min_length=1) query: str = Field(min_length=1)
+2
View File
@@ -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: str | None = Field(default=None, min_length=1)
bypass_parcel_type_filter: bool = Field(default=False, exclude=True)
class AddressSuggestion(BaseModel): class AddressSuggestion(BaseModel):
+578 -33
View File
@@ -3,27 +3,60 @@
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.tbank.base import (
TBankPaymentAdapterError,
TBankPaymentNotificationTokenError,
TBankPaymentRequestError,
)
from app.domain.payment_notifications import (
TBankPaymentNotificationAction,
resolve_tbank_payment_notification_action,
should_apply_tbank_payment_status,
)
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.request import AddressSuggestRequest, DeliveryCalculationRequest from app.schemas.payment import (
InitPaymentRequest,
InitPaymentResponse,
TBankPaymentNotification,
)
from app.cities import cities_map
from app.schemas.request import (
AddressSuggestRequest,
DeliveryCalculationRequest,
SuggestAddressRequest,
)
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 +78,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 +100,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
) -> object: ...
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_provider_order_registered(
self,
session: object,
order_uuid: str,
provider_order_id: 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 +169,41 @@ 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_adapters: (
Mapping[str, PaymentPriceValidationAdapterProtocol] | None
) = None,
order_repository: OrderRepositoryProtocol | None = None,
order_registration_adapters: (
Mapping[str, 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_adapters = dict(
payment_price_validation_adapters or {}
)
self._order_repository = order_repository
self._order_registration_adapters = dict(order_registration_adapters or {})
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
} }
@@ -151,12 +261,15 @@ class AggregatorService:
return [self._coerce_delivery_price(price) for price in filtered_and_sorted] return [self._coerce_delivery_price(price) for price in filtered_and_sorted]
async def suggest_addresses( async def suggest_addresses(
self, request: AddressSuggestRequest self, request: SuggestAddressRequest
) -> list[AddressSuggestion]: ) -> list[AddressSuggestion]:
provider = self._resolve_address_suggestion_provider(request.country_code) provider_request = self._build_address_suggest_request(request)
provider = self._resolve_address_suggestion_provider(
provider_request.country_code
)
try: try:
suggestions = await provider.suggest(request) suggestions = await provider.suggest(provider_request)
except AddressSuggestionRequestError as exc: except AddressSuggestionRequestError as exc:
raise InvalidAddressSuggestRequestError( raise InvalidAddressSuggestRequestError(
"Address suggestion request is invalid for the configured provider." "Address suggestion request is invalid for the configured provider."
@@ -168,27 +281,435 @@ 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:
provider = request.system_data.tariff.provider
validation_adapter = self._payment_price_validation_adapters.get(provider)
if validation_adapter is None:
logger.warning(
"init_payment_price_validation_provider_not_configured",
order_uuid=order_uuid,
provider=provider,
)
raise InvalidInitPaymentRequestError(
"Payment price validation is not configured for the tariff provider."
)
tariff_code = request.system_data.tariff.tariff_code
requested_price = request.system_data.tariff.price
try:
provider_price = await validation_adapter.get_payment_price(request)
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 provider 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(
"Provider 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 provider 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( logger.warning(
"Order request is invalid for the configured provider." "tbank_notification_token_invalid",
order_id=notification.OrderId,
)
raise InvalidTBankPaymentNotificationError(
"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,
) )
logger.info(
"tbank_notification_action_resolved",
order_id=notification.OrderId,
status=notification.Status,
action=action.name,
)
order = await self._load_order_and_mark_payment_status(notification)
if action is TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY:
return "OK"
provider = self._resolve_order_provider(order)
if self._has_existing_registration(order, provider):
logger.info(
"tbank_notification_registration_skipped_duplicate",
order_id=notification.OrderId,
provider=provider,
)
return "OK"
registration_result = await self._register_provider_order(
order, order_uuid=notification.OrderId, provider=provider
)
await self._save_order_registration(
order_uuid=notification.OrderId,
provider=provider,
result=registration_result,
)
logger.info(
"tbank_notification_order_registered",
order_id=notification.OrderId,
provider=provider,
)
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:
# FOR UPDATE: сериализуем конкурентные уведомления по одному заказу,
# чтобы внеочередной статус не затирал уже записанный (lost update).
order = await self._order_repository.get_order_by_order_uuid(
session,
notification.OrderId,
for_update=True,
)
if order is None:
logger.warning(
"tbank_payment_notification_order_not_found",
order_uuid=notification.OrderId,
)
raise TBankPaymentNotificationProcessingError(
"Order was not found for TBank payment notification."
)
if should_apply_tbank_payment_status(
order.payment_status, notification.Status
):
await self._order_repository.mark_payment_status(
session,
notification.OrderId,
notification.Status,
notification.PaymentId,
)
else:
logger.info(
"tbank_payment_status_downgrade_skipped",
order_uuid=notification.OrderId,
current_status=order.payment_status,
incoming_status=notification.Status,
)
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
@staticmethod
def _resolve_order_provider(order: object) -> str:
provider = getattr(order, "provider", None)
if isinstance(provider, str) and provider:
return provider
request = AggregatorService._to_init_payment_request_from_order(order)
return request.system_data.tariff.provider
@staticmethod
def _has_existing_registration(order: object, provider: str) -> bool:
return bool(getattr(order, "provider_order_id", None))
@staticmethod
def _extract_registration_id(result: object) -> str:
for attr in ("order_uuid", "order_number"):
value = getattr(result, attr, None)
if isinstance(value, str) and value:
return value
raise TBankPaymentNotificationProcessingError(
"Order registration result is missing an identifier."
)
async def _register_provider_order(
self, order: object, *, order_uuid: str, provider: str
) -> object:
registration_adapter = self._order_registration_adapters.get(provider)
if registration_adapter is None:
raise TBankPaymentNotificationProcessingError(
"Order registration adapter is not configured for the provider."
)
try:
request = self._to_init_payment_request_from_order(order)
return await registration_adapter.register_order(request, order_uuid)
except Exception as exc:
logger.exception(
"provider_order_registration_failed",
provider=provider,
order_uuid=getattr(order, "order_uuid", None),
)
raise TBankPaymentNotificationProcessingError(
"Provider order registration failed."
) from exc
async def _save_order_registration(
self,
*,
order_uuid: str,
provider: str,
result: object,
) -> None:
if self._order_repository is None:
raise TBankPaymentNotificationProcessingError(
"Order repository is not configured."
)
registration_id = self._extract_registration_id(result)
try:
async with self._order_repository.session() as session:
order = await self._order_repository.mark_provider_order_registered(
session,
order_uuid,
registration_id,
)
if order is None:
logger.warning(
"order_registration_order_not_found",
provider=provider,
order_uuid=order_uuid,
registration_id=registration_id,
)
raise TBankPaymentNotificationProcessingError(
"Order was not found while saving registration identifier."
)
except TBankPaymentNotificationProcessingError:
raise
except Exception as exc:
logger.exception(
"order_registration_persistence_failed",
provider=provider,
order_uuid=order_uuid,
registration_id=registration_id,
)
raise TBankPaymentNotificationProcessingError(
"Order registration 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,
provider=request.system_data.tariff.provider,
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,
@@ -286,6 +807,30 @@ class AggregatorService:
def _coerce_address_suggestion(value: object) -> AddressSuggestion: def _coerce_address_suggestion(value: object) -> AddressSuggestion:
return AddressSuggestion.model_validate(value, from_attributes=True) return AddressSuggestion.model_validate(value, from_attributes=True)
@staticmethod
def _build_address_suggest_request(
request: SuggestAddressRequest,
) -> AddressSuggestRequest:
city_entry = cities_map.get(request.city)
if not isinstance(city_entry, dict):
raise InvalidAddressSuggestRequestError(
f"City is not configured for id {request.city}."
)
city_name = city_entry.get("city")
country_code = city_entry.get("country")
if not isinstance(city_name, str) or not isinstance(country_code, str):
raise InvalidAddressSuggestRequestError(
f"City mapping is invalid for id {request.city}."
)
return AddressSuggestRequest(
country_code=country_code,
city=city_name,
query=request.query,
limit=request.limit,
)
def _resolve_address_suggestion_provider( def _resolve_address_suggestion_provider(
self, country_code: str self, country_code: str
) -> AddressSuggestionProvider: ) -> AddressSuggestionProvider:
+181
View File
@@ -0,0 +1,181 @@
"""Background service that e-mails provider waybill PDFs to customers."""
import asyncio
from collections.abc import Callable, Mapping, 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} сформирована "
"транспортная накладная.\n"
"PDF-файл накладной приложен к этому письму.\n"
)
_EMAIL_BODY_URL_LINE_TEMPLATE = (
"Также накладная доступна по ссылке: {waybill_url}\n"
)
_ATTACHMENT_FILENAME_TEMPLATE = "waybill_{order_uuid}.pdf"
class WaybillPDFDownloaderProtocol(Protocol):
async def download_waybill_pdf(self, order: "OrderRecord") -> bytes: ...
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
provider: str
account_email: str
provider_waybill_id: str | None
provider_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_downloaders: Mapping[str, WaybillPDFDownloaderProtocol],
email_sender: EmailSenderProtocol,
batch_size: int,
datetime_now: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
) -> None:
self._repository = order_repository
self._waybill_downloaders = dict(waybill_downloaders)
self._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,
provider=order.provider,
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.debug(
"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:
if order.provider_waybill_url is None and order.provider_waybill_id is None:
return
downloader = self._resolve_downloader(order.provider)
pdf_bytes = await downloader.download_waybill_pdf(order)
subject = _EMAIL_SUBJECT_TEMPLATE.format(order_uuid=order.order_uuid)
body = _EMAIL_BODY_TEMPLATE.format(order_uuid=order.order_uuid)
if order.provider_waybill_url is not None:
body += _EMAIL_BODY_URL_LINE_TEMPLATE.format(
waybill_url=order.provider_waybill_url,
)
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,
provider=order.provider,
account_email=order.account_email,
sent_at=sent_at,
)
def _resolve_downloader(self, provider: str) -> WaybillPDFDownloaderProtocol:
downloader = self._waybill_downloaders.get(provider)
if downloader is None:
raise RuntimeError(f"Waybill downloader is not configured for {provider}.")
return downloader
+207
View File
@@ -0,0 +1,207 @@
"""Background service that polls providers for waybill updates."""
import asyncio
from collections.abc import Callable, Mapping, 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__)
class OrderInfoAdapterProtocol(Protocol):
async def get_order(self, provider_order_id: str) -> object: ...
class WaybillInfoAdapterProtocol(Protocol):
async def get_waybill(self, provider_waybill_id: str) -> object: ...
class OrderRecord(Protocol):
order_uuid: str
provider: str
provider_order_id: str | None
provider_waybill_id: 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_adapters: Mapping[str, OrderInfoAdapterProtocol],
waybill_info_adapters: Mapping[str, WaybillInfoAdapterProtocol],
batch_size: int,
datetime_now: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
) -> None:
self._repository = order_repository
self._order_info_adapters = dict(order_info_adapters)
self._waybill_info_adapters = dict(waybill_info_adapters)
self._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,
provider=order.provider,
provider_order_id=order.provider_order_id,
provider_waybill_id=order.provider_waybill_id,
)
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.debug(
"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.provider_waybill_id is None:
provider_order_id = order.provider_order_id
if provider_order_id is None:
return
adapter = self._resolve_order_info_adapter(order.provider)
info = await adapter.get_order(provider_order_id)
status_code = _extract_status_code(info)
waybill_id = _extract_waybill_id(info)
await self._repository.record_order_poll(
session,
order_uuid=order.order_uuid,
order_status=status_code,
waybill_uuid=waybill_id,
polled_at=polled_at,
)
logger.info(
"waybill_poll_order_result",
order_uuid=order.order_uuid,
provider=order.provider,
provider_order_id=provider_order_id,
provider_order_status=status_code,
provider_waybill_id=waybill_id,
)
return
adapter = self._resolve_waybill_info_adapter(order.provider)
waybill = await adapter.get_waybill(order.provider_waybill_id)
waybill_url = _extract_waybill_url(waybill)
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,
provider=order.provider,
provider_waybill_id=order.provider_waybill_id,
provider_waybill_url=waybill_url,
)
def _resolve_order_info_adapter(self, provider: str) -> OrderInfoAdapterProtocol:
adapter = self._order_info_adapters.get(provider)
if adapter is None:
raise RuntimeError(f"Order info adapter is not configured for {provider}.")
return adapter
def _resolve_waybill_info_adapter(
self, provider: str
) -> WaybillInfoAdapterProtocol:
adapter = self._waybill_info_adapters.get(provider)
if adapter is None:
raise RuntimeError(
f"Waybill info adapter is not configured for {provider}."
)
return adapter
def _extract_status_code(info: object) -> str | None:
value = getattr(info, "status_code", None)
return value if isinstance(value, str) and value else None
def _extract_waybill_id(info: object) -> str | None:
for attr in ("waybill_id", "waybill_uuid", "waybill_number"):
value = getattr(info, attr, None)
if isinstance(value, str) and value:
return value
return None
def _extract_waybill_url(info: object) -> str | None:
value = getattr(info, "url", None)
return value if isinstance(value, str) and value else None
View File
+147
View File
@@ -0,0 +1,147 @@
"""Background worker that e-mails provider waybill PDFs."""
import asyncio
import signal
from dataclasses import dataclass
import httpx
import structlog
from app.adapters.delivery_providers.registry import (
resolve_delivery_provider_timeout_seconds,
)
from app.adapters.delivery_providers.cdek.auth import CDEKAuthClient
from app.adapters.delivery_providers.cdek.client import CDEKClient
from app.adapters.delivery_providers.cse.client import CSEClient
from app.adapters.delivery_providers.cse.order_mapper import (
CSEOrderRegistrationParams,
)
from app.adapters.email import SMTPEmailSender
from app.adapters.postgres.engine import (
create_postgres_engine,
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__)
@dataclass(frozen=True)
class CDEKWaybillPDFDownloader:
client: CDEKClient
async def download_waybill_pdf(self, order: object) -> bytes:
url = getattr(order, "provider_waybill_url", None)
if not isinstance(url, str) or not url:
raise RuntimeError("CDEK waybill URL is missing.")
return await self.client.download_waybill_pdf(url)
@dataclass(frozen=True)
class CSEWaybillPDFDownloader:
client: CSEClient
async def download_waybill_pdf(self, order: object) -> bytes:
waybill_number = getattr(order, "provider_waybill_id", None)
if not isinstance(waybill_number, str) or not waybill_number:
raise RuntimeError("CSE waybill number is missing.")
return await self.client.download_waybill_pdf(waybill_number)
async def _run(settings: Settings, stop_event: asyncio.Event) -> None:
http_client = httpx.AsyncClient(
timeout=resolve_delivery_provider_timeout_seconds(settings.delivery_providers)
)
waybill_downloaders: dict[str, object] = {}
cdek_config = settings.delivery_providers.cdek
if cdek_config.enabled:
auth_client = CDEKAuthClient(
http_client=http_client,
base_url=cdek_config.base_url,
client_id=cdek_config.client_id,
client_secret=cdek_config.client_secret,
timeout_seconds=cdek_config.timeout_seconds,
)
cdek_client = CDEKClient(
http_client=http_client,
auth_client=auth_client,
base_url=cdek_config.base_url,
timeout_seconds=cdek_config.timeout_seconds,
retry_attempts=cdek_config.retry_attempts,
retry_backoff_seconds=cdek_config.retry_backoff_seconds,
)
waybill_downloaders["cdek"] = CDEKWaybillPDFDownloader(cdek_client)
cse_config = settings.delivery_providers.cse
if cse_config.enabled:
cse_client = CSEClient(
http_client=http_client,
base_url=cse_config.base_url,
login=cse_config.login,
password=cse_config.password,
registration_params=CSEOrderRegistrationParams(
payer=cse_config.payer,
payment_method=cse_config.payment_method,
shipping_method=cse_config.shipping_method,
),
timeout_seconds=cse_config.timeout_seconds,
retry_attempts=cse_config.retry_attempts,
retry_backoff_seconds=cse_config.retry_backoff_seconds,
)
waybill_downloaders["cse"] = CSEWaybillPDFDownloader(cse_client)
email_sender = SMTPEmailSender(
smtp_host=settings.email.smtp_host,
smtp_port=settings.email.smtp_port,
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_downloaders=waybill_downloaders,
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())
+116
View File
@@ -0,0 +1,116 @@
"""Background worker that polls delivery providers for waybill updates."""
import asyncio
import signal
import httpx
import structlog
from app.adapters.delivery_providers.registry import (
resolve_delivery_provider_timeout_seconds,
)
from app.adapters.delivery_providers.cdek.auth import CDEKAuthClient
from app.adapters.delivery_providers.cdek.client import CDEKClient
from app.adapters.delivery_providers.cse.client import CSEClient
from app.adapters.delivery_providers.cse.order_mapper import (
CSEOrderRegistrationParams,
)
from app.adapters.postgres.engine import (
create_postgres_engine,
create_postgres_session_factory,
)
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=resolve_delivery_provider_timeout_seconds(settings.delivery_providers)
)
order_info_adapters: dict[str, object] = {}
waybill_info_adapters: dict[str, object] = {}
cdek_config = settings.delivery_providers.cdek
if cdek_config.enabled:
auth_client = CDEKAuthClient(
http_client=http_client,
base_url=cdek_config.base_url,
client_id=cdek_config.client_id,
client_secret=cdek_config.client_secret,
timeout_seconds=cdek_config.timeout_seconds,
)
cdek_client = CDEKClient(
http_client=http_client,
auth_client=auth_client,
base_url=cdek_config.base_url,
timeout_seconds=cdek_config.timeout_seconds,
retry_attempts=cdek_config.retry_attempts,
retry_backoff_seconds=cdek_config.retry_backoff_seconds,
)
order_info_adapters["cdek"] = cdek_client
waybill_info_adapters["cdek"] = cdek_client
cse_config = settings.delivery_providers.cse
if cse_config.enabled:
cse_client = CSEClient(
http_client=http_client,
base_url=cse_config.base_url,
login=cse_config.login,
password=cse_config.password,
registration_params=CSEOrderRegistrationParams(
payer=cse_config.payer,
payment_method=cse_config.payment_method,
shipping_method=cse_config.shipping_method,
),
timeout_seconds=cse_config.timeout_seconds,
retry_attempts=cse_config.retry_attempts,
retry_backoff_seconds=cse_config.retry_backoff_seconds,
)
order_info_adapters["cse"] = cse_client
engine = create_postgres_engine(settings.postgres)
session_factory = create_postgres_session_factory(engine)
repository = OrderRepository(session_factory=session_factory)
service = WaybillPollerService(
order_repository=repository,
order_info_adapters=order_info_adapters,
waybill_info_adapters=waybill_info_adapters,
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())
-51
View File
@@ -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
+132
View File
@@ -0,0 +1,132 @@
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://redis:6379/0"
price_cache_ttl_seconds: 900
delivery_providers:
cdek:
enabled: true
base_url: "https://api.edu.cdek.ru/v2"
client_id: "${CDEK_CLIENT_ID}"
client_secret: "${CDEK_CLIENT_SECRET}"
retry_attempts: 2
retry_backoff_seconds: 0.2
timeout_seconds: 10.0
cache_ttl_seconds: 900
cse:
enabled: true
base_url: "http://lk-test.cse.ru/1c/ws/web1c.1cws"
login: "${CSE_LOGIN}"
password: "${CSE_PASSWORD}"
retry_attempts: 2
retry_backoff_seconds: 0.2
timeout_seconds: 10.0
cache_ttl_seconds: 900
delivery_service_guids:
- "6da21fe8-4f13-11dc-bda1-0015170f8c09" # Россия доставка
payer: "0" # Заказчик
payment_method: "1" # Безналичный расчёт
shipping_method: "5052d0b3-5ea3-46f2-823f-1472686a51dd" # авто
tbank_payment:
init_url: "https://securepay.tinkoff.ru/v2/Init"
notification_url: "${TBANK_NOTIFICATION_URL}"
success_url: "${TBANK_SUCCESS_URL}"
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
+43 -8
View File
@@ -14,14 +14,44 @@ repository:
redis_dsn: "redis://localhost:6379/0" redis_dsn: "redis://localhost:6379/0"
price_cache_ttl_seconds: 900 price_cache_ttl_seconds: 900
adapter: delivery_providers:
cdek_base_url: "https://api.cdek.ru/v2" cdek:
cdek_client_id: "test-client-id" enabled: true
cdek_client_secret: "test-client-secret" base_url: "https://api.cdek.ru/v2"
cdek_retry_attempts: 2 client_id: "test-client-id"
cdek_retry_backoff_seconds: 0.2 client_secret: "test-client-secret"
cdek_timeout_seconds: 10.0 retry_attempts: 2
cdek_cache_ttl_seconds: 900 retry_backoff_seconds: 0.2
timeout_seconds: 10.0
cache_ttl_seconds: 900
cse:
enabled: true
base_url: "http://lk-test.cse.ru/1c/ws/web1c.1cws"
login: "test"
password: "2016"
retry_attempts: 2
retry_backoff_seconds: 0.2
timeout_seconds: 10.0
cache_ttl_seconds: 900
delivery_service_guids:
- "tariff-guid-2"
payer: "0"
payment_method: "1"
shipping_method: "5052d0b3-5ea3-46f2-823f-1472686a51dd"
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:
@@ -83,3 +113,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"
+105
View File
@@ -0,0 +1,105 @@
services:
app:
image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:${IMAGE_TAG}
container_name: g2s-aggregator${CONTAINER_SUFFIX}
ports:
- "${APP_PORT}: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${CONTAINER_SUFFIX}"
redis:
image: redis:7-alpine
container_name: redis${CONTAINER_SUFFIX}
command: ["redis-server", "--save", "", "--appendonly", "no"]
ports:
- "${REDIS_PORT}:6379"
restart: unless-stopped
postgres:
image: postgres:16-alpine
container_name: postgres${CONTAINER_SUFFIX}
environment:
POSTGRES_DB: g2s_aggregator
POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports:
- "${POSTGRES_PORT}: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:${IMAGE_TAG}
container_name: g2s-aggregator-migrations${CONTAINER_SUFFIX}
depends_on:
postgres:
condition: service_healthy
volumes:
- ./config.yaml:/config.yaml
command: ["uv", "run", "--no-sync", "alembic", "upgrade", "head"]
restart: "no"
logging:
driver: "json-file"
options:
tag: "g2s-aggregator-migrations${CONTAINER_SUFFIX}"
waybill-poller:
image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:${IMAGE_TAG}
container_name: g2s-aggregator-waybill-poller${CONTAINER_SUFFIX}
depends_on:
postgres:
condition: service_healthy
migrations:
condition: service_completed_successfully
volumes:
- ./config.yaml:/config.yaml
command: ["uv", "run", "--no-sync", "python", "-m", "app.workers.waybill_poller"]
restart: unless-stopped
logging:
driver: "json-file"
options:
tag: "g2s-aggregator-waybill-poller${CONTAINER_SUFFIX}"
waybill-email-sender:
image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:${IMAGE_TAG}
container_name: g2s-aggregator-waybill-email-sender${CONTAINER_SUFFIX}
depends_on:
postgres:
condition: service_healthy
migrations:
condition: service_completed_successfully
volumes:
- ./config.yaml:/config.yaml
command:
["uv", "run", "--no-sync", "python", "-m", "app.workers.waybill_email_sender"]
restart: unless-stopped
logging:
driver: "json-file"
options:
tag: "g2s-aggregator-waybill-email-sender${CONTAINER_SUFFIX}"
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
container_name: otel-collector${CONTAINER_SUFFIX}
user: "0"
environment:
ENV_NAME: ${ENV_NAME}
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
- /var/lib/docker/containers:/var/lib/docker/containers:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
restart: unless-stopped
command: ["--config=/etc/otel-collector-config.yaml"]
volumes:
postgres:
-16
View File
@@ -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"
+92 -43
View File
@@ -12,66 +12,115 @@ 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
### 10. Список городов
GET {{base_url}}/v2/location/cities?code=2662
Authorization: Bearer {{auth_token}}
+115
View File
@@ -0,0 +1,115 @@
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
filelog:
include:
- /var/lib/docker/containers/*/*-json.log
include_file_path: true
operators:
- type: json_parser
parse_to: attributes
on_error: send
- type: filter
expr: |
attributes["attrs"] == nil or
attributes["attrs"]["tag"] == nil or
(
attributes["attrs"]["tag"] != "g2s-aggregator${CONTAINER_SUFFIX}" and
attributes["attrs"]["tag"] != "g2s-aggregator-migrations${CONTAINER_SUFFIX}" and
attributes["attrs"]["tag"] != "g2s-aggregator-waybill-poller${CONTAINER_SUFFIX}" and
attributes["attrs"]["tag"] != "g2s-aggregator-waybill-email-sender${CONTAINER_SUFFIX}"
)
on_error: send
- type: regex_parser
parse_from: attributes["attrs"]["tag"]
regex: '^(?P<service_name>g2s-aggregator(?:-migrations|-waybill-poller|-waybill-email-sender)?)(?:-stage)?$'
on_error: send
- type: move
from: attributes["service_name"]
to: resource["service.name"]
on_error: send
- type: regex_parser
parse_from: attributes["log.file.path"]
regex: '^/var/lib/docker/containers/(?P<container_id>[^/]+)/'
on_error: send
- type: move
from: attributes["container_id"]
to: resource["service.instance.id"]
on_error: send
- type: remove
field: attributes["attrs"]
on_error: send
- type: json_parser
parse_from: attributes.log
parse_to: attributes
on_error: send
- type: trace_parser
trace_id:
parse_from: attributes.trace_id
span_id:
parse_from: attributes.span_id
on_error: send
- type: time_parser
parse_from: attributes.timestamp
layout: '%Y-%m-%dT%H:%M:%S.%fZ'
on_error: send
- type: severity_parser
parse_from: attributes.level
on_error: send
- type: move
from: attributes.event
to: body
on_error: send
- type: remove
field: attributes.log
on_error: send
docker_stats:
endpoint: unix:///var/run/docker.sock
collection_interval: 30s
container_labels_as_resource_attributes: true
api_version: "1.43"
processors:
resource/env:
attributes:
- key: deployment.environment
value: "${ENV_NAME}"
action: upsert
- key: service.namespace
value: g2s
action: upsert
resource/version:
attributes:
- key: service.version
value: "${IMAGE_TAG}"
action: upsert
filter/environment:
error_mode: ignore
metrics:
metric:
- '${METRICS_FILTER_EXPRESSION}'
exporters:
otlp:
endpoint: "${SIGNOZ_OTLP_ENDPOINT}"
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [resource/env, resource/version]
exporters: [otlp]
logs:
receivers: [filelog]
processors: [resource/env, resource/version]
exporters: [otlp]
metrics:
receivers: [otlp, docker_stats]
processors: [filter/environment, resource/env]
exporters: [otlp]
+150 -42
View File
@@ -1,4 +1,4 @@
# spec/overview.md # overview.md
Контекст, специфичный для проекта агрегатора служб доставки. Контекст, специфичный для проекта агрегатора служб доставки.
Глобальные правила определены в AGENTS.md. Глобальные правила определены в AGENTS.md.
@@ -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:
cityId: int
city: str
street: str
house: str
apartment: str | None
zip: str
comment: str | None comment: str | None
sender: senderContact:
name: str fullName: str
email: str email: str | None
phones: list[{number: str}] phone: str
recipient: phoneExt: str | None
name: str isCompany: bool
email: str companyName: str | None # обязателен при isCompany=true
phones: list[{number: str}] inn: str | None # обязателен при isCompany=true
from_location: kpp: str | None # обязателен при isCompany=true
address: str receiverAddress: <структура senderAddress>
city: str receiverContact: <структура senderContact>
country_code: str content:
to_location: description: str | None
address: str pickupDate: datetime # ISO 8601
city: str deliveryDate: datetime | None # ISO 8601
country_code: str accountEmail: str
services: list[{code: str, parameter: str}] systemData:
packages: list[{number: str, weight: int, length: int, width: int, height: int, comment: str | None}] tariff:
```
`from_location.address` и `to_location.address` должны содержать точные значения адреса, выбранные клиентом; order flow не выполняет address suggestion lookup.
### Выходная: `OrderCreateResponse`
```
provider: str provider: str
order_uuid: 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
``` ```
`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.
### Выходная: `InitPaymentResponse`
```
payment_url: 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 # База данных заявок
``` ```
Generated
-1615
View File
File diff suppressed because it is too large Load Diff
+28 -24
View File
@@ -1,32 +1,36 @@
[tool.poetry] [project]
name = "g2s-aggregator" name = "g2s-aggregator"
version = "0.1.0" version = "0.1.0"
description = "" description = ""
authors = ["Your Name <you@example.com>"]
readme = "README.md" readme = "README.md"
requires-python = ">=3.14"
dependencies = [
"fastapi>=0.135.1,<0.136.0",
"uvicorn[standard]>=0.41.0,<0.42.0",
"httpx>=0.28.1,<0.29.0",
"pydantic>=2.12.5,<3.0.0",
"pydantic-settings>=2.13.1,<3.0.0",
"aioredis>=2.0.1,<3.0.0",
"structlog>=25.5.0,<26.0.0",
"opentelemetry-api>=1.40.0,<2.0.0",
"opentelemetry-sdk>=1.40.0,<2.0.0",
"opentelemetry-exporter-otlp>=1.40.0,<2.0.0",
"opentelemetry-instrumentation-fastapi>=0.61b0",
"opentelemetry-instrumentation-httpx>=0.61b0",
"opentelemetry-instrumentation-redis>=0.61b0",
"pytest>=9.0.2,<10.0.0",
"redis>=7.3.0,<8.0.0",
"sqlalchemy>=2.0.49,<3.0.0",
"asyncpg>=0.31.0,<0.32.0",
"alembic>=1.18.4,<2.0.0",
"aiosqlite>=0.22.1,<0.23.0",
"aiosmtplib>=4.0.2,<5.0.0",
"email-validator>=2.2.0,<3.0.0",
]
[tool.poetry.dependencies] [tool.uv]
python = "^3.14" package = false
fastapi = "^0.135.1"
uvicorn = {extras = ["standard"], version = "^0.41.0"}
httpx = "^0.28.1"
pydantic = "^2.12.5"
pydantic-settings = "^2.13.1"
aioredis = "^2.0.1"
structlog = "^25.5.0"
opentelemetry-api = "^1.40.0"
opentelemetry-sdk = "^1.40.0"
opentelemetry-exporter-otlp = "^1.40.0"
opentelemetry-instrumentation-fastapi = "^0.61b0"
opentelemetry-instrumentation-httpx = "^0.61b0"
opentelemetry-instrumentation-redis = "^0.61b0"
pytest = "^9.0.2"
redis = "^7.3.0"
[tool.pytest.ini_options] [tool.pytest.ini_options]
pythonpath = ["."] pythonpath = ["."]
addopts = ["--import-mode=importlib"]
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
-229
View File
@@ -1,229 +0,0 @@
#!/usr/bin/env python3
"""
Generate spec/index.md from spec/tasks/*.md
Assumptions:
- Each task file starts with YAML-like front matter:
---
id: 001
title: Add endpoint /reports/addons
status: TODO # TODO | DONE
created: 2026-02-01
---
Notes:
- status lives inside each task file (as requested).
- index.md is fully overwritten and marked as generated.
- No external deps (no PyYAML). Supports simple "key: value" scalars.
Usage:
python3 spec/gen_spec_index.py
python3 spec/gen_spec_index.py --check # verify index is up-to-date (CI)
"""
from __future__ import annotations
import argparse
import datetime as dt
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional, Tuple
ROOT = Path(__file__).resolve().parents[1]
SPEC_DIR = ROOT / "spec"
TASKS_DIR = SPEC_DIR / "tasks"
INDEX_PATH = SPEC_DIR / "index.md"
ALLOWED_STATUS = {"TODO", "DONE"}
@dataclass(frozen=True)
class TaskMeta:
file: Path
id: str
title: str
status: str
created: str # YYYY-MM-DD
FRONT_MATTER_RE = re.compile(r"^---\s*$")
def read_front_matter(md_path: Path) -> Tuple[Dict[str, str], int]:
"""
Returns (meta_dict, end_line_index_exclusive).
If no front matter, returns ({}, 0).
"""
text = md_path.read_text(encoding="utf-8")
lines = text.splitlines()
if not lines or not FRONT_MATTER_RE.match(lines[0]):
return {}, 0
meta: Dict[str, str] = {}
i = 1
while i < len(lines):
if FRONT_MATTER_RE.match(lines[i]):
return meta, i + 1
line = lines[i].strip()
i += 1
if not line or line.startswith("#"):
continue
# Very small YAML subset: key: value
if ":" not in line:
continue
key, val = line.split(":", 1)
key = key.strip()
val = val.strip()
# Strip optional quotes
if (val.startswith('"') and val.endswith('"')) or (val.startswith("'") and val.endswith("'")):
val = val[1:-1]
meta[key] = val
# Started front matter but never closed
raise ValueError(f"{md_path}: front matter starts with '---' but missing closing '---'")
def normalize_id(md_path: Path, meta: Dict[str, str]) -> str:
# Prefer explicit id, else derive from filename prefix like "001-something.md"
if "id" in meta and meta["id"].strip():
return meta["id"].strip()
m = re.match(r"^(\d+)", md_path.stem)
if m:
return m.group(1)
raise ValueError(f"{md_path}: missing 'id' in front matter and filename doesn't start with digits")
def normalize_title(md_path: Path, meta: Dict[str, str], content_start_line: int) -> str:
if "title" in meta and meta["title"].strip():
return meta["title"].strip()
# Fallback: first markdown heading after front matter: "# Title"
text = md_path.read_text(encoding="utf-8")
lines = text.splitlines()
for line in lines[content_start_line:]:
line = line.strip()
if line.startswith("# "):
return line[2:].strip()
raise ValueError(f"{md_path}: missing 'title' in front matter and no '# ' heading found")
def normalize_status(md_path: Path, meta: Dict[str, str]) -> str:
status = meta.get("status", "").strip().upper()
if status not in ALLOWED_STATUS:
raise ValueError(f"{md_path}: invalid or missing 'status' (must be one of: {sorted(ALLOWED_STATUS)})")
return status
def normalize_created(md_path: Path, meta: Dict[str, str]) -> str:
created = meta.get("created", "").strip()
if created:
# Validate YYYY-MM-DD
try:
dt.date.fromisoformat(created)
except ValueError:
raise ValueError(f"{md_path}: invalid 'created' date '{created}' (expected YYYY-MM-DD)")
return created
# Fallback: file mtime (not perfect, but better than failing)
mtime = dt.datetime.fromtimestamp(md_path.stat().st_mtime, tz=dt.timezone.utc).date().isoformat()
return mtime
def load_tasks() -> List[TaskMeta]:
if not TASKS_DIR.exists():
raise FileNotFoundError(f"Tasks directory not found: {TASKS_DIR}")
md_files = sorted(TASKS_DIR.glob("*.md"))
tasks: List[TaskMeta] = []
seen_ids: Dict[str, Path] = {}
for f in md_files:
meta, content_start = read_front_matter(f)
task_id = normalize_id(f, meta)
if task_id in seen_ids:
raise ValueError(f"Duplicate task id '{task_id}': {seen_ids[task_id]} and {f}")
seen_ids[task_id] = f
title = normalize_title(f, meta, content_start)
status = normalize_status(f, meta)
created = normalize_created(f, meta)
tasks.append(TaskMeta(file=f, id=task_id, title=title, status=status, created=created))
# Sort by numeric id if possible
def sort_key(t: TaskMeta):
try:
return (0, int(t.id))
except ValueError:
return (1, t.id)
return sorted(tasks, key=sort_key)
def render_index(tasks: List[TaskMeta]) -> str:
lines: List[str] = []
lines.append("# Spec Tasks Index")
lines.append("")
lines.append("> ⚠️ This file is generated. Do not edit manually.")
lines.append("")
lines.append("## Tasks")
lines.append("")
lines.append("| ID | Status | Created | Title | File |")
lines.append("|---:|:------:|:-------:|:------|:-----|")
for t in tasks:
rel = t.file.relative_to(ROOT).as_posix()
title = t.title.replace("|", "\\|")
lines.append(f"| {t.id} | {t.status} | {t.created} | {title} | `{rel}` |")
lines.append("")
lines.append("## Summary")
lines.append("")
todo = sum(1 for t in tasks if t.status == "TODO")
done = sum(1 for t in tasks if t.status == "DONE")
lines.append(f"- Total: **{len(tasks)}**")
lines.append(f"- TODO: **{todo}**")
lines.append(f"- DONE: **{done}**")
lines.append("")
return "\n".join(lines)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--check", action="store_true", help="fail if spec/index.md is not up-to-date")
args = parser.parse_args()
tasks = load_tasks()
content = render_index(tasks)
if args.check:
existing = INDEX_PATH.read_text(encoding="utf-8") if INDEX_PATH.exists() else ""
if existing != content:
print("spec/index.md is not up-to-date. Run: python3 spec/gen_spec_index.py", file=sys.stderr)
return 2
return 0
SPEC_DIR.mkdir(parents=True, exist_ok=True)
INDEX_PATH.write_text(content, encoding="utf-8")
print(f"Wrote {INDEX_PATH.relative_to(ROOT)} ({len(tasks)} tasks)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
-40
View File
@@ -1,40 +0,0 @@
# Spec Tasks Index
> ⚠️ This file is generated. Do not edit manually.
## Tasks
| ID | Status | Created | Title | File |
|---:|:------:|:-------:|:------|:-----|
| 000 | DONE | 2026-02-01 | Task format reference | `spec/tasks/000_template.md` |
| 001 | DONE | 2026-03-07 | Create app skeleton and component configuration | `spec/tasks/001_create_app_skeleton_and_config.md` |
| 002 | DONE | 2026-03-07 | Implement pure domain price rules | `spec/tasks/002_implement_pure_domain_quote_rules.md` |
| 003 | DONE | 2026-03-07 | Add CDEK provider adapter | `spec/tasks/003_add_cdek_provider_adapter.md` |
| 004 | DONE | 2026-03-07 | Add Redis price cache repository | `spec/tasks/004_add_redis_price_cache_repository.md` |
| 005 | DONE | 2026-03-07 | Implement aggregator service workflow | `spec/tasks/005_implement_aggregator_service_workflow.md` |
| 006 | DONE | 2026-03-07 | Add delivery price controller endpoint | `spec/tasks/006_add_delivery_price_controller.md` |
| 007 | DONE | 2026-03-07 | Add observability correlation and telemetry | `spec/tasks/007_add_observability_correlation.md` |
| 008 | DONE | 2026-03-07 | Add SigNoz to Telegram alerting configuration | `spec/tasks/008_add_signoz_telegram_alerting.md` |
| 009 | DONE | 2026-03-07 | Add local infrastructure stack | `spec/tasks/009_add_local_infra_stack.md` |
| 010 | DONE | 2026-03-08 | Migrate to YAML-only configuration loading | `spec/tasks/010_migrate_to_yaml_only_configuration.md` |
| 011 | DONE | 2026-03-08 | Migrate Redis repository client to aioredis | `spec/tasks/011_migrate_redis_repository_to_aioredis.md` |
| 012 | DONE | 2026-03-08 | Remove observability and SigNoz stack for phase 1 | `spec/tasks/012_remove_observability_and_signoz_for_phase1.md` |
| 013 | DONE | 2026-03-09 | Add configurable provider price multiplier in domain logic | `spec/tasks/013_add_configurable_provider_price_multiplier.md` |
| 014 | DONE | 2026-03-12 | Add minimal structlog JSON logging | `spec/tasks/014_add_minimal_structlog_json_logging.md` |
| 015 | DONE | 2026-03-13 | Add minimal OpenTelemetry tracing | `spec/tasks/015_add_minimal_opentelemetry_tracing.md` |
| 016 | DONE | 2026-03-14 | Add CDEK order registration adapter | `spec/tasks/016_add_cdek_order_registration_adapter.md` |
| 017 | DONE | 2026-03-14 | Return all tariffs from CDEK price calculation | `spec/tasks/017_return_all_cdek_tariffs.md` |
| 018 | DONE | 2026-03-16 | Add optional parcel type filter to price request | `spec/tasks/018_add_optional_parcel_type_filter_to_price_request.md` |
| 019 | DONE | 2026-03-14 | Add CDEK order creation endpoint | `spec/tasks/019_add_cdek_order_creation_endpoint.md` |
| 020 | DONE | 2026-03-21 | Rename price request model and use cities_map for CDEK codes | `spec/tasks/020_rename_price_request_model_and_use_cities_map_for_cdek_codes.md` |
| 021 | DONE | 2026-03-25 | Add address suggestion adapter and country provider mapping | `spec/tasks/021_add_address_suggestion_adapter_and_country_mapping.md` |
| 022 | DONE | 2026-03-25 | Add address suggestion endpoint | `spec/tasks/022_add_address_suggestion_endpoint.md` |
| 023 | DONE | 2026-03-29 | Add Yandex Geosuggest address suggestion adapter and CIS routing | `spec/tasks/023_add_yandex_geosuggest_address_suggestion_adapter.md` |
| 024 | DONE | 2026-03-29 | Add TomTom address suggestion adapter and Europe routing | `spec/tasks/024_add_tomtom_address_suggestion_adapter.md` |
| 025 | DONE | 2026-04-03 | Remove company from Create Delivery Order parties | `spec/tasks/025_remove_company_from_create_delivery_order.md` |
## Summary
- Total: **26**
- TODO: **0**
- DONE: **26**
-34
View File
@@ -1,34 +0,0 @@
---
id: 000
title: Task format reference
status: DONE
created: 2026-02-01
---
## Context
В репозитории хранится эталонная задача, которая показывает обязательный формат задач.
## Goal
Дать каноничный пример минимального формата задачи, обязательного для всех файлов в `spec/tasks/`.
## Constraints
- Содержать только требования к формату, без продуктовой реализации.
- Оставаться совместимой с обязательной структурой задач Planner.
- Не добавлять требований к реализации.
## Acceptance criteria
- Front matter содержит `id`, `title`, `status`, `created`.
- Все обязательные секции присутствуют в требуемом порядке.
- Содержимое остаётся эталонной, неисполняемой задачей.
## Definition of Done
- [x] Front matter использует обязательные поля.
- [x] Обязательные секции присутствуют.
- [x] Файл пригоден только как reference формата.
## Tests
- Runtime-тесты не требуются.
- Формат валидируется успешной генерацией spec index.
## Commands
- `python3 spec/gen_spec_index.py --check`
@@ -1,38 +0,0 @@
---
id: 001
title: Create app skeleton and component configuration
status: DONE
created: 2026-03-07
---
## Context
Сейчас в репозитории есть только specification-файлы, при этом `spec/overview.md` задаёт конкретную структуру приложения и требования к конфигурации компонентов.
## Goal
Создать начальный `app/` skeleton и модели конфигурации из project overview, включая отдельные секции конфигурации для Controller, Service, Business Logic, Repository, Adapter, Observability и Alerts.
## Constraints
- Соблюдать layered architecture из `AGENTS.md`.
- Scope задачи: только scaffolding и configuration, без business workflows.
- Не реализовывать provider HTTP calls, cache behavior или aggregation logic в рамках этой задачи.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Структура `app/` создана согласно `spec/overview.md`.
- `app/config.py` содержит отдельные component configuration sections. Config must be a .yaml-file added to .gitignore.
- FastAPI entrypoint существует и успешно импортирует configuration.
- В файлах Controller, Service, Repository и Adapter отсутствуют business rules.
## Definition of Done
- [ ] Созданы обязательные директории `app/` и базовые файлы.
- [ ] Реализованы раздельные секции конфигурации по компонентам.
- [ ] Проходит app import smoke test.
- [ ] Изменения остаются строго в scope задачи.
## Tests
- Добавить/import smoke test для FastAPI app startup.
- Добавить unit tests, проверяющие загрузку configuration sections из environment variables.
## Commands
- `poetry run pytest tests/smoke/test_app_import.py -q`
- `poetry run pytest tests/config/test_config_sections.py -q`
@@ -1,37 +0,0 @@
---
id: 002
title: Implement pure domain price rules
status: DONE
created: 2026-03-07
---
## Context
`spec/overview.md` требует business rules для фильтрации тарифов, сравнения цен, сортировки и нормализации входных данных в чистом слое Business Logic.
## Goal
Реализовать детерминированные, dependency-free domain functions в `app/domain/price.py` для нормализации запроса и фильтрации/упорядочивания котировок.
## Constraints
- Business Logic должна быть pure и dependency-free.
- Запрещены прямые IO, adapters, repositories, framework imports и внешние вызовы.
- Слои Service и Controller не должны забирать domain rules из этой задачи.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Domain functions нормализуют значения запроса по явным детерминированным правилам.
- Domain functions отфильтровывают невалидные котировки по определённым domain conditions.
- Domain functions сортируют валидные котировки по возрастанию цены.
- Логика реализована только в `app/domain/` и доступна для вызова из Service.
## Definition of Done
- [ ] Реализованы pure domain functions для normalization/filtering/sorting.
- [ ] Поведение функций детерминированное и без side effects.
- [ ] В domain module нет импортов не-domain зависимостей.
- [ ] Unit tests (без mocks) покрывают стандартные и edge cases.
## Tests
- Добавить `tests/domain/test_price.py` только с unit tests.
- Покрыть edge cases: пустой input, невалидные price, одинаковые цены, границы нормализации.
## Commands
- `poetry run pytest tests/domain/test_price.py -q`
@@ -1,43 +0,0 @@
---
id: 003
title: Add CDEK provider adapter
status: DONE
created: 2026-03-07
---
## Context
Phase 1 в `spec/overview.md` требует поддержку provider CDEK с OAuth2 authentication и маппингом расчёта тарифа.
## Goal
Реализовать adapter interfaces и модули CDEK adapter: provider contract, OAuth2 token handling, HTTP client behavior, преобразование ответа в `DeliveryPrice` и чтение параметров CDEK из секции `adapter` YAML-конфига.
## Constraints
- Только Adapter layer; без business decisions и sorting logic.
- Внешний IO должен оставаться внутри adapter modules.
- Timeout для CDEK должен быть 10 секунд согласно specification.
- Параметры CDEK (`base_url`, OAuth2 credentials, retry policy, timeout, cache TTL) должны приходить из секции `adapter` в `.yaml` конфиге.
- Запрещён хардкод credentials и runtime-параметров адаптера.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Существует интерфейс `DeliveryProvider` со стабильным контрактом `get_price()`.
- Модуль CDEK auth получает OAuth2 token через client credentials и переиспользует валидный token до истечения.
- CDEK client выполняет tariff request с timeout и retry behavior.
- CDEK mapper преобразует provider response в унифицированную schema `DeliveryPrice`.
- Adapter наружу предоставляет только provider-facing API, необходимый Service.
- Адаптер использует значения из секции `adapter` YAML-конфига, включая `timeout_seconds=10` и `cache_ttl_seconds=900` по умолчанию.
## Definition of Done
- [ ] Реализован base provider interface.
- [ ] Реализованы модули CDEK auth/client/mapper.
- [ ] CDEK adapter возвращает унифицированную price model при успешном ответе.
- [ ] Adapter tests покрывают auth refresh, timeout/retry и mapping cases.
## Tests
- Добавить adapter unit tests для token lifecycle behavior.
- Добавить adapter tests для response mapping и HTTP error handling.
- Добавить tests, проверяющие применение параметров CDEK из секции `adapter` YAML-конфига.
- Для внешних HTTP взаимодействий использовать stubs/mocks.
## Commands
- `poetry run pytest tests/adapters/delivery_providers/cdek -q`
@@ -1,40 +0,0 @@
---
id: 004
title: Add Redis price cache repository
status: DONE
created: 2026-03-07
---
## Context
Product requirements требуют кеширование ответов provider с configurable TTL, а архитектура закрепляет доступ к данным за слоем Repository.
## Goal
Реализовать `PriceCache` repository на Redis с операциями `get`, `set` и `invalidate`, включая поддержку TTL из секции `repository` YAML-конфига.
## Constraints
- Только Repository layer: data access и persistence mapping.
- В repository code не допускаются business decisions и workflow orchestration.
- Генерация cache key не должна реализовываться в repository.
- Параметры Redis и TTL должны читаться из секции `repository` в `.yaml` конфиге.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Repository module существует по пути `app/repositories/cache/redis_cache.py`.
- `PriceCache` предоставляет async методы `get`, `set`, `invalidate`.
- `set` применяет TTL из configuration.
- Serialization/deserialization cached payload выполняются детерминированно.
- Repository использует только параметры секции `repository` YAML-конфига для подключения к Redis и настройки TTL.
## Definition of Done
- [ ] Реализован Redis-backed cache repository.
- [ ] Интерфейс repository соответствует требуемым операциям.
- [ ] Поведение TTL покрыто тестами.
- [ ] В repository отсутствует business logic.
## Tests
- Добавить repository tests для cache hit/miss, set/get roundtrip, invalidate и TTL expiration.
- Добавить tests, проверяющие чтение TTL и Redis connection settings из секции `repository` YAML-конфига.
- Использовать Redis test double или изолированный test Redis instance.
## Commands
- `poetry run pytest tests/repositories/cache/test_redis_cache.py -q`
@@ -1,39 +0,0 @@
---
id: 005
title: Implement aggregator service workflow
status: DONE
created: 2026-03-07
---
## Context
`spec/overview.md` определяет `AggregatorService.get_all_prices()` как application orchestrator для параллельных provider calls, graceful degradation, caching и отсортированного унифицированного результата.
## Goal
Реализовать `AggregatorService.get_all_prices(request: DeliveryRequest) -> list[DeliveryPrice]` в `app/services/aggregator.py` с параллельным выполнением, cache coordination и делегированием business rules в domain functions.
## Constraints
- Service выполняет только orchestration; pure business rules остаются в Business Logic.
- Использовать dependency injection для providers и repository.
- Для вызовов providers использовать `asyncio.gather(..., return_exceptions=True)`.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Service вызывает все зарегистрированные providers параллельно.
- Failures/timeouts отдельных providers не валят весь запрос; упавшие providers исключаются.
- Cache проверяется до внешнего provider request и обновляется после успешного provider response.
- Фильтрация/сортировка котировок делегируется domain functions.
- Возвращаемый список котировок унифицирован и отсортирован по цене по возрастанию.
## Definition of Done
- [ ] Метод Service реализован с injected dependencies.
- [ ] Параллельная orchestration использует `return_exceptions=True`.
- [ ] Реализована cache coordination для provider responses.
- [ ] Service tests покрывают full-success, partial-failure, all-failure и cache-hit сценарии.
## Tests
- Добавить service tests со stubs/mocks для adapters и repository.
- Проверить orchestration и исключение неуспешных providers.
- Проверить делегирование sorting/filtering в domain logic.
## Commands
- `poetry run pytest tests/services/test_aggregator.py -q`
@@ -1,37 +0,0 @@
---
id: 006
title: Add delivery price controller endpoint
status: DONE
created: 2026-03-07
---
## Context
API contract требует endpoint `POST /api/v1/delivery/price` с DTO validation и HTTP error mapping, при этом Controller должен вызывать ровно один метод Service.
## Goal
Реализовать request/response schemas и endpoint в `app/controllers/v1/delivery.py`, который валидирует входные данные, делегирует в `AggregatorService.get_all_prices()` и маппит service exceptions в HTTP responses.
## Constraints
- Controller не должен содержать business logic или provider-specific branching.
- На каждый запрос Controller должен вызывать ровно один метод Service.
- Validation должна использовать Pydantic models из schema layer.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- `POST /api/v1/delivery/price` принимает payload `DeliveryRequest` и возвращает `list[DeliveryPrice]`.
- Невалидный input возвращает validation error response.
- Controller делегирует обработку в `AggregatorService.get_all_prices()`.
- Service exceptions маппятся в детерминированные HTTP responses.
## Definition of Done
- [ ] Реализованы request и response schemas.
- [ ] Controller endpoint подключён в FastAPI router.
- [ ] Path и HTTP method endpoint соответствуют specification.
- [ ] API tests покрывают успешный ответ, validation failure и mapped service error.
## Tests
- Добавить controller tests для поведения route и делегирования в service.
- Добавить API-level tests для валидации request schema и response schema.
## Commands
- `poetry run pytest tests/controllers/v1/test_delivery.py -q`
@@ -1,47 +0,0 @@
---
id: 007
title: Add observability correlation and telemetry
status: DONE
created: 2026-03-07
---
## Context
Проект требует связать traces, metrics и logs через request correlation (`request_id` и `trace_id`) и добавить manual spans вокруг ключевых операций.
## Goal
Реализовать observability wiring: middleware для `request_id`, propagation structured logging context с корреляцией по `trace_id`, OpenTelemetry instrumentation для FastAPI/httpx, manual spans для операций service/provider/cache и метрики из `spec/overview.md`.
## Constraints
- Все observability concerns держать вне Business Logic.
- При добавлении telemetry не вводить business decision-making.
- Инструментировать только слои и операции, указанные в `spec/overview.md`.
- Конфигурация telemetry должна читаться из секции `observability` в `.yaml` конфиге.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Middleware назначает UUID `request_id` для каждого запроса.
- `request_id` добавляется в structured logs через contextvars.
- Логи коррелируются с трассировкой через `trace_id`.
- Включена OpenTelemetry instrumentation для FastAPI и httpx.
- Есть manual spans для `AggregatorService.get_all_prices`, provider `get_price` и cache operations.
- Span attributes включают `provider`, `from_city`, `to_city`, `weight_kg`, `cache_hit`, `tariffs_found` там, где применимо.
- Реализованы и экспортируются метрики: количество запросов, количество ошибок, latency (включая p50/p99), доступность провайдеров.
## Definition of Done
- [ ] Реализован middleware корреляции запросов.
- [ ] В logging context есть binding `request_id`.
- [ ] Реализована автоматическая и manual tracing instrumentation.
- [ ] Реализованы метрики, соответствующие overview.
- [ ] Observability tests проверяют создание spans, обязательные attributes и метрики.
## Tests
- Добавить middleware tests для генерации и propagation `request_id`.
- Добавить telemetry tests с in-memory span exporter для проверки names/attributes.
- Добавить logging tests, проверяющие наличие `request_id` в log context.
- Добавить tests для проверки экспорта метрик (request count, error count, latency, provider availability).
## Commands
- `poetry run pytest tests/controllers/test_middleware_request_id.py -q`
- `poetry run pytest tests/observability/test_tracing.py -q`
- `poetry run pytest tests/observability/test_logging_context.py -q`
- `poetry run pytest tests/observability/test_metrics.py -q`
@@ -1,41 +0,0 @@
---
id: 008
title: Add SigNoz to Telegram alerting configuration
status: DONE
created: 2026-03-07
---
## Context
`spec/overview.md` требует anomaly alerting через цепочку `SigNoz Alert Rules -> Webhook -> Telegram Bot API` с вынесением token в configuration.
## Goal
Добавить configuration и infrastructure artifacts, необходимые для маршрутизации anomaly alerts из SigNoz в Telegram и для соответствия alert thresholds требованиям проекта через секцию `alerts` в `.yaml` конфиге.
## Constraints
- Alert transport/configuration должны быть отделены от business logic.
- Telegram credentials должны приходить из секции `alerts` YAML-конфига и не быть hardcoded.
- Thresholds должны соответствовать alert conditions из `spec/overview.md`.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Configuration содержит отдельную секцию `alerts` в `.yaml` конфиге с Telegram settings.
- Infrastructure artifact(s) описывают routing от SigNoz alert webhook к Telegram Bot API.
- Представлены alert conditions для:
- provider 5xx: 5 ошибок за 5 минут
- provider p99 latency > 5000ms в течение 10 минут
- provider unavailable более 5 минут
- Setup instructions содержат пример секции `alerts` YAML-конфига и шаги верификации.
## Definition of Done
- [ ] Реализована schema конфигурации Alerts.
- [ ] Добавлены artifact(s) для маршрутизации SigNoz-to-Telegram.
- [ ] Определены все три обязательных anomaly conditions.
- [ ] Инструкции валидации исполнимы в local environment.
## Tests
- Добавить config tests для проверки загрузки alert settings из секции `alerts` YAML-конфига и обязательных полей.
- Добавить tests для alert payload transformation/transport logic, если он реализован в коде.
## Commands
- `poetry run pytest tests/config/test_alerts_config.py -q`
- `docker compose config`
-39
View File
@@ -1,39 +0,0 @@
---
id: 009
title: Add local infrastructure stack
status: DONE
created: 2026-03-07
---
## Context
Overview задаёт локальные infrastructure components `app`, `redis` и `signoz`, необходимые для runtime и observability.
## Goal
Добавить Docker Compose infrastructure definitions для local development, включая application service, Redis cache и SigNoz observability backend.
## Constraints
- Scope задачи: только infrastructure wiring.
- Не переносить business logic в infrastructure scripts.
- Имена services должны соответствовать overview: `app`, `redis`, `signoz`.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Compose file определяет services `app`, `redis`, `signoz`.
- Service `app` содержит environment wiring для cache и observability endpoints.
- Service `redis` достижим из `app` во внутренней сети.
- Service `signoz` достижим для telemetry export при локальном запуске.
## Definition of Done
- [ ] Добавлен compose definition с обязательными services.
- [ ] Задокументированы environment variables для локальной интеграции.
- [ ] `docker compose config` проходит валидацию.
- [ ] Добавлены базовые startup instructions.
## Tests
- Добавить/обновить infrastructure checks для валидации compose syntax.
- Добавить smoke sequence команд для проверки локального запуска.
## Commands
- `docker compose config`
- `docker compose up -d redis signoz`
- `docker compose ps`
@@ -1,39 +0,0 @@
---
id: 010
title: Migrate to YAML-only configuration loading
status: DONE
created: 2026-03-08
---
## Context
В проекте используется смешанный подход конфигурации с переменными окружения. Требуется унифицировать источник конфигурации и оставить только `config.yaml`.
## Goal
Убрать использование переменных окружения из runtime-конфигурации приложения и перевести загрузку всех параметров на чтение из `config.yaml`.
## Constraints
- Соблюдать layered architecture из `AGENTS.md`.
- Scope задачи: только механизм загрузки конфигурации и места её потребления.
- Запрещено менять бизнес-правила и поведение use-case, не связанное с конфигурацией.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Runtime-конфигурация приложения загружается только из `config.yaml`.
- Переменные окружения не используются для чтения параметров Controller, Service, Business Logic, Repository, Adapter, Observability и Alerts.
- При отсутствии обязательных полей в `config.yaml` приложение завершает запуск с детерминированной ошибкой валидации конфигурации.
- Тесты конфигурации больше не опираются на `monkeypatch` переменных окружения и проверяют чтение из YAML.
## Definition of Done
- [ ] Удалено чтение переменных окружения из конфигурационного слоя.
- [ ] Все компонентные секции конфигурации продолжают читаться из `config.yaml`.
- [ ] Обновлены тесты конфигурации под YAML-only поведение.
- [ ] Smoke test старта приложения проходит с валидным `config.yaml`.
## Tests
- Обновить `tests/config/test_config_sections.py` под проверку YAML-only загрузки.
- Добавить/обновить негативные тесты отсутствующих обязательных YAML-полей.
- Проверить app startup smoke test с валидным `config.yaml`.
## Commands
- `poetry run pytest tests/config/test_config_sections.py -q`
- `poetry run pytest tests/smoke/test_app_import.py -q`
@@ -1,41 +0,0 @@
---
id: 011
title: Migrate Redis repository client to aioredis
status: DONE
created: 2026-03-08
---
## Context
Текущая реализация кеш-репозитория использует клиент `redis`. Требуется перейти на `aioredis` для асинхронного доступа к Redis без изменения бизнес-поведения приложения.
## Goal
Перевести слой Repository (`PriceCache`) и связанные точки инициализации/конфигурации на `aioredis`, сохранив существующий контракт репозитория и runtime-поведение кеша.
## Constraints
- Изменения только в слоях Repository и инициализации зависимостей, необходимых для подключения клиента.
- Не изменять бизнес-правила, Controller/API-контракты и оркестрацию Service.
- SQL/внешний IO вне Repository и Adapter не добавлять.
- Публичный интерфейс `PriceCache` (`get`, `set`, `invalidate`) должен остаться совместимым.
- Не изменять файлы в `spec/` кроме этой задачи и сгенерированного `spec/index.md`.
## Acceptance criteria
- В кодовой базе для Redis repository используется `aioredis` вместо `redis`.
- Инициализация Redis-клиента и операции `get/set/invalidate` работают асинхронно через `aioredis`.
- Поведение TTL и сериализации кеша не изменено относительно текущего контракта.
- Конфигурация подключения к Redis продолжает читаться из секции `repository` YAML-конфига.
- Ошибки подключения/операций Redis обрабатываются детерминированно в рамках текущего repository-контракта.
## Definition of Done
- [ ] Обновлены зависимости проекта для использования `aioredis`.
- [ ] Репозиторий кеша переведен на `aioredis` без изменения публичного интерфейса.
- [ ] Обновлены/добавлены тесты репозитория для нового клиента.
- [ ] Все релевантные тесты проходят.
## Tests
- Обновить `tests/repositories/cache/test_redis_cache.py` под использование `aioredis`.
- Добавить/обновить тесты на cache hit/miss, `set/get` roundtrip, `invalidate`, TTL expiration.
- Добавить негативный тест на ошибку Redis-клиента при операции чтения или записи.
## Commands
- `poetry run pytest tests/repositories/cache/test_redis_cache.py -q`
- `poetry run pytest tests/services/test_aggregator.py -q`
@@ -1,47 +0,0 @@
---
id: 012
title: Remove observability and SigNoz stack for phase 1
status: DONE
created: 2026-03-08
---
## Context
На первом этапе принято решение отказаться от логирования, мониторинга, алертинга и инфраструктуры SigNoz. Текущая кодовая база и тесты содержат эти зависимости и сценарии.
## Goal
Удалить из проекта все runtime- и test-артефакты, связанные с логами, мониторингом, алертами, Telegram alerting и SigNoz, сохранив рабочий API расчета доставки и кеширование.
## Constraints
- Соблюдать layered architecture из `AGENTS.md`; не переносить бизнес-правила между слоями.
- Scope задачи ограничен удалением observability/alerting/SigNoz и зависимых конфигураций, инфраструктурных и тестовых артефактов.
- Не изменять бизнес-логику расчета тарифов, provider integration и API-контракт `POST /api/v1/delivery/price`.
- Не добавлять новую систему мониторинга или алертинга в рамках этой задачи.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Из runtime-приложения удалены middleware, instrumentation и иные механизмы, реализующие request/log correlation, tracing, metrics и alerting.
- В коде и конфигурации приложения отсутствуют секции и параметры `observability` и `alerts`, связанные с OpenTelemetry, structlog, SigNoz и Telegram.
- Из инфраструктурных файлов удалены сервис/настройки SigNoz и маршрутизация алертов.
- Удалены или обновлены тесты observability/alerts; тестовый набор для контроллера, сервиса, репозитория и конфигурации остается зеленым.
- По файлам приложения и инфраструктуры нет упоминаний `signoz`, `opentelemetry`, `structlog`, `telegram`.
## Definition of Done
- [ ] Удалены runtime-компоненты observability/alerting.
- [ ] Обновлены YAML-конфиги и config schemas без секций observability/alerts.
- [ ] Обновлены инфраструктурные файлы без SigNoz.
- [ ] Удалены/обновлены тесты observability и alerts.
- [ ] Пройдены все команды из раздела Commands.
## Tests
- Обновить `tests/config/test_config_sections.py` под конфигурацию без observability/alerts.
- Удалить или заменить `tests/config/test_alerts_config.py` и `tests/observability/*` в соответствии с новым scope.
- Обновить `tests/smoke/test_local_infra_stack.py` под инфраструктуру без SigNoz.
- Подтвердить, что `tests/controllers/v1/test_delivery.py`, `tests/services/test_aggregator.py`, `tests/repositories/cache/test_redis_cache.py` проходят без observability-зависимостей.
## Commands
- `poetry run pytest tests/controllers/v1/test_delivery.py -q`
- `poetry run pytest tests/services/test_aggregator.py -q`
- `poetry run pytest tests/repositories/cache/test_redis_cache.py -q`
- `poetry run pytest tests/config/test_config_sections.py -q`
- `poetry run pytest tests/smoke/test_local_infra_stack.py -q`
- `! rg -n "(signoz|opentelemetry|structlog|telegram)" app tests docker-compose.yml pyproject.toml config.yaml config.example.yaml config.test.yaml infra`
@@ -1,47 +0,0 @@
---
id: 013
title: Add configurable provider price multiplier in domain logic
status: DONE
created: 2026-03-09
---
## Context
Сейчас `app/domain/price.py` валидирует и сортирует тарифы провайдеров без дополнительной постобработки цены. Появилось новое требование: перед возвратом результата применять к цене провайдера конфигурируемый multiplier из YAML-конфига и округлять итог до целого числа.
## Goal
Добавить в Business Logic детерминированное правило пересчёта `DeliveryPrice.price` через multiplier из секции `business_logic` и провести это значение через конфигурацию и service orchestration без переноса формулы в Service или Adapter.
## Constraints
- Соблюдать layered architecture из `AGENTS.md`.
- Формула умножения и округления должна жить только в `app/domain/`; Service может только передавать нужный параметр и делегировать вызов.
- Значение multiplier должно читаться из YAML-конфига через секцию `business_logic`.
- После применения multiplier цена должна оставаться `Decimal`, но иметь целочисленное значение.
- Правило округления должно быть явным и детерминированным: до `Decimal("1")` c `ROUND_HALF_UP`.
- Не изменять provider adapters, внешние HTTP-контракты и формат ответа кроме нового значения `price`.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- В `business_logic` configuration section добавлено обязательное поле для multiplier цены провайдера с валидацией положительного значения.
- Domain functions применяют multiplier к каждой валидной цене провайдера до сортировки результата.
- Итоговая цена после умножения округляется до целого значения по правилу `ROUND_HALF_UP`.
- Service не содержит формулу расчёта и по-прежнему делегирует обработку цен в domain logic.
- Поведение одинаково для свежих ответов провайдера и для cache hit: в обоих случаях наружу возвращается уже пересчитанная цена.
## Definition of Done
- [ ] Обновлена конфигурация `business_logic` и YAML fixtures/example configs для multiplier.
- [ ] Реализована pure domain logic для пересчёта и округления цены.
- [ ] Service wiring передаёт multiplier в domain logic без добавления business rules в Service.
- [ ] Unit/service/config tests покрывают стандартные и edge-case сценарии.
## Tests
- Обновить `tests/domain/test_price.py` для проверки multiplier, округления `ROUND_HALF_UP`, пустого input и невалидных цен.
- Обновить `tests/services/test_aggregator.py` для проверки делегирования multiplier и одинакового поведения fresh/cache hit.
- Обновить `tests/config/test_config_sections.py` для проверки чтения multiplier из YAML и валидации невалидных значений.
- При необходимости обновить `tests/smoke/test_app_import.py` под обязательное новое поле конфигурации.
## Commands
- `poetry run pytest tests/domain/test_price.py -q`
- `poetry run pytest tests/services/test_aggregator.py -q`
- `poetry run pytest tests/config/test_config_sections.py -q`
- `poetry run pytest tests/smoke/test_app_import.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -1,44 +0,0 @@
---
id: 014
title: Add minimal structlog JSON logging
status: DONE
created: 2026-03-12
---
## Context
После удаления observability stack приложение осталось без централизованной конфигурации логирования. При этом в runtime-коде есть локальная настройка `logging.basicConfig(...)`, а новое требование состоит в том, чтобы все runtime-логи приложения выводились в JSON без возврата tracing, metrics и alerting.
## Goal
Подключить минимальную централизованную настройку `structlog`, чтобы runtime-логи приложения и Uvicorn выводились как JSON-объекты, а прикладные модули использовали общий logging setup вместо локальной конфигурации.
## Constraints
- Соблюдать layered architecture из `AGENTS.md`.
- Scope задачи: только logging wiring, JSON formatting и интеграция существующих logger calls.
- `structlog` использовать только для JSON logging; не добавлять `request_id`, `trace_id`, OpenTelemetry, metrics, SigNoz, Telegram и иные observability features.
- Конфигурация логирования должна быть централизована на уровне app startup или отдельного runtime-модуля; в Controller, Service, Repository, Adapter и Business Logic запрещено вызывать `logging.basicConfig(...)`.
- Не изменять API contract, business rules, provider protocol, cache behavior и логику обработки ошибок.
- используй context7, чтобы узнать контракт текущей версии пакета structlog
## Acceptance criteria
- При старте приложения выполняется единая инициализация logging на базе `structlog`.
- Логи приложения и `uvicorn.error`/`uvicorn.access` сериализуются в JSON, одна запись на строку.
- Каждая лог-запись содержит как минимум поля `event`, `level`, `timestamp` и `logger`.
- В прикладных модулях отсутствуют локальные вызовы `logging.basicConfig(...)` и текстовые formatter-конфигурации для runtime logging.
- Добавлен тест, который валидирует emitted log line как корректный JSON и проверяет обязательные поля.
## Definition of Done
- [ ] Добавлена централизованная конфигурация JSON logging на базе `structlog`.
- [ ] Удалены локальные настройки logging из прикладных модулей.
- [ ] `uvicorn.error` и `uvicorn.access` подключены к тому же JSON logging setup.
- [ ] Добавлены или обновлены тесты для logging bootstrap и JSON serialization.
- [ ] Пройдены все команды из раздела Commands.
## Tests
- Добавить `tests/logging/test_json_logging.py` для проверки JSON serialization и обязательных полей лог-записи.
- Обновить `tests/smoke/test_app_import.py` для проверки вызова централизованного logging bootstrap при создании app.
- При изменении конфигурации Uvicorn loggers добавить тест на `uvicorn.error` и `uvicorn.access` без запуска реального сервера.
## Commands
- `poetry run pytest tests/logging/test_json_logging.py -q`
- `poetry run pytest tests/smoke/test_app_import.py -q`
- `poetry run pytest tests/adapters/delivery_providers/cdek/test_client.py -q`
@@ -1,50 +0,0 @@
---
id: 015
title: Add minimal OpenTelemetry tracing
status: DONE
created: 2026-03-13
---
## Context
После удаления observability stack в задаче `012` runtime-приложение осталось без трассировки. Появилось новое требование: вернуть только минимальный tracing для входящих HTTP-запросов и исходящих вызовов провайдера/Redis, без возврата metrics, alerting и trace/log correlation.
## Goal
Подключить минимальный OpenTelemetry tracing через централизованный runtime bootstrap: настроить `TracerProvider` и OTLP exporter из YAML-конфига, добавить instrumentation для FastAPI, httpx и Redis и встроить этот bootstrap в startup приложения без переноса telemetry concern в Business Logic.
## Constraints
- Соблюдать layered architecture из `AGENTS.md`.
- Scope задачи: только tracing bootstrap, YAML configuration, instrumentation wiring и тесты на это поведение.
- Не добавлять metrics, request correlation middleware, `request_id`, `trace_id` в логи, SigNoz, Telegram alerting и иные observability features кроме tracing.
- Не добавлять business rules, branching или data access в runtime tracing bootstrap.
- Инициализация OpenTelemetry должна жить в отдельном runtime-модуле или app startup; Controller, Service, Repository, Adapter и Business Logic не должны вручную создавать exporter/provider.
- Конфигурация tracing должна читаться из отдельной секции `observability` в YAML-файлах конфигурации.
- Bootstrap tracing должен быть идемпотентным: повторные вызовы `create_app()` в тестах не должны дублировать global instrumentation и span processors.
- Не изменять API contract, логику расчета тарифов, кэш semantics и provider protocol.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- В конфигурации приложения добавлена секция `observability` с параметрами включения tracing, `service_name`, OTLP endpoint и флагом insecure transport.
- При `observability.enabled = true` приложение на старте создает `TracerProvider` с `Resource(service.name=...)` и OTLP exporter, используя значения из YAML-конфига.
- При `observability.enabled = true` централизованно включается instrumentation для FastAPI, httpx и Redis.
- При `observability.enabled = false` приложение стартует без инициализации exporter/provider и без регистрации instrumentation.
- Повторное создание приложения или повторный вызов tracing bootstrap не приводит к duplicate instrumentation/span processor registration.
- Реализация не восстанавливает metrics, alerts, request/log correlation и не меняет JSON logging contract.
## Definition of Done
- [ ] Добавлена секция `observability` в config schema и runtime YAML-файлы.
- [ ] Реализован централизованный runtime bootstrap для OpenTelemetry tracing.
- [ ] `create_app()` подключает tracing bootstrap без бизнес-логики и без дублирования инициализации.
- [ ] Добавлены tests на конфигурацию, enabled/disabled режимы и идемпотентность tracing setup.
- [ ] Добавлен tracing test, подтверждающий создание spans для FastAPI/httpx/Redis instrumentation или корректный wiring этих instrumentors.
## Tests
- Обновить `tests/config/test_config_sections.py` для проверки секции `observability`, обязательных полей и disabled/enabled конфигурации.
- Обновить `tests/smoke/test_app_import.py` для проверки вызова централизованного tracing bootstrap при создании app.
- Добавить `tests/tracing/test_bootstrap.py` для проверки OTLP exporter/provider setup, `service.name`, enabled/disabled режимов и идемпотентности instrumentation.
- Добавить tracing test с in-memory exporter или эквивалентным deterministic harness для проверки, что FastAPI/httpx/Redis instrumentation корректно подключается без запуска внешнего OTLP collector.
## Commands
- `poetry run pytest tests/config/test_config_sections.py -q`
- `poetry run pytest tests/smoke/test_app_import.py -q`
- `poetry run pytest tests/tracing/test_bootstrap.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -1,43 +0,0 @@
---
id: 016
title: Add CDEK order registration adapter
status: DONE
created: 2026-03-14
---
## Context
Сейчас CDEK adapter поддерживает только расчёт тарифа. Новый сценарий требует регистрацию заказа в CDEK по контракту из `http-client.http` в разделе `Регистрация заказа (тип "доставка", до двери)`.
## Goal
Расширить существующий CDEK adapter регистрацией заказа: добавить в текущий adapter/provider сбор payload по контракту, вызов `POST /v2/orders`, детерминированную обработку provider errors и маппинг успешного ответа во внутреннюю response model.
## Constraints
- Изменения ограничены существующим Adapter layer и schema/mapper моделями, необходимыми для стабильного adapter contract.
- Использовать существующий OAuth2 flow CDEK; не дублировать auth logic.
- Контракт запроса должен соответствовать разделу `Регистрация заказа (тип "доставка", до двери)` из `http-client.http`.
- В scope этой задачи входят только значения `type=2` и `tariff_code=535`; не расширять поддержку на другие типы заказа и тарифы.
- Не добавлять новые controller/service модули, controller routing, service orchestration, cache behavior и business logic.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Существующий CDEK adapter/provider предоставляет стабильный метод регистрации заказа, принимающий валидированную internal model вместо raw dict.
- Adapter отправляет `POST /v2/orders` с bearer token и JSON payload, соответствующим контракту из `http-client.http`.
- Успешный ответ CDEK маппится во внутреннюю response model с `order_uuid`, полученным из `entity.uuid`.
- Provider validation errors класса 4xx маппятся в детерминированную provider request error, а transport/5xx ошибки — в adapter client error.
- Adapter tests покрывают success case, payload mapping, response mapping и error handling для order registration.
## Definition of Done
- [ ] Расширен существующий adapter method set для регистрации заказа CDEK.
- [ ] Реализованы request/response mapper(s) для order registration.
- [ ] Переиспользуется существующий OAuth2 auth client.
- [ ] Добавлены adapter tests для order registration сценария.
## Tests
- Добавить `tests/adapters/delivery_providers/cdek/test_order_client.py` для success/error сценариев регистрации заказа.
- Проверить, что payload содержит поля из контракта `http-client.http`.
- Проверить маппинг `entity.uuid` в внутреннюю response model.
- Для внешних HTTP взаимодействий использовать stubs/mocks.
## Commands
- `poetry run pytest tests/adapters/delivery_providers/cdek/test_order_client.py -q`
- `python3 spec/gen_spec_index.py --check`
-49
View File
@@ -1,49 +0,0 @@
---
id: 017
title: Return all tariffs from CDEK price calculation
status: DONE
created: 2026-03-14
---
## Context
Сейчас `POST /api/v1/delivery/price` для CDEK возвращает только один тариф, хотя продуктовый контракт требует возвращать унифицированный список тарифов. Текущая реализация adapter/service использует однокотировочный provider contract и CDEK mapper берёт только первый элемент `tariff_codes[0]`.
## Goal
Изменить price flow так, чтобы CDEK adapter возвращал все тарифы из успешного ответа CDEK, а service агрегировал и сортировал объединённый список тарифов без изменения архитектурных границ слоёв.
## Constraints
- Scope задачи ограничен расчётом стоимости доставки и CDEK tariff list response; не изменять order creation flow.
- Controller должен сохранить текущий публичный endpoint `POST /api/v1/delivery/price` и по-прежнему вызывать ровно один метод Service.
- Service остаётся orchestration layer: собирает тарифы от провайдеров, работает с cache и делегирует фильтрацию/сортировку в Business Logic.
- Pure business rules не переносить в Service или Adapter.
- Изменение должно затронуть provider contract так, чтобы один provider мог вернуть несколько тарифов в одном запросе.
- Scope задачи не включает добавление новых провайдеров, новых endpoint'ов и расширение response schema beyond текущего `DeliveryPrice`, если это не требуется для возврата всех тарифов.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Интерфейс `DeliveryProvider` поддерживает возврат списка тарифов для одного provider request.
- CDEK mapper преобразует все элементы `tariff_codes` из валидного ответа CDEK в список `DeliveryPrice`, а не только первый тариф.
- CDEK provider adapter возвращает service полный список тарифов, полученный из ответа CDEK.
- `AggregatorService.get_all_prices()` корректно обрабатывает список тарифов от каждого provider, объединяет их в единый список и передаёт его в domain filtering/sorting.
- Cache coordination в service поддерживает сохранение и чтение списка тарифов для provider request.
- `POST /api/v1/delivery/price` возвращает все тарифы CDEK в унифицированном формате, отсортированные по цене по возрастанию.
- Existing graceful degradation сохраняется: failure одного provider исключает только его тарифы и не ломает успешные результаты других providers.
## Definition of Done
- [ ] Обновлён provider contract для возврата списка тарифов.
- [ ] Обновлены CDEK mapper и adapter/client для многотарифного ответа.
- [ ] Обновлён `AggregatorService.get_all_prices()` для объединения и кеширования списков тарифов.
- [ ] Добавлены или обновлены tests для adapter, service и controller/API сценариев многотарифного ответа.
## Tests
- Обновить `tests/adapters/delivery_providers/cdek/test_mapper.py` для проверки mapping всех тарифов из `tariff_codes`.
- Обновить `tests/adapters/delivery_providers/cdek/test_client.py` для проверки, что provider возвращает список тарифов, а не один объект.
- Обновить `tests/services/test_aggregator.py` для flattening provider results, cache hit/miss со списком тарифов и partial failure сценариев.
- Обновить `tests/controllers/v1/test_delivery.py` для проверки, что API возвращает несколько тарифов CDEK в одном ответе.
## Commands
- `poetry run pytest tests/adapters/delivery_providers/cdek/test_mapper.py -q`
- `poetry run pytest tests/adapters/delivery_providers/cdek/test_client.py -q`
- `poetry run pytest tests/services/test_aggregator.py -q`
- `poetry run pytest tests/controllers/v1/test_delivery.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -1,49 +0,0 @@
---
id: 018
title: Add optional parcel type filter to price request
status: DONE
created: 2026-03-16
---
## Context
Сейчас `POST /api/v1/delivery/price` возвращает все доступные тарифы без возможности отфильтровать их по типу отправления. Новый пользовательский сценарий требует опциональный параметр `parcel_type`, который должен фильтровать уже агрегированный список тарифов для всех провайдеров.
## Goal
Расширить контракт `DeliveryRequest` опциональным полем `parcel_type` и добавить детерминированную фильтрацию тарифов в price flow по правилам `doc` и `parcel` без изменения публичного path endpoint.
## Constraints
- `parcel_type` является необязательным полем запроса `POST /api/v1/delivery/price`.
- Допустимые значения ограничены `doc` и `parcel`; другие значения должны отклоняться schema validation.
- Правило фильтрации является pure business rule и должно жить в `app/domain/`; Controller только валидирует DTO, Service только оркестрирует и передаёт параметр в domain logic.
- Фильтрация должна применяться к унифицированному списку тарифов для всех провайдеров и не должна требовать provider-specific branching или изменения provider transport contract.
- Для `doc` включаются только тарифы, у которых `service_name` содержит `документ` или `document` без учёта регистра.
- Для `parcel` возвращаются все остальные тарифы, не попавшие под правило `doc`.
- Если `parcel_type` не передан, поведение endpoint остаётся прежним: возвращаются все тарифы.
- Scope задачи не включает изменение order flow, добавление новых endpoint'ов, расширение набора значений `parcel_type` и иные изменения вне price flow.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- `DeliveryRequest` поддерживает опциональное поле `parcel_type`.
- `POST /api/v1/delivery/price` принимает запросы без `parcel_type` и возвращает полный список тарифов без дополнительной фильтрации.
- При `parcel_type=doc` ответ содержит только тарифы, у которых `service_name` содержит `документ` или `document` без учёта регистра.
- При `parcel_type=parcel` ответ содержит только тарифы, у которых `service_name` не содержит `документ` и `document` без учёта регистра.
- Невалидное значение `parcel_type` приводит к 422 response на уровне schema validation.
- Фильтрация работает одинаково для тарифов, полученных из provider responses и из cache.
## Definition of Done
- [ ] Обновлён контракт price request с опциональным `parcel_type`.
- [ ] Реализована pure domain logic для фильтрации тарифов по `parcel_type`.
- [ ] Service wiring передаёт `parcel_type` в domain logic без добавления business rules в Service.
- [ ] Controller/API tests покрывают сценарии `doc`, `parcel`, отсутствие параметра и невалидное значение.
- [ ] Поведение одинаково для fresh provider results и cache hit.
## Tests
- Обновить `tests/domain/test_price.py` для проверки case-insensitive фильтрации по `документ` и `document`, а также поведения без `parcel_type`.
- Обновить `tests/services/test_aggregator.py` для проверки делегирования `parcel_type` в domain logic и одинакового результата для fresh path и cache hit.
- Обновить `tests/controllers/v1/test_delivery.py` для проверки optional request field, 422 на невалидное значение и response filtering для `doc` и `parcel`.
## Commands
- `poetry run pytest tests/domain/test_price.py -q`
- `poetry run pytest tests/services/test_aggregator.py -q`
- `poetry run pytest tests/controllers/v1/test_delivery.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -1,53 +0,0 @@
---
id: 019
title: Add CDEK order creation endpoint
status: DONE
created: 2026-03-14
---
## Context
Сейчас order flow должен принимать уже готовые точные адреса в полях `from_location.address` и `to_location.address`. Для нового пользовательского сценария нужен отдельный endpoint `/order`, который создаёт заказ в CDEK по контракту из `http-client.http`, не выполняя address suggestion lookup внутри order flow.
## Goal
Добавить `POST /api/v1/delivery/order` в существующий controller и существующий service с request/response schemas и error mapping для регистрации заказа в CDEK через adapter contract из задачи `016`.
## Constraints
- Controller отвечает только за DTO validation, routing и mapping service exceptions в HTTP responses.
- Endpoint должен вызывать ровно один метод Service: `AggregatorService.create_order()`.
- Новый endpoint должен быть добавлен в существующий controller модуль `app/controllers/v1/delivery.py`; не создавать отдельный controller модуль.
- Логика создания заказа должна быть добавлена в существующий service модуль `app/services/aggregator.py`; не создавать отдельный service модуль.
- Service оркестрирует только вызов injected CDEK order adapter и не содержит business logic или provider HTTP-деталей.
- `from_location.address` и `to_location.address` считаются уже выбранными точными строками адреса; в рамках этой задачи запрещено добавлять address suggestion routing, внешние address lookup вызовы и нормализацию адреса.
- Контракт входного запроса должен соответствовать разделу `Регистрация заказа (тип "доставка", до двери)` из `http-client.http`.
- В scope задачи входят только значения `type=2` и `tariff_code=535`; не расширять поддержку на другие типы заказа и тарифы.
- Scope задачи не включает `POST /api/v1/delivery/suggest-address`, конфигурацию address suggestion providers, кеширование, агрегацию тарифов, расчёт стоимости, новые провайдеры и расширение order flow за пределы CDEK.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Существует endpoint `POST /api/v1/delivery/order`, принимающий payload по контракту из `http-client.http`.
- Реализованы request/response schemas `OrderCreateRequest` и `OrderCreateResponse` для создания заказа.
- Endpoint реализован в существующем controller `app/controllers/v1/delivery.py`.
- Controller делегирует обработку только в `AggregatorService.create_order()`.
- Service вызывает injected adapter для регистрации заказа и возвращает `OrderCreateResponse` с `provider` и `order_uuid`.
- Логика orchestration размещена в существующем service `app/services/aggregator.py`.
- Endpoint принимает значения `from_location.address` и `to_location.address` как opaque input strings и не выполняет address suggestion lookup перед вызовом adapter.
- Ошибки валидации входного payload возвращают 422, provider request errors маппятся в 400, недоступность CDEK и transport failures — в 503.
- API и service tests покрывают success case и основные failure scenarios.
## Definition of Done
- [ ] Добавлены order request/response schemas.
- [ ] Реализован метод `AggregatorService.create_order()` для оркестрации регистрации заказа.
- [ ] Реализован endpoint `POST /api/v1/delivery/order` в существующем controller.
- [ ] Добавлены service и controller/API tests для order creation flow.
## Tests
- Добавить `tests/services/test_order.py` для проверки вызова adapter и маппинга ошибок сервиса.
- Добавить `tests/controllers/v1/test_order.py` для success case, schema validation и HTTP mapping ошибок.
- При необходимости обновить `tests/smoke/test_app_import.py` для проверки подключения нового endpoint и service wiring без новых модулей.
- Использовать test doubles для adapter dependency.
## Commands
- `poetry run pytest tests/services/test_order.py -q`
- `poetry run pytest tests/controllers/v1/test_order.py -q`
- `poetry run pytest tests/smoke/test_app_import.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -1,58 +0,0 @@
---
id: 020
title: Rename price request model and use cities_map for CDEK codes
status: DONE
created: 2026-03-21
---
## Context
Текущий price flow принимает `DeliveryRequest` со строковыми `from_city` и `to_city`, а также использует внешний lookup города через `_resolve_city_code()` в CDEK client. Новый контракт API переходит на идентификаторы городов и локальный справочник `cities_map`, который хранит provider-specific данные города. Для этого изменения нужен только минимальный набор новых валидаций, необходимый для корректного price flow.
## Goal
Переименовать request model расчёта доставки в `DeliveryCalculationRequest`, перевести поля `from_city` и `to_city` на тип `int`, убрать `country_code` из price API и использовать `cities_map` как источник provider city codes для CDEK tariff request вместо `_resolve_city_code()`, добавив только минимально необходимые валидации для этого перехода.
## Constraints
- Scope задачи ограничен price calculation flow: request schema, controller/service wiring, domain normalization, cache key composition, provider contract и CDEK adapter/client.
- `POST /api/v1/delivery/order`, `OrderCreateRequest`, `OrderCreateResponse` и `country_code` внутри order payload не изменять.
- Controller не добавляет business rules; Service не реализует provider-specific lookup; pure validation и normalization остаются в Business Logic.
- Для расчёта CDEK использовать только локальный `cities_map`; внешний city lookup через CDEK API и `_resolve_city_code()` должен быть удалён из client.
- Не требовать полноты `cities_map`; отсутствие записи города или provider-specific данных должно обрабатываться детерминированно.
- Учитывать provider-specific структуру `cities_map`, но не реализовывать новых providers в рамках этой задачи.
- Новые проверки ограничить минимально необходимыми для работы контракта и CDEK lookup: тип city identifier во входной schema и наличие валидного `cdek.code` для используемого города.
- Не добавлять предварительную валидацию всего `cities_map`, проверку неиспользуемых provider sections, проверку `city_uuid`, `full_name`, `label`, `country` или иные дополнительные consistency-checks.
- Не добавлять новые range/business validations для city identifier сверх тех, что нужны для поиска города в `cities_map`.
- Не изменять файлы в `spec/`, кроме этой задачи, `spec/overview.md` и сгенерированного `spec/index.md`.
## Acceptance criteria
- Модель запроса расчёта переименована в `DeliveryCalculationRequest`; controller, service и provider contract для price flow используют новое имя.
- Поля `from_city` и `to_city` в price request принимают целочисленные идентификаторы города; поле `country_code` отсутствует в публичном API расчёта доставки.
- Domain normalization и service cache key больше не используют `country_code` и корректно работают с city identifiers.
- Новые валидации, добавленные этой задачей, ограничены:
- schema-level проверкой, что `from_city` и `to_city` передаются как `int`
- runtime-проверкой, что для конкретного city identifier, использованного в CDEK price flow, существует валидный `cdek.code`
- CDEK client строит payload `from_location.code` и `to_location.code` на основе `cities_map` и значений `cdek.code` для переданных city identifiers.
- Если city identifier отсутствует в `cities_map` или для него нет валидного `cdek.code`, CDEK price flow завершается детерминированной provider request error без внешнего city lookup.
- Метод `_resolve_city_code()` и связанный HTTP lookup `location/suggest/cities` удалены из CDEK client; price flow не обращается к CDEK API подсказок городов.
- Existing order creation flow и order schemas остаются без изменения.
## Definition of Done
- [ ] Обновлены request schema, controller/service/provider type hints и связанные импорты для нового имени `DeliveryCalculationRequest`.
- [ ] Удалён `country_code` из price calculation contract, normalization и cache key.
- [ ] Реализован lookup CDEK city codes через `cities_map` без внешнего city suggest API.
- [ ] Удалён `_resolve_city_code()` и обновлены adapter tests под новое поведение.
- [ ] Добавлены или обновлены tests только для минимально необходимой schema validation, service wiring, cache key и CDEK payload mapping/error scenarios.
## Tests
- Обновить `tests/domain/test_price.py` только для проверки, что normalizer корректно принимает и сохраняет city identifiers без `country_code`.
- Обновить `tests/services/test_aggregator.py` для проверки нового request model, отсутствия `country_code` в service flow и обновлённого cache key.
- Обновить `tests/controllers/v1/test_delivery.py` только для проверки API schema с `from_city`/`to_city` типа `int` и 422 на невалидные значения типа.
- Обновить `tests/adapters/delivery_providers/cdek/test_client.py` для проверки построения payload по `cities_map`, ошибок при отсутствии `cdek.code` или city entry и отсутствия city lookup request.
- При необходимости обновить `tests/smoke/test_app_import.py` только для совместимости нового request model с app wiring.
## Commands
- `poetry run pytest tests/domain/test_price.py -q`
- `poetry run pytest tests/services/test_aggregator.py -q`
- `poetry run pytest tests/controllers/v1/test_delivery.py -q`
- `poetry run pytest tests/adapters/delivery_providers/cdek/test_client.py -q`
- `poetry run pytest tests/smoke/test_app_import.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -1,45 +0,0 @@
---
id: 021
title: Add address suggestion adapter and country provider mapping
status: DONE
created: 2026-03-25
---
## Context
Перед созданием заказа клиенту нужно получить точное значение адреса для полей `from_location.address` и `to_location.address`. Для этого нужен отдельный address suggestion flow с внешним provider, а выбор provider должен определяться по `country_code` через YAML-конфиг.
## Goal
Добавить contract для address suggestion adapters, секцию конфигурации address suggestions с маппингом `country_code -> provider_id` и реализовать интеграцию с `dadata.ru` для стран, сопоставленных с provider id `dadata`.
## Constraints
- Изменения ограничены Adapter layer, config schema/loading и моделями, необходимыми для стабильного adapter contract.
- Внешний IO должен оставаться внутри address suggestion adapter modules.
- В scope задачи входит только интеграция с `dadata.ru`; concrete HTTP integration второго европейского provider не реализовывать в рамках этой задачи.
- Конфигурация должна читаться из `.yaml` и содержать отдельную секцию для address suggestion providers и country mapping.
- Adapter не должен содержать business decisions; он только отправляет запрос, маппит ответ и детерминированно обрабатывает provider/transport errors.
- Не изменять файлы в `spec/`.
- Поищи описание api dadata и следуй её описанию при составлении payload запроса
## Acceptance criteria
- Существует интерфейс `AddressSuggestionProvider` со стабильным контрактом `suggest(request: AddressSuggestRequest) -> list[AddressSuggestion]`.
- В YAML-конфиге добавлена секция address suggestions с настройками `dadata` и маппингом `country_code -> provider_id`.
- Реализован adapter `dadata`, который принимает internal request model и возвращает унифицированный список `AddressSuggestion`.
- Provider-specific поля ответа `dadata` не утекают за пределы adapter contract.
- Ошибки `dadata` класса 4xx маппятся в детерминированную provider request error, а transport/5xx ошибки — в adapter client error.
- Тесты покрывают загрузку конфигурации address suggestions, request/response mapping `dadata` и основные error scenarios.
## Definition of Done
- [ ] Добавлен base contract для address suggestion providers.
- [ ] Добавлена YAML-конфигурация address suggestions и country mapping.
- [ ] Реализован adapter `dadata` для address suggestions.
- [ ] Добавлены config и adapter tests для нового flow.
## Tests
- Обновить `tests/config/test_config_sections.py` для проверки секции address suggestions и маппинга стран на provider id.
- Добавить `tests/adapters/address_suggestions/dadata/test_client.py` для success/error сценариев и mapping.
- Проверить, что наружу возвращается только унифицированная model `AddressSuggestion`.
## Commands
- `poetry run pytest tests/config/test_config_sections.py -q`
- `poetry run pytest tests/adapters/address_suggestions/dadata/test_client.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -1,49 +0,0 @@
---
id: 022
title: Add address suggestion endpoint
status: DONE
created: 2026-03-25
---
## Context
Перед реализацией order flow клиенту нужен отдельный endpoint, который возвращает подсказки адреса и позволяет выбрать точное значение для `from_location.address` и `to_location.address`. Выбор provider должен происходить по `country_code` через конфигурационный маппинг стран.
## Goal
Добавить `POST /api/v1/delivery/suggest-address` в существующий controller и существующий service с request/response schemas, routing на address suggestion provider по `country_code` и детерминированным HTTP error mapping.
## Constraints
- Controller отвечает только за DTO validation, routing и mapping service exceptions в HTTP responses.
- Endpoint должен вызывать ровно один метод Service: `AggregatorService.suggest_addresses()`.
- Новый endpoint должен быть добавлен в существующий controller модуль `app/controllers/v1/delivery.py`; не создавать отдельный controller модуль.
- Логика provider selection должна быть добавлена в существующий service модуль `app/services/aggregator.py`; не создавать отдельный service модуль.
- Service выбирает provider по `country_code` через injected config mapping и вызывает ровно один address suggestion adapter; provider HTTP-детали в Service запрещены.
- Scope задачи не включает создание заказа CDEK, изменение `POST /api/v1/delivery/order`, расчёт стоимости доставки, cache behavior и concrete HTTP integration европейского provider.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Существует endpoint `POST /api/v1/delivery/suggest-address`, принимающий `AddressSuggestRequest` и возвращающий `list[AddressSuggestion]`.
- Реализованы request/response schemas `AddressSuggestRequest` и `AddressSuggestion`.
- Endpoint реализован в существующем controller `app/controllers/v1/delivery.py`.
- Controller делегирует обработку только в `AggregatorService.suggest_addresses()`.
- Service определяет provider по `country_code` через конфигурационный маппинг и вызывает только соответствующий registered adapter.
- Если `country_code` отсутствует в маппинге или сопоставлен с незарегистрированным provider, endpoint возвращает детерминированный 400 response.
- Provider request errors маппятся в 400, недоступность внешнего сервиса и transport failures — в 503.
- Service и controller/API tests покрывают как минимум сценарии: route в `dadata`, route в второй provider через test double, unsupported country и provider failure.
## Definition of Done
- [ ] Добавлены address suggestion request/response schemas.
- [ ] Реализован метод `AggregatorService.suggest_addresses()` для provider routing и orchestration.
- [ ] Реализован endpoint `POST /api/v1/delivery/suggest-address` в существующем controller.
- [ ] Добавлены service и controller/API tests для address suggestion flow.
## Tests
- Добавить `tests/services/test_address_suggestions.py` для проверки routing по `country_code`, unsupported country, незарегистрированного provider и provider failures.
- Добавить `tests/controllers/v1/test_address_suggestions.py` для success case, schema validation и HTTP mapping ошибок.
- При необходимости обновить `tests/smoke/test_app_import.py` для проверки подключения нового endpoint и service wiring без новых controller/service модулей.
- Использовать test doubles для address suggestion adapters.
## Commands
- `poetry run pytest tests/services/test_address_suggestions.py -q`
- `poetry run pytest tests/controllers/v1/test_address_suggestions.py -q`
- `poetry run pytest tests/smoke/test_app_import.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -1,55 +0,0 @@
---
id: 023
title: Add Yandex Geosuggest address suggestion adapter and CIS routing
status: DONE
created: 2026-03-29
---
## Context
Текущий flow подсказок адреса поддерживает только `dadata` и уже использует routing по `country_code` через YAML-конфиг. Появилось новое требование: для части стран СНГ использовать отдельный provider `Yandex Geosuggest`, чтобы разгрузить `dadata` и зафиксировать routing по странам на уровне конфигурации.
## Goal
Добавить новый adapter `yandex_geosuggest` для address suggestions, настроить routing по `country_code` так, чтобы `RU`, `BY` и `KZ` оставались на `dadata`, а `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM` и `UZ` шли через `Yandex Geosuggest`, и покрыть это тестами без изменения публичного API endpoint.
## Constraints
- Scope задачи ограничен flow подсказок адреса: YAML-конфиг, wiring зависимостей, service routing, новый adapter и тесты.
- Controller path и публичный контракт `POST /api/v1/delivery/suggest-address` не изменять.
- Service остаётся orchestration layer: выбирает provider по injected config mapping и вызывает ровно один adapter без provider-specific HTTP-логики.
- Внешний IO должен оставаться только внутри нового adapter `app/adapters/address_suggestions/yandex_geosuggest/`.
- Для `RU`, `BY` и `KZ` existing mapping на `dadata` должен сохраниться без изменения контракта `dadata` adapter.
- Для `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM` и `UZ` конфиг должен выбирать provider id `yandex_geosuggest`.
- Новый adapter должен следовать официальному HTTP-контракту Yandex Geosuggest: `GET https://suggest-maps.yandex.ru/v1/suggest`, обязательные query-параметры `apikey` и `text`; для полного адреса использовать `print_address=1`; если в internal request передан `limit`, он должен маппиться в query-параметр `results`.
- Unified mapping наружу должен по-прежнему возвращать только `AddressSuggestion` без утечки provider-specific payload.
- Для Yandex Geosuggest поле `postal_code` не извлекается из provider response и должно возвращаться как `None`.
- Ошибки Yandex Geosuggest `400` должны маппиться в deterministic provider request error; `403`, `429`, transport errors и `5xx` должны маппиться в adapter client error/unavailable path.
- Scope задачи не включает добавление европейского provider, изменение order flow, price flow, новых endpoint'ов и расширение internal model `AddressSuggestion`.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- В конфиге address suggestion providers добавлен provider id `yandex_geosuggest` с параметрами, необходимыми для вызова Yandex Geosuggest.
- Country mapping в конфиге маршрутизирует `RU`, `BY`, `KZ` на `dadata`, а `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM`, `UZ` на `yandex_geosuggest`.
- Реализован adapter `app/adapters/address_suggestions/yandex_geosuggest/client.py`, который отправляет запрос в Yandex Geosuggest по официальному контракту и возвращает `list[AddressSuggestion]`.
- Adapter использует `request.city` и `request.query` как значение `text`, передаёт `print_address=1`, а `request.limit` при наличии маппит в `results`.
- Поля `address`, `street`, `house` и `flat` детерминированно извлекаются из ответа Yandex Geosuggest без утечки внешнего payload за пределы adapter contract, а `postal_code` возвращается как `None`.
- `AggregatorService.suggest_addresses()` по `country_code` выбирает `yandex_geosuggest` для стран `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM`, `UZ` и сохраняет routing на `dadata` для `RU`, `BY`, `KZ`.
- При ответе Yandex Geosuggest с `400` service flow возвращает deterministic bad-request path, а при `403`, `429`, `5xx` и transport failure — deterministic unavailable path.
## Definition of Done
- [ ] Добавлена конфигурация `yandex_geosuggest` и обновлён mapping стран для address suggestions.
- [ ] Реализован новый Yandex Geosuggest adapter без утечки HTTP-деталей в Service.
- [ ] Обновлён wiring address suggestion providers без изменения публичного endpoint контракта.
- [ ] Добавлены tests для config, adapter mapping/error handling и service routing по странам СНГ, включая `postal_code=None` для Yandex Geosuggest.
- [ ] Пройдены все команды из раздела Commands.
## Tests
- Обновить `tests/config/test_config_sections.py` для проверки секции `yandex_geosuggest` и явного country mapping: `RU`, `BY`, `KZ` -> `dadata`; `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM`, `UZ` -> `yandex_geosuggest`.
- Добавить `tests/adapters/address_suggestions/yandex_geosuggest/test_client.py` для success case, mapping `limit -> results`, извлечения `address`/`street`/`house`/`flat`, возврата `postal_code=None` и error scenarios `400`, `403`, `429`, `5xx`.
- Обновить `tests/services/test_address_suggestions.py` для проверки routing на `yandex_geosuggest` по странам `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM`, `UZ` и сохранения routing на `dadata` для `RU`, `BY`, `KZ`.
- При необходимости обновить `tests/controllers/v1/test_address_suggestions.py` только для совместимости существующего endpoint с новым provider routing без изменения публичного API.
## Commands
- `poetry run pytest tests/config/test_config_sections.py -q`
- `poetry run pytest tests/adapters/address_suggestions/yandex_geosuggest/test_client.py -q`
- `poetry run pytest tests/services/test_address_suggestions.py -q`
- `poetry run pytest tests/controllers/v1/test_address_suggestions.py -q`
- `python3 spec/gen_spec_index.py`
@@ -1,56 +0,0 @@
---
id: 024
title: Add TomTom address suggestion adapter and Europe routing
status: DONE
created: 2026-03-29
---
## Context
Текущий flow подсказок адреса поддерживает `dadata` для `RU`, `BY`, `KZ` и `yandex_geosuggest` для части стран СНГ. Следующий этап требует добавить третий provider `TomTom` для европейских стран, сохранив текущий публичный API и routing через YAML-конфиг.
## Goal
Добавить новый adapter `tomtom` для address suggestions на базе TomTom Search API Fuzzy Search, настроить routing европейских стран на provider id `tomtom` через конфиг и покрыть это config, adapter и service tests без изменения публичного API endpoint.
## Constraints
- Scope задачи ограничен flow подсказок адреса: YAML-конфиг, wiring зависимостей, service routing, новый adapter и тесты.
- Controller path и публичный контракт `POST /api/v1/delivery/suggest-address` не изменять.
- Service остаётся orchestration layer: выбирает provider по injected config mapping и вызывает ровно один adapter без provider-specific HTTP-логики.
- Внешний IO должен оставаться только внутри нового adapter `app/adapters/address_suggestions/tomtom/`.
- Новый adapter должен следовать официальному HTTP-контракту TomTom Search API Fuzzy Search: `GET https://api.tomtom.com/search/2/search/{query}.json`.
- TomTom adapter должен формировать `query` из `request.city` и `request.query`, передавать `countrySet=request.country_code`, `typeahead=true`, а `request.limit` при наличии маппить в query-параметр `limit`.
- Для исключения POI из address suggestion flow adapter должен отправлять address-oriented `idxSet=PAD,Addr,Str,EPP`.
- Routing европейских стран должен определяться только YAML-маппингом `country_code -> provider_id`; запрещено добавлять в Service hardcoded классификацию Европы.
- Unified mapping наружу должен возвращать только `AddressSuggestion` без утечки provider-specific payload.
- Для TomTom Search поля должны маппиться детерминированно: `address.freeformAddress -> address`, `address.streetName -> street`, `address.streetNumber -> house`, `address.postalCode -> postal_code`, `flat=None`.
- Ошибки TomTom `400` должны маппиться в deterministic provider request error; `403`, `429`, transport errors и `5xx` должны маппиться в adapter client error/unavailable path.
- Scope задачи не включает изменение order flow, price flow, новых endpoint'ов, расширение internal model `AddressSuggestion` и изменение контрактов существующих adapters `dadata` и `yandex_geosuggest`.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- В конфиге address suggestion providers добавлен provider id `tomtom` с параметрами, необходимыми для вызова TomTom Search API Fuzzy Search.
- Country mapping в конфиге маршрутизирует на `tomtom` европейские страны, явно перечисленные в YAML, при этом routing для `RU`, `BY`, `KZ` на `dadata` и для `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM`, `UZ` на `yandex_geosuggest` сохраняется без изменений.
- Реализован adapter `app/adapters/address_suggestions/tomtom/client.py`, который отправляет запрос в TomTom Search API Fuzzy Search по официальному контракту и возвращает `list[AddressSuggestion]`.
- Adapter объединяет `request.city` и `request.query` в search query, передаёт `countrySet=request.country_code`, `typeahead=true`, `idxSet=PAD,Addr,Str,EPP` и при наличии `request.limit` маппит его в `limit`.
- Поля `address`, `street`, `house` и `postal_code` детерминированно извлекаются из ответа TomTom (`freeformAddress`, `streetName`, `streetNumber`, `postalCode`), а `flat` возвращается как `None`.
- `AggregatorService.suggest_addresses()` по `country_code` выбирает `tomtom` для европейских стран, явно сопоставленных в YAML-конфиге, и сохраняет существующий routing на `dadata` и `yandex_geosuggest` для уже поддержанных стран.
- При ответе TomTom с `400` service flow возвращает deterministic bad-request path, а при `403`, `429`, `5xx` и transport failure — deterministic unavailable path.
## Definition of Done
- [ ] Добавлена конфигурация `tomtom` и обновлён country mapping для европейских стран.
- [ ] Реализован новый TomTom adapter без утечки HTTP-деталей в Service.
- [ ] Обновлён wiring address suggestion providers без изменения публичного endpoint контракта.
- [ ] Добавлены tests для config, adapter mapping/error handling и service routing на `tomtom` с сохранением существующего routing на `dadata` и `yandex_geosuggest`.
- [ ] Пройдены все команды из раздела Commands.
## Tests
- Обновить `tests/config/test_config_sections.py` для проверки секции `tomtom` и country mapping, в котором европейские страны маршрутизируются на `tomtom`, а существующие маппинги `RU`, `BY`, `KZ` -> `dadata` и `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM`, `UZ` -> `yandex_geosuggest` сохраняются.
- Добавить `tests/adapters/address_suggestions/tomtom/test_client.py` для success case, mapping `limit`, формирования search query из `city` и `query`, передачи `typeahead=true` и `idxSet=PAD,Addr,Str,EPP`, возврата `flat=None`, извлечения `address`/`street`/`house`/`postal_code` и error scenarios `400`, `403`, `429`, `5xx`.
- Обновить `tests/services/test_address_suggestions.py` для проверки routing на `tomtom` по европейским странам из YAML-конфига и сохранения routing на `dadata` и `yandex_geosuggest` для уже поддержанных стран.
- При необходимости обновить `tests/controllers/v1/test_address_suggestions.py` только для совместимости существующего endpoint с новым provider routing без изменения публичного API.
## Commands
- `poetry run pytest tests/config/test_config_sections.py -q`
- `poetry run pytest tests/adapters/address_suggestions/tomtom/test_client.py -q`
- `poetry run pytest tests/services/test_address_suggestions.py -q`
- `poetry run pytest tests/controllers/v1/test_address_suggestions.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -1,51 +0,0 @@
---
id: 025
title: Remove company from Create Delivery Order parties
status: DONE
created: 2026-04-03
---
## Context
Текущий контракт `POST /api/v1/delivery/order` допускает поле `company` в `sender`, потому что order party schema используется и для `sender`, и для `recipient`. Новый сценарий требует упростить Create Delivery Order payload: поля `sender.company` и `recipient.company` больше не должны приниматься и не должны отправляться в CDEK.
## Goal
Удалить `company` из request contract Create Delivery Order на уровне schema validation, controller/API contract и CDEK order registration payload без изменения endpoint path, service orchestration и response model.
## Constraints
- Изменения ограничены существующими модулями order flow: `app/schemas/order.py`, `app/controllers/v1/delivery.py`, `app/services/aggregator.py`, `app/adapters/delivery_providers/cdek/` и связанными тестами; не создавать новые controller/service модули.
- `POST /api/v1/delivery/order` должен оставаться в существующем controller и по-прежнему вызывать ровно один метод Service: `AggregatorService.create_order()`.
- Service не должен получать новую business logic; orchestration order flow должна остаться прежней.
- Поля `sender.company` и `recipient.company` должны отсутствовать в публичном request contract и не должны сериализоваться в payload, отправляемый в CDEK.
- Payload, содержащий `sender.company` или `recipient.company`, должен считаться невалидным и отклоняться schema validation с HTTP 422.
- `from_location.address` и `to_location.address` остаются opaque input strings; не добавлять address suggestion lookup, нормализацию адреса или иные изменения order flow.
- Scope задачи не включает изменение response contract, price flow, address suggestion flow, provider routing, кеширование, новые провайдеры и расширение поддерживаемых `type`/`tariff_code`.
- Обновить пример контракта Create Delivery Order в `http-client.http`, чтобы он не содержал `company`.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- `OrderCreateRequest` больше не содержит поле `company` ни для `sender`, ни для `recipient`.
- `POST /api/v1/delivery/order` успешно принимает прежний payload без `company` и не требует никаких новых полей.
- Если request payload содержит `sender.company` или `recipient.company`, endpoint возвращает 422 на уровне schema validation.
- `AggregatorService.create_order()` продолжает только оркестрировать вызов injected order adapter без новой business logic.
- CDEK adapter отправляет JSON payload для регистрации заказа без поля `company`.
- `http-client.http` содержит актуальный пример регистрации заказа без `company`.
- Service, controller/API и adapter tests покрывают success case после удаления поля и rejection scenario для `company`.
## Definition of Done
- [ ] Обновлён order request contract без поля `company` для `sender` и `recipient`.
- [ ] Schema validation отклоняет `sender.company` и `recipient.company`.
- [ ] CDEK order registration payload больше не содержит `company`.
- [ ] Обновлены order flow tests для service, controller/API и adapter layers.
- [ ] Обновлён пример Create Delivery Order в `http-client.http`.
## Tests
- Обновить `tests/services/test_order.py`, убрав `company` из валидных fixture payloads и сохранив проверку service orchestration.
- Обновить `tests/controllers/v1/test_order.py` для success case без `company` и добавить сценарий 422 при передаче `sender.company` и `recipient.company`.
- Обновить `tests/adapters/delivery_providers/cdek/test_order_client.py`, чтобы проверить отсутствие `company` в отправляемом CDEK payload.
- При необходимости обновить другие order-related tests, завязанные на старый request contract.
## Commands
- `poetry run pytest tests/services/test_order.py -q`
- `poetry run pytest tests/controllers/v1/test_order.py -q`
- `poetry run pytest tests/adapters/delivery_providers/cdek/test_order_client.py -q`
- `python3 spec/gen_spec_index.py --check`
View File
@@ -13,7 +13,7 @@ from app.adapters.delivery_providers.cdek.client import (
) )
from app.cities import cities_map from app.cities import cities_map
from app import config as config_module from app import config as config_module
from app.config import AdapterConfig, Settings from app.config import CDEKDeliveryProviderConfig, Settings
from app.schemas.request import DeliveryCalculationRequest, DeliveryEntity from app.schemas.request import DeliveryCalculationRequest, DeliveryEntity
@@ -287,29 +287,48 @@ repository:
redis_dsn: "redis://localhost:6379/0" redis_dsn: "redis://localhost:6379/0"
price_cache_ttl_seconds: 900 price_cache_ttl_seconds: 900
adapter: delivery_providers:
cdek_base_url: "https://api.cdek.test/v2" cdek:
cdek_client_id: "yaml-id" base_url: "https://api.cdek.test/v2"
cdek_client_secret: "yaml-secret" client_id: "yaml-id"
cdek_retry_attempts: 0 client_secret: "yaml-secret"
cdek_retry_backoff_seconds: 0.1 retry_attempts: 0
cdek_timeout_seconds: 7.5 retry_backoff_seconds: 0.1
cdek_cache_ttl_seconds: 777 timeout_seconds: 7.5
cache_ttl_seconds: 777
cse:
enabled: false
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",
) )
monkeypatch.setattr(config_module, "_resolve_runtime_config_file", lambda: str(config_file)) monkeypatch.setattr(config_module, "_resolve_runtime_config_file", lambda: str(config_file))
settings = Settings() settings = Settings()
http_client = RecordingHTTPClient() http_client = RecordingHTTPClient()
provider = CDEKProvider.from_adapter_config( provider = CDEKProvider.from_config(
http_client=http_client, # type: ignore[arg-type] http_client=http_client, # type: ignore[arg-type]
adapter_config=settings.adapter, config=settings.delivery_providers.cdek,
) )
result = asyncio.run(provider.get_prices(_make_request())) result = asyncio.run(provider.get_prices(_make_request()))
@@ -333,14 +352,14 @@ observability:
def test_provider_uses_default_adapter_timeout_and_cache_ttl() -> None: def test_provider_uses_default_adapter_timeout_and_cache_ttl() -> None:
adapter_config = AdapterConfig( config = CDEKDeliveryProviderConfig(
cdek_client_id="default-id", client_id="default-id",
cdek_client_secret="default-secret", client_secret="default-secret",
) )
http_client = RecordingHTTPClient() http_client = RecordingHTTPClient()
provider = CDEKProvider.from_adapter_config( provider = CDEKProvider.from_config(
http_client=http_client, # type: ignore[arg-type] http_client=http_client, # type: ignore[arg-type]
adapter_config=adapter_config, config=config,
) )
result = asyncio.run(provider.get_prices(_make_request())) result = asyncio.run(provider.get_prices(_make_request()))

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