Compare commits
34 Commits
18f7e251e3
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| c2728ae268 | |||
| c942885abe | |||
| 7287a1398e | |||
| a2d9a243f7 | |||
| 4262b8a200 | |||
| e4d9b581a6 | |||
| facdde00c9 | |||
| 2d8f31d3d9 | |||
| 6906db7739 | |||
| 843175f12e | |||
| c6c37640fd | |||
| 6a3da82ddc | |||
| 798b6e38bd | |||
| 02f5ef93b0 | |||
| aeee641c6c | |||
| cbcd9ca1bc | |||
| 65c07f1da3 | |||
| f82a7c7606 | |||
| b58fd11966 | |||
| ed33357af2 | |||
| 5f85006e1d | |||
| c2757d24fc | |||
| 6c0f97adf6 | |||
| 6a2bf05ba5 | |||
| 6caea9e111 | |||
| 50124fb2c9 | |||
| 5526f90cb3 | |||
| c494d50566 | |||
| 39cd5ddc7a | |||
| 6b66af5eb3 | |||
| 78e9ad4fa9 | |||
| bddac60965 | |||
| 2b201a08be | |||
| ab0b66e1c2 |
@@ -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
|
||||
"
|
||||
@@ -1,7 +1,11 @@
|
||||
.idea
|
||||
*.iml
|
||||
/config.yaml
|
||||
/docker-compose.yml
|
||||
/otel-collector-config.yaml
|
||||
__pycache__
|
||||
http-client.private.env.json
|
||||
scripts
|
||||
.codex
|
||||
cse-api.pdf
|
||||
cse_geography*.tsv
|
||||
|
||||
+12
-8
@@ -1,16 +1,20 @@
|
||||
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
|
||||
|
||||
RUN pip install --no-cache-dir poetry
|
||||
|
||||
COPY pyproject.toml poetry.lock ./
|
||||
RUN poetry config virtualenvs.create false \
|
||||
&& poetry install --no-interaction --no-root
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --frozen --no-install-project --no-dev
|
||||
|
||||
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"]
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -0,0 +1,41 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
path_separator = os
|
||||
sqlalchemy.url =
|
||||
|
||||
[post_write_hooks]
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARNING
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARNING
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Alembic async migration environment."""
|
||||
|
||||
from asyncio import run
|
||||
from logging.config import fileConfig
|
||||
|
||||
from alembic import context
|
||||
from sqlalchemy import pool
|
||||
from sqlalchemy.engine import Connection
|
||||
from sqlalchemy.ext.asyncio import async_engine_from_config
|
||||
|
||||
from app.config import get_settings
|
||||
from app.repositories.order.models import Base
|
||||
|
||||
config = context.config
|
||||
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def _database_url() -> str:
|
||||
configured_url = config.get_main_option("sqlalchemy.url")
|
||||
if configured_url:
|
||||
return configured_url
|
||||
return get_settings().postgres.dsn
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=_database_url(),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def do_run_migrations(connection: Connection) -> None:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
async def run_async_migrations() -> None:
|
||||
configuration = config.get_section(config.config_ini_section, {})
|
||||
configuration["sqlalchemy.url"] = _database_url()
|
||||
connectable = async_engine_from_config(
|
||||
configuration,
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
|
||||
async with connectable.connect() as connection:
|
||||
await connection.run_sync(do_run_migrations)
|
||||
|
||||
await connectable.dispose()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run(run_async_migrations())
|
||||
@@ -0,0 +1,20 @@
|
||||
"""${message}"""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: str | None = ${repr(down_revision)}
|
||||
branch_labels: str | Sequence[str] | None = ${repr(branch_labels)}
|
||||
depends_on: str | Sequence[str] | None = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Create orders table."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
revision: str = "20260412_028"
|
||||
down_revision: str | None = None
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.create_table(
|
||||
"orders",
|
||||
sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False),
|
||||
sa.Column("order_uuid", sa.String(length=128), nullable=False),
|
||||
sa.Column("payment_url", sa.String(length=2048), nullable=False),
|
||||
sa.Column("price", sa.Integer(), nullable=False),
|
||||
sa.Column("delivery_type", sa.Integer(), nullable=False),
|
||||
sa.Column("tariff_code", sa.Integer(), nullable=False),
|
||||
sa.Column("sender", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column("recipient", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column(
|
||||
"from_location",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"to_location",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("packages", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
|
||||
sa.Column("services", postgresql.JSONB(astext_type=sa.Text()), nullable=True),
|
||||
sa.Column("comment", sa.String(length=1024), nullable=True),
|
||||
sa.Column("payment_status", sa.String(length=64), nullable=True),
|
||||
sa.Column("tbank_payment_id", sa.BigInteger(), nullable=True),
|
||||
sa.Column("cdek_order_uuid", sa.String(length=128), nullable=True),
|
||||
sa.Column(
|
||||
"created_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column(
|
||||
"updated_at",
|
||||
sa.DateTime(timezone=True),
|
||||
server_default=sa.text("now()"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
sa.UniqueConstraint("order_uuid", name="uq_orders_order_uuid"),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("orders")
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Rework orders table to a single payload JSONB column."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
revision: str = "20260513_032"
|
||||
down_revision: str | None = "20260412_028"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_column("orders", "sender")
|
||||
op.drop_column("orders", "recipient")
|
||||
op.drop_column("orders", "from_location")
|
||||
op.drop_column("orders", "to_location")
|
||||
op.drop_column("orders", "packages")
|
||||
op.drop_column("orders", "services")
|
||||
op.drop_column("orders", "comment")
|
||||
op.drop_column("orders", "delivery_type")
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column(
|
||||
"payload",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column("account_email", sa.String(length=320), nullable=False),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("orders", "account_email")
|
||||
op.drop_column("orders", "payload")
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column("delivery_type", sa.Integer(), nullable=False),
|
||||
)
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column("comment", sa.String(length=1024), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column(
|
||||
"services",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column(
|
||||
"packages",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column(
|
||||
"to_location",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column(
|
||||
"from_location",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column(
|
||||
"recipient",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column(
|
||||
"sender",
|
||||
postgresql.JSONB(astext_type=sa.Text()),
|
||||
nullable=False,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Add CDEK waybill columns to orders table."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260523_033"
|
||||
down_revision: str | None = "20260513_032"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column("cdek_waybill_uuid", sa.String(length=128), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column("cdek_waybill_url", sa.String(length=2048), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("orders", "cdek_waybill_url")
|
||||
op.drop_column("orders", "cdek_waybill_uuid")
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Add CDEK polling columns to orders table."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260524_034"
|
||||
down_revision: str | None = "20260523_033"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column("cdek_order_status", sa.String(length=64), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column(
|
||||
"cdek_polled_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("orders", "cdek_polled_at")
|
||||
op.drop_column("orders", "cdek_order_status")
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Add waybill_email_sent_at column to orders table."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260524_035"
|
||||
down_revision: str | None = "20260524_034"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column(
|
||||
"waybill_email_sent_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("orders", "waybill_email_sent_at")
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Add payment_email_sent_at column to orders table."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260527_036"
|
||||
down_revision: str | None = "20260524_035"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column(
|
||||
"payment_email_sent_at",
|
||||
sa.DateTime(timezone=True),
|
||||
nullable=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("orders", "payment_email_sent_at")
|
||||
@@ -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")
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Base interface for delivery providers."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from app.schemas.payment import InitPaymentRequest
|
||||
from app.schemas.request import DeliveryCalculationRequest
|
||||
from app.schemas.response import DeliveryPrice
|
||||
|
||||
@@ -18,5 +20,30 @@ class DeliveryProvider(ABC):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PaymentPriceValidationProvider(Protocol):
|
||||
name: str
|
||||
|
||||
async def get_payment_price(
|
||||
self,
|
||||
request: InitPaymentRequest,
|
||||
) -> DeliveryPrice | None: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class OrderRegistrationProvider(Protocol):
|
||||
name: str
|
||||
|
||||
async def register_order(
|
||||
self,
|
||||
request: InitPaymentRequest,
|
||||
order_uuid: str,
|
||||
) -> object: ...
|
||||
|
||||
|
||||
class ProviderClientError(RuntimeError):
|
||||
"""Raised when a provider call fails for temporary or provider-side reasons."""
|
||||
|
||||
|
||||
class ProviderRequestError(RuntimeError):
|
||||
"""Raised when a provider rejects input request data."""
|
||||
|
||||
@@ -3,27 +3,44 @@
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
from typing import Any
|
||||
import logging
|
||||
|
||||
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.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 (
|
||||
CDEKOrderInfo,
|
||||
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_response,
|
||||
map_cdek_waybill_info_response,
|
||||
resolve_cdek_city_code,
|
||||
)
|
||||
from app.cities import cities_map
|
||||
from app.config import AdapterConfig
|
||||
from app.schemas.order import OrderCreateRequest, OrderCreateResponse
|
||||
from app.config import CDEKDeliveryProviderConfig
|
||||
from app.schemas.payment import InitPaymentRequest
|
||||
from app.schemas.request import DeliveryCalculationRequest
|
||||
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."""
|
||||
|
||||
|
||||
@@ -48,6 +65,7 @@ class CDEKClient:
|
||||
normalized_base_url = base_url.rstrip("/")
|
||||
self._tariff_url = f"{normalized_base_url}/calculator/tarifflist"
|
||||
self._orders_url = f"{normalized_base_url}/orders"
|
||||
self._print_orders_url = f"{normalized_base_url}/print/orders"
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._retry_attempts = retry_attempts
|
||||
self._retry_backoff_seconds = retry_backoff_seconds
|
||||
@@ -57,6 +75,29 @@ class CDEKClient:
|
||||
self, request: DeliveryCalculationRequest
|
||||
) -> dict[str, Any]:
|
||||
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):
|
||||
try:
|
||||
token = await self._auth_client.get_access_token()
|
||||
@@ -78,10 +119,27 @@ class CDEKClient:
|
||||
if attempt < self._retry_attempts:
|
||||
await self._sleep(self._retry_delay(attempt))
|
||||
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(
|
||||
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:
|
||||
response.raise_for_status()
|
||||
raw_payload = response.json()
|
||||
@@ -94,8 +152,10 @@ class CDEKClient:
|
||||
|
||||
raise CDEKClientError("CDEK tariff request failed unexpectedly.")
|
||||
|
||||
async def register_order(self, request: OrderCreateRequest) -> OrderCreateResponse:
|
||||
payload = map_cdek_order_request(request)
|
||||
async def register_order(
|
||||
self, request: InitPaymentRequest, order_uuid: str
|
||||
) -> CDEKOrderRegistrationResult:
|
||||
payload = map_cdek_order_request(request, order_uuid)
|
||||
for attempt in range(self._retry_attempts + 1):
|
||||
try:
|
||||
token = await self._auth_client.get_access_token()
|
||||
@@ -123,6 +183,17 @@ class CDEKClient:
|
||||
)
|
||||
|
||||
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(
|
||||
"CDEK order registration request was rejected with status "
|
||||
f"{response.status_code}."
|
||||
@@ -141,6 +212,12 @@ class CDEKClient:
|
||||
"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:
|
||||
return map_cdek_order_response(raw_payload)
|
||||
except CDEKOrderMappingError as exc:
|
||||
@@ -150,6 +227,141 @@ class CDEKClient:
|
||||
|
||||
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:
|
||||
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
|
||||
def _get_cdek_city_code(city_id: int) -> int:
|
||||
city_entry = cities_map.get(str(city_id))
|
||||
@@ -212,28 +452,28 @@ class CDEKProvider(DeliveryProvider):
|
||||
self.cache_ttl_seconds = cache_ttl_seconds
|
||||
|
||||
@classmethod
|
||||
def from_adapter_config(
|
||||
def from_config(
|
||||
cls,
|
||||
*,
|
||||
http_client: httpx.AsyncClient,
|
||||
adapter_config: AdapterConfig,
|
||||
config: CDEKDeliveryProviderConfig,
|
||||
) -> "CDEKProvider":
|
||||
auth_client = CDEKAuthClient(
|
||||
http_client=http_client,
|
||||
base_url=adapter_config.cdek_base_url,
|
||||
client_id=adapter_config.cdek_client_id,
|
||||
client_secret=adapter_config.cdek_client_secret,
|
||||
timeout_seconds=adapter_config.cdek_timeout_seconds,
|
||||
base_url=config.base_url,
|
||||
client_id=config.client_id,
|
||||
client_secret=config.client_secret,
|
||||
timeout_seconds=config.timeout_seconds,
|
||||
)
|
||||
client = CDEKClient(
|
||||
http_client=http_client,
|
||||
auth_client=auth_client,
|
||||
base_url=adapter_config.cdek_base_url,
|
||||
timeout_seconds=adapter_config.cdek_timeout_seconds,
|
||||
retry_attempts=adapter_config.cdek_retry_attempts,
|
||||
retry_backoff_seconds=adapter_config.cdek_retry_backoff_seconds,
|
||||
base_url=config.base_url,
|
||||
timeout_seconds=config.timeout_seconds,
|
||||
retry_attempts=config.retry_attempts,
|
||||
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(
|
||||
self, request: DeliveryCalculationRequest
|
||||
@@ -241,5 +481,67 @@ class CDEKProvider(DeliveryProvider):
|
||||
raw_payload = await self._client.get_raw_price(request)
|
||||
return map_cdek_response(raw_payload)
|
||||
|
||||
async def register_order(self, request: OrderCreateRequest) -> OrderCreateResponse:
|
||||
return await self._client.register_order(request)
|
||||
async def get_payment_price(
|
||||
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(
|
||||
tariff: dict[str, Any],
|
||||
*,
|
||||
@@ -47,6 +65,8 @@ def _map_tariff(
|
||||
currency=str(raw_currency).upper(),
|
||||
delivery_days_min=int(period_min),
|
||||
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:
|
||||
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.")
|
||||
tariffs.append(tariff)
|
||||
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."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
|
||||
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):
|
||||
"""Raised when CDEK order payload cannot be mapped."""
|
||||
|
||||
|
||||
def map_cdek_order_request(request: OrderCreateRequest) -> dict[str, Any]:
|
||||
return request.model_dump(mode="python", exclude_none=True)
|
||||
_CDEK_WAYBILL_PRINT_TYPE = "WAYBILL"
|
||||
_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")
|
||||
if not isinstance(entity, dict):
|
||||
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:
|
||||
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
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""E-mail adapters."""
|
||||
|
||||
from app.adapters.email.smtp_client import SMTPEmailSender, SMTPEmailSenderError
|
||||
|
||||
__all__ = ["SMTPEmailSender", "SMTPEmailSenderError"]
|
||||
@@ -0,0 +1,136 @@
|
||||
"""SMTP e-mail adapter built on top of aiosmtplib."""
|
||||
|
||||
from email.message import EmailMessage
|
||||
from typing import Protocol
|
||||
|
||||
import aiosmtplib
|
||||
import structlog
|
||||
|
||||
log = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class SMTPEmailSenderError(Exception):
|
||||
"""Raised when the SMTP delivery fails."""
|
||||
|
||||
|
||||
class SMTPSendFn(Protocol):
|
||||
async def __call__(
|
||||
self,
|
||||
message: EmailMessage,
|
||||
*,
|
||||
hostname: str,
|
||||
port: int,
|
||||
username: str | None,
|
||||
password: str | None,
|
||||
use_tls: bool,
|
||||
start_tls: bool,
|
||||
timeout: float,
|
||||
) -> object: ...
|
||||
|
||||
|
||||
class SMTPEmailSender:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
smtp_host: str,
|
||||
smtp_port: int,
|
||||
username: str,
|
||||
password: str,
|
||||
from_address: str,
|
||||
use_tls: bool = True,
|
||||
timeout_seconds: float = 10.0,
|
||||
send: SMTPSendFn | None = None,
|
||||
) -> None:
|
||||
self._smtp_host = smtp_host
|
||||
self._smtp_port = smtp_port
|
||||
self._username = username
|
||||
self._password = password
|
||||
self._from_address = from_address
|
||||
self._use_tls = use_tls
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._send = send or _default_send
|
||||
|
||||
async def send_email(
|
||||
self,
|
||||
*,
|
||||
to: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
attachment_bytes: bytes | None = None,
|
||||
attachment_filename: str | None = None,
|
||||
) -> None:
|
||||
message = self._build_message(
|
||||
to=to,
|
||||
subject=subject,
|
||||
body=body,
|
||||
attachment_bytes=attachment_bytes,
|
||||
attachment_filename=attachment_filename,
|
||||
)
|
||||
try:
|
||||
await self._send(
|
||||
message,
|
||||
hostname=self._smtp_host,
|
||||
port=self._smtp_port,
|
||||
username=self._username or None,
|
||||
password=self._password or None,
|
||||
use_tls=self._use_tls and self._smtp_port == 465,
|
||||
start_tls=self._use_tls and self._smtp_port != 465,
|
||||
timeout=self._timeout_seconds,
|
||||
)
|
||||
except Exception as exc:
|
||||
log.warning(
|
||||
"smtp_send_failed",
|
||||
smtp_host=self._smtp_host,
|
||||
smtp_port=self._smtp_port,
|
||||
to=to,
|
||||
error=str(exc),
|
||||
)
|
||||
raise SMTPEmailSenderError(
|
||||
f"SMTP send to {to} failed: {exc}"
|
||||
) from exc
|
||||
|
||||
def _build_message(
|
||||
self,
|
||||
*,
|
||||
to: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
attachment_bytes: bytes | None = None,
|
||||
attachment_filename: str | None = None,
|
||||
) -> EmailMessage:
|
||||
message = EmailMessage()
|
||||
message["From"] = self._from_address
|
||||
message["To"] = to
|
||||
message["Subject"] = subject
|
||||
message.set_content(body)
|
||||
if attachment_bytes is not None and attachment_filename is not None:
|
||||
message.add_attachment(
|
||||
attachment_bytes,
|
||||
maintype="application",
|
||||
subtype="pdf",
|
||||
filename=attachment_filename,
|
||||
)
|
||||
return message
|
||||
|
||||
|
||||
async def _default_send(
|
||||
message: EmailMessage,
|
||||
*,
|
||||
hostname: str,
|
||||
port: int,
|
||||
username: str | None,
|
||||
password: str | None,
|
||||
use_tls: bool,
|
||||
start_tls: bool,
|
||||
timeout: float,
|
||||
) -> object:
|
||||
return await aiosmtplib.send(
|
||||
message,
|
||||
hostname=hostname,
|
||||
port=port,
|
||||
username=username,
|
||||
password=password,
|
||||
use_tls=use_tls,
|
||||
start_tls=start_tls,
|
||||
timeout=timeout,
|
||||
)
|
||||
@@ -0,0 +1,16 @@
|
||||
"""PostgreSQL async engine and session factory management."""
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
||||
from sqlalchemy.ext.asyncio import create_async_engine
|
||||
|
||||
from app.config import PostgresConfig
|
||||
|
||||
|
||||
def create_postgres_engine(config: PostgresConfig) -> AsyncEngine:
|
||||
return create_async_engine(config.dsn, pool_pre_ping=True)
|
||||
|
||||
|
||||
def create_postgres_session_factory(
|
||||
engine: AsyncEngine,
|
||||
) -> async_sessionmaker[AsyncSession]:
|
||||
return async_sessionmaker(engine, expire_on_commit=False)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""TBank payment adapter package."""
|
||||
|
||||
from app.adapters.tbank.client import TBankAdapter
|
||||
|
||||
__all__ = ["TBankAdapter"]
|
||||
@@ -0,0 +1,28 @@
|
||||
"""TBank payment adapter errors."""
|
||||
|
||||
|
||||
class TBankPaymentAdapterError(RuntimeError):
|
||||
"""Raised when a TBank payment adapter call fails."""
|
||||
|
||||
|
||||
class TBankPaymentRequestError(TBankPaymentAdapterError):
|
||||
"""Raised when TBank rejects payment request data."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
status_code: int | None = None,
|
||||
error_code: str | None = None,
|
||||
provider_message: str | None = None,
|
||||
details: str | None = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.error_code = error_code
|
||||
self.provider_message = provider_message
|
||||
self.details = details
|
||||
|
||||
|
||||
class TBankPaymentNotificationTokenError(TBankPaymentRequestError):
|
||||
"""Raised when a TBank payment notification token is invalid."""
|
||||
@@ -0,0 +1,257 @@
|
||||
"""TBank payment adapter."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Awaitable, Callable
|
||||
import hashlib
|
||||
import hmac
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
from app.adapters.tbank.base import (
|
||||
TBankPaymentAdapterError,
|
||||
TBankPaymentNotificationTokenError,
|
||||
TBankPaymentRequestError,
|
||||
)
|
||||
from app.config import TBankPaymentConfig
|
||||
from app.schemas.payment import TBankPaymentNotification
|
||||
|
||||
|
||||
class TBankAdapter:
|
||||
def __init__(
|
||||
self,
|
||||
http_client: httpx.AsyncClient,
|
||||
*,
|
||||
init_url: str,
|
||||
notification_url: str,
|
||||
success_url: str,
|
||||
terminal_key: str,
|
||||
password: str,
|
||||
timeout_seconds: float = 10.0,
|
||||
retry_attempts: int = 2,
|
||||
retry_backoff_seconds: float = 0.2,
|
||||
sleep: Callable[[float], Awaitable[None]] = asyncio.sleep,
|
||||
) -> None:
|
||||
self._http_client = http_client
|
||||
self._init_url = init_url
|
||||
self._notification_url = notification_url
|
||||
self._success_url = success_url
|
||||
self._terminal_key = terminal_key
|
||||
self._password = password
|
||||
self._timeout_seconds = timeout_seconds
|
||||
self._retry_attempts = retry_attempts
|
||||
self._retry_backoff_seconds = retry_backoff_seconds
|
||||
self._sleep = sleep
|
||||
|
||||
@classmethod
|
||||
def from_config(
|
||||
cls,
|
||||
*,
|
||||
http_client: httpx.AsyncClient,
|
||||
config: TBankPaymentConfig,
|
||||
) -> "TBankAdapter":
|
||||
return cls(
|
||||
http_client=http_client,
|
||||
init_url=config.init_url,
|
||||
notification_url=config.notification_url,
|
||||
success_url=config.success_url,
|
||||
terminal_key=config.auth.terminal_key,
|
||||
password=config.auth.password,
|
||||
timeout_seconds=config.timeout_seconds,
|
||||
retry_attempts=config.retry_attempts,
|
||||
retry_backoff_seconds=config.retry_backoff_seconds,
|
||||
)
|
||||
|
||||
async def create_payment_link(self, order_uuid: str, amount_kopecks: int) -> str:
|
||||
payload = self._build_payload(
|
||||
order_uuid=order_uuid,
|
||||
amount_kopecks=amount_kopecks,
|
||||
)
|
||||
|
||||
for attempt in range(self._retry_attempts + 1):
|
||||
try:
|
||||
response = await self._http_client.post(
|
||||
self._init_url,
|
||||
json=payload,
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
timeout=self._timeout_seconds,
|
||||
)
|
||||
except (httpx.TimeoutException, httpx.TransportError) as exc:
|
||||
if attempt < self._retry_attempts:
|
||||
await self._sleep(self._retry_delay(attempt))
|
||||
continue
|
||||
raise TBankPaymentAdapterError(
|
||||
"TBank payment init request failed after retry attempts."
|
||||
) from exc
|
||||
|
||||
if self._should_retry(response.status_code):
|
||||
if attempt < self._retry_attempts:
|
||||
await self._sleep(self._retry_delay(attempt))
|
||||
continue
|
||||
raise TBankPaymentAdapterError(
|
||||
"TBank payment init request failed with retriable status "
|
||||
f"{response.status_code}."
|
||||
)
|
||||
|
||||
if 400 <= response.status_code < 500:
|
||||
raise _build_tbank_request_error(
|
||||
"TBank payment init request was rejected.",
|
||||
status_code=response.status_code,
|
||||
payload=_response_json_or_none(response),
|
||||
)
|
||||
|
||||
try:
|
||||
response.raise_for_status()
|
||||
raw_payload = response.json()
|
||||
except (httpx.HTTPError, TypeError, ValueError) as exc:
|
||||
raise TBankPaymentAdapterError(
|
||||
"TBank payment init returned invalid payload."
|
||||
) from exc
|
||||
|
||||
return self._map_payment_url(raw_payload)
|
||||
|
||||
raise TBankPaymentAdapterError("TBank payment init request failed unexpectedly.")
|
||||
|
||||
def verify_payment_notification(
|
||||
self,
|
||||
notification: TBankPaymentNotification,
|
||||
) -> None:
|
||||
payload = notification.model_dump(mode="python")
|
||||
expected_token = _build_tbank_token(payload, password=self._password)
|
||||
if not hmac.compare_digest(expected_token, notification.Token):
|
||||
raise TBankPaymentNotificationTokenError(
|
||||
"TBank payment notification token is invalid."
|
||||
)
|
||||
|
||||
def _build_payload(self, *, order_uuid: str, amount_kopecks: int) -> dict[str, Any]:
|
||||
if not order_uuid.strip():
|
||||
raise TBankPaymentRequestError("TBank payment order id must be non-empty.")
|
||||
if amount_kopecks <= 0:
|
||||
raise TBankPaymentRequestError("TBank payment amount must be positive.")
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"TerminalKey": self._terminal_key,
|
||||
"Amount": amount_kopecks,
|
||||
"OrderId": order_uuid,
|
||||
"NotificationURL": self._notification_url,
|
||||
"SuccessURL": f"{self._success_url}/{order_uuid}",
|
||||
}
|
||||
payload["Token"] = _build_tbank_token(payload, password=self._password)
|
||||
return payload
|
||||
|
||||
def _retry_delay(self, attempt: int) -> float:
|
||||
return self._retry_backoff_seconds * (attempt + 1)
|
||||
|
||||
@staticmethod
|
||||
def _should_retry(status_code: int) -> bool:
|
||||
return status_code == 429 or status_code >= 500
|
||||
|
||||
@staticmethod
|
||||
def _map_payment_url(payload: object) -> str:
|
||||
if not isinstance(payload, dict):
|
||||
raise TBankPaymentAdapterError(
|
||||
"TBank payment init payload must be a JSON object."
|
||||
)
|
||||
|
||||
if payload.get("Success") is False:
|
||||
raise _build_tbank_request_error(
|
||||
"TBank payment init request was rejected.",
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
payment_url = payload.get("PaymentURL")
|
||||
if not isinstance(payment_url, str) or not payment_url.strip():
|
||||
raise TBankPaymentAdapterError(
|
||||
"TBank payment init response must include PaymentURL."
|
||||
)
|
||||
return payment_url.strip()
|
||||
|
||||
|
||||
def _build_tbank_token(payload: dict[str, Any], *, password: str) -> str:
|
||||
token_payload = {
|
||||
key: value
|
||||
for key, value in payload.items()
|
||||
if key != "Token" and not isinstance(value, (dict, list))
|
||||
}
|
||||
token_payload["Password"] = password
|
||||
token_source = "".join(
|
||||
_stringify_token_value(token_payload[key]) for key in sorted(token_payload)
|
||||
)
|
||||
return hashlib.sha256(token_source.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def _stringify_token_value(value: Any) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "true" if value else "false"
|
||||
return str(value)
|
||||
|
||||
|
||||
def _response_json_or_none(response: httpx.Response) -> object | None:
|
||||
try:
|
||||
return response.json()
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _build_tbank_request_error(
|
||||
message: str,
|
||||
*,
|
||||
status_code: int | None = None,
|
||||
payload: object | None = None,
|
||||
) -> TBankPaymentRequestError:
|
||||
error_code = _payload_text_value(payload, "ErrorCode")
|
||||
provider_message = _payload_text_value(payload, "Message")
|
||||
details = _payload_text_value(payload, "Details")
|
||||
return TBankPaymentRequestError(
|
||||
_format_tbank_request_error_message(
|
||||
message,
|
||||
status_code=status_code,
|
||||
error_code=error_code,
|
||||
provider_message=provider_message,
|
||||
details=details,
|
||||
),
|
||||
status_code=status_code,
|
||||
error_code=error_code,
|
||||
provider_message=provider_message,
|
||||
details=details,
|
||||
)
|
||||
|
||||
|
||||
def _payload_text_value(payload: object | None, key: str) -> str | None:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
|
||||
value = payload.get(key)
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return None
|
||||
return text
|
||||
|
||||
|
||||
def _format_tbank_request_error_message(
|
||||
message: str,
|
||||
*,
|
||||
status_code: int | None,
|
||||
error_code: str | None,
|
||||
provider_message: str | None,
|
||||
details: str | None,
|
||||
) -> str:
|
||||
fields: list[str] = []
|
||||
if status_code is not None:
|
||||
fields.append(f"status_code={status_code}")
|
||||
if error_code is not None:
|
||||
fields.append(f"error_code={error_code}")
|
||||
if provider_message is not None:
|
||||
fields.append(f"message={provider_message}")
|
||||
if details is not None:
|
||||
fields.append(f"details={details}")
|
||||
|
||||
if not fields:
|
||||
return message
|
||||
return f"{message} {' '.join(fields)}"
|
||||
+14399
-4706
File diff suppressed because it is too large
Load Diff
+87
-10
@@ -38,14 +38,59 @@ class RepositoryConfig(BaseModel):
|
||||
price_cache_ttl_seconds: int = 900
|
||||
|
||||
|
||||
class AdapterConfig(BaseModel):
|
||||
cdek_base_url: str = "https://api.cdek.ru/v2"
|
||||
cdek_client_id: str = ""
|
||||
cdek_client_secret: str = ""
|
||||
cdek_retry_attempts: int = Field(default=2, ge=0)
|
||||
cdek_retry_backoff_seconds: float = Field(default=0.2, ge=0)
|
||||
cdek_timeout_seconds: float = Field(default=10.0, gt=0)
|
||||
cdek_cache_ttl_seconds: int = Field(default=900, gt=0)
|
||||
class DeliveryProviderBaseConfig(BaseModel):
|
||||
enabled: bool = True
|
||||
timeout_seconds: float = Field(default=10.0, gt=0)
|
||||
retry_attempts: int = Field(default=2, ge=0)
|
||||
retry_backoff_seconds: float = Field(default=0.2, ge=0)
|
||||
cache_ttl_seconds: int = Field(default=900, gt=0)
|
||||
|
||||
|
||||
class CDEKDeliveryProviderConfig(DeliveryProviderBaseConfig):
|
||||
base_url: str = "https://api.cdek.ru/v2"
|
||||
client_id: str = ""
|
||||
client_secret: str = ""
|
||||
|
||||
|
||||
class CSEDeliveryProviderConfig(DeliveryProviderBaseConfig):
|
||||
base_url: str = "https://web.cse.ru/1c/ws/Web1C.1cws"
|
||||
login: str = ""
|
||||
password: str = ""
|
||||
# CSE service GUIDs from GetReferenceData: Services that are allowed to be
|
||||
# shown as delivery tariffs. Additional/non-delivery services must not be
|
||||
# included here.
|
||||
delivery_service_guids: list[str] = Field(default_factory=list)
|
||||
# Contract-specific required parameters for SaveDocuments (order registration).
|
||||
# Urgency is not here: it comes from the tariff selected by the client.
|
||||
payer: str = ""
|
||||
payment_method: str = ""
|
||||
shipping_method: str = ""
|
||||
|
||||
|
||||
class DeliveryProvidersConfig(BaseModel):
|
||||
cdek: CDEKDeliveryProviderConfig = Field(
|
||||
default_factory=CDEKDeliveryProviderConfig
|
||||
)
|
||||
cse: CSEDeliveryProviderConfig = Field(default_factory=CSEDeliveryProviderConfig)
|
||||
|
||||
|
||||
class 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):
|
||||
@@ -97,6 +142,26 @@ class ObservabilityConfig(BaseModel):
|
||||
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):
|
||||
model_config = SettingsConfigDict(
|
||||
extra="ignore",
|
||||
@@ -106,11 +171,20 @@ class Settings(BaseSettings):
|
||||
service: ServiceConfig = Field(default_factory=ServiceConfig)
|
||||
business_logic: BusinessLogicConfig = Field(default_factory=BusinessLogicConfig)
|
||||
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(
|
||||
default_factory=AddressSuggestionsConfig
|
||||
)
|
||||
observability: ObservabilityConfig
|
||||
waybill_poller: WaybillPollerConfig = Field(default_factory=WaybillPollerConfig)
|
||||
email: EmailAdapterConfig
|
||||
waybill_email_sender: WaybillEmailSenderConfig = Field(
|
||||
default_factory=WaybillEmailSenderConfig
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def settings_customise_sources(
|
||||
@@ -137,9 +211,12 @@ class _RequiredYamlSections(BaseModel):
|
||||
service: dict[str, Any]
|
||||
business_logic: 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]
|
||||
observability: dict[str, Any]
|
||||
email: dict[str, Any]
|
||||
|
||||
|
||||
def _resolve_runtime_config_file() -> str:
|
||||
|
||||
+144
-22
@@ -1,18 +1,32 @@
|
||||
"""Delivery API controller skeleton."""
|
||||
|
||||
import structlog
|
||||
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.tomtom import TomTomAddressSuggestionProvider
|
||||
from app.adapters.address_suggestions.yandex_geosuggest import (
|
||||
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.controllers.http_client import build_controller_http_client
|
||||
from app.repositories.cache.redis_cache import PriceCache
|
||||
from app.schemas.order import OrderCreateRequest, OrderCreateResponse
|
||||
from app.schemas.request import AddressSuggestRequest, DeliveryCalculationRequest
|
||||
from app.repositories.order import OrderRepository
|
||||
from app.runtime.metrics import get_cache_metrics
|
||||
from app.schemas.payment import (
|
||||
InitPaymentRequest,
|
||||
InitPaymentResponse,
|
||||
TBankPaymentNotification,
|
||||
)
|
||||
from app.schemas.request import DeliveryCalculationRequest, SuggestAddressRequest
|
||||
from app.schemas.response import AddressSuggestion, DeliveryPrice
|
||||
from app.services.aggregator import (
|
||||
AddressSuggestionsUnavailableError,
|
||||
@@ -20,10 +34,15 @@ from app.services.aggregator import (
|
||||
AggregatorServiceError,
|
||||
InvalidAddressSuggestRequestError,
|
||||
InvalidDeliveryRequestError,
|
||||
InvalidOrderCreateRequestError,
|
||||
InvalidInitPaymentRequestError,
|
||||
InvalidTBankPaymentNotificationError,
|
||||
InitPaymentUnavailableError,
|
||||
TBankPaymentNotificationProcessingError,
|
||||
UnsupportedAddressSuggestionCountryError,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/delivery", tags=["delivery"])
|
||||
|
||||
|
||||
@@ -32,11 +51,17 @@ _AGGREGATOR_SERVICE_STATE_KEY = "aggregator_service"
|
||||
|
||||
def _build_aggregator_service(settings: Settings) -> AggregatorService:
|
||||
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,
|
||||
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(
|
||||
http_client=http_client,
|
||||
@@ -50,12 +75,34 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
|
||||
http_client=http_client,
|
||||
config=settings.address_suggestions.tomtom,
|
||||
)
|
||||
providers = (cdek_provider,)
|
||||
cache = PriceCache.from_repository_config(settings.repository)
|
||||
cache = PriceCache.from_repository_config(
|
||||
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(
|
||||
providers=providers,
|
||||
providers=delivery_provider_registry.providers,
|
||||
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=(
|
||||
dadata_provider,
|
||||
yandex_geosuggest_provider,
|
||||
@@ -114,9 +161,15 @@ async def get_delivery_price(
|
||||
response_model=list[AddressSuggestion],
|
||||
)
|
||||
async def suggest_addresses(
|
||||
address_request: AddressSuggestRequest,
|
||||
address_request: SuggestAddressRequest,
|
||||
service: AggregatorService = Depends(get_aggregator_service),
|
||||
) -> list[AddressSuggestion]:
|
||||
logger.info(
|
||||
"suggest_address_requested",
|
||||
city=address_request.city,
|
||||
query=address_request.query,
|
||||
limit=address_request.limit,
|
||||
)
|
||||
try:
|
||||
return await service.suggest_addresses(address_request)
|
||||
except UnsupportedAddressSuggestionCountryError as exc:
|
||||
@@ -147,27 +200,96 @@ async def suggest_addresses(
|
||||
|
||||
@router.post(
|
||||
"/order",
|
||||
response_model=OrderCreateResponse,
|
||||
response_model=InitPaymentResponse,
|
||||
)
|
||||
async def create_delivery_order(
|
||||
order_request: OrderCreateRequest,
|
||||
async def init_payment(
|
||||
payment_request: InitPaymentRequest,
|
||||
service: AggregatorService = Depends(get_aggregator_service),
|
||||
) -> OrderCreateResponse:
|
||||
) -> InitPaymentResponse:
|
||||
try:
|
||||
return await service.create_order(order_request)
|
||||
except InvalidOrderCreateRequestError as exc:
|
||||
return await service.init_payment(payment_request)
|
||||
except InvalidInitPaymentRequestError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
"code": "invalid_order_create_request",
|
||||
"message": "Order request contains invalid or unsupported CDEK data.",
|
||||
"code": "invalid_init_payment_request",
|
||||
"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
|
||||
except AggregatorServiceError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail={
|
||||
"code": "order_creation_unavailable",
|
||||
"message": "CDEK order creation is temporarily unavailable.",
|
||||
"code": "init_payment_unavailable",
|
||||
"message": "Payment initialization is temporarily unavailable.",
|
||||
},
|
||||
) 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)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Pure rules for CDEK order polling lifecycle."""
|
||||
|
||||
TERMINAL_ORDER_STATUSES: frozenset[str] = frozenset(
|
||||
{"INVALID", "DELIVERED", "NOT_DELIVERED", "CANCELLED"}
|
||||
)
|
||||
|
||||
|
||||
def is_terminal_order_status(code: str | None) -> bool:
|
||||
if code is None:
|
||||
return False
|
||||
return code in TERMINAL_ORDER_STATUSES
|
||||
@@ -0,0 +1,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
@@ -12,6 +12,7 @@ DIMENSIONS_ROUND_SCALE = 1
|
||||
MIN_WEIGHT_KG = Decimal("0.01")
|
||||
MIN_DIMENSION_CM = Decimal("0.1")
|
||||
INTEGER_PRICE_QUANTIZER = Decimal("1")
|
||||
KOPECKS_IN_RUBLE = Decimal("100")
|
||||
DOC_SERVICE_MARKERS = ("документ", "document")
|
||||
|
||||
_MISSING = object()
|
||||
@@ -46,6 +47,8 @@ class ProviderPrice:
|
||||
currency: str
|
||||
delivery_days_min: int
|
||||
delivery_days_max: int
|
||||
tariff_code: str | None = None
|
||||
bypass_parcel_type_filter: bool = False
|
||||
|
||||
|
||||
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(
|
||||
prices: Iterable[ProviderPrice],
|
||||
*,
|
||||
@@ -132,7 +177,8 @@ def filter_prices_by_parcel_type(
|
||||
return [
|
||||
price
|
||||
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,
|
||||
delivery_days_min=min_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:
|
||||
rounded = _round_half_up(weight, scale)
|
||||
if rounded < MIN_WEIGHT_KG:
|
||||
@@ -216,6 +271,13 @@ def _normalize_text(value: object) -> str:
|
||||
return str(value).strip()
|
||||
|
||||
|
||||
def _try_to_str(value: object) -> str | None:
|
||||
if value is _MISSING or value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _normalize_city_id(value: object) -> int:
|
||||
city_id = _try_to_int(value)
|
||||
if city_id is None:
|
||||
|
||||
@@ -5,12 +5,14 @@ from fastapi import FastAPI
|
||||
from app.config import Settings, get_settings
|
||||
from app.controllers.v1.delivery import router as delivery_router
|
||||
from app.runtime.logging import configure_logging
|
||||
from app.runtime.metrics import configure_metrics
|
||||
from app.runtime.tracing import configure_tracing
|
||||
|
||||
|
||||
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
configure_logging()
|
||||
resolved_settings = settings or get_settings()
|
||||
configure_metrics(resolved_settings.observability)
|
||||
|
||||
application = FastAPI(title="G2S Aggregator", version="0.1.0")
|
||||
application.state.settings = resolved_settings
|
||||
|
||||
+52
-2
@@ -7,10 +7,14 @@ import json
|
||||
from typing import Any, Callable, Protocol
|
||||
|
||||
from pydantic import BaseModel
|
||||
import structlog
|
||||
|
||||
from app.config import RepositoryConfig
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class RedisClientProtocol(Protocol):
|
||||
async def get(self, key: str) -> bytes | str | None: ...
|
||||
|
||||
@@ -19,6 +23,14 @@ class RedisClientProtocol(Protocol):
|
||||
async def delete(self, key: str) -> Any: ...
|
||||
|
||||
|
||||
class CacheMetricsProtocol(Protocol):
|
||||
def record_request(self, *, operation: str, outcome: str) -> None: ...
|
||||
|
||||
def record_error(self, *, operation: str, error_type: str) -> None: ...
|
||||
|
||||
def record_hit(self, *, hit: bool) -> None: ...
|
||||
|
||||
|
||||
class PriceCacheRepositoryError(RuntimeError):
|
||||
"""Deterministic repository error raised on Redis operation failures."""
|
||||
|
||||
@@ -26,9 +38,16 @@ class PriceCacheRepositoryError(RuntimeError):
|
||||
class PriceCache:
|
||||
"""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._ttl_seconds = ttl_seconds
|
||||
self._metrics = metrics
|
||||
|
||||
@classmethod
|
||||
def from_repository_config(
|
||||
@@ -36,20 +55,25 @@ class PriceCache:
|
||||
repository_config: RepositoryConfig,
|
||||
*,
|
||||
client_factory: Callable[[str], RedisClientProtocol] | None = None,
|
||||
metrics: CacheMetricsProtocol | None = None,
|
||||
) -> "PriceCache":
|
||||
resolved_factory = client_factory or _default_redis_client_factory
|
||||
redis_client = resolved_factory(repository_config.redis_dsn)
|
||||
return cls(
|
||||
redis_client=redis_client,
|
||||
ttl_seconds=repository_config.price_cache_ttl_seconds,
|
||||
metrics=metrics,
|
||||
)
|
||||
|
||||
async def get(self, key: str) -> Any | None:
|
||||
try:
|
||||
payload = await self._redis_client.get(key)
|
||||
except Exception as exc:
|
||||
self._record_failure(operation="get", error=exc)
|
||||
raise PriceCacheRepositoryError("Redis cache read failed.") from exc
|
||||
if payload is None:
|
||||
self._record_request(operation="get", outcome="miss")
|
||||
self._record_hit(hit=False)
|
||||
return None
|
||||
|
||||
if isinstance(payload, bytes):
|
||||
@@ -59,7 +83,10 @@ class PriceCache:
|
||||
else:
|
||||
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:
|
||||
serialized_payload = _serialize(value)
|
||||
@@ -67,13 +94,36 @@ class PriceCache:
|
||||
try:
|
||||
await self._redis_client.set(key, serialized_payload, ex=resolved_ttl)
|
||||
except Exception as exc:
|
||||
self._record_failure(operation="set", error=exc)
|
||||
raise PriceCacheRepositoryError("Redis cache write failed.") from exc
|
||||
self._record_request(operation="set", outcome="success")
|
||||
|
||||
async def invalidate(self, key: str) -> None:
|
||||
try:
|
||||
await self._redis_client.delete(key)
|
||||
except Exception as exc:
|
||||
self._record_failure(operation="invalidate", error=exc)
|
||||
raise PriceCacheRepositoryError("Redis cache invalidate failed.") from exc
|
||||
self._record_request(operation="invalidate", outcome="success")
|
||||
|
||||
def _record_failure(self, *, operation: str, error: Exception) -> None:
|
||||
error_type = type(error).__name__
|
||||
logger.warning(
|
||||
"cache.operation_failed",
|
||||
operation=operation,
|
||||
error_type=error_type,
|
||||
)
|
||||
if self._metrics is not None:
|
||||
self._metrics.record_request(operation=operation, outcome="error")
|
||||
self._metrics.record_error(operation=operation, error_type=error_type)
|
||||
|
||||
def _record_request(self, *, operation: str, outcome: str) -> None:
|
||||
if self._metrics is not None:
|
||||
self._metrics.record_request(operation=operation, outcome=outcome)
|
||||
|
||||
def _record_hit(self, *, hit: bool) -> None:
|
||||
if self._metrics is not None:
|
||||
self._metrics.record_hit(hit=hit)
|
||||
|
||||
|
||||
def _default_redis_client_factory(redis_dsn: str) -> RedisClientProtocol:
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Order repository exports."""
|
||||
|
||||
from app.repositories.order.repository import OrderData, OrderRepository
|
||||
|
||||
__all__ = ("OrderData", "OrderRepository")
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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
@@ -1,11 +1,13 @@
|
||||
"""Centralized runtime logging bootstrap."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from collections.abc import Iterable
|
||||
from typing import TextIO
|
||||
from typing import Any, TextIO
|
||||
|
||||
import structlog
|
||||
from opentelemetry import trace
|
||||
|
||||
|
||||
_UVICORN_LOGGER_NAMES = ("uvicorn", "uvicorn.error", "uvicorn.access")
|
||||
@@ -18,10 +20,14 @@ def configure_logging(
|
||||
) -> None:
|
||||
resolved_stream = sys.stdout if stream is None else stream
|
||||
formatter = structlog.stdlib.ProcessorFormatter(
|
||||
foreign_pre_chain=list(_shared_processors()),
|
||||
foreign_pre_chain=[
|
||||
_add_trace_context,
|
||||
_render_context_in_event,
|
||||
*_shared_processors(),
|
||||
],
|
||||
processors=[
|
||||
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)
|
||||
@@ -31,8 +37,10 @@ def configure_logging(
|
||||
structlog.reset_defaults()
|
||||
structlog.configure(
|
||||
processors=[
|
||||
*_shared_processors(),
|
||||
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||
_add_trace_context,
|
||||
_render_context_in_event,
|
||||
*_shared_processors(),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
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(
|
||||
logger: logging.Logger,
|
||||
*,
|
||||
|
||||
@@ -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])
|
||||
@@ -1,59 +0,0 @@
|
||||
"""Schemas for CDEK order registration."""
|
||||
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
|
||||
class OrderPhone(BaseModel):
|
||||
number: str = Field(min_length=1)
|
||||
|
||||
|
||||
class OrderParty(BaseModel):
|
||||
name: str = Field(min_length=1)
|
||||
email: str = Field(min_length=1)
|
||||
phones: list[OrderPhone] = Field(min_length=1)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def reject_company_field(cls, value: object) -> object:
|
||||
if isinstance(value, dict) and "company" in value:
|
||||
raise ValueError("company is not allowed")
|
||||
return value
|
||||
|
||||
|
||||
class OrderLocation(BaseModel):
|
||||
address: str = Field(min_length=1)
|
||||
city: str = Field(min_length=1)
|
||||
country_code: str = Field(min_length=2, max_length=2)
|
||||
|
||||
|
||||
class OrderService(BaseModel):
|
||||
code: str = Field(min_length=1)
|
||||
parameter: str = Field(min_length=1)
|
||||
|
||||
|
||||
class OrderPackage(BaseModel):
|
||||
number: str = Field(min_length=1)
|
||||
weight: int = Field(gt=0)
|
||||
length: int = Field(gt=0)
|
||||
width: int = Field(gt=0)
|
||||
height: int = Field(gt=0)
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
class OrderCreateRequest(BaseModel):
|
||||
type: Literal[2]
|
||||
tariff_code: Literal[535]
|
||||
comment: str | None = None
|
||||
sender: OrderParty
|
||||
recipient: OrderParty
|
||||
from_location: OrderLocation
|
||||
to_location: OrderLocation
|
||||
services: list[OrderService] = Field(min_length=1)
|
||||
packages: list[OrderPackage] = Field(min_length=1)
|
||||
|
||||
|
||||
class OrderCreateResponse(BaseModel):
|
||||
provider: str = Field(min_length=1)
|
||||
order_uuid: str = Field(min_length=1)
|
||||
@@ -0,0 +1,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)
|
||||
@@ -26,7 +26,21 @@ class DeliveryCalculationRequest(BaseModel):
|
||||
parcel_type: ParcelType | None = None
|
||||
|
||||
|
||||
class SuggestAddressRequest(BaseModel):
|
||||
"""API request for the /suggest-address endpoint.
|
||||
|
||||
``city`` is a city identifier (key in ``cities_map``); the city name and
|
||||
country code are resolved from it by the service layer.
|
||||
"""
|
||||
|
||||
city: str = Field(min_length=1)
|
||||
query: str = Field(min_length=1)
|
||||
limit: int | None = Field(default=None, gt=0)
|
||||
|
||||
|
||||
class AddressSuggestRequest(BaseModel):
|
||||
"""Provider-facing address suggestion request."""
|
||||
|
||||
country_code: str = Field(min_length=2, max_length=2)
|
||||
city: str = Field(min_length=1)
|
||||
query: str = Field(min_length=1)
|
||||
|
||||
@@ -12,6 +12,8 @@ class DeliveryPrice(BaseModel):
|
||||
currency: str = Field(min_length=3, max_length=3)
|
||||
delivery_days_min: int = Field(ge=0)
|
||||
delivery_days_max: int = Field(ge=0)
|
||||
tariff_code: str | None = Field(default=None, min_length=1)
|
||||
bypass_parcel_type_filter: bool = Field(default=False, exclude=True)
|
||||
|
||||
|
||||
class AddressSuggestion(BaseModel):
|
||||
|
||||
+578
-33
@@ -3,27 +3,60 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
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 typing import Protocol
|
||||
from uuid import uuid4
|
||||
|
||||
import structlog
|
||||
|
||||
from app.adapters.address_suggestions.base import (
|
||||
AddressSuggestionClientError,
|
||||
AddressSuggestionProvider,
|
||||
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 (
|
||||
DEFAULT_PROVIDER_PRICE_MULTIPLIER,
|
||||
DEFAULT_WEIGHT_ROUND_SCALE,
|
||||
NormalizedDeliveryRequest,
|
||||
calculate_expected_payment_amount_kopecks,
|
||||
filter_and_sort_prices,
|
||||
is_init_payment_price_valid,
|
||||
normalize_delivery_request,
|
||||
)
|
||||
from app.schemas.order import OrderCreateRequest, OrderCreateResponse
|
||||
from app.schemas.request import AddressSuggestRequest, DeliveryCalculationRequest
|
||||
from app.repositories.order import OrderData
|
||||
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
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class AggregatorServiceError(RuntimeError):
|
||||
"""Base exception for AggregatorService failures."""
|
||||
@@ -45,12 +78,20 @@ class AddressSuggestionsUnavailableError(AggregatorServiceError):
|
||||
"""Raised when address suggestions cannot be completed."""
|
||||
|
||||
|
||||
class InvalidOrderCreateRequestError(AggregatorServiceError):
|
||||
"""Raised when provider rejects order creation payload as invalid."""
|
||||
class InvalidInitPaymentRequestError(AggregatorServiceError):
|
||||
"""Raised when provider rejects payment init payload as invalid."""
|
||||
|
||||
|
||||
class OrderCreationUnavailableError(AggregatorServiceError):
|
||||
"""Raised when order creation cannot be completed."""
|
||||
class InitPaymentUnavailableError(AggregatorServiceError):
|
||||
"""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):
|
||||
@@ -59,6 +100,65 @@ class PriceCacheProtocol(Protocol):
|
||||
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):
|
||||
def __call__(
|
||||
self,
|
||||
@@ -69,31 +169,41 @@ class FilterAndSortPricesFn(Protocol):
|
||||
) -> list[object]: ...
|
||||
|
||||
|
||||
class OrderRegistrationAdapterProtocol(Protocol):
|
||||
async def register_order(
|
||||
self, request: OrderCreateRequest
|
||||
) -> OrderCreateResponse: ...
|
||||
|
||||
|
||||
class AggregatorService:
|
||||
def __init__(
|
||||
self,
|
||||
providers: Sequence[DeliveryProvider],
|
||||
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_country_to_provider: Mapping[str, str] | None = None,
|
||||
*,
|
||||
weight_round_scale: int = DEFAULT_WEIGHT_ROUND_SCALE,
|
||||
provider_price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER,
|
||||
filter_and_sort_prices_fn: FilterAndSortPricesFn = filter_and_sort_prices,
|
||||
order_uuid_factory: Callable[[], str] = lambda: str(uuid4()),
|
||||
) -> None:
|
||||
self._providers = tuple(providers)
|
||||
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._provider_price_multiplier = provider_price_multiplier
|
||||
self._filter_and_sort_prices = filter_and_sort_prices_fn
|
||||
self._order_uuid_factory = order_uuid_factory
|
||||
self._address_suggestion_providers: dict[str, AddressSuggestionProvider] = {
|
||||
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]
|
||||
|
||||
async def suggest_addresses(
|
||||
self, request: AddressSuggestRequest
|
||||
self, request: SuggestAddressRequest
|
||||
) -> 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:
|
||||
suggestions = await provider.suggest(request)
|
||||
suggestions = await provider.suggest(provider_request)
|
||||
except AddressSuggestionRequestError as exc:
|
||||
raise InvalidAddressSuggestRequestError(
|
||||
"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]
|
||||
|
||||
async def create_order(self, request: OrderCreateRequest) -> OrderCreateResponse:
|
||||
if self._order_adapter is None:
|
||||
raise OrderCreationUnavailableError(
|
||||
"Order registration adapter is not configured."
|
||||
async def init_payment(self, request: InitPaymentRequest) -> InitPaymentResponse:
|
||||
if self._payment_adapter is None:
|
||||
raise InitPaymentUnavailableError("Payment 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:
|
||||
created_order = await self._order_adapter.register_order(request)
|
||||
except ProviderRequestError as exc:
|
||||
raise InvalidOrderCreateRequestError(
|
||||
"Order request is invalid for the configured provider."
|
||||
self._payment_adapter.verify_payment_notification(notification)
|
||||
except TBankPaymentNotificationTokenError as exc:
|
||||
logger.warning(
|
||||
"tbank_notification_token_invalid",
|
||||
order_id=notification.OrderId,
|
||||
)
|
||||
raise InvalidTBankPaymentNotificationError(
|
||||
"TBank payment notification token is invalid."
|
||||
) from exc
|
||||
except Exception as exc:
|
||||
raise OrderCreationUnavailableError(
|
||||
"Order creation is temporarily unavailable."
|
||||
except TBankPaymentAdapterError as exc:
|
||||
raise TBankPaymentNotificationProcessingError(
|
||||
"TBank payment notification verification failed."
|
||||
) from exc
|
||||
|
||||
return OrderCreateResponse.model_validate(
|
||||
created_order,
|
||||
from_attributes=True,
|
||||
action = resolve_tbank_payment_notification_action(
|
||||
status=notification.Status,
|
||||
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(
|
||||
self,
|
||||
@@ -286,6 +807,30 @@ class AggregatorService:
|
||||
def _coerce_address_suggestion(value: object) -> AddressSuggestion:
|
||||
return AddressSuggestion.model_validate(value, from_attributes=True)
|
||||
|
||||
@staticmethod
|
||||
def _build_address_suggest_request(
|
||||
request: SuggestAddressRequest,
|
||||
) -> AddressSuggestRequest:
|
||||
city_entry = cities_map.get(request.city)
|
||||
if not isinstance(city_entry, dict):
|
||||
raise InvalidAddressSuggestRequestError(
|
||||
f"City is not configured for id {request.city}."
|
||||
)
|
||||
|
||||
city_name = city_entry.get("city")
|
||||
country_code = city_entry.get("country")
|
||||
if not isinstance(city_name, str) or not isinstance(country_code, str):
|
||||
raise InvalidAddressSuggestRequestError(
|
||||
f"City mapping is invalid for id {request.city}."
|
||||
)
|
||||
|
||||
return AddressSuggestRequest(
|
||||
country_code=country_code,
|
||||
city=city_name,
|
||||
query=request.query,
|
||||
limit=request.limit,
|
||||
)
|
||||
|
||||
def _resolve_address_suggestion_provider(
|
||||
self, country_code: str
|
||||
) -> AddressSuggestionProvider:
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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())
|
||||
@@ -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())
|
||||
@@ -1,51 +0,0 @@
|
||||
controller:
|
||||
api_prefix: "/api/v1"
|
||||
request_id_header: "X-Request-ID"
|
||||
|
||||
service:
|
||||
provider_timeout_seconds: 10.0
|
||||
max_parallel_providers: 8
|
||||
|
||||
business_logic:
|
||||
weight_round_scale: 2
|
||||
provider_price_multiplier: 1.0
|
||||
|
||||
repository:
|
||||
redis_dsn: "redis://localhost:6379/0"
|
||||
price_cache_ttl_seconds: 900
|
||||
|
||||
adapter:
|
||||
cdek_base_url: "https://api.cdek.ru/v2"
|
||||
cdek_client_id: ""
|
||||
cdek_client_secret: ""
|
||||
cdek_retry_attempts: 2
|
||||
cdek_retry_backoff_seconds: 0.2
|
||||
cdek_timeout_seconds: 10.0
|
||||
cdek_cache_ttl_seconds: 900
|
||||
|
||||
address_suggestions:
|
||||
country_to_provider:
|
||||
RU: "dadata"
|
||||
BY: "dadata"
|
||||
KZ: "dadata"
|
||||
AM: "yandex_geosuggest"
|
||||
AZ: "yandex_geosuggest"
|
||||
KG: "yandex_geosuggest"
|
||||
MD: "yandex_geosuggest"
|
||||
TJ: "yandex_geosuggest"
|
||||
TM: "yandex_geosuggest"
|
||||
UZ: "yandex_geosuggest"
|
||||
dadata:
|
||||
url: "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address"
|
||||
api_key: ""
|
||||
timeout_seconds: 10.0
|
||||
yandex_geosuggest:
|
||||
url: "https://suggest-maps.yandex.ru/v1/suggest"
|
||||
api_key: ""
|
||||
timeout_seconds: 10.0
|
||||
|
||||
observability:
|
||||
enabled: false
|
||||
service_name: "g2s-aggregator"
|
||||
otlp_endpoint: "http://localhost:4317"
|
||||
otlp_insecure: true
|
||||
@@ -0,0 +1,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
@@ -14,14 +14,44 @@ repository:
|
||||
redis_dsn: "redis://localhost:6379/0"
|
||||
price_cache_ttl_seconds: 900
|
||||
|
||||
adapter:
|
||||
cdek_base_url: "https://api.cdek.ru/v2"
|
||||
cdek_client_id: "test-client-id"
|
||||
cdek_client_secret: "test-client-secret"
|
||||
cdek_retry_attempts: 2
|
||||
cdek_retry_backoff_seconds: 0.2
|
||||
cdek_timeout_seconds: 10.0
|
||||
cdek_cache_ttl_seconds: 900
|
||||
delivery_providers:
|
||||
cdek:
|
||||
enabled: true
|
||||
base_url: "https://api.cdek.ru/v2"
|
||||
client_id: "test-client-id"
|
||||
client_secret: "test-client-secret"
|
||||
retry_attempts: 2
|
||||
retry_backoff_seconds: 0.2
|
||||
timeout_seconds: 10.0
|
||||
cache_ttl_seconds: 900
|
||||
cse:
|
||||
enabled: true
|
||||
base_url: "http://lk-test.cse.ru/1c/ws/web1c.1cws"
|
||||
login: "test"
|
||||
password: "2016"
|
||||
retry_attempts: 2
|
||||
retry_backoff_seconds: 0.2
|
||||
timeout_seconds: 10.0
|
||||
cache_ttl_seconds: 900
|
||||
delivery_service_guids:
|
||||
- "tariff-guid-2"
|
||||
payer: "0"
|
||||
payment_method: "1"
|
||||
shipping_method: "5052d0b3-5ea3-46f2-823f-1472686a51dd"
|
||||
|
||||
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:
|
||||
country_to_provider:
|
||||
@@ -83,3 +113,8 @@ observability:
|
||||
service_name: "g2s-aggregator-test"
|
||||
otlp_endpoint: "http://localhost:4317"
|
||||
otlp_insecure: true
|
||||
|
||||
email:
|
||||
smtp_host: "smtp.test"
|
||||
smtp_port: 587
|
||||
from_address: "no-reply@test"
|
||||
|
||||
@@ -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:
|
||||
@@ -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"
|
||||
+94
-45
@@ -12,66 +12,115 @@ grant_type=client_credentials&client_id={{client_id}}&client_secret={{client_sec
|
||||
GET {{base_url}}/v2/orders?cdek_number=10240410458
|
||||
Authorization: Bearer {{auth_token}}
|
||||
|
||||
### 3. Регистрация заказа (тип "доставка", до двери)
|
||||
POST {{base_url}}/v2/orders
|
||||
Authorization: Bearer {{auth_token}}
|
||||
### 3. Инициализация оплаты доставки
|
||||
POST http://localhost:8000/api/v1/delivery/order
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"type": 2,
|
||||
"tariff_code": 535,
|
||||
"comment": "Тестовый заказ",
|
||||
"sender": {
|
||||
"name": "Петр Петров",
|
||||
"senderAddress": {
|
||||
"cityId": 1,
|
||||
"city": "Дубай",
|
||||
"street": "Sheikh Zayed Road",
|
||||
"house": "10",
|
||||
"apartment": "201",
|
||||
"zip": "12345",
|
||||
"comment": "Домофон 12"
|
||||
},
|
||||
"senderContact": {
|
||||
"fullName": "Петр Петров",
|
||||
"email": "sender@example.com",
|
||||
"phones": [
|
||||
{
|
||||
"number": "+79009876543"
|
||||
}
|
||||
]
|
||||
"phone": "+79009876543",
|
||||
"phoneExt": null,
|
||||
"isCompany": false,
|
||||
"companyName": null,
|
||||
"inn": null,
|
||||
"kpp": null
|
||||
},
|
||||
"recipient": {
|
||||
"name": "Иван Иванов",
|
||||
"receiverAddress": {
|
||||
"cityId": 2,
|
||||
"city": "Шарджа",
|
||||
"street": "Al Wahda",
|
||||
"house": "5",
|
||||
"apartment": null,
|
||||
"zip": "54321",
|
||||
"comment": null
|
||||
},
|
||||
"receiverContact": {
|
||||
"fullName": "Иван Иванов",
|
||||
"email": "ivan@example.com",
|
||||
"phones": [
|
||||
{
|
||||
"number": "+79001234567"
|
||||
}
|
||||
]
|
||||
"phone": "+79001234567",
|
||||
"phoneExt": "101",
|
||||
"isCompany": false,
|
||||
"companyName": null,
|
||||
"inn": null,
|
||||
"kpp": null
|
||||
},
|
||||
"from_location": {
|
||||
"address": "ул. Ленина, 1",
|
||||
"city": "Москва",
|
||||
"country_code": "RU"
|
||||
"content": {
|
||||
"description": "Наушники"
|
||||
},
|
||||
"to_location": {
|
||||
"address": "ул. Пушкина, 10",
|
||||
"city": "Новосибирск",
|
||||
"country_code": "RU"
|
||||
},
|
||||
"services": [
|
||||
{
|
||||
"code": "INSURANCE",
|
||||
"parameter": "1000"
|
||||
"pickupDate": "2026-05-15T10:00:00.000Z",
|
||||
"deliveryDate": "2026-05-18T18:00:00.000Z",
|
||||
"accountEmail": "client@example.com",
|
||||
"systemData": {
|
||||
"tariff": {
|
||||
"provider": "СДЭК",
|
||||
"serviceName": "Экспресс лайт",
|
||||
"price": 125000,
|
||||
"deliveryDaysMin": 1,
|
||||
"deliveryDaysMax": 2,
|
||||
"tariffCode": 535
|
||||
},
|
||||
"parcelType": "parcel",
|
||||
"docPackaging": null,
|
||||
"weight": "1.0",
|
||||
"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. Список доступных тарифов
|
||||
GET {{base_url}}/v2/calculator/alltariffs
|
||||
Authorization: Bearer {{auth_token}}
|
||||
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}}
|
||||
|
||||
@@ -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]
|
||||
+147
-39
@@ -1,4 +1,4 @@
|
||||
# spec/overview.md
|
||||
# overview.md
|
||||
|
||||
Контекст, специфичный для проекта агрегатора служб доставки.
|
||||
Глобальные правила определены в AGENTS.md.
|
||||
@@ -17,8 +17,13 @@
|
||||
|
||||
- Принимать запрос на расчёт стоимости доставки (идентификаторы городов отправления/назначения, вес, габариты)
|
||||
- Поддерживать необязательный параметр `parcel_type` в запросе расчёта стоимости доставки для фильтрации тарифов по типу отправления
|
||||
- Предоставлять отдельный endpoint подсказок адреса, чтобы frontend мог получить точное значение для `from_location.address` и `to_location.address` перед созданием заказа
|
||||
- Принимать запрос на создание заказа CDEK по контракту из `http-client.http` для сценария "доставка, до двери"
|
||||
- Предоставлять отдельный endpoint подсказок адреса, чтобы frontend мог получить точное значение для `from_location.address` и `to_location.address` перед инициализацией оплаты доставки
|
||||
- Принимать запрос на инициализацию оплаты доставки через TBank и возвращать ссылку на оплату без регистрации заказа в CDEK
|
||||
- Принимать сумму оплаты в поле `price` в копейках
|
||||
- После успешного получения ссылки на оплату от TBank сохранять все данные заявки вместе со ссылкой на оплату в PostgreSQL; ошибка сохранения не блокирует возврат ссылки клиенту
|
||||
- Принимать HTTP-уведомления TBank о статусах платежа на отдельный webhook endpoint
|
||||
- При получении валидного уведомления TBank со статусом `CONFIRMED` находить сохранённую заявку в PostgreSQL и регистрировать заказ в CDEK
|
||||
- Остальные валидные статусы платежа TBank подтверждать без регистрации заказа в CDEK
|
||||
- Выбирать сервис подсказок адреса по `country_code` через маппинг стран в конфиге
|
||||
- Для `RU`, `BY` и `KZ`, сопоставленных с provider id `dadata`, использовать `dadata.ru`
|
||||
- Для `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM` и `UZ`, сопоставленных с provider id `yandex_geosuggest`, использовать Yandex Geosuggest
|
||||
@@ -38,8 +43,11 @@
|
||||
### Фаза 1
|
||||
- **CDEK** — https://apidoc.cdek.ru/
|
||||
- Аутентификация: OAuth2 (client credentials)
|
||||
- Операции: расчёт тарифа, регистрация заказа
|
||||
- Операции: расчёт тарифа
|
||||
- Cache TTL: 15 минут
|
||||
- **TBank** — https://securepay.tinkoff.ru/v2/Init
|
||||
- Аутентификация: `TerminalKey` и token на основе password
|
||||
- Операции: инициализация платежа, получение payment URL, проверка подписи HTTP-уведомлений
|
||||
|
||||
### Фаза 2+
|
||||
- Дополнительные провайдеры (Boxberry, DHL и др.) — подключаются через интерфейс `DeliveryProvider` без изменений в логике агрегации
|
||||
@@ -51,10 +59,11 @@
|
||||
### Controller (`app/controllers/v1/delivery.py`)
|
||||
- `POST /api/v1/delivery/price` — принимает `DeliveryCalculationRequest`, возвращает `list[DeliveryPrice]`
|
||||
- `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
|
||||
- Маппинг исключений сервиса в 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`)
|
||||
- `AggregatorService.get_all_prices(request: DeliveryCalculationRequest) -> list[DeliveryPrice]`
|
||||
@@ -64,8 +73,12 @@
|
||||
- Сортирует тарифы по цене
|
||||
- `AggregatorService.suggest_addresses(request: AddressSuggestRequest) -> list[AddressSuggestion]`
|
||||
- `AggregatorService.suggest_addresses()` выбирает address suggestion provider по `country_code` через injected config mapping и оркестрирует ровно один adapter call
|
||||
- `AggregatorService.create_order(request: OrderCreateRequest) -> OrderCreateResponse`
|
||||
- `AggregatorService.create_order()` оркестрирует регистрацию заказа в CDEK через injected adapter dependency
|
||||
- `AggregatorService.init_payment(request: InitPaymentRequest) -> InitPaymentResponse`
|
||||
- `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-деталей
|
||||
|
||||
### Business Logic (`app/domain/`)
|
||||
@@ -74,6 +87,7 @@
|
||||
- Логика сравнения цен
|
||||
- Правила применения конфигурируемого мультипликатора к ценам провайдеров
|
||||
- Округление цены после применения мультипликатора до целого значения по детерминированному правилу
|
||||
- Правило выбора действия для TBank payment notification по статусу платежа
|
||||
- Нормализация входных данных (например, идентификаторов городов и правил округления веса)
|
||||
- Чистые функции, без IO, без зависимостей от фреймворков
|
||||
|
||||
@@ -82,6 +96,15 @@
|
||||
- Операции: `get(key)`, `set(key, value, ttl)`, `invalidate(key)`
|
||||
- Без бизнес-решений; формирование ключа — ответственность 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`)
|
||||
- `base.py` — абстрактный интерфейс `DeliveryProvider`:
|
||||
```python
|
||||
@@ -92,11 +115,33 @@
|
||||
- `cdek/client.py` — HTTP-клиент (httpx AsyncClient), аутентификация, ретраи, таймаут (10с)
|
||||
- `cdek/auth.py` — управление OAuth2-токеном
|
||||
- `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
|
||||
- Для расчёта тарифа 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`)
|
||||
- `base.py` — абстрактный интерфейс `AddressSuggestionProvider`:
|
||||
@@ -165,39 +210,89 @@ flat: str | None
|
||||
postal_code: str | None
|
||||
```
|
||||
|
||||
### Входная: `OrderCreateRequest`
|
||||
### Входная: `InitPaymentRequest`
|
||||
Контракт ручки `/api/v1/delivery/order` использует camelCase в JSON; Pydantic-
|
||||
модели хранят snake_case поля и принимают входной JSON через alias-generator.
|
||||
```
|
||||
type: Literal[2]
|
||||
tariff_code: Literal[535]
|
||||
comment: str | None
|
||||
sender:
|
||||
name: str
|
||||
email: str
|
||||
phones: list[{number: str}]
|
||||
recipient:
|
||||
name: str
|
||||
email: str
|
||||
phones: list[{number: str}]
|
||||
from_location:
|
||||
address: str
|
||||
orderUuid: str
|
||||
senderAddress:
|
||||
cityId: int
|
||||
city: str
|
||||
country_code: str
|
||||
to_location:
|
||||
address: str
|
||||
city: str
|
||||
country_code: str
|
||||
services: list[{code: str, parameter: str}]
|
||||
packages: list[{number: str, weight: int, length: int, width: int, height: int, comment: str | None}]
|
||||
street: str
|
||||
house: str
|
||||
apartment: str | None
|
||||
zip: str
|
||||
comment: str | None
|
||||
senderContact:
|
||||
fullName: str
|
||||
email: str | None
|
||||
phone: str
|
||||
phoneExt: str | None
|
||||
isCompany: bool
|
||||
companyName: str | None # обязателен при isCompany=true
|
||||
inn: str | None # обязателен при isCompany=true
|
||||
kpp: str | None # обязателен при isCompany=true
|
||||
receiverAddress: <структура senderAddress>
|
||||
receiverContact: <структура senderContact>
|
||||
content:
|
||||
description: str | None
|
||||
pickupDate: datetime # ISO 8601
|
||||
deliveryDate: datetime | None # ISO 8601
|
||||
accountEmail: str
|
||||
systemData:
|
||||
tariff:
|
||||
provider: str
|
||||
serviceName: str
|
||||
price: int # копейки
|
||||
deliveryDaysMin: int
|
||||
deliveryDaysMax: int
|
||||
tariffCode: int
|
||||
parcelType: Literal[doc, parcel]
|
||||
docPackaging: Literal[envelope, bag] | None # для doc
|
||||
weight: str
|
||||
dimensions: # null/отсутствует для doc
|
||||
length: str
|
||||
width: str
|
||||
height: str
|
||||
```
|
||||
|
||||
`from_location.address` и `to_location.address` должны содержать точные значения адреса, выбранные клиентом; order flow не выполняет address suggestion lookup.
|
||||
`systemData.tariff.price` задаётся в копейках, является обязательным целым
|
||||
числом и должен быть больше 0; backend использует его как сумму платежа
|
||||
TBank без пересчёта.
|
||||
`senderAddress.cityId` и `receiverAddress.cityId` используют общий справочник
|
||||
`cities_map` (тот же идентификатор, что и в `DeliveryCalculationRequest`).
|
||||
Для `parcelType='doc'` `systemData.dimensions` отсутствует или равен `null`,
|
||||
для `parcelType='parcel'` — обязателен.
|
||||
При `isCompany=true` поля `companyName`, `inn`, `kpp` обязательны.
|
||||
`pickupDate` и `deliveryDate` принимаются и сохраняются как ISO datetime.
|
||||
|
||||
### Выходная: `OrderCreateResponse`
|
||||
### Выходная: `InitPaymentResponse`
|
||||
```
|
||||
provider: str
|
||||
order_uuid: str
|
||||
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) |
|
||||
| Валидация | Pydantic v2 |
|
||||
| Кеш | Redis |
|
||||
| База данных | PostgreSQL (asyncpg + SQLAlchemy 2.0 async) |
|
||||
| Миграции | Alembic (async) |
|
||||
| Конфигурация | pydantic-settings |
|
||||
| Сервер | Uvicorn |
|
||||
|
||||
@@ -241,15 +338,25 @@ app/
|
||||
│ ├── auth.py
|
||||
│ ├── mapper.py
|
||||
│ └── order_mapper.py
|
||||
│ └── tbank/
|
||||
│ ├── base.py
|
||||
│ └── client.py
|
||||
│ └── postgres/
|
||||
│ └── engine.py # AsyncEngine & session factory
|
||||
├── repositories/
|
||||
│ └── cache/
|
||||
│ └── redis_cache.py # Repository
|
||||
│ ├── cache/
|
||||
│ │ └── redis_cache.py # Repository (Redis)
|
||||
│ └── order/
|
||||
│ ├── models.py # SQLAlchemy ORM model
|
||||
│ └── repository.py # Repository (PostgreSQL)
|
||||
├── schemas/
|
||||
│ ├── address.py
|
||||
│ ├── request.py
|
||||
│ ├── response.py
|
||||
│ └── order.py
|
||||
└── config.py
|
||||
│ └── payment.py
|
||||
├── config.py
|
||||
alembic/ # Alembic migrations
|
||||
alembic.ini
|
||||
```
|
||||
|
||||
---
|
||||
@@ -260,4 +367,5 @@ app/
|
||||
# docker-compose сервисы
|
||||
app # FastAPI-приложение
|
||||
redis # Кеш тарифов
|
||||
postgres # База данных заявок
|
||||
```
|
||||
Generated
-1615
File diff suppressed because it is too large
Load Diff
+28
-24
@@ -1,32 +1,36 @@
|
||||
[tool.poetry]
|
||||
[project]
|
||||
name = "g2s-aggregator"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = ["Your Name <you@example.com>"]
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"fastapi>=0.135.1,<0.136.0",
|
||||
"uvicorn[standard]>=0.41.0,<0.42.0",
|
||||
"httpx>=0.28.1,<0.29.0",
|
||||
"pydantic>=2.12.5,<3.0.0",
|
||||
"pydantic-settings>=2.13.1,<3.0.0",
|
||||
"aioredis>=2.0.1,<3.0.0",
|
||||
"structlog>=25.5.0,<26.0.0",
|
||||
"opentelemetry-api>=1.40.0,<2.0.0",
|
||||
"opentelemetry-sdk>=1.40.0,<2.0.0",
|
||||
"opentelemetry-exporter-otlp>=1.40.0,<2.0.0",
|
||||
"opentelemetry-instrumentation-fastapi>=0.61b0",
|
||||
"opentelemetry-instrumentation-httpx>=0.61b0",
|
||||
"opentelemetry-instrumentation-redis>=0.61b0",
|
||||
"pytest>=9.0.2,<10.0.0",
|
||||
"redis>=7.3.0,<8.0.0",
|
||||
"sqlalchemy>=2.0.49,<3.0.0",
|
||||
"asyncpg>=0.31.0,<0.32.0",
|
||||
"alembic>=1.18.4,<2.0.0",
|
||||
"aiosqlite>=0.22.1,<0.23.0",
|
||||
"aiosmtplib>=4.0.2,<5.0.0",
|
||||
"email-validator>=2.2.0,<3.0.0",
|
||||
]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.14"
|
||||
fastapi = "^0.135.1"
|
||||
uvicorn = {extras = ["standard"], version = "^0.41.0"}
|
||||
httpx = "^0.28.1"
|
||||
pydantic = "^2.12.5"
|
||||
pydantic-settings = "^2.13.1"
|
||||
aioredis = "^2.0.1"
|
||||
structlog = "^25.5.0"
|
||||
opentelemetry-api = "^1.40.0"
|
||||
opentelemetry-sdk = "^1.40.0"
|
||||
opentelemetry-exporter-otlp = "^1.40.0"
|
||||
opentelemetry-instrumentation-fastapi = "^0.61b0"
|
||||
opentelemetry-instrumentation-httpx = "^0.61b0"
|
||||
opentelemetry-instrumentation-redis = "^0.61b0"
|
||||
pytest = "^9.0.2"
|
||||
redis = "^7.3.0"
|
||||
[tool.uv]
|
||||
package = false
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["."]
|
||||
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
addopts = ["--import-mode=importlib"]
|
||||
|
||||
@@ -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())
|
||||
@@ -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**
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -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`
|
||||
@@ -13,7 +13,7 @@ from app.adapters.delivery_providers.cdek.client import (
|
||||
)
|
||||
from app.cities import cities_map
|
||||
from app import config as config_module
|
||||
from app.config import AdapterConfig, Settings
|
||||
from app.config import CDEKDeliveryProviderConfig, Settings
|
||||
from app.schemas.request import DeliveryCalculationRequest, DeliveryEntity
|
||||
|
||||
|
||||
@@ -287,29 +287,48 @@ repository:
|
||||
redis_dsn: "redis://localhost:6379/0"
|
||||
price_cache_ttl_seconds: 900
|
||||
|
||||
adapter:
|
||||
cdek_base_url: "https://api.cdek.test/v2"
|
||||
cdek_client_id: "yaml-id"
|
||||
cdek_client_secret: "yaml-secret"
|
||||
cdek_retry_attempts: 0
|
||||
cdek_retry_backoff_seconds: 0.1
|
||||
cdek_timeout_seconds: 7.5
|
||||
cdek_cache_ttl_seconds: 777
|
||||
delivery_providers:
|
||||
cdek:
|
||||
base_url: "https://api.cdek.test/v2"
|
||||
client_id: "yaml-id"
|
||||
client_secret: "yaml-secret"
|
||||
retry_attempts: 0
|
||||
retry_backoff_seconds: 0.1
|
||||
timeout_seconds: 7.5
|
||||
cache_ttl_seconds: 777
|
||||
cse:
|
||||
enabled: false
|
||||
|
||||
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:
|
||||
enabled: false
|
||||
service_name: "cdek-adapter-test-service"
|
||||
otlp_endpoint: "http://collector:4317"
|
||||
otlp_insecure: true
|
||||
|
||||
email:
|
||||
smtp_host: "smtp.test"
|
||||
smtp_port: 587
|
||||
from_address: "no-reply@test"
|
||||
""".strip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(config_module, "_resolve_runtime_config_file", lambda: str(config_file))
|
||||
settings = Settings()
|
||||
http_client = RecordingHTTPClient()
|
||||
provider = CDEKProvider.from_adapter_config(
|
||||
provider = CDEKProvider.from_config(
|
||||
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()))
|
||||
@@ -333,14 +352,14 @@ observability:
|
||||
|
||||
|
||||
def test_provider_uses_default_adapter_timeout_and_cache_ttl() -> None:
|
||||
adapter_config = AdapterConfig(
|
||||
cdek_client_id="default-id",
|
||||
cdek_client_secret="default-secret",
|
||||
config = CDEKDeliveryProviderConfig(
|
||||
client_id="default-id",
|
||||
client_secret="default-secret",
|
||||
)
|
||||
http_client = RecordingHTTPClient()
|
||||
provider = CDEKProvider.from_adapter_config(
|
||||
provider = CDEKProvider.from_config(
|
||||
http_client=http_client, # type: ignore[arg-type]
|
||||
adapter_config=adapter_config,
|
||||
config=config,
|
||||
)
|
||||
|
||||
result = asyncio.run(provider.get_prices(_make_request()))
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user