Compare commits
25 Commits
a3ce93b058
...
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 |
@@ -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
|
.idea
|
||||||
*.iml
|
*.iml
|
||||||
/config.yaml
|
/config.yaml
|
||||||
|
/docker-compose.yml
|
||||||
|
/otel-collector-config.yaml
|
||||||
__pycache__
|
__pycache__
|
||||||
http-client.private.env.json
|
http-client.private.env.json
|
||||||
scripts
|
scripts
|
||||||
.codex
|
.codex
|
||||||
|
cse-api.pdf
|
||||||
|
cse_geography*.tsv
|
||||||
|
|||||||
+10
-8
@@ -1,18 +1,20 @@
|
|||||||
FROM python:3.14-slim
|
FROM python:3.14-slim
|
||||||
|
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1 \
|
||||||
|
UV_COMPILE_BYTECODE=1 \
|
||||||
|
UV_LINK_MODE=copy \
|
||||||
|
UV_FROZEN=1 \
|
||||||
|
PATH="/app/.venv/bin:$PATH"
|
||||||
|
|
||||||
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN pip install --no-cache-dir poetry
|
COPY pyproject.toml uv.lock ./
|
||||||
|
RUN uv sync --frozen --no-install-project --no-dev
|
||||||
COPY pyproject.toml poetry.lock ./
|
|
||||||
RUN poetry config virtualenvs.create false \
|
|
||||||
&& poetry install --no-interaction --no-root
|
|
||||||
|
|
||||||
COPY app ./app
|
COPY app ./app
|
||||||
COPY config.yaml ./config.yaml
|
|
||||||
COPY alembic.ini ./alembic.ini
|
COPY alembic.ini ./alembic.ini
|
||||||
COPY alembic ./alembic
|
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
|
|
||||||
@@ -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."""
|
"""Base interface for delivery providers."""
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from app.schemas.payment import InitPaymentRequest
|
||||||
from app.schemas.request import DeliveryCalculationRequest
|
from app.schemas.request import DeliveryCalculationRequest
|
||||||
from app.schemas.response import DeliveryPrice
|
from app.schemas.response import DeliveryPrice
|
||||||
|
|
||||||
@@ -18,6 +20,27 @@ class DeliveryProvider(ABC):
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PaymentPriceValidationProvider(Protocol):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
async def get_payment_price(
|
||||||
|
self,
|
||||||
|
request: InitPaymentRequest,
|
||||||
|
) -> DeliveryPrice | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class OrderRegistrationProvider(Protocol):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
async def register_order(
|
||||||
|
self,
|
||||||
|
request: InitPaymentRequest,
|
||||||
|
order_uuid: str,
|
||||||
|
) -> object: ...
|
||||||
|
|
||||||
|
|
||||||
class ProviderClientError(RuntimeError):
|
class ProviderClientError(RuntimeError):
|
||||||
"""Raised when a provider call fails for temporary or provider-side reasons."""
|
"""Raised when a provider call fails for temporary or provider-side reasons."""
|
||||||
|
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ from app.adapters.delivery_providers.cdek.order_mapper import (
|
|||||||
resolve_cdek_city_code,
|
resolve_cdek_city_code,
|
||||||
)
|
)
|
||||||
from app.cities import cities_map
|
from app.cities import cities_map
|
||||||
from app.config import AdapterConfig
|
from app.config import CDEKDeliveryProviderConfig
|
||||||
from app.schemas.payment import InitPaymentRequest
|
from app.schemas.payment import InitPaymentRequest
|
||||||
from app.schemas.request import DeliveryCalculationRequest
|
from app.schemas.request import DeliveryCalculationRequest
|
||||||
from app.schemas.response import DeliveryPrice
|
from app.schemas.response import DeliveryPrice
|
||||||
@@ -153,9 +153,9 @@ class CDEKClient:
|
|||||||
raise CDEKClientError("CDEK tariff request failed unexpectedly.")
|
raise CDEKClientError("CDEK tariff request failed unexpectedly.")
|
||||||
|
|
||||||
async def register_order(
|
async def register_order(
|
||||||
self, request: InitPaymentRequest
|
self, request: InitPaymentRequest, order_uuid: str
|
||||||
) -> CDEKOrderRegistrationResult:
|
) -> CDEKOrderRegistrationResult:
|
||||||
payload = map_cdek_order_request(request)
|
payload = map_cdek_order_request(request, order_uuid)
|
||||||
for attempt in range(self._retry_attempts + 1):
|
for attempt in range(self._retry_attempts + 1):
|
||||||
try:
|
try:
|
||||||
token = await self._auth_client.get_access_token()
|
token = await self._auth_client.get_access_token()
|
||||||
@@ -233,6 +233,13 @@ class CDEKClient:
|
|||||||
url,
|
url,
|
||||||
failure_message="CDEK get order failed",
|
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:
|
try:
|
||||||
return map_cdek_order_info_response(raw_payload)
|
return map_cdek_order_info_response(raw_payload)
|
||||||
except CDEKOrderMappingError as exc:
|
except CDEKOrderMappingError as exc:
|
||||||
@@ -445,28 +452,28 @@ class CDEKProvider(DeliveryProvider):
|
|||||||
self.cache_ttl_seconds = cache_ttl_seconds
|
self.cache_ttl_seconds = cache_ttl_seconds
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_adapter_config(
|
def from_config(
|
||||||
cls,
|
cls,
|
||||||
*,
|
*,
|
||||||
http_client: httpx.AsyncClient,
|
http_client: httpx.AsyncClient,
|
||||||
adapter_config: AdapterConfig,
|
config: CDEKDeliveryProviderConfig,
|
||||||
) -> "CDEKProvider":
|
) -> "CDEKProvider":
|
||||||
auth_client = CDEKAuthClient(
|
auth_client = CDEKAuthClient(
|
||||||
http_client=http_client,
|
http_client=http_client,
|
||||||
base_url=adapter_config.cdek_base_url,
|
base_url=config.base_url,
|
||||||
client_id=adapter_config.cdek_client_id,
|
client_id=config.client_id,
|
||||||
client_secret=adapter_config.cdek_client_secret,
|
client_secret=config.client_secret,
|
||||||
timeout_seconds=adapter_config.cdek_timeout_seconds,
|
timeout_seconds=config.timeout_seconds,
|
||||||
)
|
)
|
||||||
client = CDEKClient(
|
client = CDEKClient(
|
||||||
http_client=http_client,
|
http_client=http_client,
|
||||||
auth_client=auth_client,
|
auth_client=auth_client,
|
||||||
base_url=adapter_config.cdek_base_url,
|
base_url=config.base_url,
|
||||||
timeout_seconds=adapter_config.cdek_timeout_seconds,
|
timeout_seconds=config.timeout_seconds,
|
||||||
retry_attempts=adapter_config.cdek_retry_attempts,
|
retry_attempts=config.retry_attempts,
|
||||||
retry_backoff_seconds=adapter_config.cdek_retry_backoff_seconds,
|
retry_backoff_seconds=config.retry_backoff_seconds,
|
||||||
)
|
)
|
||||||
return cls(client=client, cache_ttl_seconds=adapter_config.cdek_cache_ttl_seconds)
|
return cls(client=client, cache_ttl_seconds=config.cache_ttl_seconds)
|
||||||
|
|
||||||
async def get_prices(
|
async def get_prices(
|
||||||
self, request: DeliveryCalculationRequest
|
self, request: DeliveryCalculationRequest
|
||||||
@@ -490,9 +497,9 @@ class CDEKProvider(DeliveryProvider):
|
|||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
async def register_order(
|
async def register_order(
|
||||||
self, request: InitPaymentRequest
|
self, request: InitPaymentRequest, order_uuid: str
|
||||||
) -> CDEKOrderRegistrationResult:
|
) -> CDEKOrderRegistrationResult:
|
||||||
return await self._client.register_order(request)
|
return await self._client.register_order(request, order_uuid)
|
||||||
|
|
||||||
async def get_order(self, cdek_order_uuid: str) -> CDEKOrderInfo:
|
async def get_order(self, cdek_order_uuid: str) -> CDEKOrderInfo:
|
||||||
return await self._client.get_order(cdek_order_uuid)
|
return await self._client.get_order(cdek_order_uuid)
|
||||||
@@ -513,3 +520,28 @@ def _response_text_or_none(response: httpx.Response) -> str | None:
|
|||||||
return response.text
|
return response.text
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_requests_with_errors(
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
requests = payload.get("requests")
|
||||||
|
if not isinstance(requests, list):
|
||||||
|
return []
|
||||||
|
|
||||||
|
requests_with_errors: list[dict[str, object]] = []
|
||||||
|
for request in requests:
|
||||||
|
if not isinstance(request, dict):
|
||||||
|
continue
|
||||||
|
errors = request.get("errors")
|
||||||
|
if not isinstance(errors, list) or not errors:
|
||||||
|
continue
|
||||||
|
requests_with_errors.append(
|
||||||
|
{
|
||||||
|
"request_uuid": request.get("request_uuid"),
|
||||||
|
"type": request.get("type"),
|
||||||
|
"state": request.get("state"),
|
||||||
|
"errors": errors,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return requests_with_errors
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ def map_cdek_response(payload: dict[str, Any]) -> list[DeliveryPrice]:
|
|||||||
def map_cdek_response_for_tariff_code(
|
def map_cdek_response_for_tariff_code(
|
||||||
payload: dict[str, Any],
|
payload: dict[str, Any],
|
||||||
*,
|
*,
|
||||||
tariff_code: int,
|
tariff_code: str,
|
||||||
) -> DeliveryPrice | None:
|
) -> DeliveryPrice | None:
|
||||||
tariff_codes = payload.get("tariff_codes")
|
tariff_codes = payload.get("tariff_codes")
|
||||||
if not isinstance(tariff_codes, list):
|
if not isinstance(tariff_codes, list):
|
||||||
@@ -84,19 +84,17 @@ def _get_tariffs(payload: dict[str, Any]) -> list[dict[str, Any]]:
|
|||||||
return tariffs
|
return tariffs
|
||||||
|
|
||||||
|
|
||||||
def _tariff_code_matches(value: object, expected_tariff_code: int) -> bool:
|
def _tariff_code_matches(value: object, expected_tariff_code: str) -> bool:
|
||||||
if isinstance(value, bool) or value is None:
|
extracted = _extract_tariff_code(value)
|
||||||
return False
|
if extracted is None:
|
||||||
try:
|
|
||||||
return int(value) == expected_tariff_code
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
return False
|
return False
|
||||||
|
return extracted == expected_tariff_code
|
||||||
|
|
||||||
|
|
||||||
def _extract_tariff_code(value: object) -> int | None:
|
def _extract_tariff_code(value: object) -> str | None:
|
||||||
if isinstance(value, bool) or value is None:
|
if isinstance(value, bool) or value is None:
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
return int(value)
|
return str(int(value))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class CDEKOrderMappingError(ValueError):
|
|||||||
|
|
||||||
_CDEK_WAYBILL_PRINT_TYPE = "WAYBILL"
|
_CDEK_WAYBILL_PRINT_TYPE = "WAYBILL"
|
||||||
_CDEK_WAYBILL_RELATED_ENTITY_TYPE = "waybill"
|
_CDEK_WAYBILL_RELATED_ENTITY_TYPE = "waybill"
|
||||||
|
_CDEK_INSURANCE_SERVICE_CODE = "INSURANCE"
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -42,23 +43,37 @@ class CDEKWaybillInfo:
|
|||||||
url: str | None
|
url: str | None
|
||||||
|
|
||||||
|
|
||||||
def map_cdek_order_request(request: InitPaymentRequest) -> dict[str, Any]:
|
def map_cdek_order_request(
|
||||||
|
request: InitPaymentRequest, order_uuid: str
|
||||||
|
) -> dict[str, Any]:
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
"number": request.order_uuid,
|
"number": order_uuid,
|
||||||
"type": 2,
|
"type": 2,
|
||||||
"tariff_code": request.system_data.tariff.tariff_code,
|
"tariff_code": _to_cdek_tariff_code(request.system_data.tariff.tariff_code),
|
||||||
"print": _CDEK_WAYBILL_PRINT_TYPE,
|
"print": _CDEK_WAYBILL_PRINT_TYPE,
|
||||||
"sender": _map_party(request.sender_contact),
|
"sender": _map_party(request.sender_contact),
|
||||||
"recipient": _map_party(request.receiver_contact),
|
"recipient": _map_party(request.receiver_contact),
|
||||||
"from_location": _map_location(request.sender_address),
|
"from_location": _map_location(request.sender_address),
|
||||||
"to_location": _map_location(request.receiver_address),
|
"to_location": _map_location(request.receiver_address),
|
||||||
"packages": [_map_package(request)],
|
"packages": [_map_package(request, order_uuid)],
|
||||||
}
|
}
|
||||||
|
services = _map_services(request)
|
||||||
|
if services:
|
||||||
|
payload["services"] = services
|
||||||
if request.content.description:
|
if request.content.description:
|
||||||
payload["comment"] = request.content.description
|
payload["comment"] = request.content.description
|
||||||
return payload
|
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:
|
def map_cdek_order_response(payload: dict[str, Any]) -> CDEKOrderRegistrationResult:
|
||||||
entity = payload.get("entity")
|
entity = payload.get("entity")
|
||||||
if not isinstance(entity, dict):
|
if not isinstance(entity, dict):
|
||||||
@@ -115,7 +130,9 @@ def map_cdek_order_info_response(payload: dict[str, Any]) -> CDEKOrderInfo:
|
|||||||
"CDEK order info response must include entity.uuid."
|
"CDEK order info response must include entity.uuid."
|
||||||
)
|
)
|
||||||
|
|
||||||
status_code = _latest_status_code(entity.get("statuses"))
|
status_code = _invalid_create_request_status(
|
||||||
|
payload.get("requests")
|
||||||
|
) or _latest_status_code(entity.get("statuses"))
|
||||||
waybill_uuid, _ = _extract_waybill(payload)
|
waybill_uuid, _ = _extract_waybill(payload)
|
||||||
return CDEKOrderInfo(
|
return CDEKOrderInfo(
|
||||||
order_uuid=order_uuid,
|
order_uuid=order_uuid,
|
||||||
@@ -161,7 +178,28 @@ def _latest_status_code(statuses: object) -> str | None:
|
|||||||
return dated[-1][1]
|
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]:
|
def _extract_waybill(payload: dict[str, Any]) -> tuple[str | None, str | None]:
|
||||||
|
entity = payload.get("entity")
|
||||||
|
related_entities = (
|
||||||
|
entity.get("related_entities")
|
||||||
|
if isinstance(entity, dict)
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
if not isinstance(related_entities, list):
|
||||||
related_entities = payload.get("related_entities")
|
related_entities = payload.get("related_entities")
|
||||||
if not isinstance(related_entities, list):
|
if not isinstance(related_entities, list):
|
||||||
return None, None
|
return None, None
|
||||||
@@ -280,12 +318,12 @@ def _map_location(address: Address) -> dict[str, Any]:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _map_package(request: InitPaymentRequest) -> dict[str, Any]:
|
def _map_package(request: InitPaymentRequest, order_uuid: str) -> dict[str, Any]:
|
||||||
system_data: SystemData = request.system_data
|
system_data: SystemData = request.system_data
|
||||||
weight_grams = kilograms_string_to_grams(request.system_data.weight)
|
weight_grams = kilograms_string_to_grams(request.system_data.weight)
|
||||||
description = request.content.description or request.order_uuid
|
description = request.content.description or order_uuid
|
||||||
package: dict[str, Any] = {
|
package: dict[str, Any] = {
|
||||||
"number": request.order_uuid,
|
"number": order_uuid,
|
||||||
"weight": weight_grams,
|
"weight": weight_grams,
|
||||||
"comment": description,
|
"comment": description,
|
||||||
}
|
}
|
||||||
@@ -294,6 +332,18 @@ def _map_package(request: InitPaymentRequest) -> dict[str, Any]:
|
|||||||
return package
|
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]:
|
def _map_dimensions(dimensions: Dimensions) -> dict[str, int]:
|
||||||
return {
|
return {
|
||||||
"length": centimeters_string_to_int(dimensions.length, "length"),
|
"length": centimeters_string_to_int(dimensions.length, "length"),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -56,8 +56,8 @@ class SMTPEmailSender:
|
|||||||
to: str,
|
to: str,
|
||||||
subject: str,
|
subject: str,
|
||||||
body: str,
|
body: str,
|
||||||
attachment_bytes: bytes,
|
attachment_bytes: bytes | None = None,
|
||||||
attachment_filename: str,
|
attachment_filename: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
message = self._build_message(
|
message = self._build_message(
|
||||||
to=to,
|
to=to,
|
||||||
@@ -95,14 +95,15 @@ class SMTPEmailSender:
|
|||||||
to: str,
|
to: str,
|
||||||
subject: str,
|
subject: str,
|
||||||
body: str,
|
body: str,
|
||||||
attachment_bytes: bytes,
|
attachment_bytes: bytes | None = None,
|
||||||
attachment_filename: str,
|
attachment_filename: str | None = None,
|
||||||
) -> EmailMessage:
|
) -> EmailMessage:
|
||||||
message = EmailMessage()
|
message = EmailMessage()
|
||||||
message["From"] = self._from_address
|
message["From"] = self._from_address
|
||||||
message["To"] = to
|
message["To"] = to
|
||||||
message["Subject"] = subject
|
message["Subject"] = subject
|
||||||
message.set_content(body)
|
message.set_content(body)
|
||||||
|
if attachment_bytes is not None and attachment_filename is not None:
|
||||||
message.add_attachment(
|
message.add_attachment(
|
||||||
attachment_bytes,
|
attachment_bytes,
|
||||||
maintype="application",
|
maintype="application",
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ class TBankAdapter:
|
|||||||
"Amount": amount_kopecks,
|
"Amount": amount_kopecks,
|
||||||
"OrderId": order_uuid,
|
"OrderId": order_uuid,
|
||||||
"NotificationURL": self._notification_url,
|
"NotificationURL": self._notification_url,
|
||||||
"SuccessURL": self._success_url,
|
"SuccessURL": f"{self._success_url}/{order_uuid}",
|
||||||
}
|
}
|
||||||
payload["Token"] = _build_tbank_token(payload, password=self._password)
|
payload["Token"] = _build_tbank_token(payload, password=self._password)
|
||||||
return payload
|
return payload
|
||||||
|
|||||||
+14399
-4706
File diff suppressed because it is too large
Load Diff
+38
-10
@@ -38,14 +38,40 @@ class RepositoryConfig(BaseModel):
|
|||||||
price_cache_ttl_seconds: int = 900
|
price_cache_ttl_seconds: int = 900
|
||||||
|
|
||||||
|
|
||||||
class AdapterConfig(BaseModel):
|
class DeliveryProviderBaseConfig(BaseModel):
|
||||||
cdek_base_url: str = "https://api.cdek.ru/v2"
|
enabled: bool = True
|
||||||
cdek_client_id: str = ""
|
timeout_seconds: float = Field(default=10.0, gt=0)
|
||||||
cdek_client_secret: str = ""
|
retry_attempts: int = Field(default=2, ge=0)
|
||||||
cdek_retry_attempts: int = Field(default=2, ge=0)
|
retry_backoff_seconds: float = Field(default=0.2, ge=0)
|
||||||
cdek_retry_backoff_seconds: float = Field(default=0.2, ge=0)
|
cache_ttl_seconds: int = Field(default=900, gt=0)
|
||||||
cdek_timeout_seconds: float = Field(default=10.0, gt=0)
|
|
||||||
cdek_cache_ttl_seconds: int = Field(default=900, gt=0)
|
|
||||||
|
class CDEKDeliveryProviderConfig(DeliveryProviderBaseConfig):
|
||||||
|
base_url: str = "https://api.cdek.ru/v2"
|
||||||
|
client_id: str = ""
|
||||||
|
client_secret: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class CSEDeliveryProviderConfig(DeliveryProviderBaseConfig):
|
||||||
|
base_url: str = "https://web.cse.ru/1c/ws/Web1C.1cws"
|
||||||
|
login: str = ""
|
||||||
|
password: str = ""
|
||||||
|
# CSE service GUIDs from GetReferenceData: Services that are allowed to be
|
||||||
|
# shown as delivery tariffs. Additional/non-delivery services must not be
|
||||||
|
# included here.
|
||||||
|
delivery_service_guids: list[str] = Field(default_factory=list)
|
||||||
|
# Contract-specific required parameters for SaveDocuments (order registration).
|
||||||
|
# Urgency is not here: it comes from the tariff selected by the client.
|
||||||
|
payer: str = ""
|
||||||
|
payment_method: str = ""
|
||||||
|
shipping_method: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class DeliveryProvidersConfig(BaseModel):
|
||||||
|
cdek: CDEKDeliveryProviderConfig = Field(
|
||||||
|
default_factory=CDEKDeliveryProviderConfig
|
||||||
|
)
|
||||||
|
cse: CSEDeliveryProviderConfig = Field(default_factory=CSEDeliveryProviderConfig)
|
||||||
|
|
||||||
|
|
||||||
class TBankPaymentAuthConfig(BaseModel):
|
class TBankPaymentAuthConfig(BaseModel):
|
||||||
@@ -145,7 +171,9 @@ class Settings(BaseSettings):
|
|||||||
service: ServiceConfig = Field(default_factory=ServiceConfig)
|
service: ServiceConfig = Field(default_factory=ServiceConfig)
|
||||||
business_logic: BusinessLogicConfig = Field(default_factory=BusinessLogicConfig)
|
business_logic: BusinessLogicConfig = Field(default_factory=BusinessLogicConfig)
|
||||||
repository: RepositoryConfig = Field(default_factory=RepositoryConfig)
|
repository: RepositoryConfig = Field(default_factory=RepositoryConfig)
|
||||||
adapter: AdapterConfig = Field(default_factory=AdapterConfig)
|
delivery_providers: DeliveryProvidersConfig = Field(
|
||||||
|
default_factory=DeliveryProvidersConfig
|
||||||
|
)
|
||||||
tbank_payment: TBankPaymentConfig
|
tbank_payment: TBankPaymentConfig
|
||||||
postgres: PostgresConfig
|
postgres: PostgresConfig
|
||||||
address_suggestions: AddressSuggestionsConfig = Field(
|
address_suggestions: AddressSuggestionsConfig = Field(
|
||||||
@@ -183,7 +211,7 @@ class _RequiredYamlSections(BaseModel):
|
|||||||
service: dict[str, Any]
|
service: dict[str, Any]
|
||||||
business_logic: dict[str, Any]
|
business_logic: dict[str, Any]
|
||||||
repository: dict[str, Any]
|
repository: dict[str, Any]
|
||||||
adapter: dict[str, Any]
|
delivery_providers: dict[str, Any]
|
||||||
tbank_payment: dict[str, Any]
|
tbank_payment: dict[str, Any]
|
||||||
postgres: dict[str, Any]
|
postgres: dict[str, Any]
|
||||||
address_suggestions: dict[str, Any]
|
address_suggestions: dict[str, Any]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
"""Delivery API controller skeleton."""
|
"""Delivery API controller skeleton."""
|
||||||
|
|
||||||
|
import structlog
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
||||||
from fastapi.responses import PlainTextResponse
|
from fastapi.responses import PlainTextResponse
|
||||||
|
|
||||||
@@ -9,18 +10,23 @@ from app.adapters.address_suggestions.tomtom import TomTomAddressSuggestionProvi
|
|||||||
from app.adapters.address_suggestions.yandex_geosuggest import (
|
from app.adapters.address_suggestions.yandex_geosuggest import (
|
||||||
YandexGeosuggestAddressSuggestionProvider,
|
YandexGeosuggestAddressSuggestionProvider,
|
||||||
)
|
)
|
||||||
from app.adapters.delivery_providers.cdek import CDEKProvider
|
from app.adapters.delivery_providers.registry import (
|
||||||
|
build_delivery_provider_registry,
|
||||||
|
resolve_delivery_provider_timeout_seconds,
|
||||||
|
)
|
||||||
|
from app.adapters.email import SMTPEmailSender
|
||||||
from app.adapters.tbank import TBankAdapter
|
from app.adapters.tbank import TBankAdapter
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
from app.controllers.http_client import build_controller_http_client
|
from app.controllers.http_client import build_controller_http_client
|
||||||
from app.repositories.cache.redis_cache import PriceCache
|
from app.repositories.cache.redis_cache import PriceCache
|
||||||
from app.repositories.order import OrderRepository
|
from app.repositories.order import OrderRepository
|
||||||
|
from app.runtime.metrics import get_cache_metrics
|
||||||
from app.schemas.payment import (
|
from app.schemas.payment import (
|
||||||
InitPaymentRequest,
|
InitPaymentRequest,
|
||||||
InitPaymentResponse,
|
InitPaymentResponse,
|
||||||
TBankPaymentNotification,
|
TBankPaymentNotification,
|
||||||
)
|
)
|
||||||
from app.schemas.request import AddressSuggestRequest, DeliveryCalculationRequest
|
from app.schemas.request import DeliveryCalculationRequest, SuggestAddressRequest
|
||||||
from app.schemas.response import AddressSuggestion, DeliveryPrice
|
from app.schemas.response import AddressSuggestion, DeliveryPrice
|
||||||
from app.services.aggregator import (
|
from app.services.aggregator import (
|
||||||
AddressSuggestionsUnavailableError,
|
AddressSuggestionsUnavailableError,
|
||||||
@@ -35,6 +41,8 @@ from app.services.aggregator import (
|
|||||||
UnsupportedAddressSuggestionCountryError,
|
UnsupportedAddressSuggestionCountryError,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/delivery", tags=["delivery"])
|
router = APIRouter(prefix="/delivery", tags=["delivery"])
|
||||||
|
|
||||||
|
|
||||||
@@ -43,11 +51,13 @@ _AGGREGATOR_SERVICE_STATE_KEY = "aggregator_service"
|
|||||||
|
|
||||||
def _build_aggregator_service(settings: Settings) -> AggregatorService:
|
def _build_aggregator_service(settings: Settings) -> AggregatorService:
|
||||||
http_client = build_controller_http_client(
|
http_client = build_controller_http_client(
|
||||||
timeout_seconds=settings.adapter.cdek_timeout_seconds
|
timeout_seconds=resolve_delivery_provider_timeout_seconds(
|
||||||
|
settings.delivery_providers
|
||||||
)
|
)
|
||||||
cdek_provider = CDEKProvider.from_adapter_config(
|
)
|
||||||
|
delivery_provider_registry = build_delivery_provider_registry(
|
||||||
http_client=http_client,
|
http_client=http_client,
|
||||||
adapter_config=settings.adapter,
|
config=settings.delivery_providers,
|
||||||
)
|
)
|
||||||
payment_adapter = TBankAdapter.from_config(
|
payment_adapter = TBankAdapter.from_config(
|
||||||
http_client=http_client,
|
http_client=http_client,
|
||||||
@@ -65,18 +75,34 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
|
|||||||
http_client=http_client,
|
http_client=http_client,
|
||||||
config=settings.address_suggestions.tomtom,
|
config=settings.address_suggestions.tomtom,
|
||||||
)
|
)
|
||||||
providers = (cdek_provider,)
|
cache = PriceCache.from_repository_config(
|
||||||
cache = PriceCache.from_repository_config(settings.repository)
|
settings.repository,
|
||||||
|
metrics=get_cache_metrics(),
|
||||||
|
)
|
||||||
postgres_engine = create_postgres_engine(settings.postgres)
|
postgres_engine = create_postgres_engine(settings.postgres)
|
||||||
postgres_session_factory = create_postgres_session_factory(postgres_engine)
|
postgres_session_factory = create_postgres_session_factory(postgres_engine)
|
||||||
order_repository = OrderRepository(session_factory=postgres_session_factory)
|
order_repository = OrderRepository(session_factory=postgres_session_factory)
|
||||||
|
email_sender = SMTPEmailSender(
|
||||||
|
smtp_host=settings.email.smtp_host,
|
||||||
|
smtp_port=settings.email.smtp_port,
|
||||||
|
username=settings.email.username,
|
||||||
|
password=settings.email.password,
|
||||||
|
from_address=settings.email.from_address,
|
||||||
|
use_tls=settings.email.use_tls,
|
||||||
|
timeout_seconds=settings.email.timeout_seconds,
|
||||||
|
)
|
||||||
service = AggregatorService(
|
service = AggregatorService(
|
||||||
providers=providers,
|
providers=delivery_provider_registry.providers,
|
||||||
cache=cache,
|
cache=cache,
|
||||||
payment_adapter=payment_adapter,
|
payment_adapter=payment_adapter,
|
||||||
payment_price_validation_adapter=cdek_provider,
|
payment_price_validation_adapters=(
|
||||||
|
delivery_provider_registry.payment_price_validation_adapters
|
||||||
|
),
|
||||||
order_repository=order_repository,
|
order_repository=order_repository,
|
||||||
order_registration_adapter=cdek_provider,
|
order_registration_adapters=(
|
||||||
|
delivery_provider_registry.order_registration_adapters
|
||||||
|
),
|
||||||
|
email_sender=email_sender,
|
||||||
address_suggestion_providers=(
|
address_suggestion_providers=(
|
||||||
dadata_provider,
|
dadata_provider,
|
||||||
yandex_geosuggest_provider,
|
yandex_geosuggest_provider,
|
||||||
@@ -135,9 +161,15 @@ async def get_delivery_price(
|
|||||||
response_model=list[AddressSuggestion],
|
response_model=list[AddressSuggestion],
|
||||||
)
|
)
|
||||||
async def suggest_addresses(
|
async def suggest_addresses(
|
||||||
address_request: AddressSuggestRequest,
|
address_request: SuggestAddressRequest,
|
||||||
service: AggregatorService = Depends(get_aggregator_service),
|
service: AggregatorService = Depends(get_aggregator_service),
|
||||||
) -> list[AddressSuggestion]:
|
) -> list[AddressSuggestion]:
|
||||||
|
logger.info(
|
||||||
|
"suggest_address_requested",
|
||||||
|
city=address_request.city,
|
||||||
|
query=address_request.query,
|
||||||
|
limit=address_request.limit,
|
||||||
|
)
|
||||||
try:
|
try:
|
||||||
return await service.suggest_addresses(address_request)
|
return await service.suggest_addresses(address_request)
|
||||||
except UnsupportedAddressSuggestionCountryError as exc:
|
except UnsupportedAddressSuggestionCountryError as exc:
|
||||||
@@ -210,9 +242,23 @@ async def handle_tbank_payment_notification(
|
|||||||
notification: TBankPaymentNotification,
|
notification: TBankPaymentNotification,
|
||||||
service: AggregatorService = Depends(get_aggregator_service),
|
service: AggregatorService = Depends(get_aggregator_service),
|
||||||
) -> PlainTextResponse:
|
) -> 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:
|
try:
|
||||||
response_body = await service.handle_tbank_payment_notification(notification)
|
response_body = await service.handle_tbank_payment_notification(notification)
|
||||||
except InvalidTBankPaymentNotificationError as exc:
|
except InvalidTBankPaymentNotificationError as exc:
|
||||||
|
logger.warning(
|
||||||
|
"tbank_notification_rejected",
|
||||||
|
order_id=notification.OrderId,
|
||||||
|
reason="invalid_token",
|
||||||
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_400_BAD_REQUEST,
|
status_code=status.HTTP_400_BAD_REQUEST,
|
||||||
detail={
|
detail={
|
||||||
@@ -221,6 +267,11 @@ async def handle_tbank_payment_notification(
|
|||||||
},
|
},
|
||||||
) from exc
|
) from exc
|
||||||
except TBankPaymentNotificationProcessingError as exc:
|
except TBankPaymentNotificationProcessingError as exc:
|
||||||
|
logger.error(
|
||||||
|
"tbank_notification_processing_failed",
|
||||||
|
order_id=notification.OrderId,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail={
|
detail={
|
||||||
@@ -229,6 +280,11 @@ async def handle_tbank_payment_notification(
|
|||||||
},
|
},
|
||||||
) from exc
|
) from exc
|
||||||
except AggregatorServiceError as exc:
|
except AggregatorServiceError as exc:
|
||||||
|
logger.error(
|
||||||
|
"tbank_notification_processing_failed",
|
||||||
|
order_id=notification.OrderId,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail={
|
detail={
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from enum import Enum
|
|||||||
|
|
||||||
class TBankPaymentNotificationAction(str, Enum):
|
class TBankPaymentNotificationAction(str, Enum):
|
||||||
ACKNOWLEDGE_ONLY = "acknowledge_only"
|
ACKNOWLEDGE_ONLY = "acknowledge_only"
|
||||||
REGISTER_CDEK_ORDER = "register_cdek_order"
|
REGISTER_PROVIDER_ORDER = "register_provider_order"
|
||||||
|
|
||||||
|
|
||||||
def resolve_tbank_payment_notification_action(
|
def resolve_tbank_payment_notification_action(
|
||||||
@@ -15,5 +15,42 @@ def resolve_tbank_payment_notification_action(
|
|||||||
error_code: str,
|
error_code: str,
|
||||||
) -> TBankPaymentNotificationAction:
|
) -> TBankPaymentNotificationAction:
|
||||||
if status == "CONFIRMED" and success is True and error_code == "0":
|
if status == "CONFIRMED" and success is True and error_code == "0":
|
||||||
return TBankPaymentNotificationAction.REGISTER_CDEK_ORDER
|
return TBankPaymentNotificationAction.REGISTER_PROVIDER_ORDER
|
||||||
return TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY
|
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)
|
||||||
|
|||||||
+9
-2
@@ -47,7 +47,7 @@ class ProviderPrice:
|
|||||||
currency: str
|
currency: str
|
||||||
delivery_days_min: int
|
delivery_days_min: int
|
||||||
delivery_days_max: int
|
delivery_days_max: int
|
||||||
tariff_code: int | None = None
|
tariff_code: str | None = None
|
||||||
bypass_parcel_type_filter: bool = False
|
bypass_parcel_type_filter: bool = False
|
||||||
|
|
||||||
|
|
||||||
@@ -225,7 +225,7 @@ def _normalize_price(
|
|||||||
currency=currency,
|
currency=currency,
|
||||||
delivery_days_min=min_days,
|
delivery_days_min=min_days,
|
||||||
delivery_days_max=max_days,
|
delivery_days_max=max_days,
|
||||||
tariff_code=_try_to_int(_get_optional_attr(candidate, "tariff_code")),
|
tariff_code=_try_to_str(_get_optional_attr(candidate, "tariff_code")),
|
||||||
bypass_parcel_type_filter=_extract_bypass_parcel_type_filter(candidate),
|
bypass_parcel_type_filter=_extract_bypass_parcel_type_filter(candidate),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -271,6 +271,13 @@ def _normalize_text(value: object) -> str:
|
|||||||
return str(value).strip()
|
return str(value).strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _try_to_str(value: object) -> str | None:
|
||||||
|
if value is _MISSING or value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
def _normalize_city_id(value: object) -> int:
|
def _normalize_city_id(value: object) -> int:
|
||||||
city_id = _try_to_int(value)
|
city_id = _try_to_int(value)
|
||||||
if city_id is None:
|
if city_id is None:
|
||||||
|
|||||||
@@ -5,12 +5,14 @@ from fastapi import FastAPI
|
|||||||
from app.config import Settings, get_settings
|
from app.config import Settings, get_settings
|
||||||
from app.controllers.v1.delivery import router as delivery_router
|
from app.controllers.v1.delivery import router as delivery_router
|
||||||
from app.runtime.logging import configure_logging
|
from app.runtime.logging import configure_logging
|
||||||
|
from app.runtime.metrics import configure_metrics
|
||||||
from app.runtime.tracing import configure_tracing
|
from app.runtime.tracing import configure_tracing
|
||||||
|
|
||||||
|
|
||||||
def create_app(settings: Settings | None = None) -> FastAPI:
|
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||||
configure_logging()
|
configure_logging()
|
||||||
resolved_settings = settings or get_settings()
|
resolved_settings = settings or get_settings()
|
||||||
|
configure_metrics(resolved_settings.observability)
|
||||||
|
|
||||||
application = FastAPI(title="G2S Aggregator", version="0.1.0")
|
application = FastAPI(title="G2S Aggregator", version="0.1.0")
|
||||||
application.state.settings = resolved_settings
|
application.state.settings = resolved_settings
|
||||||
|
|||||||
+52
-2
@@ -7,10 +7,14 @@ import json
|
|||||||
from typing import Any, Callable, Protocol
|
from typing import Any, Callable, Protocol
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
import structlog
|
||||||
|
|
||||||
from app.config import RepositoryConfig
|
from app.config import RepositoryConfig
|
||||||
|
|
||||||
|
|
||||||
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class RedisClientProtocol(Protocol):
|
class RedisClientProtocol(Protocol):
|
||||||
async def get(self, key: str) -> bytes | str | None: ...
|
async def get(self, key: str) -> bytes | str | None: ...
|
||||||
|
|
||||||
@@ -19,6 +23,14 @@ class RedisClientProtocol(Protocol):
|
|||||||
async def delete(self, key: str) -> Any: ...
|
async def delete(self, key: str) -> Any: ...
|
||||||
|
|
||||||
|
|
||||||
|
class CacheMetricsProtocol(Protocol):
|
||||||
|
def record_request(self, *, operation: str, outcome: str) -> None: ...
|
||||||
|
|
||||||
|
def record_error(self, *, operation: str, error_type: str) -> None: ...
|
||||||
|
|
||||||
|
def record_hit(self, *, hit: bool) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
class PriceCacheRepositoryError(RuntimeError):
|
class PriceCacheRepositoryError(RuntimeError):
|
||||||
"""Deterministic repository error raised on Redis operation failures."""
|
"""Deterministic repository error raised on Redis operation failures."""
|
||||||
|
|
||||||
@@ -26,9 +38,16 @@ class PriceCacheRepositoryError(RuntimeError):
|
|||||||
class PriceCache:
|
class PriceCache:
|
||||||
"""Repository for cached provider payloads."""
|
"""Repository for cached provider payloads."""
|
||||||
|
|
||||||
def __init__(self, redis_client: RedisClientProtocol, *, ttl_seconds: int) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
redis_client: RedisClientProtocol,
|
||||||
|
*,
|
||||||
|
ttl_seconds: int,
|
||||||
|
metrics: CacheMetricsProtocol | None = None,
|
||||||
|
) -> None:
|
||||||
self._redis_client = redis_client
|
self._redis_client = redis_client
|
||||||
self._ttl_seconds = ttl_seconds
|
self._ttl_seconds = ttl_seconds
|
||||||
|
self._metrics = metrics
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_repository_config(
|
def from_repository_config(
|
||||||
@@ -36,20 +55,25 @@ class PriceCache:
|
|||||||
repository_config: RepositoryConfig,
|
repository_config: RepositoryConfig,
|
||||||
*,
|
*,
|
||||||
client_factory: Callable[[str], RedisClientProtocol] | None = None,
|
client_factory: Callable[[str], RedisClientProtocol] | None = None,
|
||||||
|
metrics: CacheMetricsProtocol | None = None,
|
||||||
) -> "PriceCache":
|
) -> "PriceCache":
|
||||||
resolved_factory = client_factory or _default_redis_client_factory
|
resolved_factory = client_factory or _default_redis_client_factory
|
||||||
redis_client = resolved_factory(repository_config.redis_dsn)
|
redis_client = resolved_factory(repository_config.redis_dsn)
|
||||||
return cls(
|
return cls(
|
||||||
redis_client=redis_client,
|
redis_client=redis_client,
|
||||||
ttl_seconds=repository_config.price_cache_ttl_seconds,
|
ttl_seconds=repository_config.price_cache_ttl_seconds,
|
||||||
|
metrics=metrics,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get(self, key: str) -> Any | None:
|
async def get(self, key: str) -> Any | None:
|
||||||
try:
|
try:
|
||||||
payload = await self._redis_client.get(key)
|
payload = await self._redis_client.get(key)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
self._record_failure(operation="get", error=exc)
|
||||||
raise PriceCacheRepositoryError("Redis cache read failed.") from exc
|
raise PriceCacheRepositoryError("Redis cache read failed.") from exc
|
||||||
if payload is None:
|
if payload is None:
|
||||||
|
self._record_request(operation="get", outcome="miss")
|
||||||
|
self._record_hit(hit=False)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if isinstance(payload, bytes):
|
if isinstance(payload, bytes):
|
||||||
@@ -59,7 +83,10 @@ class PriceCache:
|
|||||||
else:
|
else:
|
||||||
raise TypeError("Redis cache payload must be bytes or str.")
|
raise TypeError("Redis cache payload must be bytes or str.")
|
||||||
|
|
||||||
return json.loads(serialized_payload)
|
result = json.loads(serialized_payload)
|
||||||
|
self._record_request(operation="get", outcome="hit")
|
||||||
|
self._record_hit(hit=True)
|
||||||
|
return result
|
||||||
|
|
||||||
async def set(self, key: str, value: Any, ttl: int | None = None) -> None:
|
async def set(self, key: str, value: Any, ttl: int | None = None) -> None:
|
||||||
serialized_payload = _serialize(value)
|
serialized_payload = _serialize(value)
|
||||||
@@ -67,13 +94,36 @@ class PriceCache:
|
|||||||
try:
|
try:
|
||||||
await self._redis_client.set(key, serialized_payload, ex=resolved_ttl)
|
await self._redis_client.set(key, serialized_payload, ex=resolved_ttl)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
self._record_failure(operation="set", error=exc)
|
||||||
raise PriceCacheRepositoryError("Redis cache write failed.") from exc
|
raise PriceCacheRepositoryError("Redis cache write failed.") from exc
|
||||||
|
self._record_request(operation="set", outcome="success")
|
||||||
|
|
||||||
async def invalidate(self, key: str) -> None:
|
async def invalidate(self, key: str) -> None:
|
||||||
try:
|
try:
|
||||||
await self._redis_client.delete(key)
|
await self._redis_client.delete(key)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
self._record_failure(operation="invalidate", error=exc)
|
||||||
raise PriceCacheRepositoryError("Redis cache invalidate failed.") from exc
|
raise PriceCacheRepositoryError("Redis cache invalidate failed.") from exc
|
||||||
|
self._record_request(operation="invalidate", outcome="success")
|
||||||
|
|
||||||
|
def _record_failure(self, *, operation: str, error: Exception) -> None:
|
||||||
|
error_type = type(error).__name__
|
||||||
|
logger.warning(
|
||||||
|
"cache.operation_failed",
|
||||||
|
operation=operation,
|
||||||
|
error_type=error_type,
|
||||||
|
)
|
||||||
|
if self._metrics is not None:
|
||||||
|
self._metrics.record_request(operation=operation, outcome="error")
|
||||||
|
self._metrics.record_error(operation=operation, error_type=error_type)
|
||||||
|
|
||||||
|
def _record_request(self, *, operation: str, outcome: str) -> None:
|
||||||
|
if self._metrics is not None:
|
||||||
|
self._metrics.record_request(operation=operation, outcome=outcome)
|
||||||
|
|
||||||
|
def _record_hit(self, *, hit: bool) -> None:
|
||||||
|
if self._metrics is not None:
|
||||||
|
self._metrics.record_hit(hit=hit)
|
||||||
|
|
||||||
|
|
||||||
def _default_redis_client_factory(redis_dsn: str) -> RedisClientProtocol:
|
def _default_redis_client_factory(redis_dsn: str) -> RedisClientProtocol:
|
||||||
|
|||||||
@@ -32,7 +32,12 @@ class Order(Base):
|
|||||||
order_uuid: Mapped[str] = mapped_column(String(128), nullable=False)
|
order_uuid: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
payment_url: Mapped[str] = mapped_column(String(2048), nullable=False)
|
payment_url: Mapped[str] = mapped_column(String(2048), nullable=False)
|
||||||
price: Mapped[int] = mapped_column(Integer, nullable=False)
|
price: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
tariff_code: 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)
|
account_email: Mapped[str] = mapped_column(String(320), nullable=False)
|
||||||
payload: Mapped[dict[str, Any]] = mapped_column(
|
payload: Mapped[dict[str, Any]] = mapped_column(
|
||||||
_json_payload_type(),
|
_json_payload_type(),
|
||||||
@@ -40,11 +45,17 @@ class Order(Base):
|
|||||||
)
|
)
|
||||||
payment_status: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
payment_status: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
tbank_payment_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
tbank_payment_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||||
cdek_order_uuid: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
provider_order_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
cdek_order_status: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
provider_order_status: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
cdek_waybill_uuid: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
provider_waybill_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
cdek_waybill_url: Mapped[str | None] = mapped_column(String(2048), nullable=True)
|
provider_waybill_url: Mapped[str | None] = mapped_column(
|
||||||
cdek_polled_at: Mapped[datetime | 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),
|
DateTime(timezone=True),
|
||||||
nullable=True,
|
nullable=True,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from dataclasses import dataclass
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import and_, or_, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
from app.domain.cdek_polling import TERMINAL_ORDER_STATUSES
|
from app.domain.cdek_polling import TERMINAL_ORDER_STATUSES
|
||||||
@@ -18,7 +18,8 @@ class OrderData:
|
|||||||
order_uuid: str
|
order_uuid: str
|
||||||
payment_url: str
|
payment_url: str
|
||||||
price: int
|
price: int
|
||||||
tariff_code: int
|
tariff_code: str
|
||||||
|
provider: str
|
||||||
account_email: str
|
account_email: str
|
||||||
payload: dict[str, Any]
|
payload: dict[str, Any]
|
||||||
|
|
||||||
@@ -36,6 +37,7 @@ class OrderRepository:
|
|||||||
payment_url=order_data.payment_url,
|
payment_url=order_data.payment_url,
|
||||||
price=order_data.price,
|
price=order_data.price,
|
||||||
tariff_code=order_data.tariff_code,
|
tariff_code=order_data.tariff_code,
|
||||||
|
provider=order_data.provider,
|
||||||
account_email=order_data.account_email,
|
account_email=order_data.account_email,
|
||||||
payload=order_data.payload,
|
payload=order_data.payload,
|
||||||
)
|
)
|
||||||
@@ -48,10 +50,13 @@ class OrderRepository:
|
|||||||
self,
|
self,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
order_uuid: str,
|
order_uuid: str,
|
||||||
|
*,
|
||||||
|
for_update: bool = False,
|
||||||
) -> Order | None:
|
) -> Order | None:
|
||||||
result = await session.execute(
|
statement = select(Order).where(Order.order_uuid == order_uuid)
|
||||||
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()
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
async def mark_payment_status(
|
async def mark_payment_status(
|
||||||
@@ -70,17 +75,17 @@ class OrderRepository:
|
|||||||
await session.flush()
|
await session.flush()
|
||||||
return order
|
return order
|
||||||
|
|
||||||
async def mark_cdek_order_registered(
|
async def mark_provider_order_registered(
|
||||||
self,
|
self,
|
||||||
session: AsyncSession,
|
session: AsyncSession,
|
||||||
order_uuid: str,
|
order_uuid: str,
|
||||||
cdek_order_uuid: str,
|
provider_order_id: str,
|
||||||
) -> Order | None:
|
) -> Order | None:
|
||||||
order = await self.get_order_by_order_uuid(session, order_uuid)
|
order = await self.get_order_by_order_uuid(session, order_uuid)
|
||||||
if order is None:
|
if order is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
order.cdek_order_uuid = cdek_order_uuid
|
order.provider_order_id = provider_order_id
|
||||||
await session.flush()
|
await session.flush()
|
||||||
return order
|
return order
|
||||||
|
|
||||||
@@ -90,17 +95,24 @@ class OrderRepository:
|
|||||||
*,
|
*,
|
||||||
limit: int,
|
limit: int,
|
||||||
) -> Sequence[Order]:
|
) -> Sequence[Order]:
|
||||||
statement = (
|
cdek_pending = and_(
|
||||||
select(Order)
|
Order.provider == "cdek",
|
||||||
.where(
|
Order.provider_order_id.is_not(None),
|
||||||
Order.cdek_order_uuid.is_not(None),
|
Order.provider_waybill_url.is_(None),
|
||||||
Order.cdek_waybill_url.is_(None),
|
|
||||||
(
|
(
|
||||||
Order.cdek_order_status.is_(None)
|
Order.provider_order_status.is_(None)
|
||||||
| Order.cdek_order_status.not_in(TERMINAL_ORDER_STATUSES)
|
| Order.provider_order_status.not_in(TERMINAL_ORDER_STATUSES)
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.order_by(Order.cdek_polled_at.asc().nulls_first())
|
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)
|
.limit(limit)
|
||||||
.with_for_update(skip_locked=True)
|
.with_for_update(skip_locked=True)
|
||||||
)
|
)
|
||||||
@@ -120,10 +132,10 @@ class OrderRepository:
|
|||||||
if order is None:
|
if order is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
order.cdek_order_status = order_status
|
order.provider_order_status = order_status
|
||||||
if waybill_uuid is not None and order.cdek_waybill_uuid is None:
|
if waybill_uuid is not None and order.provider_waybill_id is None:
|
||||||
order.cdek_waybill_uuid = waybill_uuid
|
order.provider_waybill_id = waybill_uuid
|
||||||
order.cdek_polled_at = polled_at
|
order.provider_polled_at = polled_at
|
||||||
await session.flush()
|
await session.flush()
|
||||||
return order
|
return order
|
||||||
|
|
||||||
@@ -139,9 +151,25 @@ class OrderRepository:
|
|||||||
if order is None:
|
if order is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if waybill_url is not None and order.cdek_waybill_url is None:
|
if waybill_url is not None and order.provider_waybill_url is None:
|
||||||
order.cdek_waybill_url = waybill_url
|
order.provider_waybill_url = waybill_url
|
||||||
order.cdek_polled_at = polled_at
|
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()
|
await session.flush()
|
||||||
return order
|
return order
|
||||||
|
|
||||||
@@ -151,10 +179,18 @@ class OrderRepository:
|
|||||||
*,
|
*,
|
||||||
limit: int,
|
limit: int,
|
||||||
) -> Sequence[Order]:
|
) -> 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 = (
|
statement = (
|
||||||
select(Order)
|
select(Order)
|
||||||
.where(
|
.where(
|
||||||
Order.cdek_waybill_url.is_not(None),
|
or_(cdek_ready, cse_ready),
|
||||||
Order.waybill_email_sent_at.is_(None),
|
Order.waybill_email_sent_at.is_(None),
|
||||||
)
|
)
|
||||||
.order_by(Order.created_at.asc())
|
.order_by(Order.created_at.asc())
|
||||||
|
|||||||
+57
-3
@@ -1,11 +1,13 @@
|
|||||||
"""Centralized runtime logging bootstrap."""
|
"""Centralized runtime logging bootstrap."""
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import sys
|
import sys
|
||||||
from collections.abc import Iterable
|
from collections.abc import Iterable
|
||||||
from typing import TextIO
|
from typing import Any, TextIO
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
from opentelemetry import trace
|
||||||
|
|
||||||
|
|
||||||
_UVICORN_LOGGER_NAMES = ("uvicorn", "uvicorn.error", "uvicorn.access")
|
_UVICORN_LOGGER_NAMES = ("uvicorn", "uvicorn.error", "uvicorn.access")
|
||||||
@@ -18,7 +20,11 @@ def configure_logging(
|
|||||||
) -> None:
|
) -> None:
|
||||||
resolved_stream = sys.stdout if stream is None else stream
|
resolved_stream = sys.stdout if stream is None else stream
|
||||||
formatter = structlog.stdlib.ProcessorFormatter(
|
formatter = structlog.stdlib.ProcessorFormatter(
|
||||||
foreign_pre_chain=list(_shared_processors()),
|
foreign_pre_chain=[
|
||||||
|
_add_trace_context,
|
||||||
|
_render_context_in_event,
|
||||||
|
*_shared_processors(),
|
||||||
|
],
|
||||||
processors=[
|
processors=[
|
||||||
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
||||||
structlog.processors.JSONRenderer(sort_keys=True, ensure_ascii=False),
|
structlog.processors.JSONRenderer(sort_keys=True, ensure_ascii=False),
|
||||||
@@ -31,8 +37,10 @@ def configure_logging(
|
|||||||
structlog.reset_defaults()
|
structlog.reset_defaults()
|
||||||
structlog.configure(
|
structlog.configure(
|
||||||
processors=[
|
processors=[
|
||||||
*_shared_processors(),
|
|
||||||
structlog.stdlib.PositionalArgumentsFormatter(),
|
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||||
|
_add_trace_context,
|
||||||
|
_render_context_in_event,
|
||||||
|
*_shared_processors(),
|
||||||
structlog.processors.StackInfoRenderer(),
|
structlog.processors.StackInfoRenderer(),
|
||||||
structlog.processors.format_exc_info,
|
structlog.processors.format_exc_info,
|
||||||
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
||||||
@@ -60,6 +68,52 @@ def _shared_processors() -> tuple[structlog.types.Processor, ...]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_trace_context(
|
||||||
|
_logger: Any,
|
||||||
|
_method_name: str,
|
||||||
|
event_dict: structlog.types.EventDict,
|
||||||
|
) -> structlog.types.EventDict:
|
||||||
|
span_context = trace.get_current_span().get_span_context()
|
||||||
|
if not span_context.is_valid:
|
||||||
|
return event_dict
|
||||||
|
|
||||||
|
event_dict["trace_id"] = format(span_context.trace_id, "032x")
|
||||||
|
event_dict["span_id"] = format(span_context.span_id, "016x")
|
||||||
|
return event_dict
|
||||||
|
|
||||||
|
|
||||||
|
def _render_context_in_event(
|
||||||
|
_logger: Any,
|
||||||
|
_method_name: str,
|
||||||
|
event_dict: structlog.types.EventDict,
|
||||||
|
) -> structlog.types.EventDict:
|
||||||
|
event = str(event_dict.get("event", ""))
|
||||||
|
context = " ".join(
|
||||||
|
f"{key}={_serialize_log_value(value)}"
|
||||||
|
for key, value in sorted(event_dict.items())
|
||||||
|
if key
|
||||||
|
not in {
|
||||||
|
"event",
|
||||||
|
"exc_info",
|
||||||
|
"stack_info",
|
||||||
|
"_record",
|
||||||
|
"_from_structlog",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
event_dict["event"] = f"{event} {context}" if context else event
|
||||||
|
return event_dict
|
||||||
|
|
||||||
|
|
||||||
|
def _serialize_log_value(value: object) -> str:
|
||||||
|
return json.dumps(
|
||||||
|
value,
|
||||||
|
ensure_ascii=False,
|
||||||
|
sort_keys=True,
|
||||||
|
separators=(",", ":"),
|
||||||
|
default=str,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _configure_logger(
|
def _configure_logger(
|
||||||
logger: logging.Logger,
|
logger: logging.Logger,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -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])
|
||||||
@@ -48,6 +48,7 @@ class Contact(_CamelModel):
|
|||||||
email: str | None = None
|
email: str | None = None
|
||||||
phone: str = Field(min_length=1)
|
phone: str = Field(min_length=1)
|
||||||
phone_ext: str | None = None
|
phone_ext: str | None = None
|
||||||
|
has_extra_phone: str | None = None
|
||||||
is_company: bool
|
is_company: bool
|
||||||
company_name: str | None = None
|
company_name: str | None = None
|
||||||
inn: str | None = None
|
inn: str | None = None
|
||||||
@@ -74,6 +75,7 @@ class Contact(_CamelModel):
|
|||||||
|
|
||||||
class Content(_CamelModel):
|
class Content(_CamelModel):
|
||||||
description: str | None = None
|
description: str | None = None
|
||||||
|
declared_value: int = Field(alias="declared_value", strict=True)
|
||||||
|
|
||||||
|
|
||||||
class SystemDataTariff(_CamelModel):
|
class SystemDataTariff(_CamelModel):
|
||||||
@@ -82,7 +84,7 @@ class SystemDataTariff(_CamelModel):
|
|||||||
price: int = Field(gt=0, strict=True, description="Payment amount in kopecks.")
|
price: int = Field(gt=0, strict=True, description="Payment amount in kopecks.")
|
||||||
delivery_days_min: int = Field(ge=0, strict=True)
|
delivery_days_min: int = Field(ge=0, strict=True)
|
||||||
delivery_days_max: int = Field(ge=0, strict=True)
|
delivery_days_max: int = Field(ge=0, strict=True)
|
||||||
tariff_code: int = Field(gt=0, strict=True)
|
tariff_code: str = Field(min_length=1)
|
||||||
|
|
||||||
|
|
||||||
class Dimensions(_CamelModel):
|
class Dimensions(_CamelModel):
|
||||||
@@ -114,7 +116,6 @@ class SystemData(_CamelModel):
|
|||||||
|
|
||||||
|
|
||||||
class InitPaymentRequest(_CamelModel):
|
class InitPaymentRequest(_CamelModel):
|
||||||
order_uuid: str = Field(min_length=1)
|
|
||||||
sender_address: Address
|
sender_address: Address
|
||||||
sender_contact: Contact
|
sender_contact: Contact
|
||||||
receiver_address: Address
|
receiver_address: Address
|
||||||
@@ -124,6 +125,8 @@ class InitPaymentRequest(_CamelModel):
|
|||||||
delivery_date: datetime | None = None
|
delivery_date: datetime | None = None
|
||||||
account_email: EmailStr
|
account_email: EmailStr
|
||||||
system_data: SystemData
|
system_data: SystemData
|
||||||
|
agree_privacy: bool
|
||||||
|
agree_terms: bool
|
||||||
|
|
||||||
|
|
||||||
class InitPaymentResponse(BaseModel):
|
class InitPaymentResponse(BaseModel):
|
||||||
|
|||||||
@@ -26,7 +26,21 @@ class DeliveryCalculationRequest(BaseModel):
|
|||||||
parcel_type: ParcelType | None = None
|
parcel_type: ParcelType | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SuggestAddressRequest(BaseModel):
|
||||||
|
"""API request for the /suggest-address endpoint.
|
||||||
|
|
||||||
|
``city`` is a city identifier (key in ``cities_map``); the city name and
|
||||||
|
country code are resolved from it by the service layer.
|
||||||
|
"""
|
||||||
|
|
||||||
|
city: str = Field(min_length=1)
|
||||||
|
query: str = Field(min_length=1)
|
||||||
|
limit: int | None = Field(default=None, gt=0)
|
||||||
|
|
||||||
|
|
||||||
class AddressSuggestRequest(BaseModel):
|
class AddressSuggestRequest(BaseModel):
|
||||||
|
"""Provider-facing address suggestion request."""
|
||||||
|
|
||||||
country_code: str = Field(min_length=2, max_length=2)
|
country_code: str = Field(min_length=2, max_length=2)
|
||||||
city: str = Field(min_length=1)
|
city: str = Field(min_length=1)
|
||||||
query: str = Field(min_length=1)
|
query: str = Field(min_length=1)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ class DeliveryPrice(BaseModel):
|
|||||||
currency: str = Field(min_length=3, max_length=3)
|
currency: str = Field(min_length=3, max_length=3)
|
||||||
delivery_days_min: int = Field(ge=0)
|
delivery_days_min: int = Field(ge=0)
|
||||||
delivery_days_max: int = Field(ge=0)
|
delivery_days_max: int = Field(ge=0)
|
||||||
tariff_code: int | None = Field(default=None, ge=0)
|
tariff_code: str | None = Field(default=None, min_length=1)
|
||||||
bypass_parcel_type_filter: bool = Field(default=False, exclude=True)
|
bypass_parcel_type_filter: bool = Field(default=False, exclude=True)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+243
-71
@@ -3,10 +3,12 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
from collections.abc import Iterable, Mapping, Sequence
|
from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||||
from contextlib import AbstractAsyncContextManager
|
from contextlib import AbstractAsyncContextManager
|
||||||
|
from datetime import datetime, timezone
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
from uuid import uuid4
|
||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
@@ -20,9 +22,6 @@ from app.adapters.delivery_providers.base import (
|
|||||||
ProviderClientError,
|
ProviderClientError,
|
||||||
ProviderRequestError,
|
ProviderRequestError,
|
||||||
)
|
)
|
||||||
from app.adapters.delivery_providers.cdek.order_mapper import (
|
|
||||||
CDEKOrderRegistrationResult,
|
|
||||||
)
|
|
||||||
from app.adapters.tbank.base import (
|
from app.adapters.tbank.base import (
|
||||||
TBankPaymentAdapterError,
|
TBankPaymentAdapterError,
|
||||||
TBankPaymentNotificationTokenError,
|
TBankPaymentNotificationTokenError,
|
||||||
@@ -31,6 +30,7 @@ from app.adapters.tbank.base import (
|
|||||||
from app.domain.payment_notifications import (
|
from app.domain.payment_notifications import (
|
||||||
TBankPaymentNotificationAction,
|
TBankPaymentNotificationAction,
|
||||||
resolve_tbank_payment_notification_action,
|
resolve_tbank_payment_notification_action,
|
||||||
|
should_apply_tbank_payment_status,
|
||||||
)
|
)
|
||||||
from app.domain.price import (
|
from app.domain.price import (
|
||||||
DEFAULT_PROVIDER_PRICE_MULTIPLIER,
|
DEFAULT_PROVIDER_PRICE_MULTIPLIER,
|
||||||
@@ -47,7 +47,12 @@ from app.schemas.payment import (
|
|||||||
InitPaymentResponse,
|
InitPaymentResponse,
|
||||||
TBankPaymentNotification,
|
TBankPaymentNotification,
|
||||||
)
|
)
|
||||||
from app.schemas.request import AddressSuggestRequest, DeliveryCalculationRequest
|
from app.cities import cities_map
|
||||||
|
from app.schemas.request import (
|
||||||
|
AddressSuggestRequest,
|
||||||
|
DeliveryCalculationRequest,
|
||||||
|
SuggestAddressRequest,
|
||||||
|
)
|
||||||
from app.schemas.response import AddressSuggestion, DeliveryPrice
|
from app.schemas.response import AddressSuggestion, DeliveryPrice
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
@@ -113,8 +118,8 @@ class PaymentPriceValidationAdapterProtocol(Protocol):
|
|||||||
|
|
||||||
class OrderRegistrationAdapterProtocol(Protocol):
|
class OrderRegistrationAdapterProtocol(Protocol):
|
||||||
async def register_order(
|
async def register_order(
|
||||||
self, request: InitPaymentRequest
|
self, request: InitPaymentRequest, order_uuid: str
|
||||||
) -> CDEKOrderRegistrationResult: ...
|
) -> object: ...
|
||||||
|
|
||||||
|
|
||||||
class OrderRepositoryProtocol(Protocol):
|
class OrderRepositoryProtocol(Protocol):
|
||||||
@@ -136,14 +141,24 @@ class OrderRepositoryProtocol(Protocol):
|
|||||||
payment_id: int,
|
payment_id: int,
|
||||||
) -> object | None: ...
|
) -> object | None: ...
|
||||||
|
|
||||||
async def mark_cdek_order_registered(
|
async def mark_provider_order_registered(
|
||||||
self,
|
self,
|
||||||
session: object,
|
session: object,
|
||||||
order_uuid: str,
|
order_uuid: str,
|
||||||
cdek_order_uuid: str,
|
provider_order_id: str,
|
||||||
) -> object | None: ...
|
) -> object | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class EmailSenderProtocol(Protocol):
|
||||||
|
async def send_email(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
to: str,
|
||||||
|
subject: str,
|
||||||
|
body: str,
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
class FilterAndSortPricesFn(Protocol):
|
class FilterAndSortPricesFn(Protocol):
|
||||||
def __call__(
|
def __call__(
|
||||||
self,
|
self,
|
||||||
@@ -160,27 +175,35 @@ class AggregatorService:
|
|||||||
providers: Sequence[DeliveryProvider],
|
providers: Sequence[DeliveryProvider],
|
||||||
cache: PriceCacheProtocol | None = None,
|
cache: PriceCacheProtocol | None = None,
|
||||||
payment_adapter: PaymentAdapterProtocol | None = None,
|
payment_adapter: PaymentAdapterProtocol | None = None,
|
||||||
payment_price_validation_adapter: (
|
payment_price_validation_adapters: (
|
||||||
PaymentPriceValidationAdapterProtocol | None
|
Mapping[str, PaymentPriceValidationAdapterProtocol] | None
|
||||||
) = None,
|
) = None,
|
||||||
order_repository: OrderRepositoryProtocol | None = None,
|
order_repository: OrderRepositoryProtocol | None = None,
|
||||||
order_registration_adapter: OrderRegistrationAdapterProtocol | None = None,
|
order_registration_adapters: (
|
||||||
|
Mapping[str, OrderRegistrationAdapterProtocol] | None
|
||||||
|
) = None,
|
||||||
|
email_sender: EmailSenderProtocol | None = None,
|
||||||
address_suggestion_providers: Sequence[AddressSuggestionProvider] = (),
|
address_suggestion_providers: Sequence[AddressSuggestionProvider] = (),
|
||||||
address_suggestion_country_to_provider: Mapping[str, str] | None = None,
|
address_suggestion_country_to_provider: Mapping[str, str] | None = None,
|
||||||
*,
|
*,
|
||||||
weight_round_scale: int = DEFAULT_WEIGHT_ROUND_SCALE,
|
weight_round_scale: int = DEFAULT_WEIGHT_ROUND_SCALE,
|
||||||
provider_price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER,
|
provider_price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER,
|
||||||
filter_and_sort_prices_fn: FilterAndSortPricesFn = filter_and_sort_prices,
|
filter_and_sort_prices_fn: FilterAndSortPricesFn = filter_and_sort_prices,
|
||||||
|
order_uuid_factory: Callable[[], str] = lambda: str(uuid4()),
|
||||||
) -> None:
|
) -> None:
|
||||||
self._providers = tuple(providers)
|
self._providers = tuple(providers)
|
||||||
self._cache = cache
|
self._cache = cache
|
||||||
self._payment_adapter = payment_adapter
|
self._payment_adapter = payment_adapter
|
||||||
self._payment_price_validation_adapter = payment_price_validation_adapter
|
self._payment_price_validation_adapters = dict(
|
||||||
|
payment_price_validation_adapters or {}
|
||||||
|
)
|
||||||
self._order_repository = order_repository
|
self._order_repository = order_repository
|
||||||
self._order_registration_adapter = order_registration_adapter
|
self._order_registration_adapters = dict(order_registration_adapters or {})
|
||||||
|
self._email_sender = email_sender
|
||||||
self._weight_round_scale = weight_round_scale
|
self._weight_round_scale = weight_round_scale
|
||||||
self._provider_price_multiplier = provider_price_multiplier
|
self._provider_price_multiplier = provider_price_multiplier
|
||||||
self._filter_and_sort_prices = filter_and_sort_prices_fn
|
self._filter_and_sort_prices = filter_and_sort_prices_fn
|
||||||
|
self._order_uuid_factory = order_uuid_factory
|
||||||
self._address_suggestion_providers: dict[str, AddressSuggestionProvider] = {
|
self._address_suggestion_providers: dict[str, AddressSuggestionProvider] = {
|
||||||
provider.name: provider for provider in address_suggestion_providers
|
provider.name: provider for provider in address_suggestion_providers
|
||||||
}
|
}
|
||||||
@@ -238,12 +261,15 @@ class AggregatorService:
|
|||||||
return [self._coerce_delivery_price(price) for price in filtered_and_sorted]
|
return [self._coerce_delivery_price(price) for price in filtered_and_sorted]
|
||||||
|
|
||||||
async def suggest_addresses(
|
async def suggest_addresses(
|
||||||
self, request: AddressSuggestRequest
|
self, request: SuggestAddressRequest
|
||||||
) -> list[AddressSuggestion]:
|
) -> list[AddressSuggestion]:
|
||||||
provider = self._resolve_address_suggestion_provider(request.country_code)
|
provider_request = self._build_address_suggest_request(request)
|
||||||
|
provider = self._resolve_address_suggestion_provider(
|
||||||
|
provider_request.country_code
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
suggestions = await provider.suggest(request)
|
suggestions = await provider.suggest(provider_request)
|
||||||
except AddressSuggestionRequestError as exc:
|
except AddressSuggestionRequestError as exc:
|
||||||
raise InvalidAddressSuggestRequestError(
|
raise InvalidAddressSuggestRequestError(
|
||||||
"Address suggestion request is invalid for the configured provider."
|
"Address suggestion request is invalid for the configured provider."
|
||||||
@@ -259,17 +285,19 @@ class AggregatorService:
|
|||||||
if self._payment_adapter is None:
|
if self._payment_adapter is None:
|
||||||
raise InitPaymentUnavailableError("Payment adapter is not configured.")
|
raise InitPaymentUnavailableError("Payment adapter is not configured.")
|
||||||
|
|
||||||
await self._validate_init_payment_price(request)
|
order_uuid = self._order_uuid_factory()
|
||||||
|
|
||||||
|
await self._validate_init_payment_price(request, order_uuid)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
payment_url = await self._payment_adapter.create_payment_link(
|
payment_url = await self._payment_adapter.create_payment_link(
|
||||||
order_uuid=request.order_uuid,
|
order_uuid=order_uuid,
|
||||||
amount_kopecks=request.system_data.tariff.price,
|
amount_kopecks=request.system_data.tariff.price,
|
||||||
)
|
)
|
||||||
except TBankPaymentRequestError as exc:
|
except TBankPaymentRequestError as exc:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"payment_init_rejected",
|
"payment_init_rejected",
|
||||||
order_uuid=request.order_uuid,
|
order_uuid=order_uuid,
|
||||||
provider_status_code=exc.status_code,
|
provider_status_code=exc.status_code,
|
||||||
provider_error_code=exc.error_code,
|
provider_error_code=exc.error_code,
|
||||||
provider_error_message=exc.provider_message,
|
provider_error_message=exc.provider_message,
|
||||||
@@ -287,39 +315,47 @@ class AggregatorService:
|
|||||||
"Payment initialization is temporarily unavailable."
|
"Payment initialization is temporarily unavailable."
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
await self._persist_order(request=request, payment_url=payment_url)
|
await self._persist_order(
|
||||||
|
request=request, order_uuid=order_uuid, payment_url=payment_url
|
||||||
|
)
|
||||||
return InitPaymentResponse(payment_url=payment_url)
|
return InitPaymentResponse(payment_url=payment_url)
|
||||||
|
|
||||||
async def _validate_init_payment_price(
|
async def _validate_init_payment_price(
|
||||||
self,
|
self,
|
||||||
request: InitPaymentRequest,
|
request: InitPaymentRequest,
|
||||||
|
order_uuid: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
if self._payment_price_validation_adapter is None:
|
provider = request.system_data.tariff.provider
|
||||||
raise InitPaymentUnavailableError(
|
validation_adapter = self._payment_price_validation_adapters.get(provider)
|
||||||
"Payment price validation adapter is not configured."
|
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
|
tariff_code = request.system_data.tariff.tariff_code
|
||||||
requested_price = request.system_data.tariff.price
|
requested_price = request.system_data.tariff.price
|
||||||
try:
|
try:
|
||||||
provider_price = await self._payment_price_validation_adapter.get_payment_price(
|
provider_price = await validation_adapter.get_payment_price(request)
|
||||||
request
|
|
||||||
)
|
|
||||||
except ProviderRequestError as exc:
|
except ProviderRequestError as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"init_payment_price_validation_request_rejected",
|
"init_payment_price_validation_request_rejected",
|
||||||
order_uuid=request.order_uuid,
|
order_uuid=order_uuid,
|
||||||
tariff_code=tariff_code,
|
tariff_code=tariff_code,
|
||||||
requested_price_kopecks=requested_price,
|
requested_price_kopecks=requested_price,
|
||||||
error=str(exc),
|
error=str(exc),
|
||||||
)
|
)
|
||||||
raise InvalidInitPaymentRequestError(
|
raise InvalidInitPaymentRequestError(
|
||||||
"Payment init request is invalid for CDEK price validation."
|
"Payment init request is invalid for provider price validation."
|
||||||
) from exc
|
) from exc
|
||||||
except ProviderClientError as exc:
|
except ProviderClientError as exc:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"init_payment_price_validation_unavailable",
|
"init_payment_price_validation_unavailable",
|
||||||
order_uuid=request.order_uuid,
|
order_uuid=order_uuid,
|
||||||
tariff_code=tariff_code,
|
tariff_code=tariff_code,
|
||||||
requested_price_kopecks=requested_price,
|
requested_price_kopecks=requested_price,
|
||||||
error=str(exc),
|
error=str(exc),
|
||||||
@@ -330,7 +366,7 @@ class AggregatorService:
|
|||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"init_payment_price_validation_unexpected_error",
|
"init_payment_price_validation_unexpected_error",
|
||||||
order_uuid=request.order_uuid,
|
order_uuid=order_uuid,
|
||||||
tariff_code=tariff_code,
|
tariff_code=tariff_code,
|
||||||
requested_price_kopecks=requested_price,
|
requested_price_kopecks=requested_price,
|
||||||
)
|
)
|
||||||
@@ -341,12 +377,12 @@ class AggregatorService:
|
|||||||
if provider_price is None:
|
if provider_price is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"init_payment_price_validation_tariff_not_found",
|
"init_payment_price_validation_tariff_not_found",
|
||||||
order_uuid=request.order_uuid,
|
order_uuid=order_uuid,
|
||||||
tariff_code=tariff_code,
|
tariff_code=tariff_code,
|
||||||
requested_price_kopecks=requested_price,
|
requested_price_kopecks=requested_price,
|
||||||
)
|
)
|
||||||
raise InvalidInitPaymentRequestError(
|
raise InvalidInitPaymentRequestError(
|
||||||
"CDEK did not return the requested tariff for payment validation."
|
"Provider did not return the requested tariff for payment validation."
|
||||||
)
|
)
|
||||||
|
|
||||||
expected_amount_kopecks = calculate_expected_payment_amount_kopecks(
|
expected_amount_kopecks = calculate_expected_payment_amount_kopecks(
|
||||||
@@ -360,7 +396,7 @@ class AggregatorService:
|
|||||||
):
|
):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"init_payment_price_mismatch",
|
"init_payment_price_mismatch",
|
||||||
order_uuid=request.order_uuid,
|
order_uuid=order_uuid,
|
||||||
tariff_code=tariff_code,
|
tariff_code=tariff_code,
|
||||||
requested_price_kopecks=requested_price,
|
requested_price_kopecks=requested_price,
|
||||||
expected_price_kopecks=expected_amount_kopecks,
|
expected_price_kopecks=expected_amount_kopecks,
|
||||||
@@ -368,7 +404,7 @@ class AggregatorService:
|
|||||||
provider_price=str(getattr(provider_price, "price", None)),
|
provider_price=str(getattr(provider_price, "price", None)),
|
||||||
)
|
)
|
||||||
raise InvalidInitPaymentRequestError(
|
raise InvalidInitPaymentRequestError(
|
||||||
"Payment amount does not match CDEK validated delivery price."
|
"Payment amount does not match provider validated delivery price."
|
||||||
)
|
)
|
||||||
|
|
||||||
async def handle_tbank_payment_notification(
|
async def handle_tbank_payment_notification(
|
||||||
@@ -383,6 +419,10 @@ class AggregatorService:
|
|||||||
try:
|
try:
|
||||||
self._payment_adapter.verify_payment_notification(notification)
|
self._payment_adapter.verify_payment_notification(notification)
|
||||||
except TBankPaymentNotificationTokenError as exc:
|
except TBankPaymentNotificationTokenError as exc:
|
||||||
|
logger.warning(
|
||||||
|
"tbank_notification_token_invalid",
|
||||||
|
order_id=notification.OrderId,
|
||||||
|
)
|
||||||
raise InvalidTBankPaymentNotificationError(
|
raise InvalidTBankPaymentNotificationError(
|
||||||
"TBank payment notification token is invalid."
|
"TBank payment notification token is invalid."
|
||||||
) from exc
|
) from exc
|
||||||
@@ -396,22 +436,90 @@ class AggregatorService:
|
|||||||
success=notification.Success,
|
success=notification.Success,
|
||||||
error_code=notification.ErrorCode,
|
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)
|
order = await self._load_order_and_mark_payment_status(notification)
|
||||||
|
|
||||||
if action is TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY:
|
if action is TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY:
|
||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
existing_cdek_order_uuid = getattr(order, "cdek_order_uuid", None)
|
provider = self._resolve_order_provider(order)
|
||||||
if existing_cdek_order_uuid:
|
if self._has_existing_registration(order, provider):
|
||||||
return "OK"
|
logger.info(
|
||||||
|
"tbank_notification_registration_skipped_duplicate",
|
||||||
registration_result = await self._register_cdek_order(order)
|
order_id=notification.OrderId,
|
||||||
await self._save_cdek_order_uuid(
|
provider=provider,
|
||||||
order_uuid=notification.OrderId,
|
|
||||||
cdek_order_uuid=registration_result.order_uuid,
|
|
||||||
)
|
)
|
||||||
return "OK"
|
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(
|
async def _load_order_and_mark_payment_status(
|
||||||
self,
|
self,
|
||||||
notification: TBankPaymentNotification,
|
notification: TBankPaymentNotification,
|
||||||
@@ -423,9 +531,12 @@ class AggregatorService:
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
async with self._order_repository.session() as session:
|
async with self._order_repository.session() as session:
|
||||||
|
# FOR UPDATE: сериализуем конкурентные уведомления по одному заказу,
|
||||||
|
# чтобы внеочередной статус не затирал уже записанный (lost update).
|
||||||
order = await self._order_repository.get_order_by_order_uuid(
|
order = await self._order_repository.get_order_by_order_uuid(
|
||||||
session,
|
session,
|
||||||
notification.OrderId,
|
notification.OrderId,
|
||||||
|
for_update=True,
|
||||||
)
|
)
|
||||||
if order is None:
|
if order is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -435,19 +546,21 @@ class AggregatorService:
|
|||||||
raise TBankPaymentNotificationProcessingError(
|
raise TBankPaymentNotificationProcessingError(
|
||||||
"Order was not found for TBank payment notification."
|
"Order was not found for TBank payment notification."
|
||||||
)
|
)
|
||||||
updated_order = await self._order_repository.mark_payment_status(
|
if should_apply_tbank_payment_status(
|
||||||
|
order.payment_status, notification.Status
|
||||||
|
):
|
||||||
|
await self._order_repository.mark_payment_status(
|
||||||
session,
|
session,
|
||||||
notification.OrderId,
|
notification.OrderId,
|
||||||
notification.Status,
|
notification.Status,
|
||||||
notification.PaymentId,
|
notification.PaymentId,
|
||||||
)
|
)
|
||||||
if updated_order is None:
|
else:
|
||||||
logger.warning(
|
logger.info(
|
||||||
"tbank_payment_notification_order_not_found",
|
"tbank_payment_status_downgrade_skipped",
|
||||||
order_uuid=notification.OrderId,
|
order_uuid=notification.OrderId,
|
||||||
)
|
current_status=order.payment_status,
|
||||||
raise TBankPaymentNotificationProcessingError(
|
incoming_status=notification.Status,
|
||||||
"Order was not found for TBank payment notification."
|
|
||||||
)
|
)
|
||||||
return order
|
return order
|
||||||
except TBankPaymentNotificationProcessingError:
|
except TBankPaymentNotificationProcessingError:
|
||||||
@@ -463,69 +576,98 @@ class AggregatorService:
|
|||||||
"TBank payment notification order update failed."
|
"TBank payment notification order update failed."
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
async def _register_cdek_order(
|
@staticmethod
|
||||||
self, order: object
|
def _resolve_order_provider(order: object) -> str:
|
||||||
) -> CDEKOrderRegistrationResult:
|
provider = getattr(order, "provider", None)
|
||||||
if self._order_registration_adapter is 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(
|
raise TBankPaymentNotificationProcessingError(
|
||||||
"CDEK order registration adapter is not configured."
|
"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:
|
try:
|
||||||
request = self._to_init_payment_request_from_order(order)
|
request = self._to_init_payment_request_from_order(order)
|
||||||
return await self._order_registration_adapter.register_order(request)
|
return await registration_adapter.register_order(request, order_uuid)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"cdek_order_registration_failed",
|
"provider_order_registration_failed",
|
||||||
|
provider=provider,
|
||||||
order_uuid=getattr(order, "order_uuid", None),
|
order_uuid=getattr(order, "order_uuid", None),
|
||||||
)
|
)
|
||||||
raise TBankPaymentNotificationProcessingError(
|
raise TBankPaymentNotificationProcessingError(
|
||||||
"CDEK order registration failed."
|
"Provider order registration failed."
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
async def _save_cdek_order_uuid(
|
async def _save_order_registration(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
order_uuid: str,
|
order_uuid: str,
|
||||||
cdek_order_uuid: str,
|
provider: str,
|
||||||
|
result: object,
|
||||||
) -> None:
|
) -> None:
|
||||||
if self._order_repository is None:
|
if self._order_repository is None:
|
||||||
raise TBankPaymentNotificationProcessingError(
|
raise TBankPaymentNotificationProcessingError(
|
||||||
"Order repository is not configured."
|
"Order repository is not configured."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
registration_id = self._extract_registration_id(result)
|
||||||
try:
|
try:
|
||||||
async with self._order_repository.session() as session:
|
async with self._order_repository.session() as session:
|
||||||
order = await self._order_repository.mark_cdek_order_registered(
|
order = await self._order_repository.mark_provider_order_registered(
|
||||||
session,
|
session,
|
||||||
order_uuid,
|
order_uuid,
|
||||||
cdek_order_uuid,
|
registration_id,
|
||||||
)
|
)
|
||||||
if order is None:
|
if order is None:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"cdek_order_uuid_order_not_found",
|
"order_registration_order_not_found",
|
||||||
|
provider=provider,
|
||||||
order_uuid=order_uuid,
|
order_uuid=order_uuid,
|
||||||
cdek_order_uuid=cdek_order_uuid,
|
registration_id=registration_id,
|
||||||
)
|
)
|
||||||
raise TBankPaymentNotificationProcessingError(
|
raise TBankPaymentNotificationProcessingError(
|
||||||
"Order was not found while saving CDEK order UUID."
|
"Order was not found while saving registration identifier."
|
||||||
)
|
)
|
||||||
except TBankPaymentNotificationProcessingError:
|
except TBankPaymentNotificationProcessingError:
|
||||||
raise
|
raise
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"cdek_order_uuid_persistence_failed",
|
"order_registration_persistence_failed",
|
||||||
|
provider=provider,
|
||||||
order_uuid=order_uuid,
|
order_uuid=order_uuid,
|
||||||
cdek_order_uuid=cdek_order_uuid,
|
registration_id=registration_id,
|
||||||
)
|
)
|
||||||
raise TBankPaymentNotificationProcessingError(
|
raise TBankPaymentNotificationProcessingError(
|
||||||
"CDEK order UUID persistence failed."
|
"Order registration persistence failed."
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
async def _persist_order(
|
async def _persist_order(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
request: InitPaymentRequest,
|
request: InitPaymentRequest,
|
||||||
|
order_uuid: str,
|
||||||
payment_url: str,
|
payment_url: str,
|
||||||
) -> None:
|
) -> None:
|
||||||
if self._order_repository is None:
|
if self._order_repository is None:
|
||||||
@@ -535,25 +677,31 @@ class AggregatorService:
|
|||||||
async with self._order_repository.session() as session:
|
async with self._order_repository.session() as session:
|
||||||
await self._order_repository.create_order(
|
await self._order_repository.create_order(
|
||||||
session,
|
session,
|
||||||
self._to_order_data(request=request, payment_url=payment_url),
|
self._to_order_data(
|
||||||
|
request=request,
|
||||||
|
order_uuid=order_uuid,
|
||||||
|
payment_url=payment_url,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(
|
logger.exception(
|
||||||
"order_persistence_failed",
|
"order_persistence_failed",
|
||||||
order_uuid=request.order_uuid,
|
order_uuid=order_uuid,
|
||||||
)
|
)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _to_order_data(
|
def _to_order_data(
|
||||||
*,
|
*,
|
||||||
request: InitPaymentRequest,
|
request: InitPaymentRequest,
|
||||||
|
order_uuid: str,
|
||||||
payment_url: str,
|
payment_url: str,
|
||||||
) -> OrderData:
|
) -> OrderData:
|
||||||
return OrderData(
|
return OrderData(
|
||||||
order_uuid=request.order_uuid,
|
order_uuid=order_uuid,
|
||||||
payment_url=payment_url,
|
payment_url=payment_url,
|
||||||
price=request.system_data.tariff.price,
|
price=request.system_data.tariff.price,
|
||||||
tariff_code=request.system_data.tariff.tariff_code,
|
tariff_code=request.system_data.tariff.tariff_code,
|
||||||
|
provider=request.system_data.tariff.provider,
|
||||||
account_email=request.account_email,
|
account_email=request.account_email,
|
||||||
payload=request.model_dump(mode="json", by_alias=True),
|
payload=request.model_dump(mode="json", by_alias=True),
|
||||||
)
|
)
|
||||||
@@ -659,6 +807,30 @@ class AggregatorService:
|
|||||||
def _coerce_address_suggestion(value: object) -> AddressSuggestion:
|
def _coerce_address_suggestion(value: object) -> AddressSuggestion:
|
||||||
return AddressSuggestion.model_validate(value, from_attributes=True)
|
return AddressSuggestion.model_validate(value, from_attributes=True)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_address_suggest_request(
|
||||||
|
request: SuggestAddressRequest,
|
||||||
|
) -> AddressSuggestRequest:
|
||||||
|
city_entry = cities_map.get(request.city)
|
||||||
|
if not isinstance(city_entry, dict):
|
||||||
|
raise InvalidAddressSuggestRequestError(
|
||||||
|
f"City is not configured for id {request.city}."
|
||||||
|
)
|
||||||
|
|
||||||
|
city_name = city_entry.get("city")
|
||||||
|
country_code = city_entry.get("country")
|
||||||
|
if not isinstance(city_name, str) or not isinstance(country_code, str):
|
||||||
|
raise InvalidAddressSuggestRequestError(
|
||||||
|
f"City mapping is invalid for id {request.city}."
|
||||||
|
)
|
||||||
|
|
||||||
|
return AddressSuggestRequest(
|
||||||
|
country_code=country_code,
|
||||||
|
city=city_name,
|
||||||
|
query=request.query,
|
||||||
|
limit=request.limit,
|
||||||
|
)
|
||||||
|
|
||||||
def _resolve_address_suggestion_provider(
|
def _resolve_address_suggestion_provider(
|
||||||
self, country_code: str
|
self, country_code: str
|
||||||
) -> AddressSuggestionProvider:
|
) -> AddressSuggestionProvider:
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Background service that e-mails CDEK waybill PDFs to customers."""
|
"""Background service that e-mails provider waybill PDFs to customers."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import Callable, Sequence
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
from contextlib import AbstractAsyncContextManager
|
from contextlib import AbstractAsyncContextManager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -15,15 +15,18 @@ logger = structlog.get_logger(__name__)
|
|||||||
_EMAIL_SUBJECT_TEMPLATE = "Накладная по заказу {order_uuid}"
|
_EMAIL_SUBJECT_TEMPLATE = "Накладная по заказу {order_uuid}"
|
||||||
_EMAIL_BODY_TEMPLATE = (
|
_EMAIL_BODY_TEMPLATE = (
|
||||||
"Здравствуйте!\n\n"
|
"Здравствуйте!\n\n"
|
||||||
"По вашему заказу {order_uuid} сформирована транспортная накладная CDEK.\n"
|
"По вашему заказу {order_uuid} сформирована "
|
||||||
|
"транспортная накладная.\n"
|
||||||
"PDF-файл накладной приложен к этому письму.\n"
|
"PDF-файл накладной приложен к этому письму.\n"
|
||||||
|
)
|
||||||
|
_EMAIL_BODY_URL_LINE_TEMPLATE = (
|
||||||
"Также накладная доступна по ссылке: {waybill_url}\n"
|
"Также накладная доступна по ссылке: {waybill_url}\n"
|
||||||
)
|
)
|
||||||
_ATTACHMENT_FILENAME_TEMPLATE = "waybill_{order_uuid}.pdf"
|
_ATTACHMENT_FILENAME_TEMPLATE = "waybill_{order_uuid}.pdf"
|
||||||
|
|
||||||
|
|
||||||
class WaybillPDFDownloaderProtocol(Protocol):
|
class WaybillPDFDownloaderProtocol(Protocol):
|
||||||
async def download_waybill_pdf(self, url: str) -> bytes: ...
|
async def download_waybill_pdf(self, order: "OrderRecord") -> bytes: ...
|
||||||
|
|
||||||
|
|
||||||
class EmailSenderProtocol(Protocol):
|
class EmailSenderProtocol(Protocol):
|
||||||
@@ -40,8 +43,10 @@ class EmailSenderProtocol(Protocol):
|
|||||||
|
|
||||||
class OrderRecord(Protocol):
|
class OrderRecord(Protocol):
|
||||||
order_uuid: str
|
order_uuid: str
|
||||||
|
provider: str
|
||||||
account_email: str
|
account_email: str
|
||||||
cdek_waybill_url: str | None
|
provider_waybill_id: str | None
|
||||||
|
provider_waybill_url: str | None
|
||||||
|
|
||||||
|
|
||||||
class WaybillEmailSenderRepositoryProtocol(Protocol):
|
class WaybillEmailSenderRepositoryProtocol(Protocol):
|
||||||
@@ -72,13 +77,13 @@ class WaybillEmailSenderService:
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
order_repository: WaybillEmailSenderRepositoryProtocol,
|
order_repository: WaybillEmailSenderRepositoryProtocol,
|
||||||
waybill_downloader: WaybillPDFDownloaderProtocol,
|
waybill_downloaders: Mapping[str, WaybillPDFDownloaderProtocol],
|
||||||
email_sender: EmailSenderProtocol,
|
email_sender: EmailSenderProtocol,
|
||||||
batch_size: int,
|
batch_size: int,
|
||||||
datetime_now: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
|
datetime_now: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
|
||||||
) -> None:
|
) -> None:
|
||||||
self._repository = order_repository
|
self._repository = order_repository
|
||||||
self._waybill_downloader = waybill_downloader
|
self._waybill_downloaders = dict(waybill_downloaders)
|
||||||
self._email_sender = email_sender
|
self._email_sender = email_sender
|
||||||
self._batch_size = batch_size
|
self._batch_size = batch_size
|
||||||
self._datetime_now = datetime_now
|
self._datetime_now = datetime_now
|
||||||
@@ -99,6 +104,7 @@ class WaybillEmailSenderService:
|
|||||||
logger.exception(
|
logger.exception(
|
||||||
"waybill_email_order_failed",
|
"waybill_email_order_failed",
|
||||||
order_uuid=order.order_uuid,
|
order_uuid=order.order_uuid,
|
||||||
|
provider=order.provider,
|
||||||
account_email=order.account_email,
|
account_email=order.account_email,
|
||||||
)
|
)
|
||||||
return SendBatchSummary(
|
return SendBatchSummary(
|
||||||
@@ -116,7 +122,7 @@ class WaybillEmailSenderService:
|
|||||||
while not stop_event.is_set():
|
while not stop_event.is_set():
|
||||||
try:
|
try:
|
||||||
summary = await self.poll_once()
|
summary = await self.poll_once()
|
||||||
logger.info(
|
logger.debug(
|
||||||
"waybill_email_tick",
|
"waybill_email_tick",
|
||||||
processed=summary.processed,
|
processed=summary.processed,
|
||||||
succeeded=summary.succeeded,
|
succeeded=summary.succeeded,
|
||||||
@@ -133,15 +139,16 @@ class WaybillEmailSenderService:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
async def _handle_order(self, session: object, order: OrderRecord) -> None:
|
async def _handle_order(self, session: object, order: OrderRecord) -> None:
|
||||||
waybill_url = order.cdek_waybill_url
|
if order.provider_waybill_url is None and order.provider_waybill_id is None:
|
||||||
if waybill_url is None:
|
|
||||||
return
|
return
|
||||||
|
|
||||||
pdf_bytes = await self._waybill_downloader.download_waybill_pdf(waybill_url)
|
downloader = self._resolve_downloader(order.provider)
|
||||||
|
pdf_bytes = await downloader.download_waybill_pdf(order)
|
||||||
subject = _EMAIL_SUBJECT_TEMPLATE.format(order_uuid=order.order_uuid)
|
subject = _EMAIL_SUBJECT_TEMPLATE.format(order_uuid=order.order_uuid)
|
||||||
body = _EMAIL_BODY_TEMPLATE.format(
|
body = _EMAIL_BODY_TEMPLATE.format(order_uuid=order.order_uuid)
|
||||||
order_uuid=order.order_uuid,
|
if order.provider_waybill_url is not None:
|
||||||
waybill_url=waybill_url,
|
body += _EMAIL_BODY_URL_LINE_TEMPLATE.format(
|
||||||
|
waybill_url=order.provider_waybill_url,
|
||||||
)
|
)
|
||||||
filename = _ATTACHMENT_FILENAME_TEMPLATE.format(order_uuid=order.order_uuid)
|
filename = _ATTACHMENT_FILENAME_TEMPLATE.format(order_uuid=order.order_uuid)
|
||||||
|
|
||||||
@@ -162,6 +169,13 @@ class WaybillEmailSenderService:
|
|||||||
logger.info(
|
logger.info(
|
||||||
"waybill_email_sent",
|
"waybill_email_sent",
|
||||||
order_uuid=order.order_uuid,
|
order_uuid=order.order_uuid,
|
||||||
|
provider=order.provider,
|
||||||
account_email=order.account_email,
|
account_email=order.account_email,
|
||||||
sent_at=sent_at,
|
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
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Background service that polls CDEK for waybill updates."""
|
"""Background service that polls providers for waybill updates."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import Callable, Sequence
|
from collections.abc import Callable, Mapping, Sequence
|
||||||
from contextlib import AbstractAsyncContextManager
|
from contextlib import AbstractAsyncContextManager
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -9,26 +9,22 @@ from typing import Protocol
|
|||||||
|
|
||||||
import structlog
|
import structlog
|
||||||
|
|
||||||
from app.adapters.delivery_providers.cdek.order_mapper import (
|
|
||||||
CDEKOrderInfo,
|
|
||||||
CDEKWaybillInfo,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = structlog.get_logger(__name__)
|
logger = structlog.get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class CDEKOrderInfoAdapterProtocol(Protocol):
|
class OrderInfoAdapterProtocol(Protocol):
|
||||||
async def get_order(self, cdek_order_uuid: str) -> CDEKOrderInfo: ...
|
async def get_order(self, provider_order_id: str) -> object: ...
|
||||||
|
|
||||||
|
|
||||||
class CDEKWaybillInfoAdapterProtocol(Protocol):
|
class WaybillInfoAdapterProtocol(Protocol):
|
||||||
async def get_waybill(self, cdek_waybill_uuid: str) -> CDEKWaybillInfo: ...
|
async def get_waybill(self, provider_waybill_id: str) -> object: ...
|
||||||
|
|
||||||
|
|
||||||
class OrderRecord(Protocol):
|
class OrderRecord(Protocol):
|
||||||
order_uuid: str
|
order_uuid: str
|
||||||
cdek_order_uuid: str | None
|
provider: str
|
||||||
cdek_waybill_uuid: str | None
|
provider_order_id: str | None
|
||||||
|
provider_waybill_id: str | None
|
||||||
|
|
||||||
|
|
||||||
class WaybillPollerRepositoryProtocol(Protocol):
|
class WaybillPollerRepositoryProtocol(Protocol):
|
||||||
@@ -70,14 +66,14 @@ class WaybillPollerService:
|
|||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
order_repository: WaybillPollerRepositoryProtocol,
|
order_repository: WaybillPollerRepositoryProtocol,
|
||||||
order_info_adapter: CDEKOrderInfoAdapterProtocol,
|
order_info_adapters: Mapping[str, OrderInfoAdapterProtocol],
|
||||||
waybill_info_adapter: CDEKWaybillInfoAdapterProtocol,
|
waybill_info_adapters: Mapping[str, WaybillInfoAdapterProtocol],
|
||||||
batch_size: int,
|
batch_size: int,
|
||||||
datetime_now: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
|
datetime_now: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
|
||||||
) -> None:
|
) -> None:
|
||||||
self._repository = order_repository
|
self._repository = order_repository
|
||||||
self._order_info_adapter = order_info_adapter
|
self._order_info_adapters = dict(order_info_adapters)
|
||||||
self._waybill_info_adapter = waybill_info_adapter
|
self._waybill_info_adapters = dict(waybill_info_adapters)
|
||||||
self._batch_size = batch_size
|
self._batch_size = batch_size
|
||||||
self._datetime_now = datetime_now
|
self._datetime_now = datetime_now
|
||||||
|
|
||||||
@@ -97,8 +93,9 @@ class WaybillPollerService:
|
|||||||
logger.exception(
|
logger.exception(
|
||||||
"waybill_poll_order_failed",
|
"waybill_poll_order_failed",
|
||||||
order_uuid=order.order_uuid,
|
order_uuid=order.order_uuid,
|
||||||
cdek_order_uuid=order.cdek_order_uuid,
|
provider=order.provider,
|
||||||
cdek_waybill_uuid=order.cdek_waybill_uuid,
|
provider_order_id=order.provider_order_id,
|
||||||
|
provider_waybill_id=order.provider_waybill_id,
|
||||||
)
|
)
|
||||||
return PollBatchSummary(
|
return PollBatchSummary(
|
||||||
processed=len(orders),
|
processed=len(orders),
|
||||||
@@ -115,7 +112,7 @@ class WaybillPollerService:
|
|||||||
while not stop_event.is_set():
|
while not stop_event.is_set():
|
||||||
try:
|
try:
|
||||||
summary = await self.poll_once()
|
summary = await self.poll_once()
|
||||||
logger.info(
|
logger.debug(
|
||||||
"waybill_poll_tick",
|
"waybill_poll_tick",
|
||||||
processed=summary.processed,
|
processed=summary.processed,
|
||||||
succeeded=summary.succeeded,
|
succeeded=summary.succeeded,
|
||||||
@@ -133,37 +130,78 @@ class WaybillPollerService:
|
|||||||
|
|
||||||
async def _handle_order(self, session: object, order: OrderRecord) -> None:
|
async def _handle_order(self, session: object, order: OrderRecord) -> None:
|
||||||
polled_at = self._datetime_now()
|
polled_at = self._datetime_now()
|
||||||
if order.cdek_waybill_uuid is None:
|
if order.provider_waybill_id is None:
|
||||||
cdek_order_uuid = order.cdek_order_uuid
|
provider_order_id = order.provider_order_id
|
||||||
if cdek_order_uuid is None:
|
if provider_order_id is None:
|
||||||
return
|
return
|
||||||
info = await self._order_info_adapter.get_order(cdek_order_uuid)
|
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(
|
await self._repository.record_order_poll(
|
||||||
session,
|
session,
|
||||||
order_uuid=order.order_uuid,
|
order_uuid=order.order_uuid,
|
||||||
order_status=info.status_code,
|
order_status=status_code,
|
||||||
waybill_uuid=info.waybill_uuid,
|
waybill_uuid=waybill_id,
|
||||||
polled_at=polled_at,
|
polled_at=polled_at,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"waybill_poll_order_result",
|
"waybill_poll_order_result",
|
||||||
order_uuid=order.order_uuid,
|
order_uuid=order.order_uuid,
|
||||||
cdek_order_uuid=cdek_order_uuid,
|
provider=order.provider,
|
||||||
cdek_order_status=info.status_code,
|
provider_order_id=provider_order_id,
|
||||||
cdek_waybill_uuid=info.waybill_uuid,
|
provider_order_status=status_code,
|
||||||
|
provider_waybill_id=waybill_id,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
waybill = await self._waybill_info_adapter.get_waybill(order.cdek_waybill_uuid)
|
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(
|
await self._repository.record_waybill_poll(
|
||||||
session,
|
session,
|
||||||
order_uuid=order.order_uuid,
|
order_uuid=order.order_uuid,
|
||||||
waybill_url=waybill.url,
|
waybill_url=waybill_url,
|
||||||
polled_at=polled_at,
|
polled_at=polled_at,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"waybill_poll_waybill_result",
|
"waybill_poll_waybill_result",
|
||||||
order_uuid=order.order_uuid,
|
order_uuid=order.order_uuid,
|
||||||
cdek_waybill_uuid=order.cdek_waybill_uuid,
|
provider=order.provider,
|
||||||
cdek_waybill_url=waybill.url,
|
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
|
||||||
|
|||||||
@@ -1,13 +1,21 @@
|
|||||||
"""Background worker that e-mails CDEK waybill PDFs."""
|
"""Background worker that e-mails provider waybill PDFs."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import signal
|
import signal
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import structlog
|
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.auth import CDEKAuthClient
|
||||||
from app.adapters.delivery_providers.cdek.client import CDEKClient
|
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.email import SMTPEmailSender
|
||||||
from app.adapters.postgres.engine import (
|
from app.adapters.postgres.engine import (
|
||||||
create_postgres_engine,
|
create_postgres_engine,
|
||||||
@@ -21,23 +29,71 @@ from app.services.waybill_email_sender import WaybillEmailSenderService
|
|||||||
logger = structlog.get_logger(__name__)
|
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:
|
async def _run(settings: Settings, stop_event: asyncio.Event) -> None:
|
||||||
http_client = httpx.AsyncClient(timeout=settings.adapter.cdek_timeout_seconds)
|
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(
|
auth_client = CDEKAuthClient(
|
||||||
http_client=http_client,
|
http_client=http_client,
|
||||||
base_url=settings.adapter.cdek_base_url,
|
base_url=cdek_config.base_url,
|
||||||
client_id=settings.adapter.cdek_client_id,
|
client_id=cdek_config.client_id,
|
||||||
client_secret=settings.adapter.cdek_client_secret,
|
client_secret=cdek_config.client_secret,
|
||||||
timeout_seconds=settings.adapter.cdek_timeout_seconds,
|
timeout_seconds=cdek_config.timeout_seconds,
|
||||||
)
|
)
|
||||||
cdek_client = CDEKClient(
|
cdek_client = CDEKClient(
|
||||||
http_client=http_client,
|
http_client=http_client,
|
||||||
auth_client=auth_client,
|
auth_client=auth_client,
|
||||||
base_url=settings.adapter.cdek_base_url,
|
base_url=cdek_config.base_url,
|
||||||
timeout_seconds=settings.adapter.cdek_timeout_seconds,
|
timeout_seconds=cdek_config.timeout_seconds,
|
||||||
retry_attempts=settings.adapter.cdek_retry_attempts,
|
retry_attempts=cdek_config.retry_attempts,
|
||||||
retry_backoff_seconds=settings.adapter.cdek_retry_backoff_seconds,
|
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(
|
email_sender = SMTPEmailSender(
|
||||||
smtp_host=settings.email.smtp_host,
|
smtp_host=settings.email.smtp_host,
|
||||||
smtp_port=settings.email.smtp_port,
|
smtp_port=settings.email.smtp_port,
|
||||||
@@ -52,7 +108,7 @@ async def _run(settings: Settings, stop_event: asyncio.Event) -> None:
|
|||||||
repository = OrderRepository(session_factory=session_factory)
|
repository = OrderRepository(session_factory=session_factory)
|
||||||
service = WaybillEmailSenderService(
|
service = WaybillEmailSenderService(
|
||||||
order_repository=repository,
|
order_repository=repository,
|
||||||
waybill_downloader=cdek_client,
|
waybill_downloaders=waybill_downloaders,
|
||||||
email_sender=email_sender,
|
email_sender=email_sender,
|
||||||
batch_size=settings.waybill_email_sender.batch_size,
|
batch_size=settings.waybill_email_sender.batch_size,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Background worker that polls CDEK for waybill updates."""
|
"""Background worker that polls delivery providers for waybill updates."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import signal
|
import signal
|
||||||
@@ -6,8 +6,15 @@ import signal
|
|||||||
import httpx
|
import httpx
|
||||||
import structlog
|
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.auth import CDEKAuthClient
|
||||||
from app.adapters.delivery_providers.cdek.client import CDEKClient
|
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 (
|
from app.adapters.postgres.engine import (
|
||||||
create_postgres_engine,
|
create_postgres_engine,
|
||||||
create_postgres_session_factory,
|
create_postgres_session_factory,
|
||||||
@@ -21,29 +28,57 @@ logger = structlog.get_logger(__name__)
|
|||||||
|
|
||||||
|
|
||||||
async def _run(settings: Settings, stop_event: asyncio.Event) -> None:
|
async def _run(settings: Settings, stop_event: asyncio.Event) -> None:
|
||||||
http_client = httpx.AsyncClient(timeout=settings.adapter.cdek_timeout_seconds)
|
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(
|
auth_client = CDEKAuthClient(
|
||||||
http_client=http_client,
|
http_client=http_client,
|
||||||
base_url=settings.adapter.cdek_base_url,
|
base_url=cdek_config.base_url,
|
||||||
client_id=settings.adapter.cdek_client_id,
|
client_id=cdek_config.client_id,
|
||||||
client_secret=settings.adapter.cdek_client_secret,
|
client_secret=cdek_config.client_secret,
|
||||||
timeout_seconds=settings.adapter.cdek_timeout_seconds,
|
timeout_seconds=cdek_config.timeout_seconds,
|
||||||
)
|
)
|
||||||
cdek_client = CDEKClient(
|
cdek_client = CDEKClient(
|
||||||
http_client=http_client,
|
http_client=http_client,
|
||||||
auth_client=auth_client,
|
auth_client=auth_client,
|
||||||
base_url=settings.adapter.cdek_base_url,
|
base_url=cdek_config.base_url,
|
||||||
timeout_seconds=settings.adapter.cdek_timeout_seconds,
|
timeout_seconds=cdek_config.timeout_seconds,
|
||||||
retry_attempts=settings.adapter.cdek_retry_attempts,
|
retry_attempts=cdek_config.retry_attempts,
|
||||||
retry_backoff_seconds=settings.adapter.cdek_retry_backoff_seconds,
|
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)
|
engine = create_postgres_engine(settings.postgres)
|
||||||
session_factory = create_postgres_session_factory(engine)
|
session_factory = create_postgres_session_factory(engine)
|
||||||
repository = OrderRepository(session_factory=session_factory)
|
repository = OrderRepository(session_factory=session_factory)
|
||||||
service = WaybillPollerService(
|
service = WaybillPollerService(
|
||||||
order_repository=repository,
|
order_repository=repository,
|
||||||
order_info_adapter=cdek_client,
|
order_info_adapters=order_info_adapters,
|
||||||
waybill_info_adapter=cdek_client,
|
waybill_info_adapters=waybill_info_adapters,
|
||||||
batch_size=settings.waybill_poller.batch_size,
|
batch_size=settings.waybill_poller.batch_size,
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
|
|||||||
@@ -1,82 +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
|
|
||||||
|
|
||||||
tbank_payment:
|
|
||||||
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
|
||||||
notification_url: "https://merchant.example.com/api/v1/delivery/tbank/notifications"
|
|
||||||
success_url: "https://merchant.example.com/payment/success"
|
|
||||||
auth:
|
|
||||||
terminal_key: "change-me-terminal-key"
|
|
||||||
password: "change-me-password"
|
|
||||||
timeout_seconds: 10.0
|
|
||||||
retry_attempts: 2
|
|
||||||
retry_backoff_seconds: 0.2
|
|
||||||
|
|
||||||
postgres:
|
|
||||||
dsn: "postgresql+asyncpg://postgres:postgres@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"
|
|
||||||
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
|
|
||||||
|
|
||||||
waybill_poller:
|
|
||||||
interval_seconds: 30
|
|
||||||
batch_size: 50
|
|
||||||
|
|
||||||
email:
|
|
||||||
smtp_host: "smtp.example.com"
|
|
||||||
smtp_port: 587
|
|
||||||
username: ""
|
|
||||||
password: ""
|
|
||||||
from_address: "no-reply@example.com"
|
|
||||||
use_tls: true
|
|
||||||
timeout_seconds: 10.0
|
|
||||||
|
|
||||||
waybill_email_sender:
|
|
||||||
interval_seconds: 30
|
|
||||||
batch_size: 50
|
|
||||||
@@ -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
|
||||||
+24
-8
@@ -14,14 +14,30 @@ repository:
|
|||||||
redis_dsn: "redis://localhost:6379/0"
|
redis_dsn: "redis://localhost:6379/0"
|
||||||
price_cache_ttl_seconds: 900
|
price_cache_ttl_seconds: 900
|
||||||
|
|
||||||
adapter:
|
delivery_providers:
|
||||||
cdek_base_url: "https://api.cdek.ru/v2"
|
cdek:
|
||||||
cdek_client_id: "test-client-id"
|
enabled: true
|
||||||
cdek_client_secret: "test-client-secret"
|
base_url: "https://api.cdek.ru/v2"
|
||||||
cdek_retry_attempts: 2
|
client_id: "test-client-id"
|
||||||
cdek_retry_backoff_seconds: 0.2
|
client_secret: "test-client-secret"
|
||||||
cdek_timeout_seconds: 10.0
|
retry_attempts: 2
|
||||||
cdek_cache_ttl_seconds: 900
|
retry_backoff_seconds: 0.2
|
||||||
|
timeout_seconds: 10.0
|
||||||
|
cache_ttl_seconds: 900
|
||||||
|
cse:
|
||||||
|
enabled: true
|
||||||
|
base_url: "http://lk-test.cse.ru/1c/ws/web1c.1cws"
|
||||||
|
login: "test"
|
||||||
|
password: "2016"
|
||||||
|
retry_attempts: 2
|
||||||
|
retry_backoff_seconds: 0.2
|
||||||
|
timeout_seconds: 10.0
|
||||||
|
cache_ttl_seconds: 900
|
||||||
|
delivery_service_guids:
|
||||||
|
- "tariff-guid-2"
|
||||||
|
payer: "0"
|
||||||
|
payment_method: "1"
|
||||||
|
shipping_method: "5052d0b3-5ea3-46f2-823f-1472686a51dd"
|
||||||
|
|
||||||
tbank_payment:
|
tbank_payment:
|
||||||
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||||
|
|||||||
@@ -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,74 +0,0 @@
|
|||||||
services:
|
|
||||||
app:
|
|
||||||
image: yusupal1ev/g2s-aggregator:0.0.7
|
|
||||||
container_name: g2s-aggregator
|
|
||||||
ports:
|
|
||||||
- "8000:8000"
|
|
||||||
depends_on:
|
|
||||||
redis:
|
|
||||||
condition: service_started
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
migrations:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
volumes:
|
|
||||||
- ./config.yaml:/config.yaml
|
|
||||||
redis:
|
|
||||||
image: redis:7-alpine
|
|
||||||
container_name: redis
|
|
||||||
command: ["redis-server", "--save", "", "--appendonly", "no"]
|
|
||||||
ports:
|
|
||||||
- "6379:6379"
|
|
||||||
postgres:
|
|
||||||
image: postgres:16-alpine
|
|
||||||
container_name: postgres
|
|
||||||
environment:
|
|
||||||
POSTGRES_DB: g2s_aggregator
|
|
||||||
POSTGRES_USER: g2s_user
|
|
||||||
POSTGRES_PASSWORD: 7ed0a5a0f24be266
|
|
||||||
ports:
|
|
||||||
- "5432:5432"
|
|
||||||
volumes:
|
|
||||||
- postgres:/var/lib/postgresql/data
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD-SHELL", "pg_isready -U g2s_user -d g2s_aggregator"]
|
|
||||||
interval: 5s
|
|
||||||
timeout: 3s
|
|
||||||
retries: 10
|
|
||||||
migrations:
|
|
||||||
image: yusupal1ev/g2s-aggregator:0.0.7
|
|
||||||
container_name: g2s-aggregator-migrations
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
volumes:
|
|
||||||
- ./config.yaml:/config.yaml
|
|
||||||
command: ["poetry", "run", "alembic", "upgrade", "head"]
|
|
||||||
restart: "no"
|
|
||||||
waybill-poller:
|
|
||||||
image: yusupal1ev/g2s-aggregator:0.0.7
|
|
||||||
container_name: g2s-aggregator-waybill-poller
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
migrations:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
volumes:
|
|
||||||
- ./config.yaml:/config.yaml
|
|
||||||
command: ["poetry", "run", "python", "-m", "app.workers.waybill_poller"]
|
|
||||||
restart: unless-stopped
|
|
||||||
waybill-email-sender:
|
|
||||||
image: yusupal1ev/g2s-aggregator:0.0.7
|
|
||||||
container_name: g2s-aggregator-waybill-email-sender
|
|
||||||
depends_on:
|
|
||||||
postgres:
|
|
||||||
condition: service_healthy
|
|
||||||
migrations:
|
|
||||||
condition: service_completed_successfully
|
|
||||||
volumes:
|
|
||||||
- ./config.yaml:/config.yaml
|
|
||||||
command:
|
|
||||||
["poetry", "run", "python", "-m", "app.workers.waybill_email_sender"]
|
|
||||||
restart: unless-stopped
|
|
||||||
volumes:
|
|
||||||
postgres:
|
|
||||||
+4
-1
@@ -17,7 +17,6 @@ POST http://localhost:8000/api/v1/delivery/order
|
|||||||
Content-Type: application/json
|
Content-Type: application/json
|
||||||
|
|
||||||
{
|
{
|
||||||
"orderUuid": "order-uuid-1",
|
|
||||||
"senderAddress": {
|
"senderAddress": {
|
||||||
"cityId": 1,
|
"cityId": 1,
|
||||||
"city": "Дубай",
|
"city": "Дубай",
|
||||||
@@ -121,3 +120,7 @@ Authorization: Bearer {{auth_token}}
|
|||||||
Accept: application/pdf
|
Accept: application/pdf
|
||||||
|
|
||||||
>>! waybill.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]
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
# spec/overview.md
|
# overview.md
|
||||||
|
|
||||||
Контекст, специфичный для проекта агрегатора служб доставки.
|
Контекст, специфичный для проекта агрегатора служб доставки.
|
||||||
Глобальные правила определены в AGENTS.md.
|
Глобальные правила определены в AGENTS.md.
|
||||||
Generated
-2128
File diff suppressed because it is too large
Load Diff
+27
-31
@@ -1,40 +1,36 @@
|
|||||||
[tool.poetry]
|
[project]
|
||||||
name = "g2s-aggregator"
|
name = "g2s-aggregator"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = ""
|
description = ""
|
||||||
authors = ["Your Name <you@example.com>"]
|
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
package-mode = false
|
requires-python = ">=3.14"
|
||||||
|
dependencies = [
|
||||||
|
"fastapi>=0.135.1,<0.136.0",
|
||||||
|
"uvicorn[standard]>=0.41.0,<0.42.0",
|
||||||
|
"httpx>=0.28.1,<0.29.0",
|
||||||
|
"pydantic>=2.12.5,<3.0.0",
|
||||||
|
"pydantic-settings>=2.13.1,<3.0.0",
|
||||||
|
"aioredis>=2.0.1,<3.0.0",
|
||||||
|
"structlog>=25.5.0,<26.0.0",
|
||||||
|
"opentelemetry-api>=1.40.0,<2.0.0",
|
||||||
|
"opentelemetry-sdk>=1.40.0,<2.0.0",
|
||||||
|
"opentelemetry-exporter-otlp>=1.40.0,<2.0.0",
|
||||||
|
"opentelemetry-instrumentation-fastapi>=0.61b0",
|
||||||
|
"opentelemetry-instrumentation-httpx>=0.61b0",
|
||||||
|
"opentelemetry-instrumentation-redis>=0.61b0",
|
||||||
|
"pytest>=9.0.2,<10.0.0",
|
||||||
|
"redis>=7.3.0,<8.0.0",
|
||||||
|
"sqlalchemy>=2.0.49,<3.0.0",
|
||||||
|
"asyncpg>=0.31.0,<0.32.0",
|
||||||
|
"alembic>=1.18.4,<2.0.0",
|
||||||
|
"aiosqlite>=0.22.1,<0.23.0",
|
||||||
|
"aiosmtplib>=4.0.2,<5.0.0",
|
||||||
|
"email-validator>=2.2.0,<3.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.poetry.dependencies]
|
[tool.uv]
|
||||||
python = "^3.14"
|
package = false
|
||||||
fastapi = "^0.135.1"
|
|
||||||
uvicorn = {extras = ["standard"], version = "^0.41.0"}
|
|
||||||
httpx = "^0.28.1"
|
|
||||||
pydantic = "^2.12.5"
|
|
||||||
pydantic-settings = "^2.13.1"
|
|
||||||
aioredis = "^2.0.1"
|
|
||||||
structlog = "^25.5.0"
|
|
||||||
opentelemetry-api = "^1.40.0"
|
|
||||||
opentelemetry-sdk = "^1.40.0"
|
|
||||||
opentelemetry-exporter-otlp = "^1.40.0"
|
|
||||||
opentelemetry-instrumentation-fastapi = "^0.61b0"
|
|
||||||
opentelemetry-instrumentation-httpx = "^0.61b0"
|
|
||||||
opentelemetry-instrumentation-redis = "^0.61b0"
|
|
||||||
pytest = "^9.0.2"
|
|
||||||
redis = "^7.3.0"
|
|
||||||
sqlalchemy = "^2.0.49"
|
|
||||||
asyncpg = "^0.31.0"
|
|
||||||
alembic = "^1.18.4"
|
|
||||||
aiosqlite = "^0.22.1"
|
|
||||||
aiosmtplib = "^4.0.2"
|
|
||||||
email-validator = "^2.2.0"
|
|
||||||
|
|
||||||
[tool.pytest.ini_options]
|
[tool.pytest.ini_options]
|
||||||
pythonpath = ["."]
|
pythonpath = ["."]
|
||||||
addopts = ["--import-mode=importlib"]
|
addopts = ["--import-mode=importlib"]
|
||||||
|
|
||||||
|
|
||||||
[build-system]
|
|
||||||
requires = ["poetry-core"]
|
|
||||||
build-backend = "poetry.core.masonry.api"
|
|
||||||
|
|||||||
@@ -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,49 +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` |
|
|
||||||
| 026 | DONE | 2026-04-05 | Align CDEK order contract with single phone and kilogram package weight | `spec/tasks/026_align_cdek_order_contract_single_phone_and_weight_units.md` |
|
|
||||||
| 027 | DONE | 2026-04-11 | Add TBank payment adapter, init_payment endpoint and rename order flow | `spec/tasks/027_add_tbank_payment_adapter_and_order_payment_link.md` |
|
|
||||||
| 028 | DONE | 2026-04-12 | Add PostgreSQL adapter, order repository and persist order after payment link creation | `spec/tasks/028_add_postgresql_order_persistence.md` |
|
|
||||||
| 029 | DONE | 2026-04-18 | Add TBank payment notification and success URLs | `spec/tasks/029_add_tbank_payment_urls.md` |
|
|
||||||
| 030 | DONE | 2026-04-18 | Add TBank payment notification webhook and CDEK order creation | `spec/tasks/030_add_tbank_payment_notification_webhook.md` |
|
|
||||||
| 031 | TODO | 2026-04-18 | Validate init-payment price with CDEK tariff | `spec/tasks/031_validate_init_payment_price_with_cdek.md` |
|
|
||||||
| 032 | TODO | 2026-05-13 | Rework init-payment contract to camelCase and structured address/contact | `spec/tasks/032_rework_init_payment_contract_camelcase.md` |
|
|
||||||
| 033 | DONE | 2026-05-23 | Add CDEK waybill polling worker | `spec/tasks/033_add_waybill_polling_worker.md` |
|
|
||||||
| 034 | DONE | 2026-05-23 | Add CDEK waybill e-mail sender worker | `spec/tasks/034_add_waybill_email_sender_worker.md` |
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
- Total: **35**
|
|
||||||
- TODO: **2**
|
|
||||||
- DONE: **33**
|
|
||||||
@@ -1,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`
|
|
||||||
@@ -1,52 +0,0 @@
|
|||||||
---
|
|
||||||
id: 026
|
|
||||||
title: Align CDEK order contract with single phone and kilogram package weight
|
|
||||||
status: DONE
|
|
||||||
created: 2026-04-05
|
|
||||||
---
|
|
||||||
|
|
||||||
## Context
|
|
||||||
Текущий order flow принимает `sender.phones` и `recipient.phones` как список, требует обязательное поле `services` и пробрасывает `packages.weight` в CDEK payload без явной фиксации единиц измерения. Новый контракт должен принимать один телефон на сторону, разрешать отсутствие `services` и гарантировать, что во входном API вес упаковки задаётся в килограммах, а в CDEK отправляется в граммах.
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
Обновить flow `POST /api/v1/delivery/order` по слоям Controller, Service и Adapter так, чтобы public/internal request contract использовал `phone` вместо `phones`, поле `services` было необязательным, а CDEK order mapper конвертировал `packages.weight` из килограммов во входном запросе в граммы во внешнем provider payload.
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
- Изменения ограничены существующими модулями order flow: `app/schemas/order.py`, `app/controllers/v1/delivery.py`, `app/services/aggregator.py`, `app/adapters/delivery_providers/cdek/`, `http-client.http` и связанными тестами.
|
|
||||||
- `POST /api/v1/delivery/order` должен оставаться в существующем controller и по-прежнему вызывать ровно один метод Service: `AggregatorService.create_order()`.
|
|
||||||
- Service остаётся orchestration layer и не получает новую business logic; он только принимает обновлённую order model и делегирует её adapter.
|
|
||||||
- Во внутреннем order flow и публичном API поле `phones` должно быть удалено; передача нескольких телефонов больше не поддерживается.
|
|
||||||
- CDEK adapter может сериализовать внутренний `phone` в provider-specific поле `phones`, если это требуется внешним контрактом CDEK, но множественность телефонов не должна возвращаться во внутренние модели и controller contract.
|
|
||||||
- `services` должно быть необязательным полем request schema; при отсутствии значения нельзя подставлять фиктивные service entries.
|
|
||||||
- Конвертация единиц `packages.weight` должна выполняться только на границе CDEK adapter mapping: входной order request использует килограммы, исходящий payload в CDEK использует граммы.
|
|
||||||
- Scope задачи не включает изменение response contract, price flow, address suggestion flow, provider routing, кеширование, новые провайдеры и расширение поддерживаемых `type`/`tariff_code`.
|
|
||||||
- Не изменять файлы в `spec/`.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- `OrderCreateRequest` использует `sender.phone` и `recipient.phone` вместо `sender.phones` и `recipient.phones`.
|
|
||||||
- Если request payload содержит `sender.phones` или `recipient.phones`, endpoint возвращает 422 на уровне schema validation.
|
|
||||||
- `POST /api/v1/delivery/order` принимает валидный payload без поля `services`.
|
|
||||||
- Если `services` отсутствует или равен `null`, service и adapter flow успешно обрабатывают запрос без добавления `services` в исходящий CDEK payload.
|
|
||||||
- `AggregatorService.create_order()` продолжает только оркестрировать вызов injected order adapter и не содержит преобразования `phone`/`phones` или килограммов в граммы.
|
|
||||||
- CDEK order mapper формирует provider payload с полями `sender.phones` и `recipient.phones`, каждое из которых содержит ровно один элемент, полученный из соответствующего внутреннего поля `phone`.
|
|
||||||
- Для каждого элемента `packages` исходящий payload CDEK содержит `weight`, равный значению входного `packages.weight`, умноженному на `1000`.
|
|
||||||
- `http-client.http` содержит актуальный пример Create Delivery Order с `phone` вместо `phones`, без обязательного `services` и с весом, отражающим controller contract в килограммах.
|
|
||||||
|
|
||||||
## Definition of Done
|
|
||||||
- [ ] Обновлён public/internal order request contract на `phone` вместо `phones`.
|
|
||||||
- [ ] `services` сделано необязательным без изменения endpoint path и service orchestration.
|
|
||||||
- [ ] Реализован mapping одного телефона в provider payload `phones` и конвертация `packages.weight` из килограммов в граммы.
|
|
||||||
- [ ] Обновлены controller, service и adapter tests под новый контракт и unit conversion.
|
|
||||||
- [ ] Обновлён пример запроса в `http-client.http`.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
- Обновить `tests/controllers/v1/test_order.py` для success case с `phone`, сценариев 422 при передаче `sender.phones` и `recipient.phones`, а также для запроса без `services`.
|
|
||||||
- Обновить `tests/services/test_order.py` для проверки, что service принимает обновлённую order model, делегирует её adapter без новой логики и корректно работает с `services=None`.
|
|
||||||
- Обновить `tests/adapters/delivery_providers/cdek/test_order_client.py` для проверки mapping `phone -> phones[0]`, отсутствия `services` в исходящем payload при `None` и конвертации `packages.weight` из килограммов в граммы.
|
|
||||||
- При необходимости обновить другие order-related tests и fixtures, завязанные на старые поля `phones` и обязательность `services`.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
- `poetry run pytest tests/controllers/v1/test_order.py -q`
|
|
||||||
- `poetry run pytest tests/services/test_order.py -q`
|
|
||||||
- `poetry run pytest tests/adapters/delivery_providers/cdek/test_order_client.py -q`
|
|
||||||
- `python3 spec/gen_spec_index.py --check`
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
---
|
|
||||||
id: 027
|
|
||||||
title: Add TBank payment adapter, init_payment endpoint and rename order flow
|
|
||||||
status: DONE
|
|
||||||
created: 2026-04-11
|
|
||||||
---
|
|
||||||
|
|
||||||
## Context
|
|
||||||
В проекте ещё нет платёжного адаптера, каталог `app/adapters/` содержит только `delivery_providers/` и `address_suggestions/`. Текущий endpoint `POST /api/v1/delivery/order` регистрирует заказ в CDEK. В новом флоу endpoint будет инициировать оплату через TBank и возвращать ссылку на оплату, без регистрации заказа в CDEK. В связи с изменением бизнес-смысла необходимо переименовать endpoint, сервисный метод, все DTO и модели флоу.
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
1. Добавить новый payment adapter `TBankAdapter` для генерации ссылки на оплату TBank.
|
|
||||||
2. Переименовать endpoint `POST /api/v1/delivery/order` → `POST /api/v1/delivery/init-payment`.
|
|
||||||
3. Переименовать сервисный метод `AggregatorService.create_order()` → `AggregatorService.init_payment()`.
|
|
||||||
4. Переименовать все DTO и модели флоу: `OrderCreateRequest` → `InitPaymentRequest`, `OrderCreateResponse` → `InitPaymentResponse`, `InvalidOrderCreateRequestError` → `InvalidInitPaymentRequestError`, `OrderCreationUnavailableError` → `InitPaymentUnavailableError`.
|
|
||||||
5. Переименовать файл схем `app/schemas/order.py` → `app/schemas/payment.py`.
|
|
||||||
6. Добавить в `InitPaymentRequest` обязательное поле `price: int` (в копейках) и вызвать `payment_adapter.create_payment_link()` из `init_payment()`, вернув `payment_url` в `InitPaymentResponse`.
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
- Новый payment adapter должен располагаться в отдельном каталоге `app/adapters/tbank/` с модулями `base.py` и `client.py`; не размещать payment logic внутри `delivery_providers/` или `address_suggestions/`.
|
|
||||||
- Payment adapter MUST инкапсулировать HTTP взаимодействие с TBank API, auth, retries, serialization и error handling; наружу должен экспонироваться только service-facing method (`create_payment_link(order_uuid: str, amount_kopecks: int) -> str`), без утечки HTTP деталей в Service.
|
|
||||||
- Payment adapter MUST владеть собственной секцией конфигурации (`TBankPaymentConfig`) с обязательными полями auth и URL; конфигурация читается из `config.yaml`, который остаётся в `.gitignore`.
|
|
||||||
- `InitPaymentRequest` MUST содержать обязательное поле `price: int` в копейках с валидацией `gt=0`; единица измерения явно зафиксирована в schema и в `spec/overview.md`.
|
|
||||||
- `AggregatorService.init_payment()` MUST вызывать `payment_adapter.create_payment_link()`. Payment adapter передаётся в service через dependency injection (новый аргумент конструктора).
|
|
||||||
- Ошибки payment adapter MUST маппиться в `InvalidInitPaymentRequestError` (→ 400) и `InitPaymentUnavailableError` (→ 503) на уровне controller.
|
|
||||||
- Controller `POST /api/v1/delivery/init-payment` MUST вызывать ровно один метод Service (`AggregatorService.init_payment()`); старый endpoint `/order` удаляется, новые дополнительные endpoints запрещены.
|
|
||||||
- Service слой не содержит HTTP, retries или TBank-специфичных деталей.
|
|
||||||
- Scope задачи не включает: повторные попытки оплаты, refund flow, webhook обработку платёжных уведомлений, persistence заказов, изменения price flow, address suggestion flow, а также поддержку других платёжных провайдеров.
|
|
||||||
- Не изменять файлы в `spec/` кроме создания этой задачи и обновления `spec/overview.md` при необходимости (обновление выполняется Planner).
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- В `app/adapters/tbank/` существуют `base.py` с исключениями адаптера и `client.py` с реализацией `TBankAdapter`.
|
|
||||||
- `TBankAdapter` реализует async метод `create_payment_link(order_uuid: str, amount_kopecks: int) -> str`, принимает идентификатор заказа и сумму в копейках и возвращает URL.
|
|
||||||
- `TBankAdapter` имеет собственную конфигурацию `TBankPaymentConfig` с обязательными полями auth и URL; конфигурация читается из yaml.
|
|
||||||
- Файл `app/schemas/order.py` переименован в `app/schemas/payment.py`; все импорты обновлены.
|
|
||||||
- `InitPaymentRequest` (бывший `OrderCreateRequest`) содержит обязательное поле `price: int` (в копейках) с валидацией `gt=0`; запрос без `price` или с нецелым/отрицательным значением возвращает 422.
|
|
||||||
- `InitPaymentResponse` (бывший `OrderCreateResponse`) содержит поле `payment_url: str` (минимум 1 символ) и возвращается из endpoint `POST /api/v1/delivery/init-payment`.
|
|
||||||
- `AggregatorService.init_payment()` (бывший `create_order()`) принимает `InitPaymentRequest`, вызывает `payment_adapter.create_payment_link()` с `order_uuid` и `price`, и возвращает `InitPaymentResponse` с `payment_url`.
|
|
||||||
- Сервисные исключения переименованы: `InvalidInitPaymentRequestError` (бывший `InvalidOrderCreateRequestError`), `InitPaymentUnavailableError` (бывший `OrderCreationUnavailableError`).
|
|
||||||
- Endpoint `POST /api/v1/delivery/order` удалён; endpoint `POST /api/v1/delivery/init-payment` возвращает `InitPaymentResponse`.
|
|
||||||
- Ошибки TBank provider request мапятся в 400, transport/недоступность — в 503 на уровне controller.
|
|
||||||
- Controller продолжает вызывать ровно один метод Service.
|
|
||||||
- Пример запроса в `http-client.http` обновлён: использует `/init-payment`, содержит поле `price` в копейках и отражает обновлённый response contract.
|
|
||||||
- `config.yaml` пример (в `.gitignore`) содержит секцию с параметрами TBank adapter.
|
|
||||||
|
|
||||||
## Definition of Done
|
|
||||||
- [ ] Создан модуль `app/adapters/tbank/` с `base.py` и `client.py`, реализующий `TBankAdapter`.
|
|
||||||
- [ ] Добавлена конфигурация TBank adapter (`TBankPaymentConfig`) в `app/config.py` и соответствующий пример в `config.yaml`.
|
|
||||||
- [ ] `app/schemas/order.py` переименован в `app/schemas/payment.py`; все импорты в контроллере, сервисе и тестах обновлены.
|
|
||||||
- [ ] `OrderCreateRequest` → `InitPaymentRequest`, `OrderCreateResponse` → `InitPaymentResponse` во всех файлах.
|
|
||||||
- [ ] `InvalidOrderCreateRequestError` → `InvalidInitPaymentRequestError`, `OrderCreationUnavailableError` → `InitPaymentUnavailableError` во всех файлах.
|
|
||||||
- [ ] `AggregatorService.create_order()` → `AggregatorService.init_payment()`; метод вызывает TBank adapter и возвращает `InitPaymentResponse`.
|
|
||||||
- [ ] Payment adapter injected в service через wiring в `app/controllers/v1/delivery.py::_build_aggregator_service`.
|
|
||||||
- [ ] Endpoint `/order` удалён, добавлен `/init-payment`; controller function переименована в `init_payment`.
|
|
||||||
- [ ] Controller маппит `InvalidInitPaymentRequestError` → 400, `InitPaymentUnavailableError` → 503.
|
|
||||||
- [ ] Обновлены unit tests для schemas, service, controller, а также добавлены unit tests для TBank adapter (HTTP client с stub transport).
|
|
||||||
- [ ] Обновлён пример запроса в `http-client.http`.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
- Добавить `tests/adapters/tbank/test_client.py` с проверкой: формирование payment request payload (auth, сумма в копейках, order uuid), маппинг успешного ответа в URL, маппинг 4xx в provider request error, маппинг 5xx/transport в client error, retry behavior если реализован.
|
|
||||||
- Переименовать `tests/services/test_order.py` → `tests/services/test_init_payment.py`; обновить: stub payment adapter; success case включает вызов payment adapter и возврат `payment_url`; маппинг payment adapter errors в `InvalidInitPaymentRequestError` / `InitPaymentUnavailableError`.
|
|
||||||
- Переименовать `tests/controllers/v1/test_order.py` → `tests/controllers/v1/test_init_payment.py`; обновить: endpoint `/init-payment`; payload содержит `price`; success response включает `payment_url`; запрос без `price` и с `price <= 0` возвращает 422; сценарии 400/503 продолжают работать.
|
|
||||||
- Обновить `tests/config/test_config_sections.py` если добавлена новая обязательная секция в yaml.
|
|
||||||
- При необходимости обновить `tests/smoke/test_app_import.py` для проверки wiring payment adapter.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
- `poetry run pytest tests/adapters/tbank/test_client.py -q`
|
|
||||||
- `poetry run pytest tests/services/test_init_payment.py -q`
|
|
||||||
- `poetry run pytest tests/controllers/v1/test_init_payment.py -q`
|
|
||||||
- `poetry run pytest tests/config/test_config_sections.py -q`
|
|
||||||
- `poetry run pytest tests/smoke/test_app_import.py -q`
|
|
||||||
- `python3 spec/gen_spec_index.py --check`
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
---
|
|
||||||
id: 028
|
|
||||||
title: Add PostgreSQL adapter, order repository and persist order after payment link creation
|
|
||||||
status: DONE
|
|
||||||
created: 2026-04-12
|
|
||||||
---
|
|
||||||
|
|
||||||
## Context
|
|
||||||
После создания ссылки на оплату через TBank adapter данные заявки нигде не сохраняются. Необходимо добавить PostgreSQL-адаптер для управления подключением к базе данных, репозиторий для сохранения данных заявки и интегрировать сохранение в существующий flow `AggregatorService.init_payment()`. Зависимости `sqlalchemy` (2.0.49) и `asyncpg` (0.31.0) уже присутствуют в `pyproject.toml`. Alembic (1.18.4) также доступен для миграций.
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
1. Добавить PostgreSQL-адаптер (`app/adapters/postgres/`) для управления async-сессиями SQLAlchemy (`AsyncEngine`, `async_sessionmaker`).
|
|
||||||
2. Добавить репозиторий заявок (`app/repositories/order/`) с методом `create_order()` для сохранения данных заявки вместе со ссылкой на оплату в PostgreSQL.
|
|
||||||
3. Определить SQLAlchemy model для таблицы заявок в `app/repositories/order/models.py`.
|
|
||||||
4. Создать Alembic-миграцию для создания таблицы заявок.
|
|
||||||
5. Расширить `AggregatorService.init_payment()`: после успешного получения `payment_url` от TBank adapter сохранять данные `InitPaymentRequest` вместе с `payment_url` через order repository.
|
|
||||||
6. Добавить секцию конфигурации PostgreSQL (`PostgresConfig`) в `app/config.py` и пример в `config.yaml`.
|
|
||||||
7. Добавить сервис PostgreSQL в `docker-compose.yml`.
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
- PostgreSQL adapter (`app/adapters/postgres/`) MUST содержать только управление подключением (engine, session factory). Без бизнес-логики, без SQL-запросов.
|
|
||||||
- Repository (`app/repositories/order/`) MUST содержать только операции с базой данных. Без бизнес-решений, без workflow-логики.
|
|
||||||
- Service MUST оркестрировать вызовы TBank adapter и order repository. Если сохранение в БД завершается ошибкой после успешного получения `payment_url`, Service MUST всё равно вернуть `payment_url` клиенту (сохранение не должно блокировать ответ); ошибку сохранения логировать.
|
|
||||||
- SQLAlchemy model MUST использовать `sqlalchemy.orm.DeclarativeBase` (SQLAlchemy 2.0 style).
|
|
||||||
- Для миграций использовать Alembic с async-конфигурацией (`asyncpg`).
|
|
||||||
- Конфигурация PostgreSQL MUST быть в отдельной секции `postgres` в `config.yaml` с обязательным полем `dsn`; `config.yaml` уже в `.gitignore`.
|
|
||||||
- `_RequiredYamlSections` в `app/config.py` MUST быть обновлён для включения секции `postgres`.
|
|
||||||
- Order repository передаётся в `AggregatorService` через dependency injection (новый параметр конструктора).
|
|
||||||
- PostgreSQL adapter создаётся в wiring (`_build_aggregator_service`) в controller и передаёт session factory в order repository.
|
|
||||||
- Таблица заявок MUST содержать как минимум: `id` (UUID, PK), `order_uuid` (str, unique), `payment_url` (str), `price` (int, копейки), `tariff_code` (int), `sender` (JSONB), `recipient` (JSONB), `from_location` (JSONB), `to_location` (JSONB), `packages` (JSONB), `services` (JSONB, nullable), `comment` (str, nullable), `created_at` (timestamp with timezone, server default).
|
|
||||||
- Scope НЕ включает: чтение/обновление/удаление заявок, API-endpoint для списка заявок, webhook-обработку платёжных уведомлений, изменения price flow, address suggestion flow.
|
|
||||||
- НЕ изменять существующие тесты TBank adapter, не изменять поведение price и address suggestion endpoints.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- В `app/adapters/postgres/` существует модуль с функцией создания `AsyncEngine` и `async_sessionmaker` из конфигурации.
|
|
||||||
- В `app/repositories/order/` существует `OrderRepository` с async-методом `create_order(session, order_data)`, сохраняющим запись заявки.
|
|
||||||
- В `app/repositories/order/models.py` определена SQLAlchemy ORM model таблицы `orders` со всеми обязательными полями.
|
|
||||||
- Alembic инициализирован с async-конфигурацией; существует миграция для создания таблицы `orders`.
|
|
||||||
- `AggregatorService.__init__()` принимает опциональный `order_repository` через DI.
|
|
||||||
- `AggregatorService.init_payment()` после успешного получения `payment_url` вызывает `order_repository.create_order()` с данными из `InitPaymentRequest` и `payment_url`.
|
|
||||||
- Если `order_repository.create_order()` выбрасывает исключение, `init_payment()` логирует ошибку и возвращает `InitPaymentResponse(payment_url=...)` без ошибки клиенту.
|
|
||||||
- В `app/config.py` добавлена `PostgresConfig` с полем `dsn: str`.
|
|
||||||
- Секция `postgres` присутствует в `_RequiredYamlSections`.
|
|
||||||
- В `docker-compose.yml` добавлен сервис `postgres` и `app` зависит от него.
|
|
||||||
- Wiring в `_build_aggregator_service` создаёт PostgreSQL engine, session factory, `OrderRepository` и передаёт его в `AggregatorService`.
|
|
||||||
- Запросы к эндпоинту `POST /api/v1/delivery/order` продолжают возвращать `InitPaymentResponse` с `payment_url`.
|
|
||||||
|
|
||||||
## Definition of Done
|
|
||||||
- [ ] Создан модуль `app/adapters/postgres/` с engine/session factory.
|
|
||||||
- [ ] Создан `app/repositories/order/repository.py` с `OrderRepository.create_order()`.
|
|
||||||
- [ ] Создан `app/repositories/order/models.py` с ORM model таблицы `orders`.
|
|
||||||
- [ ] Alembic инициализирован (`alembic.ini`, `alembic/`), создана миграция для таблицы `orders`.
|
|
||||||
- [ ] Добавлена `PostgresConfig` в `app/config.py`; `_RequiredYamlSections` обновлён.
|
|
||||||
- [ ] `AggregatorService` принимает `order_repository` через DI и использует его в `init_payment()`.
|
|
||||||
- [ ] Ошибки сохранения заявки не блокируют возврат `payment_url` клиенту.
|
|
||||||
- [ ] Обновлён wiring в `app/controllers/v1/delivery.py`.
|
|
||||||
- [ ] Добавлен сервис `postgres` в `docker-compose.yml`.
|
|
||||||
- [ ] `config.yaml` пример содержит секцию `postgres`.
|
|
||||||
- [ ] `config.test.yaml` содержит секцию `postgres` (может использовать sqlite или тестовый DSN).
|
|
||||||
- [ ] Все существующие тесты продолжают проходить.
|
|
||||||
- [ ] Добавлены новые тесты.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
- Добавить `tests/repositories/order/test_repository.py`: проверка `create_order()` с in-memory SQLite async engine (SQLAlchemy async); проверка, что все обязательные поля сохраняются; проверка обработки дублирования `order_uuid` (unique constraint).
|
|
||||||
- Обновить `tests/services/test_init_payment.py`: добавить test case, где `order_repository.create_order()` вызывается после успешного создания payment link; добавить test case, где `order_repository.create_order()` выбрасывает исключение, а `init_payment()` всё равно возвращает `payment_url`.
|
|
||||||
- Обновить `tests/config/test_config_sections.py` для проверки наличия секции `postgres` в yaml.
|
|
||||||
- При необходимости обновить `tests/smoke/test_app_import.py` для проверки wiring order repository.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
- `poetry run pytest tests/repositories/order/test_repository.py -q`
|
|
||||||
- `poetry run pytest tests/services/test_init_payment.py -q`
|
|
||||||
- `poetry run pytest tests/config/test_config_sections.py -q`
|
|
||||||
- `poetry run pytest tests/smoke/test_app_import.py -q`
|
|
||||||
- `poetry run pytest -q`
|
|
||||||
- `python3 spec/gen_spec_index.py --check`
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
---
|
|
||||||
id: 029
|
|
||||||
title: Add TBank payment notification and success URLs
|
|
||||||
status: DONE
|
|
||||||
created: 2026-04-18
|
|
||||||
---
|
|
||||||
|
|
||||||
## Context
|
|
||||||
TBank Init API должен получать URLs для обработки webhook-уведомлений и возврата клиента после успешной оплаты. Сейчас `TBankAdapter` и секция `tbank_payment` не фиксируют передачу `NotificationURL` и `SuccessURL`.
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
Добавить в конфигурацию TBank payment обязательные параметры `notification_url` и `success_url` и передавать их в payload инициализации платежа как `NotificationURL` и `SuccessURL`.
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
- Изменения ограничены TBank payment config, TBank adapter payload mapping, wiring конфигурации и тестами.
|
|
||||||
- `NotificationURL` и `SuccessURL` MUST приходить из секции `tbank_payment` YAML-конфига.
|
|
||||||
- TBank adapter MUST продолжать инкапсулировать HTTP-взаимодействие, auth token, retries, timeout, serialization и error handling.
|
|
||||||
- Service и Controller MUST NOT содержать TBank-specific payload fields или provider HTTP-детали.
|
|
||||||
- Не изменять публичный request/response contract `POST /api/v1/delivery/order`.
|
|
||||||
- Не изменять price flow, address suggestion flow, CDEK adapter и order repository.
|
|
||||||
- Не добавлять новые endpoints.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- `TBankPaymentConfig` содержит обязательные поля `notification_url: str` и `success_url: str`.
|
|
||||||
- Runtime YAML-конфиг и test YAML-конфиг содержат секцию `tbank_payment` с `notification_url` и `success_url`.
|
|
||||||
- `TBankAdapter` при вызове TBank Init API отправляет `NotificationURL` со значением `tbank_payment.notification_url`.
|
|
||||||
- `TBankAdapter` при вызове TBank Init API отправляет `SuccessURL` со значением `tbank_payment.success_url`.
|
|
||||||
- Auth token TBank формируется с учётом тех же полей payload, которые отправляются в Init API, включая `NotificationURL` и `SuccessURL`, если текущая реализация token generation строится по request payload.
|
|
||||||
- Отсутствие `notification_url` или `success_url` в YAML-конфиге приводит к детерминированной ошибке валидации конфигурации.
|
|
||||||
- `AggregatorService.init_payment()` продолжает вызывать `payment_adapter.create_payment_link(order_uuid, price)` без дополнительных URL-аргументов.
|
|
||||||
- Endpoint `POST /api/v1/delivery/order` продолжает возвращать только `InitPaymentResponse(payment_url=...)`.
|
|
||||||
|
|
||||||
## Definition of Done
|
|
||||||
- [ ] В `app/config.py` добавлены поля `notification_url` и `success_url` в TBank payment config.
|
|
||||||
- [ ] YAML-конфиги обновлены новыми параметрами TBank payment.
|
|
||||||
- [ ] `TBankAdapter` передаёт `NotificationURL` и `SuccessURL` в payload TBank Init API.
|
|
||||||
- [ ] Token generation для TBank request остаётся корректной после добавления новых payload fields.
|
|
||||||
- [ ] Service и Controller не знают о `NotificationURL` и `SuccessURL`.
|
|
||||||
- [ ] Добавлены или обновлены тесты конфигурации.
|
|
||||||
- [ ] Добавлены или обновлены тесты TBank adapter payload.
|
|
||||||
- [ ] Все команды из раздела Commands проходят.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
- Обновить `tests/config/test_config_sections.py`: проверить обязательность `tbank_payment.notification_url` и `tbank_payment.success_url`.
|
|
||||||
- Обновить `tests/adapters/tbank/test_client.py`: проверить, что successful Init request payload содержит `NotificationURL` и `SuccessURL` из конфигурации.
|
|
||||||
- Обновить `tests/adapters/tbank/test_client.py`: проверить, что token generation остаётся детерминированной после добавления новых payload fields.
|
|
||||||
- При необходимости обновить `tests/smoke/test_app_import.py`, если smoke config требует новые обязательные поля.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
- `poetry run pytest tests/config/test_config_sections.py -q`
|
|
||||||
- `poetry run pytest tests/adapters/tbank/test_client.py -q`
|
|
||||||
- `poetry run pytest tests/smoke/test_app_import.py -q`
|
|
||||||
- `poetry run pytest -q`
|
|
||||||
- `python3 spec/gen_spec_index.py --check`
|
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
---
|
|
||||||
id: 030
|
|
||||||
title: Add TBank payment notification webhook and CDEK order creation
|
|
||||||
status: DONE
|
|
||||||
created: 2026-04-18
|
|
||||||
---
|
|
||||||
|
|
||||||
## Context
|
|
||||||
TBank Init API уже получает `NotificationURL`, а данные заявки сохраняются в PostgreSQL после создания payment link. Сейчас приложение не принимает HTTP-уведомления TBank и не создаёт заказ в CDEK после подтверждения оплаты.
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
Добавить `POST /api/v1/delivery/tbank/notifications`, который принимает payment notification от TBank, проверяет token уведомления, при валидном статусе `CONFIRMED` находит сохранённую заявку по `OrderId` и регистрирует заказ в CDEK. Валидные уведомления с другими статусами должны подтверждаться без регистрации заказа в CDEK.
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
- Controller отвечает только за routing, DTO validation и HTTP error mapping; endpoint вызывает ровно один метод Service: `AggregatorService.handle_tbank_payment_notification()`.
|
|
||||||
- Service выполняет только orchestration: verification через TBank adapter, выбор действия через pure Business Logic, чтение/обновление заявки через OrderRepository и вызов injected CDEK order adapter.
|
|
||||||
- Правило `CONFIRMED` -> создать заказ CDEK, остальные статусы -> подтвердить без CDEK должно быть pure Business Logic в `app/domain/`.
|
|
||||||
- TBank-specific token verification должна быть инкапсулирована в `app/adapters/tbank/` и использовать `tbank_payment.auth.password`.
|
|
||||||
- Проверка token MUST следовать контракту TBank HTTP-уведомлений: использовать top-level scalar fields кроме `Token`, не включать вложенные объекты (`Data`, `Receipt`), добавить `Password`, отсортировать ключи по алфавиту, конкатенировать значения, посчитать SHA-256 и сравнить с `Token`.
|
|
||||||
- Успешно обработанное уведомление MUST возвращать `HTTP 200` с plain text body `OK`.
|
|
||||||
- Валидные уведомления со статусами, отличными от `CONFIRMED`, MUST возвращать `OK` без чтения CDEK и без создания заказа.
|
|
||||||
- Повторное валидное уведомление `CONFIRMED` для заявки с уже сохранённым `cdek_order_uuid` MUST возвращать `OK` без повторного вызова CDEK.
|
|
||||||
- Если token невалиден, endpoint MUST возвращать deterministic `400` и не обращаться к repository или CDEK.
|
|
||||||
- Если заявка для `OrderId` не найдена или CDEK registration не завершилась успешно, endpoint MUST не возвращать `OK`, чтобы TBank мог повторить notification delivery.
|
|
||||||
- Repository содержит только CRUD/query/update primitives; без workflow logic и business decisions.
|
|
||||||
- CDEK order payload MUST передавать `order_uuid` как external идентификатор заказа CDEK (поле `number` в CDEK order contract), чтобы повторные вызовы CDEK при HTTP timeout/ретрае обрабатывались идемпотентно на стороне CDEK и никогда не создавали дубль заказа.
|
|
||||||
- Service MUST трактовать ответ CDEK "заказ с таким external id уже существует" (возврат существующего `entity.uuid` либо CDEK-specific duplicate response) как успех, сохранять возвращённый `cdek_order_uuid` и отвечать `OK`, а не как ошибку с повторной регистрацией.
|
|
||||||
- Не изменять public contract `POST /api/v1/delivery/order`, price flow и address suggestion flow.
|
|
||||||
- Не добавлять новые payment providers, refund flow, recurring payments, ручной retry endpoint или endpoint чтения заявок.
|
|
||||||
- Не изменять файлы в `spec/`.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- Существует schema `TBankPaymentNotification` с обязательными полями `TerminalKey`, `OrderId`, `Success`, `Status`, `PaymentId`, `ErrorCode`, `Amount`, `Token` и поддержкой дополнительных top-level полей TBank. `PaymentId` валидируется как положительное целое (TBank присылает long integer) и сохраняется в `tbank_payment_id` колонке `BIGINT`.
|
|
||||||
- `POST /api/v1/delivery/tbank/notifications` реализован в существующем controller `app/controllers/v1/delivery.py` и возвращает `PlainTextResponse("OK")` при успешной обработке.
|
|
||||||
- Controller делегирует обработку ровно в `AggregatorService.handle_tbank_payment_notification()` и не содержит branching по TBank status.
|
|
||||||
- `TBankAdapter` умеет проверять token payment notification через `tbank_payment.auth.password`; невалидный token маппится в service/controller error path без repository/CDEK side effects.
|
|
||||||
- В `app/domain/` есть pure function, которая для `Status == "CONFIRMED"`, `Success == true` и `ErrorCode == "0"` возвращает действие регистрации CDEK, а для остальных статусов возвращает действие acknowledge-only.
|
|
||||||
- `OrderRepository` предоставляет primitive для получения заявки по `order_uuid`, сохранения последнего TBank payment status/payment id и сохранения `cdek_order_uuid`.
|
|
||||||
- Таблица `orders` содержит минимум поля `payment_status`, `tbank_payment_id` (BIGINT), `cdek_order_uuid`, `updated_at` в исходной миграции; отдельная миграция не вводится, так как production БД ещё не развёрнута.
|
|
||||||
- При валидном `CONFIRMED` notification service получает order по `OrderId`, реконструирует `InitPaymentRequest` из сохранённых данных, вызывает injected CDEK order adapter registration и сохраняет полученный `cdek_order_uuid`.
|
|
||||||
- Если для `OrderId` уже сохранён `cdek_order_uuid`, повторный `CONFIRMED` notification возвращает `OK` без повторного вызова CDEK.
|
|
||||||
- CDEK order mapper проставляет `InitPaymentRequest.order_uuid` в поле external номера заказа CDEK (`number`), так что повторный POST с тем же external id не создаёт второй заказ в CDEK.
|
|
||||||
- Если CDEK create фактически завершился успешно, но сохранение `cdek_order_uuid` в DB не прошло, следующий валидный `CONFIRMED` notification MUST завершиться сохранением того же `cdek_order_uuid` (полученного по тому же external id) без создания второго заказа в CDEK.
|
|
||||||
- Валидные notification со статусами `AUTHORIZED`, `REJECTED`, `CANCELED`, `DEADLINE_EXPIRED` и неизвестными status values возвращают `OK` без регистрации CDEK order.
|
|
||||||
- Ошибка поиска заявки или ошибка CDEK registration возвращает deterministic non-OK HTTP response и логируется.
|
|
||||||
- `NotificationURL` в runtime/test/example конфигурации, если он присутствует в репозитории, указывает на `/api/v1/delivery/tbank/notifications`.
|
|
||||||
|
|
||||||
## Definition of Done
|
|
||||||
- [ ] Добавлена schema `TBankPaymentNotification`.
|
|
||||||
- [ ] Добавлена pure Business Logic для выбора действия по TBank payment notification status.
|
|
||||||
- [ ] Добавлена token verification logic в TBank adapter.
|
|
||||||
- [ ] Расширена SQLAlchemy model и обновлена существующая Alembic migration `20260412_028_create_orders_table` payment/CDEK status fields (production БД отсутствует, новая миграция не нужна).
|
|
||||||
- [ ] Расширен `OrderRepository` primitives для read/update операций, нужных webhook flow.
|
|
||||||
- [ ] Реализован `AggregatorService.handle_tbank_payment_notification()` с DI dependencies.
|
|
||||||
- [ ] Добавлен endpoint `POST /api/v1/delivery/tbank/notifications` в существующий delivery controller.
|
|
||||||
- [ ] Wiring использует существующие `TBankAdapter`, `OrderRepository` и CDEK provider/adapter без provider HTTP details в Service/Controller.
|
|
||||||
- [ ] Повторные `CONFIRMED` notification обрабатываются идемпотентно.
|
|
||||||
- [ ] CDEK order mapper проставляет `order_uuid` в поле external номера заказа CDEK.
|
|
||||||
- [ ] Service корректно обрабатывает ответ CDEK "заказ уже существует" как успех и сохраняет возвращённый `cdek_order_uuid`.
|
|
||||||
- [ ] Валидные non-`CONFIRMED` statuses подтверждаются без CDEK side effects.
|
|
||||||
- [ ] Обновлены tests для domain, adapter, repository, service, controller и smoke wiring.
|
|
||||||
- [ ] Все команды из раздела Commands проходят.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
- Добавить `tests/domain/test_payment_notifications.py`: `CONFIRMED` + `Success=true` + `ErrorCode=0` -> create CDEK order action; остальные known/unknown statuses -> acknowledge-only.
|
|
||||||
- Обновить или добавить `tests/adapters/tbank/test_notifications.py`: valid token, invalid token, exclusion of `Token`, exclusion of nested `Data`/`Receipt`, inclusion of extra scalar fields, deterministic SHA-256 comparison.
|
|
||||||
- Обновить `tests/repositories/order/test_repository.py`: получение заявки по `order_uuid`, сохранение `payment_status`/`tbank_payment_id`, сохранение `cdek_order_uuid`, duplicate/idempotency scenario.
|
|
||||||
- Обновить `tests/adapters/delivery_providers/cdek/test_order_mapper.py`: CDEK order payload содержит `InitPaymentRequest.order_uuid` в поле external номера заказа (`number`).
|
|
||||||
- Добавить `tests/services/test_tbank_notifications.py`: valid `CONFIRMED` вызывает repository lookup, CDEK registration и save `cdek_order_uuid`; duplicate `CONFIRMED` не вызывает CDEK; non-`CONFIRMED` возвращает `OK` без CDEK; invalid token не обращается к repository; missing order/CDEK failure возвращает service error; сценарий "CDEK create succeeded, save `cdek_order_uuid` raised" — следующий `CONFIRMED` notification вызывает CDEK повторно с тем же external id, получает тот же `cdek_order_uuid`, сохраняет его и возвращает `OK` (суммарно ровно один реальный заказ в CDEK).
|
|
||||||
- Добавить `tests/controllers/v1/test_tbank_notifications.py`: endpoint возвращает plain text `OK` для successful service response, 400 для invalid token, 503 для temporary processing failure, controller делегирует ровно один service method.
|
|
||||||
- Обновить `tests/smoke/test_app_import.py` при необходимости для проверки wiring нового endpoint.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
- `poetry run pytest tests/domain/test_payment_notifications.py -q`
|
|
||||||
- `poetry run pytest tests/adapters/tbank/test_notifications.py -q`
|
|
||||||
- `poetry run pytest tests/repositories/order/test_repository.py -q`
|
|
||||||
- `poetry run pytest tests/services/test_tbank_notifications.py -q`
|
|
||||||
- `poetry run pytest tests/controllers/v1/test_tbank_notifications.py -q`
|
|
||||||
- `poetry run pytest tests/smoke/test_app_import.py -q`
|
|
||||||
- `poetry run pytest -q`
|
|
||||||
- `python3 spec/gen_spec_index.py --check`
|
|
||||||
@@ -1,71 +0,0 @@
|
|||||||
---
|
|
||||||
id: 031
|
|
||||||
title: Validate init-payment price with CDEK tariff
|
|
||||||
status: TODO
|
|
||||||
created: 2026-04-18
|
|
||||||
---
|
|
||||||
|
|
||||||
## Context
|
|
||||||
`POST /api/v1/delivery/order` сейчас принимает `price` от клиента и использует это значение как сумму платежа TBank. Клиент может передать произвольную сумму, не соответствующую тарифу CDEK.
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
Перед созданием ссылки на оплату валидировать `InitPaymentRequest.price` через CDEK `POST /calculator/tarifflist` для данных заявки. Если сумма не совпадает с валидированной суммой CDEK, не создавать payment link и вернуть детерминированную ошибку.
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
- Controller не должен содержать business logic или provider-specific branching.
|
|
||||||
- Endpoint `POST /api/v1/delivery/order` должен по-прежнему вызывать ровно один метод Service: `AggregatorService.init_payment()`.
|
|
||||||
- Service выполняет только orchestration: вызывает injected CDEK adapter для расчёта цены, делегирует сравнение в Business Logic, затем вызывает TBank adapter только при успешной валидации.
|
|
||||||
- Pure правило сравнения цены должно жить в `app/domain/`: сравнение выполняется в копейках, с применением существующего provider price multiplier и округления `ROUND_HALF_UP`.
|
|
||||||
- Внешний IO для CDEK должен оставаться только в CDEK adapter; TBank-specific logic остаётся только в TBank adapter.
|
|
||||||
- CDEK validation MUST использовать существующий endpoint CDEK `POST /calculator/tarifflist`; запрещено использовать `/orders`, `/calculator/tariff` или registration flow для проверки цены.
|
|
||||||
- CDEK adapter должен переиспользовать существующие auth, retry, timeout, HTTP error handling и response mapping для tariff list там, где это совместимо с `InitPaymentRequest`.
|
|
||||||
- CDEK validation должна использовать данные существующего `InitPaymentRequest`: `tariff_code`, `from_location`, `to_location`, `packages` и `services` при наличии.
|
|
||||||
- Публичный request/response contract `POST /api/v1/delivery/order` не менять, кроме ужесточения runtime validation поля `price`.
|
|
||||||
- При mismatch цены нельзя вызывать TBank adapter и нельзя сохранять заявку в PostgreSQL.
|
|
||||||
- При ошибке CDEK validation нельзя вызывать TBank adapter и нельзя сохранять заявку в PostgreSQL.
|
|
||||||
- Не изменять price flow, address suggestion flow, payment notification webhook flow и CDEK order registration flow.
|
|
||||||
- Не добавлять новые endpoints, payment providers, ручной retry endpoint или endpoint чтения заявок.
|
|
||||||
- Не изменять файлы в `spec/`.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- CDEK adapter предоставляет service-facing метод для расчёта валидной цены доставки по `InitPaymentRequest` через `POST /calculator/tarifflist` без регистрации заказа в CDEK.
|
|
||||||
- Новый метод переиспользует существующую tariff-list инфраструктуру CDEK adapter: OAuth2 token, retry/timeout policy, handling 4xx/5xx/transport errors и mapping `tariff_codes` в internal price model.
|
|
||||||
- CDEK validation request использует `request.tariff_code`, `from_location`, `to_location`, `packages` и `services` из `InitPaymentRequest`.
|
|
||||||
- CDEK validation request отправляется на тот же configured base URL path `/calculator/tarifflist`, который используется текущим CDEK price flow.
|
|
||||||
- Если CDEK не возвращает цену для `request.tariff_code`, service завершает `init_payment()` детерминированной `InvalidInitPaymentRequestError`.
|
|
||||||
- Business Logic рассчитывает expected payment amount в копейках: provider price в `RUB` после существующего multiplier и `ROUND_HALF_UP` конвертируется в копейки и сравнивается с `InitPaymentRequest.price`.
|
|
||||||
- Если `InitPaymentRequest.price` не равен expected amount, `AggregatorService.init_payment()` поднимает `InvalidInitPaymentRequestError`.
|
|
||||||
- При успешной price validation `AggregatorService.init_payment()` вызывает TBank adapter с исходным `order_uuid` и валидированным `price`.
|
|
||||||
- При успешной price validation и успешном TBank response сохранение заявки в PostgreSQL остаётся прежним.
|
|
||||||
- При CDEK provider request error service маппит ошибку в `InvalidInitPaymentRequestError`.
|
|
||||||
- При CDEK transport, timeout или 5xx error service маппит ошибку в `InitPaymentUnavailableError`.
|
|
||||||
- Controller маппит `InvalidInitPaymentRequestError` в HTTP 400 и `InitPaymentUnavailableError` в HTTP 503 по текущему error contract.
|
|
||||||
- Tests подтверждают, что при mismatch цены TBank adapter и OrderRepository не вызываются.
|
|
||||||
|
|
||||||
## Definition of Done
|
|
||||||
- [ ] Добавлен service-facing CDEK adapter method для расчёта цены по `InitPaymentRequest` через `POST /calculator/tarifflist`.
|
|
||||||
- [ ] Метод переиспользует существующие auth, retry, timeout, error handling и tariff response mapping CDEK adapter.
|
|
||||||
- [ ] Добавлена pure Business Logic для сравнения requested price с CDEK validated price в копейках.
|
|
||||||
- [ ] `AggregatorService.init_payment()` выполняет CDEK price validation до вызова TBank adapter.
|
|
||||||
- [ ] Ошибки CDEK validation маппятся в существующие service/controller error paths.
|
|
||||||
- [ ] TBank adapter вызывается только после успешной CDEK price validation.
|
|
||||||
- [ ] OrderRepository вызывается только после успешной CDEK price validation и успешного получения `payment_url`.
|
|
||||||
- [ ] Публичный contract `POST /api/v1/delivery/order` не изменён, кроме runtime rejection невалидной цены.
|
|
||||||
- [ ] Обновлены tests для domain, CDEK adapter, service и controller.
|
|
||||||
- [ ] Все команды из раздела Commands проходят.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
- Добавить или обновить `tests/domain/test_payment_price_validation.py`: exact match, mismatch, multiplier с `ROUND_HALF_UP`, конвертация `RUB` в копейки, не-`RUB` currency как deterministic validation failure.
|
|
||||||
- Добавить или обновить `tests/adapters/delivery_providers/cdek/test_payment_price_validation.py`: validation request отправляется на `/calculator/tarifflist`; payload строится из `InitPaymentRequest`, используется `tariff_code`, `packages`, `services`, `from_location`, `to_location`; success/error mapping покрыт stubs/mocks.
|
|
||||||
- Обновить `tests/services/test_init_payment.py`: success вызывает CDEK validation до TBank; mismatch возвращает `InvalidInitPaymentRequestError` без TBank и repository calls; CDEK request error маппится в `InvalidInitPaymentRequestError`; CDEK client error маппится в `InitPaymentUnavailableError`.
|
|
||||||
- Обновить `tests/controllers/v1/test_init_payment.py`: price mismatch возвращает HTTP 400; временная ошибка CDEK validation возвращает HTTP 503; controller продолжает делегировать ровно один service method.
|
|
||||||
- При необходимости обновить `tests/smoke/test_app_import.py` для wiring нового CDEK validation dependency.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
- `poetry run pytest tests/domain/test_payment_price_validation.py -q`
|
|
||||||
- `poetry run pytest tests/adapters/delivery_providers/cdek/test_payment_price_validation.py -q`
|
|
||||||
- `poetry run pytest tests/services/test_init_payment.py -q`
|
|
||||||
- `poetry run pytest tests/controllers/v1/test_init_payment.py -q`
|
|
||||||
- `poetry run pytest tests/smoke/test_app_import.py -q`
|
|
||||||
- `poetry run pytest -q`
|
|
||||||
- `python3 spec/gen_spec_index.py --check`
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
---
|
|
||||||
id: 032
|
|
||||||
title: Rework init-payment contract to camelCase and structured address/contact
|
|
||||||
status: TODO
|
|
||||||
created: 2026-05-13
|
|
||||||
---
|
|
||||||
|
|
||||||
## Context
|
|
||||||
Фронт переходит на новый контракт ручки `POST /api/v1/delivery/order`. В нём
|
|
||||||
адрес/контакт структурированы по-новому, появились флаг юр-лица и реквизиты
|
|
||||||
(`isCompany`/`companyName`/`inn`/`kpp`/`phoneExt`), описание груза и вес
|
|
||||||
(`content.description`), даты `pickupDate`/`deliveryDate`,
|
|
||||||
`accountEmail`, блок `systemData` с зафиксированным тарифом, parcelType,
|
|
||||||
docPackaging и dimensions. Поле наименования — camelCase. Это breaking change
|
|
||||||
без обратной совместимости.
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
Привести бэкенд к новому контракту `/api/v1/delivery/order`, сохранив текущий
|
|
||||||
flow «валидация цены через CDEK → создание payment URL в TBank → persist в
|
|
||||||
PostgreSQL → возврат `payment_url`; webhook `CONFIRMED` → регистрация заказа в
|
|
||||||
CDEK».
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
- camelCase в API; внутренние имена остаются Python-friendly через Pydantic
|
|
||||||
alias-generator. JSON принимается ТОЛЬКО в camelCase.
|
|
||||||
- `orderUuid` и `systemData.tariff.tariffCode` приходят от фронта.
|
|
||||||
- `systemData.tariff.price` — итоговая сумма в копейках; backend использует её
|
|
||||||
как `amount_kopecks` для TBank без пересчёта.
|
|
||||||
- Расширить `senderAddress` и `receiverAddress` обязательным полем `cityId: int`
|
|
||||||
(то же, что в `/price`), чтобы резолвить CDEK city code из `cities_map`.
|
|
||||||
- При `isCompany=true` поля `companyName`, `inn`, `kpp` обязательны.
|
|
||||||
- При `parcelType='doc'` `dimensions` принимает значение `null`; CDEK packages
|
|
||||||
отправляются без length/width/height.
|
|
||||||
- При `parcelType='parcel'` `dimensions` обязателен.
|
|
||||||
- Адрес для CDEK собирается строкой `"{city}, {street}, {house}, кв. {apartment}"`;
|
|
||||||
если `apartment` пустой/отсутствует — без хвоста `, кв. ...`.
|
|
||||||
- `pickupDate` → CDEK `shipment_point.date`; `deliveryDate` → CDEK
|
|
||||||
`delivery_point.date` (если задан).
|
|
||||||
- `content.description` пробрасывается в CDEK `packages[0].items[0].name` и
|
|
||||||
`comment` верхнего уровня.
|
|
||||||
- `phoneExt` пробрасывается в CDEK `phones[0].additional`.
|
|
||||||
- Сохранять весь принятый payload в JSONB-колонке `payload` записи `orders`.
|
|
||||||
- ORM-модель `orders` рефакторится: вместо колонок `sender/recipient/
|
|
||||||
from_location/to_location/packages/services/comment/delivery_type` —
|
|
||||||
одна колонка `payload JSONB NOT NULL`, плюс `account_email VARCHAR`.
|
|
||||||
Колонки `order_uuid`, `payment_url`, `price`, `tariff_code`, `payment_status`,
|
|
||||||
`tbank_payment_id`, `cdek_order_uuid`, `created_at`, `updated_at` сохраняются.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- `POST /api/v1/delivery/order` принимает новый camelCase payload и возвращает
|
|
||||||
`InitPaymentResponse` без изменений (`{ "payment_url": str }`).
|
|
||||||
- Pydantic-модели валидируют: обязательность реквизитов юр-лица при
|
|
||||||
`isCompany=true`; `parcelType='doc'` ⇒ `dimensions=null`/отсутствует;
|
|
||||||
`parcelType='parcel'` ⇒ `dimensions` обязателен; `price > 0`.
|
|
||||||
- Service вызывает `payment_price_validation_adapter.get_payment_price(request)`
|
|
||||||
и `payment_adapter.create_payment_link(order_uuid, amount_kopecks)` с
|
|
||||||
`amount_kopecks = request.systemData.tariff.price`.
|
|
||||||
- CDEK order mapper из нового `InitPaymentRequest` формирует payload с полями:
|
|
||||||
`number`, `type=2`, `tariff_code`, `sender`, `recipient`,
|
|
||||||
`from_location.code/address/postal_code`, `to_location.code/address/postal_code`,
|
|
||||||
`packages[0]` с `weight` (граммы) и опциональными dimensions,
|
|
||||||
`packages[0].items` с описанием груза, `shipment_point.date`,
|
|
||||||
`delivery_point.date` (если есть), `comment`.
|
|
||||||
- При `isCompany=true` CDEK получает `contragent_type="LEGAL_ENTITY"`, `company`,
|
|
||||||
`inn`, `kpp` для соответствующей стороны.
|
|
||||||
- ORM `Order` хранит весь payload в `payload` JSONB; репозиторий сохраняет и
|
|
||||||
читает его без потерь; миграция переводит таблицу со старой структуры на
|
|
||||||
новую (drop старые колонки, добавить `payload`, `account_email`).
|
|
||||||
|
|
||||||
## Definition of Done
|
|
||||||
- [ ] Pydantic-модели `payment.py` переписаны под новый camelCase-контракт с
|
|
||||||
обязательными валидациями.
|
|
||||||
- [ ] CDEK order mapper и `_build_payment_price_payload` собирают payload из
|
|
||||||
новой структуры.
|
|
||||||
- [ ] `AggregatorService.init_payment` использует `systemData.tariff.price` и
|
|
||||||
`orderUuid` напрямую; persist использует обновлённый `OrderData`.
|
|
||||||
- [ ] ORM-модель `Order`, `OrderData` и репозиторий перешли на `payload` JSONB
|
|
||||||
и `account_email`.
|
|
||||||
- [ ] Alembic-миграция `20260513_032_rework_orders_payload.py` применяется и
|
|
||||||
откатывается.
|
|
||||||
- [ ] Тесты на schemas, mapper, payment validation, controller и service
|
|
||||||
обновлены и проходят.
|
|
||||||
- [ ] `http-client.http` обновлён под новый payload.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
- `tests/controllers/v1/test_init_payment.py` — fixture/assertion обновлены под
|
|
||||||
camelCase; добавлены кейсы валидации юр-лица и doc/parcel dimensions.
|
|
||||||
- `tests/adapters/delivery_providers/cdek/test_order_mapper.py` —
|
|
||||||
mapping `phoneExt → additional`, контрагент юр-лица, address composition,
|
|
||||||
doc без dimensions, shipment/delivery point dates.
|
|
||||||
- `tests/adapters/delivery_providers/cdek/test_payment_price_validation.py` —
|
|
||||||
validation payload собирается из `senderAddress.cityId`, `systemData.weight`,
|
|
||||||
`systemData.dimensions`.
|
|
||||||
- `tests/services/test_init_payment.py` — `init_payment` берёт сумму из
|
|
||||||
`systemData.tariff.price`, `orderUuid` пробрасывается.
|
|
||||||
- `tests/repositories/order/test_repository.py` (если есть) — обновлён под
|
|
||||||
новую модель `Order`.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
- `pytest tests/`
|
|
||||||
- `alembic upgrade head` (smoke на пустой базе)
|
|
||||||
- `python3 spec/gen_spec_index.py --check`
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
---
|
|
||||||
id: 033
|
|
||||||
title: Add CDEK waybill polling worker
|
|
||||||
status: DONE
|
|
||||||
created: 2026-05-23
|
|
||||||
---
|
|
||||||
|
|
||||||
## Context
|
|
||||||
`POST /v2/orders` в CDEK асинхронный. Когда `entity.related_entities[type=waybill].uuid`
|
|
||||||
синхронно приходит в ответе, поле `url` практически всегда пустое — PDF генерится
|
|
||||||
позже и его url доступен только при `GET /v2/print/orders/{waybill_uuid}` после
|
|
||||||
перехода накладной в `READY`. Бывают и случаи, когда waybill_uuid тоже не приходит
|
|
||||||
синхронно, или заказ уходит в терминальный `INVALID` без генерации накладной.
|
|
||||||
Ранее `_save_cdek_order_uuid` (`app/services/aggregator.py`) писал waybill-поля из
|
|
||||||
синхронного ответа POST, из-за чего запись прыгала между двумя источниками и
|
|
||||||
требовала покрытия граничных случаев в двух местах.
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
Перевести запись waybill-данных под единоличную ответственность отдельного
|
|
||||||
фонового worker-а. При регистрации заказа сохранять только `cdek_order_uuid`,
|
|
||||||
а waybill_uuid / waybill_url пополнять поллингом до тех пор, пока заказ не
|
|
||||||
дойдёт до waybill_url IS NOT NULL либо до терминального статуса.
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
- Запуск — отдельный процесс `python -m app.workers.waybill_poller`, отдельный
|
|
||||||
сервис в `docker-compose.yml`, одна реплика.
|
|
||||||
- Никаких записей waybill-полей при регистрации заказа: `_save_cdek_order_uuid`
|
|
||||||
должен сохранять только `cdek_order_uuid`.
|
|
||||||
- Поллер — единственный writer полей `cdek_order_status`, `cdek_waybill_uuid`,
|
|
||||||
`cdek_waybill_url`, `cdek_polled_at`.
|
|
||||||
- На терминальных статусах заказа (`INVALID`, `DELIVERED`, `NOT_DELIVERED`,
|
|
||||||
`CANCELLED`) опросы по этому заказу прекращаются.
|
|
||||||
- Бизнес-логика терминальности — pure функция в `app/domain/cdek_polling.py`,
|
|
||||||
без зависимостей на репозиторий/адаптер.
|
|
||||||
- Соблюсти слои AGENTS.md: новые HTTP-вызовы CDEK живут только в адаптере,
|
|
||||||
работа с БД — только в репозитории, оркестрация — в сервисе.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- При успешной регистрации заказа в БД заполняется только `cdek_order_uuid`;
|
|
||||||
`cdek_waybill_uuid` и `cdek_waybill_url` остаются `NULL`.
|
|
||||||
- Worker раз в `waybill_poller.interval_seconds` секунд выбирает заказы с
|
|
||||||
`cdek_order_uuid IS NOT NULL AND cdek_waybill_url IS NULL` и нетерминальным
|
|
||||||
`cdek_order_status` (с упорядочиванием `cdek_polled_at NULLS FIRST` и лимитом
|
|
||||||
`waybill_poller.batch_size`).
|
|
||||||
- Если у заказа `cdek_waybill_uuid IS NULL`, worker делает `GET /v2/orders/{uuid}`
|
|
||||||
и сохраняет последний статус заказа + waybill_uuid из `related_entities`.
|
|
||||||
- Если `cdek_waybill_uuid IS NOT NULL` и url пуст, worker делает
|
|
||||||
`GET /v2/print/orders/{waybill_uuid}` и сохраняет `entity.url`.
|
|
||||||
- Ошибка CDEK по одному заказу логируется и не валит весь батч.
|
|
||||||
- Worker корректно завершает работу по SIGTERM/SIGINT: закрывает HTTP-клиент и
|
|
||||||
async engine.
|
|
||||||
|
|
||||||
## Definition of Done
|
|
||||||
- [ ] Миграция `20260524_034_add_cdek_polling_columns.py` добавляет
|
|
||||||
`cdek_order_status` и `cdek_polled_at`; модель синхронизирована.
|
|
||||||
- [ ] `_save_cdek_order_uuid` и `OrderRepository.mark_cdek_order_registered`
|
|
||||||
больше не принимают waybill-поля.
|
|
||||||
- [ ] `CDEKClient.get_order` / `get_waybill` и соответствующие мапперы
|
|
||||||
(`map_cdek_order_info_response`, `map_cdek_waybill_info_response`,
|
|
||||||
dataclasses `CDEKOrderInfo`, `CDEKWaybillInfo`) реализованы; `CDEKProvider`
|
|
||||||
предоставляет обёртки.
|
|
||||||
- [ ] `app/domain/cdek_polling.py` содержит `TERMINAL_ORDER_STATUSES` и
|
|
||||||
`is_terminal_order_status`.
|
|
||||||
- [ ] `OrderRepository.list_orders_pending_waybill`, `record_order_poll`,
|
|
||||||
`record_waybill_poll` реализованы.
|
|
||||||
- [ ] `WaybillPollerService.poll_once` и `run_forever` реализованы.
|
|
||||||
- [ ] `app/workers/waybill_poller.py` поднимает зависимости, поддерживает
|
|
||||||
graceful shutdown по сигналу.
|
|
||||||
- [ ] `config.example.yaml` содержит секцию `waybill_poller`, `app/config.py` —
|
|
||||||
`WaybillPollerConfig`.
|
|
||||||
- [ ] `docker-compose.yml` содержит сервис `waybill-poller`.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
- `tests/domain/test_cdek_polling.py` — терминальные/нетерминальные статусы.
|
|
||||||
- `tests/adapters/delivery_providers/cdek/test_order_mapper.py` — мапперы
|
|
||||||
ответов GET /v2/orders/{uuid} и GET /v2/print/orders/{uuid}.
|
|
||||||
- `tests/adapters/delivery_providers/cdek/test_order_info_client.py` —
|
|
||||||
`CDEKClient.get_order` и `get_waybill`: 200/4xx/5xx, ретраи, парсинг.
|
|
||||||
- `tests/repositories/order/test_repository.py` — `list_orders_pending_waybill`,
|
|
||||||
`record_order_poll`, `record_waybill_poll` (фильтр, упорядочивание, защита
|
|
||||||
waybill_uuid/waybill_url от перезаписи).
|
|
||||||
- `tests/services/test_waybill_poller.py` — `poll_once` на стабах (два пути,
|
|
||||||
терминальный статус, изоляция ошибок), `run_forever` завершается по
|
|
||||||
stop_event.
|
|
||||||
- `tests/services/test_tbank_notifications.py` — регистрация заказа больше не
|
|
||||||
сохраняет waybill-поля.
|
|
||||||
- `tests/workers/test_waybill_poller_main.py` — `_run` собирает зависимости и
|
|
||||||
завершается по stop_event.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
- `poetry run pytest -q`
|
|
||||||
- `poetry run alembic upgrade head`
|
|
||||||
- `poetry run python -m app.workers.waybill_poller`
|
|
||||||
- `python3 spec/gen_spec_index.py --check`
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
---
|
|
||||||
id: 034
|
|
||||||
title: Add CDEK waybill e-mail sender worker
|
|
||||||
status: DONE
|
|
||||||
created: 2026-05-23
|
|
||||||
---
|
|
||||||
|
|
||||||
## Context
|
|
||||||
После задачи 033 фоновый `WaybillPollerService` дотягивает у CDEK
|
|
||||||
`cdek_waybill_url` для зарегистрированных заказов и на этом цепочка
|
|
||||||
обрывается: PDF-накладная остаётся доступной только по URL, клиенту на почту
|
|
||||||
не отправляется. Адрес получателя уже сохраняется в `Order.account_email`
|
|
||||||
(приходит из `InitPaymentRequest.account_email`).
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
Добавить отдельный фоновый сервис и worker, который для заказов с
|
|
||||||
`cdek_waybill_url IS NOT NULL AND waybill_email_sent_at IS NULL` скачивает PDF
|
|
||||||
накладной у CDEK и отправляет его вложением на `Order.account_email`.
|
|
||||||
|
|
||||||
## Constraints
|
|
||||||
- Запуск — отдельный процесс `python -m app.workers.waybill_email_sender`,
|
|
||||||
отдельный сервис в `docker-compose.yml`, одна реплика, тот же
|
|
||||||
`config.yaml`, что и у `waybill-poller`.
|
|
||||||
- Поллер waybill-данных (033) не меняем; новый сервис — единственный writer
|
|
||||||
колонки `waybill_email_sent_at`.
|
|
||||||
- Соблюдение слоёв AGENTS.md: SMTP — только в адаптере, скачивание PDF —
|
|
||||||
в CDEK-адаптере, работа с БД — в репозитории, оркестрация — в сервисе.
|
|
||||||
- Текст письма (тема, тело, имя вложения) — приватные константы сервиса,
|
|
||||||
без отдельного domain-модуля и без шаблонов в config.
|
|
||||||
- На вход (POST /api/v1/delivery/order) `accountEmail` валидируется как
|
|
||||||
`pydantic.EmailStr` — заведомо некорректный адрес отбрасывается на
|
|
||||||
контроллере и не доходит до воркера.
|
|
||||||
|
|
||||||
## Acceptance criteria
|
|
||||||
- При появлении у заказа `cdek_waybill_url` worker в течение
|
|
||||||
`waybill_email_sender.interval_seconds` скачивает PDF и отправляет письмо
|
|
||||||
на `Order.account_email`, после чего проставляет `waybill_email_sent_at`.
|
|
||||||
- Письмо содержит тему `Накладная по заказу {order_uuid}`, тело — краткое
|
|
||||||
уведомление со ссылкой на `cdek_waybill_url`, и вложение
|
|
||||||
`waybill_{order_uuid}.pdf` (`application/pdf`).
|
|
||||||
- Повторный запуск worker-а не приводит к повторной отправке (фильтр
|
|
||||||
`waybill_email_sent_at IS NULL`).
|
|
||||||
- Ошибка скачивания PDF или SMTP по одному заказу логируется и не валит
|
|
||||||
батч; заказ остаётся pending и будет переотправлен в следующем тике.
|
|
||||||
- `POST /api/v1/delivery/order` с заведомо невалидным `accountEmail`
|
|
||||||
возвращает 422.
|
|
||||||
- Worker корректно завершает работу по SIGTERM/SIGINT: закрывает HTTP-
|
|
||||||
клиент и async engine.
|
|
||||||
|
|
||||||
## Definition of Done
|
|
||||||
- [ ] Миграция `20260524_035_add_waybill_email_sent_at.py` добавляет
|
|
||||||
`waybill_email_sent_at`; модель синхронизирована.
|
|
||||||
- [ ] `OrderRepository.list_orders_pending_waybill_email` и
|
|
||||||
`record_waybill_email_sent` реализованы.
|
|
||||||
- [ ] `CDEKClient.download_waybill_pdf(url)` реализован.
|
|
||||||
- [ ] Адаптер `app/adapters/email/smtp_client.py` (`aiosmtplib`) реализован.
|
|
||||||
- [ ] `WaybillEmailSenderService.poll_once` и `run_forever` реализованы.
|
|
||||||
- [ ] `app/workers/waybill_email_sender.py` поднимает зависимости и
|
|
||||||
поддерживает graceful shutdown.
|
|
||||||
- [ ] `config.example.yaml` содержит секции `email` и
|
|
||||||
`waybill_email_sender`; `app/config.py` — `EmailAdapterConfig` и
|
|
||||||
`WaybillEmailSenderConfig`.
|
|
||||||
- [ ] `docker-compose.yml` содержит сервис `waybill-email-sender`.
|
|
||||||
- [ ] `pyproject.toml` содержит `aiosmtplib` и `email-validator`.
|
|
||||||
- [ ] `InitPaymentRequest.account_email` имеет тип `EmailStr`.
|
|
||||||
|
|
||||||
## Tests
|
|
||||||
- `tests/adapters/email/test_smtp_client.py` — корректное MIME-сообщение,
|
|
||||||
вложение PDF, проброс SMTP-ошибок.
|
|
||||||
- `tests/adapters/delivery_providers/cdek/test_waybill_download_client.py` —
|
|
||||||
`download_waybill_pdf`: 200 → bytes, 4xx/5xx, ретраи, auth header.
|
|
||||||
- `tests/repositories/order/test_repository.py` — расширить:
|
|
||||||
`list_orders_pending_waybill_email` (фильтр и порядок по `created_at`),
|
|
||||||
`record_waybill_email_sent` (защита от перезаписи).
|
|
||||||
- `tests/services/test_waybill_email_sender.py` — `poll_once` на стабах:
|
|
||||||
успешная отправка, ошибка скачивания PDF, ошибка SMTP, изоляция ошибок
|
|
||||||
между заказами, `run_forever` завершается по `stop_event`.
|
|
||||||
- `tests/workers/test_waybill_email_sender_main.py` — `_run` собирает
|
|
||||||
зависимости и завершается по `stop_event`.
|
|
||||||
- `tests/controllers/v1/test_init_payment.py` — 422 на невалидный
|
|
||||||
`accountEmail`.
|
|
||||||
|
|
||||||
## Commands
|
|
||||||
- `poetry run pytest -q`
|
|
||||||
- `poetry run alembic upgrade head`
|
|
||||||
- `poetry run python -m app.workers.waybill_email_sender`
|
|
||||||
- `python3 spec/gen_spec_index.py --check`
|
|
||||||
@@ -13,7 +13,7 @@ from app.adapters.delivery_providers.cdek.client import (
|
|||||||
)
|
)
|
||||||
from app.cities import cities_map
|
from app.cities import cities_map
|
||||||
from app import config as config_module
|
from app import config as config_module
|
||||||
from app.config import AdapterConfig, Settings
|
from app.config import CDEKDeliveryProviderConfig, Settings
|
||||||
from app.schemas.request import DeliveryCalculationRequest, DeliveryEntity
|
from app.schemas.request import DeliveryCalculationRequest, DeliveryEntity
|
||||||
|
|
||||||
|
|
||||||
@@ -287,14 +287,17 @@ repository:
|
|||||||
redis_dsn: "redis://localhost:6379/0"
|
redis_dsn: "redis://localhost:6379/0"
|
||||||
price_cache_ttl_seconds: 900
|
price_cache_ttl_seconds: 900
|
||||||
|
|
||||||
adapter:
|
delivery_providers:
|
||||||
cdek_base_url: "https://api.cdek.test/v2"
|
cdek:
|
||||||
cdek_client_id: "yaml-id"
|
base_url: "https://api.cdek.test/v2"
|
||||||
cdek_client_secret: "yaml-secret"
|
client_id: "yaml-id"
|
||||||
cdek_retry_attempts: 0
|
client_secret: "yaml-secret"
|
||||||
cdek_retry_backoff_seconds: 0.1
|
retry_attempts: 0
|
||||||
cdek_timeout_seconds: 7.5
|
retry_backoff_seconds: 0.1
|
||||||
cdek_cache_ttl_seconds: 777
|
timeout_seconds: 7.5
|
||||||
|
cache_ttl_seconds: 777
|
||||||
|
cse:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
tbank_payment:
|
tbank_payment:
|
||||||
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||||
@@ -323,9 +326,9 @@ email:
|
|||||||
monkeypatch.setattr(config_module, "_resolve_runtime_config_file", lambda: str(config_file))
|
monkeypatch.setattr(config_module, "_resolve_runtime_config_file", lambda: str(config_file))
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
http_client = RecordingHTTPClient()
|
http_client = RecordingHTTPClient()
|
||||||
provider = CDEKProvider.from_adapter_config(
|
provider = CDEKProvider.from_config(
|
||||||
http_client=http_client, # type: ignore[arg-type]
|
http_client=http_client, # type: ignore[arg-type]
|
||||||
adapter_config=settings.adapter,
|
config=settings.delivery_providers.cdek,
|
||||||
)
|
)
|
||||||
|
|
||||||
result = asyncio.run(provider.get_prices(_make_request()))
|
result = asyncio.run(provider.get_prices(_make_request()))
|
||||||
@@ -349,14 +352,14 @@ email:
|
|||||||
|
|
||||||
|
|
||||||
def test_provider_uses_default_adapter_timeout_and_cache_ttl() -> None:
|
def test_provider_uses_default_adapter_timeout_and_cache_ttl() -> None:
|
||||||
adapter_config = AdapterConfig(
|
config = CDEKDeliveryProviderConfig(
|
||||||
cdek_client_id="default-id",
|
client_id="default-id",
|
||||||
cdek_client_secret="default-secret",
|
client_secret="default-secret",
|
||||||
)
|
)
|
||||||
http_client = RecordingHTTPClient()
|
http_client = RecordingHTTPClient()
|
||||||
provider = CDEKProvider.from_adapter_config(
|
provider = CDEKProvider.from_config(
|
||||||
http_client=http_client, # type: ignore[arg-type]
|
http_client=http_client, # type: ignore[arg-type]
|
||||||
adapter_config=adapter_config,
|
config=config,
|
||||||
)
|
)
|
||||||
|
|
||||||
result = asyncio.run(provider.get_prices(_make_request()))
|
result = asyncio.run(provider.get_prices(_make_request()))
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ def test_map_cdek_response_maps_all_tariffs_to_unified_model() -> None:
|
|||||||
assert [price.currency for price in result] == ["RUB", "USD"]
|
assert [price.currency for price in result] == ["RUB", "USD"]
|
||||||
assert [price.delivery_days_min for price in result] == [2, 5]
|
assert [price.delivery_days_min for price in result] == [2, 5]
|
||||||
assert [price.delivery_days_max for price in result] == [4, 7]
|
assert [price.delivery_days_max for price in result] == [4, 7]
|
||||||
assert [price.tariff_code for price in result] == [7, 136]
|
assert [price.tariff_code for price in result] == ["7", "136"]
|
||||||
|
|
||||||
|
|
||||||
def test_map_cdek_response_returns_none_tariff_code_when_missing() -> None:
|
def test_map_cdek_response_returns_none_tariff_code_when_missing() -> None:
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ def test_provider_register_order_posts_payload_and_maps_response() -> None:
|
|||||||
http_client = SequenceHTTPClient([response])
|
http_client = SequenceHTTPClient([response])
|
||||||
provider = _make_provider(http_client)
|
provider = _make_provider(http_client)
|
||||||
|
|
||||||
result = asyncio.run(provider.register_order(make_init_payment_request()))
|
result = asyncio.run(provider.register_order(make_init_payment_request(), "order-uuid-1"))
|
||||||
|
|
||||||
assert result == CDEKOrderRegistrationResult(
|
assert result == CDEKOrderRegistrationResult(
|
||||||
order_uuid="cdek-order-uuid",
|
order_uuid="cdek-order-uuid",
|
||||||
@@ -118,7 +118,7 @@ def test_cdek_client_register_order_maps_4xx_to_request_error() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(CDEKRequestError, match="status 422"):
|
with pytest.raises(CDEKRequestError, match="status 422"):
|
||||||
asyncio.run(client.register_order(make_init_payment_request()))
|
asyncio.run(client.register_order(make_init_payment_request(), "order-uuid-1"))
|
||||||
|
|
||||||
assert len(http_client.calls) == 1
|
assert len(http_client.calls) == 1
|
||||||
|
|
||||||
@@ -149,7 +149,7 @@ def test_cdek_client_register_order_maps_duplicate_external_id_to_success() -> N
|
|||||||
retry_attempts=2,
|
retry_attempts=2,
|
||||||
)
|
)
|
||||||
|
|
||||||
result = asyncio.run(client.register_order(make_init_payment_request()))
|
result = asyncio.run(client.register_order(make_init_payment_request(), "order-uuid-1"))
|
||||||
|
|
||||||
assert result == CDEKOrderRegistrationResult(
|
assert result == CDEKOrderRegistrationResult(
|
||||||
order_uuid="existing-cdek-order-uuid",
|
order_uuid="existing-cdek-order-uuid",
|
||||||
@@ -186,7 +186,7 @@ def test_cdek_client_register_order_retries_5xx_and_raises_client_error() -> Non
|
|||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(CDEKClientError, match="retriable status 503"):
|
with pytest.raises(CDEKClientError, match="retriable status 503"):
|
||||||
asyncio.run(client.register_order(make_init_payment_request()))
|
asyncio.run(client.register_order(make_init_payment_request(), "order-uuid-1"))
|
||||||
|
|
||||||
assert len(http_client.calls) == 2
|
assert len(http_client.calls) == 2
|
||||||
assert sleep_calls == [0.25]
|
assert sleep_calls == [0.25]
|
||||||
@@ -207,4 +207,4 @@ def test_cdek_client_register_order_raises_client_error_for_invalid_success_payl
|
|||||||
)
|
)
|
||||||
|
|
||||||
with pytest.raises(CDEKClientError, match="response payload is invalid"):
|
with pytest.raises(CDEKClientError, match="response payload is invalid"):
|
||||||
asyncio.run(client.register_order(make_init_payment_request()))
|
asyncio.run(client.register_order(make_init_payment_request(), "order-uuid-1"))
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import pytest
|
import pytest
|
||||||
@@ -64,11 +65,11 @@ def test_get_order_parses_status_and_waybill_uuid() -> None:
|
|||||||
"statuses": [
|
"statuses": [
|
||||||
{"code": "ACCEPTED", "date_time": "2026-05-24T10:00:00+0000"}
|
{"code": "ACCEPTED", "date_time": "2026-05-24T10:00:00+0000"}
|
||||||
],
|
],
|
||||||
},
|
|
||||||
"related_entities": [
|
"related_entities": [
|
||||||
{"type": "waybill", "uuid": "waybill-uuid-1"}
|
{"type": "waybill", "uuid": "waybill-uuid-1"}
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
},
|
||||||
request=httpx.Request("GET", "https://api.cdek.test/v2/orders/cdek-order-uuid"),
|
request=httpx.Request("GET", "https://api.cdek.test/v2/orders/cdek-order-uuid"),
|
||||||
)
|
)
|
||||||
http_client = SequenceHTTPClient([response])
|
http_client = SequenceHTTPClient([response])
|
||||||
@@ -87,6 +88,55 @@ def test_get_order_parses_status_and_waybill_uuid() -> None:
|
|||||||
assert http_client.calls[0]["headers"] == {"Authorization": "Bearer test-token"}
|
assert http_client.calls[0]["headers"] == {"Authorization": "Bearer test-token"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_order_logs_request_errors() -> None:
|
||||||
|
response = httpx.Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"entity": {"uuid": "cdek-order-uuid", "statuses": []},
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"request_uuid": "request-uuid",
|
||||||
|
"type": "CREATE",
|
||||||
|
"state": "INVALID",
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"code": "invalid_order",
|
||||||
|
"message": "Order data is invalid",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
request=httpx.Request(
|
||||||
|
"GET", "https://api.cdek.test/v2/orders/cdek-order-uuid"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
client = _make_client(SequenceHTTPClient([response]))
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"app.adapters.delivery_providers.cdek.client.log.warning"
|
||||||
|
) as warning_mock:
|
||||||
|
asyncio.run(client.get_order("cdek-order-uuid"))
|
||||||
|
|
||||||
|
warning_mock.assert_called_once_with(
|
||||||
|
"cdek_order_request_errors",
|
||||||
|
cdek_order_uuid="cdek-order-uuid",
|
||||||
|
requests=[
|
||||||
|
{
|
||||||
|
"request_uuid": "request-uuid",
|
||||||
|
"type": "CREATE",
|
||||||
|
"state": "INVALID",
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"code": "invalid_order",
|
||||||
|
"message": "Order data is invalid",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_get_order_retries_on_5xx_and_succeeds() -> None:
|
def test_get_order_retries_on_5xx_and_succeeds() -> None:
|
||||||
flaky = httpx.Response(
|
flaky = httpx.Response(
|
||||||
503,
|
503,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from tests.payment_fixtures import make_init_payment_request
|
|||||||
|
|
||||||
|
|
||||||
def test_order_payload_uses_order_uuid_and_tariff_code_from_system_data() -> None:
|
def test_order_payload_uses_order_uuid_and_tariff_code_from_system_data() -> None:
|
||||||
payload = map_cdek_order_request(make_init_payment_request())
|
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
|
||||||
|
|
||||||
assert payload["number"] == "order-uuid-1"
|
assert payload["number"] == "order-uuid-1"
|
||||||
assert payload["type"] == 2
|
assert payload["type"] == 2
|
||||||
@@ -24,11 +24,28 @@ def test_order_payload_uses_order_uuid_and_tariff_code_from_system_data() -> Non
|
|||||||
|
|
||||||
|
|
||||||
def test_order_payload_requests_waybill_print() -> None:
|
def test_order_payload_requests_waybill_print() -> None:
|
||||||
payload = map_cdek_order_request(make_init_payment_request())
|
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
|
||||||
|
|
||||||
assert payload["print"] == "WAYBILL"
|
assert payload["print"] == "WAYBILL"
|
||||||
|
|
||||||
|
|
||||||
|
def test_order_payload_maps_declared_value_to_insurance_service() -> None:
|
||||||
|
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
|
||||||
|
|
||||||
|
assert payload["services"] == [{"code": "INSURANCE", "parameter": 50000}]
|
||||||
|
|
||||||
|
|
||||||
|
def test_order_payload_omits_insurance_service_for_zero_declared_value() -> None:
|
||||||
|
payload = map_cdek_order_request(
|
||||||
|
make_init_payment_request(
|
||||||
|
content={"description": "Docs", "declared_value": 0}
|
||||||
|
),
|
||||||
|
"order-uuid-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "services" not in payload
|
||||||
|
|
||||||
|
|
||||||
def test_map_cdek_order_response_extracts_order_uuid_without_waybill() -> None:
|
def test_map_cdek_order_response_extracts_order_uuid_without_waybill() -> None:
|
||||||
result = map_cdek_order_response({"entity": {"uuid": "cdek-order-uuid"}})
|
result = map_cdek_order_response({"entity": {"uuid": "cdek-order-uuid"}})
|
||||||
|
|
||||||
@@ -42,7 +59,8 @@ def test_map_cdek_order_response_extracts_order_uuid_without_waybill() -> None:
|
|||||||
def test_map_cdek_order_response_extracts_waybill_from_related_entities() -> None:
|
def test_map_cdek_order_response_extracts_waybill_from_related_entities() -> None:
|
||||||
result = map_cdek_order_response(
|
result = map_cdek_order_response(
|
||||||
{
|
{
|
||||||
"entity": {"uuid": "cdek-order-uuid"},
|
"entity": {
|
||||||
|
"uuid": "cdek-order-uuid",
|
||||||
"related_entities": [
|
"related_entities": [
|
||||||
{"type": "delivery", "uuid": "ignored"},
|
{"type": "delivery", "uuid": "ignored"},
|
||||||
{
|
{
|
||||||
@@ -51,6 +69,7 @@ def test_map_cdek_order_response_extracts_waybill_from_related_entities() -> Non
|
|||||||
"url": "https://cdek.test/waybill/1.pdf",
|
"url": "https://cdek.test/waybill/1.pdf",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -61,6 +80,26 @@ def test_map_cdek_order_response_extracts_waybill_from_related_entities() -> Non
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_cdek_order_response_supports_root_related_entities() -> None:
|
||||||
|
result = map_cdek_order_response(
|
||||||
|
{
|
||||||
|
"entity": {"uuid": "cdek-order-uuid"},
|
||||||
|
"related_entities": [
|
||||||
|
{
|
||||||
|
"type": "waybill",
|
||||||
|
"uuid": "waybill-uuid-legacy",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == CDEKOrderRegistrationResult(
|
||||||
|
order_uuid="cdek-order-uuid",
|
||||||
|
waybill_uuid="waybill-uuid-legacy",
|
||||||
|
waybill_url=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_map_cdek_existing_order_response_returns_waybill_for_duplicate() -> None:
|
def test_map_cdek_existing_order_response_returns_waybill_for_duplicate() -> None:
|
||||||
result = map_cdek_existing_order_response(
|
result = map_cdek_existing_order_response(
|
||||||
{
|
{
|
||||||
@@ -86,7 +125,7 @@ def test_map_cdek_existing_order_response_returns_waybill_for_duplicate() -> Non
|
|||||||
|
|
||||||
|
|
||||||
def test_order_payload_maps_phones_and_phone_ext_to_additional() -> None:
|
def test_order_payload_maps_phones_and_phone_ext_to_additional() -> None:
|
||||||
payload = map_cdek_order_request(make_init_payment_request())
|
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
|
||||||
|
|
||||||
assert payload["sender"]["phones"] == [{"number": "+79009876543"}]
|
assert payload["sender"]["phones"] == [{"number": "+79009876543"}]
|
||||||
assert payload["recipient"]["phones"] == [
|
assert payload["recipient"]["phones"] == [
|
||||||
@@ -95,7 +134,7 @@ def test_order_payload_maps_phones_and_phone_ext_to_additional() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_order_payload_maps_locations_with_address_and_postal_code() -> None:
|
def test_order_payload_maps_locations_with_address_and_postal_code() -> None:
|
||||||
payload = map_cdek_order_request(make_init_payment_request())
|
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
|
||||||
|
|
||||||
assert payload["from_location"] == {
|
assert payload["from_location"] == {
|
||||||
"code": 7017,
|
"code": 7017,
|
||||||
@@ -110,7 +149,7 @@ def test_order_payload_maps_locations_with_address_and_postal_code() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_order_payload_for_parcel_includes_dimensions() -> None:
|
def test_order_payload_for_parcel_includes_dimensions() -> None:
|
||||||
payload = map_cdek_order_request(make_init_payment_request())
|
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
|
||||||
|
|
||||||
package = payload["packages"][0]
|
package = payload["packages"][0]
|
||||||
assert package["weight"] == 1000
|
assert package["weight"] == 1000
|
||||||
@@ -121,7 +160,7 @@ def test_order_payload_for_parcel_includes_dimensions() -> None:
|
|||||||
|
|
||||||
def test_order_payload_for_doc_omits_dimensions() -> None:
|
def test_order_payload_for_doc_omits_dimensions() -> None:
|
||||||
request = make_init_payment_request(
|
request = make_init_payment_request(
|
||||||
content={"description": "Docs"},
|
content={"description": "Docs", "declared_value": 25000},
|
||||||
systemData={
|
systemData={
|
||||||
"tariff": {
|
"tariff": {
|
||||||
"provider": "СДЭК",
|
"provider": "СДЭК",
|
||||||
@@ -129,7 +168,7 @@ def test_order_payload_for_doc_omits_dimensions() -> None:
|
|||||||
"price": 50000,
|
"price": 50000,
|
||||||
"deliveryDaysMin": 1,
|
"deliveryDaysMin": 1,
|
||||||
"deliveryDaysMax": 2,
|
"deliveryDaysMax": 2,
|
||||||
"tariffCode": 535,
|
"tariffCode": "535",
|
||||||
},
|
},
|
||||||
"parcelType": "doc",
|
"parcelType": "doc",
|
||||||
"docPackaging": "envelope",
|
"docPackaging": "envelope",
|
||||||
@@ -138,7 +177,7 @@ def test_order_payload_for_doc_omits_dimensions() -> None:
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
payload = map_cdek_order_request(request)
|
payload = map_cdek_order_request(request, "order-uuid-1")
|
||||||
package = payload["packages"][0]
|
package = payload["packages"][0]
|
||||||
|
|
||||||
assert package["weight"] == 500
|
assert package["weight"] == 500
|
||||||
@@ -148,7 +187,7 @@ def test_order_payload_for_doc_omits_dimensions() -> None:
|
|||||||
|
|
||||||
|
|
||||||
def test_order_payload_omits_shipment_and_delivery_point() -> None:
|
def test_order_payload_omits_shipment_and_delivery_point() -> None:
|
||||||
payload = map_cdek_order_request(make_init_payment_request())
|
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
|
||||||
|
|
||||||
assert "shipment_point" not in payload
|
assert "shipment_point" not in payload
|
||||||
assert "delivery_point" not in payload
|
assert "delivery_point" not in payload
|
||||||
@@ -168,7 +207,7 @@ def test_order_payload_includes_company_requisites_for_legal_entity() -> None:
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
sender = map_cdek_order_request(request)["sender"]
|
sender = map_cdek_order_request(request, "order-uuid-1")["sender"]
|
||||||
|
|
||||||
assert sender["contragent_type"] == "LEGAL_ENTITY"
|
assert sender["contragent_type"] == "LEGAL_ENTITY"
|
||||||
assert sender["company"] == "Romashka LLC"
|
assert sender["company"] == "Romashka LLC"
|
||||||
@@ -176,8 +215,8 @@ def test_order_payload_includes_company_requisites_for_legal_entity() -> None:
|
|||||||
assert sender["kpp"] == "770701001"
|
assert sender["kpp"] == "770701001"
|
||||||
|
|
||||||
|
|
||||||
def test_order_payload_propagates_description_to_comments_without_items() -> None:
|
def test_order_payload_maps_content_to_comments_without_items() -> None:
|
||||||
payload = map_cdek_order_request(make_init_payment_request())
|
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
|
||||||
|
|
||||||
assert payload["comment"] == "Headphones"
|
assert payload["comment"] == "Headphones"
|
||||||
assert payload["packages"][0]["comment"] == "Headphones"
|
assert payload["packages"][0]["comment"] == "Headphones"
|
||||||
@@ -186,14 +225,18 @@ def test_order_payload_propagates_description_to_comments_without_items() -> Non
|
|||||||
|
|
||||||
def test_order_payload_falls_back_package_comment_to_order_uuid() -> None:
|
def test_order_payload_falls_back_package_comment_to_order_uuid() -> None:
|
||||||
payload = map_cdek_order_request(
|
payload = map_cdek_order_request(
|
||||||
make_init_payment_request(content={"description": None})
|
make_init_payment_request(
|
||||||
|
content={"description": None, "declared_value": 50000}
|
||||||
|
),
|
||||||
|
"order-uuid-1",
|
||||||
)
|
)
|
||||||
|
|
||||||
assert payload["packages"][0]["comment"] == "order-uuid-1"
|
assert payload["packages"][0]["comment"] == "order-uuid-1"
|
||||||
|
assert "items" not in payload["packages"][0]
|
||||||
|
|
||||||
|
|
||||||
def test_order_payload_includes_company_for_individual_sender_as_full_name() -> None:
|
def test_order_payload_includes_company_for_individual_sender_as_full_name() -> None:
|
||||||
payload = map_cdek_order_request(make_init_payment_request())
|
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
|
||||||
|
|
||||||
assert payload["sender"]["company"] == "Petr Petrov"
|
assert payload["sender"]["company"] == "Petr Petrov"
|
||||||
assert payload["recipient"]["company"] == "Ivan Ivanov"
|
assert payload["recipient"]["company"] == "Ivan Ivanov"
|
||||||
@@ -224,10 +267,10 @@ def test_map_cdek_order_info_response_picks_latest_status_by_date_time() -> None
|
|||||||
{"code": "ACCEPTED", "date_time": "2026-05-24T10:00:00+0000"},
|
{"code": "ACCEPTED", "date_time": "2026-05-24T10:00:00+0000"},
|
||||||
{"code": "INVALID", "date_time": "2026-05-24T10:00:05+0000"},
|
{"code": "INVALID", "date_time": "2026-05-24T10:00:05+0000"},
|
||||||
],
|
],
|
||||||
},
|
|
||||||
"related_entities": [
|
"related_entities": [
|
||||||
{"type": "waybill", "uuid": "waybill-uuid-1"}
|
{"type": "waybill", "uuid": "waybill-uuid-1"}
|
||||||
],
|
],
|
||||||
|
},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -250,6 +293,37 @@ def test_map_cdek_order_info_response_returns_none_status_when_statuses_empty()
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_map_cdek_order_info_response_maps_invalid_create_request_to_invalid_status() -> None:
|
||||||
|
result = map_cdek_order_info_response(
|
||||||
|
{
|
||||||
|
"entity": {
|
||||||
|
"uuid": "cdek-order-uuid",
|
||||||
|
"statuses": [
|
||||||
|
{"code": "ACCEPTED", "date_time": "2026-05-24T10:00:00+0000"}
|
||||||
|
],
|
||||||
|
},
|
||||||
|
"requests": [
|
||||||
|
{
|
||||||
|
"type": "CREATE",
|
||||||
|
"state": "INVALID",
|
||||||
|
"errors": [
|
||||||
|
{
|
||||||
|
"code": "ve_delivery_can_not_has_goods",
|
||||||
|
"message": "Заказ типа доставка не может содержать товары",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result == CDEKOrderInfo(
|
||||||
|
order_uuid="cdek-order-uuid",
|
||||||
|
status_code="INVALID",
|
||||||
|
waybill_uuid=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_map_cdek_order_info_response_raises_for_missing_entity() -> None:
|
def test_map_cdek_order_info_response_raises_for_missing_entity() -> None:
|
||||||
with pytest.raises(CDEKOrderMappingError):
|
with pytest.raises(CDEKOrderMappingError):
|
||||||
map_cdek_order_info_response({"requests": []})
|
map_cdek_order_info_response({"requests": []})
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ def test_provider_get_payment_price_omits_dimensions_for_doc() -> None:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
request = make_init_payment_request(
|
request = make_init_payment_request(
|
||||||
content={"description": "Docs"},
|
content={"description": "Docs", "declared_value": 25000},
|
||||||
systemData={
|
systemData={
|
||||||
"tariff": {
|
"tariff": {
|
||||||
"provider": "СДЭК",
|
"provider": "СДЭК",
|
||||||
@@ -132,7 +132,7 @@ def test_provider_get_payment_price_omits_dimensions_for_doc() -> None:
|
|||||||
"price": 50000,
|
"price": 50000,
|
||||||
"deliveryDaysMin": 1,
|
"deliveryDaysMin": 1,
|
||||||
"deliveryDaysMax": 2,
|
"deliveryDaysMax": 2,
|
||||||
"tariffCode": 535,
|
"tariffCode": "535",
|
||||||
},
|
},
|
||||||
"parcelType": "doc",
|
"parcelType": "doc",
|
||||||
"docPackaging": "envelope",
|
"docPackaging": "envelope",
|
||||||
|
|||||||
@@ -0,0 +1,750 @@
|
|||||||
|
import asyncio
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.adapters.delivery_providers.cse import (
|
||||||
|
CSEClient,
|
||||||
|
CSEClientError,
|
||||||
|
CSEProvider,
|
||||||
|
CSERequestError,
|
||||||
|
)
|
||||||
|
from app.adapters.delivery_providers.cse import order_mapper
|
||||||
|
from app.adapters.delivery_providers.cse.order_mapper import (
|
||||||
|
CSEOrderRegistrationParams,
|
||||||
|
)
|
||||||
|
from app.adapters.delivery_providers.cse.soap import (
|
||||||
|
build_envelope,
|
||||||
|
parse_response,
|
||||||
|
)
|
||||||
|
from app.schemas.request import DeliveryCalculationRequest, DeliveryEntity
|
||||||
|
from tests.payment_fixtures import make_init_payment_request
|
||||||
|
|
||||||
|
_CALC_RESPONSE = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">
|
||||||
|
<soap:Body>
|
||||||
|
<m:CalcResponse xmlns:m="http://www.cargo3.ru">
|
||||||
|
<m:return>
|
||||||
|
<m:Key>Calc</m:Key>
|
||||||
|
<m:List>
|
||||||
|
<m:Key>Destination</m:Key>
|
||||||
|
<m:List>
|
||||||
|
<m:Key>Tariff</m:Key>
|
||||||
|
<m:Value>tariff-guid-1</m:Value>
|
||||||
|
<m:Fields><m:Key>Total</m:Key><m:Value>1114.92</m:Value></m:Fields>
|
||||||
|
<m:Fields><m:Key>CurrencyName</m:Key><m:Value>RUR</m:Value></m:Fields>
|
||||||
|
<m:Fields><m:Key>Service</m:Key><m:Value>Россия доставка</m:Value></m:Fields>
|
||||||
|
<m:Fields><m:Key>Urgency</m:Key><m:Value>urg-std</m:Value></m:Fields>
|
||||||
|
<m:Fields><m:Key>MinPeriod</m:Key><m:Value>3</m:Value></m:Fields>
|
||||||
|
<m:Fields><m:Key>MaxPeriod</m:Key><m:Value>5</m:Value></m:Fields>
|
||||||
|
</m:List>
|
||||||
|
<m:List>
|
||||||
|
<m:Key>Tariff</m:Key>
|
||||||
|
<m:Value>tariff-guid-2</m:Value>
|
||||||
|
<m:Fields><m:Key>Total</m:Key><m:Value>2000.00</m:Value></m:Fields>
|
||||||
|
<m:Fields><m:Key>CurrencyName</m:Key><m:Value>RUR</m:Value></m:Fields>
|
||||||
|
<m:Fields><m:Key>Service</m:Key><m:Value>Экспресс</m:Value></m:Fields>
|
||||||
|
<m:Fields><m:Key>Urgency</m:Key><m:Value>urg-exp</m:Value></m:Fields>
|
||||||
|
<m:Fields><m:Key>MinPeriod</m:Key><m:Value>1</m:Value></m:Fields>
|
||||||
|
<m:Fields><m:Key>MaxPeriod</m:Key><m:Value>2</m:Value></m:Fields>
|
||||||
|
</m:List>
|
||||||
|
</m:List>
|
||||||
|
</m:return>
|
||||||
|
</m:CalcResponse>
|
||||||
|
</soap:Body>
|
||||||
|
</soap:Envelope>"""
|
||||||
|
|
||||||
|
_SAVE_RESPONSE = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">
|
||||||
|
<soap:Body>
|
||||||
|
<m:SaveDocumentsResponse xmlns:m="http://www.cargo3.ru">
|
||||||
|
<m:return>
|
||||||
|
<m:Key>SaveDocuments</m:Key>
|
||||||
|
<m:List>
|
||||||
|
<m:Key>Order</m:Key>
|
||||||
|
<m:Properties><m:Key>Number</m:Key><m:Value>CSE-000123</m:Value></m:Properties>
|
||||||
|
</m:List>
|
||||||
|
</m:return>
|
||||||
|
</m:SaveDocumentsResponse>
|
||||||
|
</soap:Body>
|
||||||
|
</soap:Envelope>"""
|
||||||
|
|
||||||
|
_SAVE_RESPONSE_DOCUMENT_ERROR = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">
|
||||||
|
<soap:Body>
|
||||||
|
<m:SaveDocumentsResponse xmlns:m="http://www.cargo3.ru">
|
||||||
|
<m:return>
|
||||||
|
<m:Key>SaveDocuments</m:Key>
|
||||||
|
<m:List>
|
||||||
|
<m:Key>Order</m:Key>
|
||||||
|
<m:Properties>
|
||||||
|
<m:Key>Error</m:Key>
|
||||||
|
<m:Value>true</m:Value>
|
||||||
|
<m:ValueType>boolean</m:ValueType>
|
||||||
|
<m:List>
|
||||||
|
<m:Key>Description</m:Key>
|
||||||
|
<m:Value>SenderAddress is invalid</m:Value>
|
||||||
|
</m:List>
|
||||||
|
</m:Properties>
|
||||||
|
</m:List>
|
||||||
|
</m:return>
|
||||||
|
</m:SaveDocumentsResponse>
|
||||||
|
</soap:Body>
|
||||||
|
</soap:Envelope>"""
|
||||||
|
|
||||||
|
_TRACKING_RESPONSE = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">
|
||||||
|
<soap:Body>
|
||||||
|
<m:TrackingResponse xmlns:m="http://www.cargo3.ru">
|
||||||
|
<m:return>
|
||||||
|
<m:Key>Tracking</m:Key>
|
||||||
|
<m:List>
|
||||||
|
<m:Key>CSE-000123</m:Key>
|
||||||
|
<m:Value>Order</m:Value>
|
||||||
|
<m:Properties><m:Key>Number</m:Key><m:Value>CSE-000123</m:Value></m:Properties>
|
||||||
|
<m:List>
|
||||||
|
<m:Key>Заказ принят, идет обработка заказа.</m:Key>
|
||||||
|
<m:Properties>
|
||||||
|
<m:Key>DateTime</m:Key><m:Value>2026-06-01T10:00:00</m:Value>
|
||||||
|
</m:Properties>
|
||||||
|
</m:List>
|
||||||
|
<m:List>
|
||||||
|
<m:Key>Накладная оформлена.</m:Key>
|
||||||
|
<m:Properties>
|
||||||
|
<m:Key>DateTime</m:Key><m:Value>2026-06-01T10:02:00</m:Value>
|
||||||
|
</m:Properties>
|
||||||
|
</m:List>
|
||||||
|
<m:Tables>
|
||||||
|
<m:Key>Waybills</m:Key>
|
||||||
|
<m:List>
|
||||||
|
<m:Key>496-AA-1676378</m:Key>
|
||||||
|
<m:Properties>
|
||||||
|
<m:Key>DocumentType</m:Key><m:Value>Waybill</m:Value>
|
||||||
|
</m:Properties>
|
||||||
|
<m:Properties>
|
||||||
|
<m:Key>Number</m:Key><m:Value>496-AA-1676378</m:Value>
|
||||||
|
</m:Properties>
|
||||||
|
</m:List>
|
||||||
|
</m:Tables>
|
||||||
|
</m:List>
|
||||||
|
</m:return>
|
||||||
|
</m:TrackingResponse>
|
||||||
|
</soap:Body>
|
||||||
|
</soap:Envelope>"""
|
||||||
|
|
||||||
|
_FORM_RESPONSE = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">
|
||||||
|
<soap:Body>
|
||||||
|
<m:GetFormsForDocumentsResponse xmlns:m="http://www.cargo3.ru">
|
||||||
|
<m:return>
|
||||||
|
<m:Key>GetPrintForms</m:Key>
|
||||||
|
<m:List>
|
||||||
|
<m:Key>496-AA-1676378</m:Key>
|
||||||
|
<m:Properties><m:Key>FormFormat</m:Key><m:Value>PDF</m:Value></m:Properties>
|
||||||
|
<m:BData>JVBERg==</m:BData>
|
||||||
|
</m:List>
|
||||||
|
</m:return>
|
||||||
|
</m:GetFormsForDocumentsResponse>
|
||||||
|
</soap:Body>
|
||||||
|
</soap:Envelope>"""
|
||||||
|
|
||||||
|
_CALC_RESPONSE_WITH_ADDITIONAL_SERVICE_FIRST = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">
|
||||||
|
<soap:Body>
|
||||||
|
<m:CalcResponse xmlns:m="http://www.cargo3.ru">
|
||||||
|
<m:return>
|
||||||
|
<m:Key>Calc</m:Key>
|
||||||
|
<m:List>
|
||||||
|
<m:Key>Destination</m:Key>
|
||||||
|
<m:List>
|
||||||
|
<m:Key>Tariff</m:Key>
|
||||||
|
<m:Value>additional-service-guid</m:Value>
|
||||||
|
<m:Fields><m:Key>Total</m:Key><m:Value>100.00</m:Value></m:Fields>
|
||||||
|
<m:Fields><m:Key>CurrencyName</m:Key><m:Value>RUR</m:Value></m:Fields>
|
||||||
|
<m:Fields>
|
||||||
|
<m:Key>Service</m:Key><m:Value>Доп. услуга</m:Value>
|
||||||
|
</m:Fields>
|
||||||
|
<m:Fields><m:Key>Urgency</m:Key><m:Value>urg-exp</m:Value></m:Fields>
|
||||||
|
<m:Fields>
|
||||||
|
<m:Key>AdditionalService</m:Key><m:Value>true</m:Value>
|
||||||
|
</m:Fields>
|
||||||
|
</m:List>
|
||||||
|
<m:List>
|
||||||
|
<m:Key>Tariff</m:Key>
|
||||||
|
<m:Value>delivery-guid</m:Value>
|
||||||
|
<m:Fields><m:Key>Total</m:Key><m:Value>793.00</m:Value></m:Fields>
|
||||||
|
<m:Fields><m:Key>CurrencyName</m:Key><m:Value>RUR</m:Value></m:Fields>
|
||||||
|
<m:Fields><m:Key>Service</m:Key><m:Value>Экспресс</m:Value></m:Fields>
|
||||||
|
<m:Fields><m:Key>Urgency</m:Key><m:Value>urg-exp</m:Value></m:Fields>
|
||||||
|
<m:Fields>
|
||||||
|
<m:Key>AdditionalService</m:Key><m:Value>false</m:Value>
|
||||||
|
</m:Fields>
|
||||||
|
<m:Fields><m:Key>MinPeriod</m:Key><m:Value>1</m:Value></m:Fields>
|
||||||
|
<m:Fields><m:Key>MaxPeriod</m:Key><m:Value>2</m:Value></m:Fields>
|
||||||
|
</m:List>
|
||||||
|
</m:List>
|
||||||
|
</m:return>
|
||||||
|
</m:CalcResponse>
|
||||||
|
</soap:Body>
|
||||||
|
</soap:Envelope>"""
|
||||||
|
|
||||||
|
_CALC_RESPONSE_WITH_BUYOUT_SERVICE_FIRST = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">
|
||||||
|
<soap:Body>
|
||||||
|
<m:CalcResponse xmlns:m="http://www.cargo3.ru">
|
||||||
|
<m:return>
|
||||||
|
<m:Key>Calc</m:Key>
|
||||||
|
<m:List>
|
||||||
|
<m:Key>Destination</m:Key>
|
||||||
|
<m:List>
|
||||||
|
<m:Key>Tariff</m:Key>
|
||||||
|
<m:Value>buyout-service-guid</m:Value>
|
||||||
|
<m:Fields><m:Key>Total</m:Key><m:Value>100.00</m:Value></m:Fields>
|
||||||
|
<m:Fields><m:Key>CurrencyName</m:Key><m:Value>RUR</m:Value></m:Fields>
|
||||||
|
<m:Fields>
|
||||||
|
<m:Key>Service</m:Key><m:Value>Частичный выкуп</m:Value>
|
||||||
|
</m:Fields>
|
||||||
|
<m:Fields><m:Key>Urgency</m:Key><m:Value>urg-exp</m:Value></m:Fields>
|
||||||
|
</m:List>
|
||||||
|
<m:List>
|
||||||
|
<m:Key>Tariff</m:Key>
|
||||||
|
<m:Value>delivery-guid</m:Value>
|
||||||
|
<m:Fields><m:Key>Total</m:Key><m:Value>793.00</m:Value></m:Fields>
|
||||||
|
<m:Fields><m:Key>CurrencyName</m:Key><m:Value>RUR</m:Value></m:Fields>
|
||||||
|
<m:Fields><m:Key>Service</m:Key><m:Value>Экспресс</m:Value></m:Fields>
|
||||||
|
<m:Fields><m:Key>Urgency</m:Key><m:Value>urg-exp</m:Value></m:Fields>
|
||||||
|
<m:Fields>
|
||||||
|
<m:Key>AdditionalService</m:Key><m:Value>false</m:Value>
|
||||||
|
</m:Fields>
|
||||||
|
<m:Fields><m:Key>MinPeriod</m:Key><m:Value>1</m:Value></m:Fields>
|
||||||
|
<m:Fields><m:Key>MaxPeriod</m:Key><m:Value>2</m:Value></m:Fields>
|
||||||
|
</m:List>
|
||||||
|
</m:List>
|
||||||
|
</m:return>
|
||||||
|
</m:CalcResponse>
|
||||||
|
</soap:Body>
|
||||||
|
</soap:Envelope>"""
|
||||||
|
|
||||||
|
_ERROR_RESPONSE = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">
|
||||||
|
<soap:Body>
|
||||||
|
<m:CalcResponse xmlns:m="http://www.cargo3.ru">
|
||||||
|
<m:return>
|
||||||
|
<m:Key>Calc</m:Key>
|
||||||
|
<m:Properties>
|
||||||
|
<m:Key>Error</m:Key>
|
||||||
|
<m:List><m:Key>Description</m:Key><m:Value>1001</m:Value></m:List>
|
||||||
|
</m:Properties>
|
||||||
|
</m:return>
|
||||||
|
</m:CalcResponse>
|
||||||
|
</soap:Body>
|
||||||
|
</soap:Envelope>"""
|
||||||
|
|
||||||
|
_DELIVERY_TYPES_RESPONSE = """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">
|
||||||
|
<soap:Body>
|
||||||
|
<m:GetReferenceDataResponse xmlns:m="http://www.cargo3.ru">
|
||||||
|
<m:return>
|
||||||
|
<m:Key>deliverytype</m:Key>
|
||||||
|
<m:List>
|
||||||
|
<m:Key>NameOfdeliverytype</m:Key>
|
||||||
|
<m:Value>ДоставкаДоДверей</m:Value>
|
||||||
|
<m:Fields><m:Key>Information</m:Key><m:Value>Дверь-Дверь</m:Value></m:Fields>
|
||||||
|
</m:List>
|
||||||
|
<m:List>
|
||||||
|
<m:Key>NameOfdeliverytype</m:Key>
|
||||||
|
<m:Value>СкладДверь</m:Value>
|
||||||
|
<m:Fields><m:Key>Information</m:Key><m:Value>Склад-Дверь</m:Value></m:Fields>
|
||||||
|
</m:List>
|
||||||
|
</m:return>
|
||||||
|
</m:GetReferenceDataResponse>
|
||||||
|
</soap:Body>
|
||||||
|
</soap:Envelope>"""
|
||||||
|
|
||||||
|
|
||||||
|
class SequenceHTTPClient:
|
||||||
|
def __init__(self, results: list[Any]) -> None:
|
||||||
|
self._results = results
|
||||||
|
self.calls: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
async def post(
|
||||||
|
self,
|
||||||
|
url: str,
|
||||||
|
*,
|
||||||
|
content: bytes | None = None,
|
||||||
|
headers: dict[str, str] | None = None,
|
||||||
|
timeout: float | None = None,
|
||||||
|
) -> httpx.Response:
|
||||||
|
self.calls.append({"url": url, "content": content, "headers": headers})
|
||||||
|
result = self._results[len(self.calls) - 1]
|
||||||
|
if isinstance(result, Exception):
|
||||||
|
raise result
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def _params() -> CSEOrderRegistrationParams:
|
||||||
|
return CSEOrderRegistrationParams(
|
||||||
|
payer="payer-1",
|
||||||
|
payment_method="pm-1",
|
||||||
|
shipping_method="sm-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_client(results: list[Any]) -> tuple[CSEClient, SequenceHTTPClient]:
|
||||||
|
http_client = SequenceHTTPClient(results)
|
||||||
|
client = CSEClient(
|
||||||
|
http_client=http_client, # type: ignore[arg-type]
|
||||||
|
base_url="http://lk-test.cse.ru/1c/ws/web1c.1cws",
|
||||||
|
login="test",
|
||||||
|
password="2016",
|
||||||
|
registration_params=_params(),
|
||||||
|
retry_attempts=0,
|
||||||
|
)
|
||||||
|
return client, http_client
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _patch_references(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
order_mapper, "resolve_cse_geography", lambda city_id: f"geo-{city_id}"
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
order_mapper, "resolve_type_of_cargo", lambda parcel_type: "cargo-guid"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _calc_request() -> DeliveryCalculationRequest:
|
||||||
|
return DeliveryCalculationRequest(
|
||||||
|
entity=DeliveryEntity.INDIVIDUAL,
|
||||||
|
from_city=1,
|
||||||
|
to_city=2,
|
||||||
|
weight_kg=1.0,
|
||||||
|
length_cm=10.0,
|
||||||
|
width_cm=10.0,
|
||||||
|
height_cm=10.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_envelope_contains_credentials_and_payload() -> None:
|
||||||
|
body = order_mapper.build_calc_body_for_calculation(_calc_request())
|
||||||
|
envelope = build_envelope("Calc", login="test", password="2016", body=body)
|
||||||
|
text = envelope.decode("utf-8")
|
||||||
|
|
||||||
|
assert "Calc" in text
|
||||||
|
assert "<m:login>test</m:login>" in text or ">test<" in text
|
||||||
|
assert "geo-1" in text
|
||||||
|
assert "geo-2" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_order_envelope_serializes_properties_before_fields() -> None:
|
||||||
|
body = order_mapper.map_cse_save_order_request(
|
||||||
|
make_init_payment_request(
|
||||||
|
systemData={
|
||||||
|
"tariff": {
|
||||||
|
"provider": "cse",
|
||||||
|
"serviceName": "Экспресс",
|
||||||
|
"price": 200000,
|
||||||
|
"deliveryDaysMin": 1,
|
||||||
|
"deliveryDaysMax": 2,
|
||||||
|
"tariffCode": "ДоставкаДоДверей|tariff-guid-2|urg-exp",
|
||||||
|
},
|
||||||
|
"parcelType": "parcel",
|
||||||
|
"weight": "1.0",
|
||||||
|
"dimensions": {"length": "10", "width": "10", "height": "10"},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"order-uuid-1",
|
||||||
|
_params(),
|
||||||
|
)
|
||||||
|
|
||||||
|
text = build_envelope(
|
||||||
|
"SaveDocuments", login="test", password="2016", body=body
|
||||||
|
).decode("utf-8")
|
||||||
|
|
||||||
|
order_start = text.index("<m:Key>Order</m:Key>")
|
||||||
|
properties_index = text.index("<m:Properties>", order_start)
|
||||||
|
fields_index = text.index("<m:Fields>", order_start)
|
||||||
|
assert properties_index < fields_index
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_calc_response_round_trip() -> None:
|
||||||
|
root = parse_response(_CALC_RESPONSE, "Calc")
|
||||||
|
assert root.key == "Calc"
|
||||||
|
assert root.items[0].items[0].field_value("Total") == "1114.92"
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_get_prices_maps_all_tariffs() -> None:
|
||||||
|
client, http_client = _build_client(
|
||||||
|
[
|
||||||
|
httpx.Response(200, text=_DELIVERY_TYPES_RESPONSE),
|
||||||
|
httpx.Response(200, text=_CALC_RESPONSE),
|
||||||
|
httpx.Response(200, text=_CALC_RESPONSE),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
provider = CSEProvider(
|
||||||
|
client,
|
||||||
|
delivery_service_guids=("tariff-guid-1", "tariff-guid-2"),
|
||||||
|
)
|
||||||
|
|
||||||
|
prices = asyncio.run(provider.get_prices(_calc_request()))
|
||||||
|
|
||||||
|
# PVZ-requiring scheme (Склад-Дверь) is excluded: only delivery types + one
|
||||||
|
# Calc per configured CSE delivery service are requested.
|
||||||
|
assert len(http_client.calls) == 3
|
||||||
|
assert [price.provider for price in prices] == ["cse", "cse"]
|
||||||
|
assert [price.tariff_code for price in prices] == [
|
||||||
|
"ДоставкаДоДверей|tariff-guid-1|urg-std",
|
||||||
|
"ДоставкаДоДверей|tariff-guid-2|urg-exp",
|
||||||
|
]
|
||||||
|
assert prices[0].price == Decimal("1114.92")
|
||||||
|
assert prices[0].currency == "RUB"
|
||||||
|
assert prices[0].delivery_days_min == 3
|
||||||
|
assert prices[0].delivery_days_max == 5
|
||||||
|
assert prices[0].service_name == "Россия доставка — Дверь-Дверь"
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_get_payment_price_selects_matching_tariff() -> None:
|
||||||
|
response = httpx.Response(200, text=_CALC_RESPONSE)
|
||||||
|
client, http_client = _build_client([response])
|
||||||
|
provider = CSEProvider(client)
|
||||||
|
request = make_init_payment_request(
|
||||||
|
systemData={
|
||||||
|
"tariff": {
|
||||||
|
"provider": "cse",
|
||||||
|
"serviceName": "Экспресс",
|
||||||
|
"price": 200000,
|
||||||
|
"deliveryDaysMin": 1,
|
||||||
|
"deliveryDaysMax": 2,
|
||||||
|
"tariffCode": "ДоставкаДоДверей|tariff-guid-2|urg-exp",
|
||||||
|
},
|
||||||
|
"parcelType": "parcel",
|
||||||
|
"weight": "1.0",
|
||||||
|
"dimensions": {"length": "10", "width": "10", "height": "10"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
price = asyncio.run(provider.get_payment_price(request))
|
||||||
|
|
||||||
|
assert price is not None
|
||||||
|
assert price.tariff_code == "ДоставкаДоДверей|tariff-guid-2|urg-exp"
|
||||||
|
assert price.price == Decimal("2000.00")
|
||||||
|
content = http_client.calls[0]["content"].decode("utf-8")
|
||||||
|
assert "Service" in content
|
||||||
|
assert "tariff-guid-2" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_get_payment_price_ignores_matching_additional_service() -> None:
|
||||||
|
response = httpx.Response(200, text=_CALC_RESPONSE_WITH_ADDITIONAL_SERVICE_FIRST)
|
||||||
|
client, _ = _build_client([response])
|
||||||
|
provider = CSEProvider(client)
|
||||||
|
request = make_init_payment_request(
|
||||||
|
systemData={
|
||||||
|
"tariff": {
|
||||||
|
"provider": "cse",
|
||||||
|
"serviceName": "Экспресс",
|
||||||
|
"price": 79300,
|
||||||
|
"deliveryDaysMin": 1,
|
||||||
|
"deliveryDaysMax": 2,
|
||||||
|
"tariffCode": "ДоставкаДоДверей|delivery-guid|urg-exp",
|
||||||
|
},
|
||||||
|
"parcelType": "parcel",
|
||||||
|
"weight": "1.0",
|
||||||
|
"dimensions": {"length": "10", "width": "10", "height": "10"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
price = asyncio.run(provider.get_payment_price(request))
|
||||||
|
|
||||||
|
assert price is not None
|
||||||
|
assert price.tariff_code == "ДоставкаДоДверей|delivery-guid|urg-exp"
|
||||||
|
assert price.price == Decimal("793.00")
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_get_payment_price_selects_matching_service_guid() -> None:
|
||||||
|
response = httpx.Response(200, text=_CALC_RESPONSE_WITH_BUYOUT_SERVICE_FIRST)
|
||||||
|
client, _ = _build_client([response])
|
||||||
|
provider = CSEProvider(client)
|
||||||
|
request = make_init_payment_request(
|
||||||
|
systemData={
|
||||||
|
"tariff": {
|
||||||
|
"provider": "cse",
|
||||||
|
"serviceName": "Экспресс",
|
||||||
|
"price": 79300,
|
||||||
|
"deliveryDaysMin": 1,
|
||||||
|
"deliveryDaysMax": 2,
|
||||||
|
"tariffCode": "ДоставкаДоДверей|delivery-guid|urg-exp",
|
||||||
|
},
|
||||||
|
"parcelType": "parcel",
|
||||||
|
"weight": "1.0",
|
||||||
|
"dimensions": {"length": "10", "width": "10", "height": "10"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
price = asyncio.run(provider.get_payment_price(request))
|
||||||
|
|
||||||
|
assert price is not None
|
||||||
|
assert price.tariff_code == "ДоставкаДоДверей|delivery-guid|urg-exp"
|
||||||
|
assert price.price == Decimal("793.00")
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_get_prices_excludes_additional_service_tariffs() -> None:
|
||||||
|
client, _ = _build_client(
|
||||||
|
[
|
||||||
|
httpx.Response(200, text=_DELIVERY_TYPES_RESPONSE),
|
||||||
|
httpx.Response(200, text=_CALC_RESPONSE_WITH_ADDITIONAL_SERVICE_FIRST),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
provider = CSEProvider(client, delivery_service_guids=("delivery-guid",))
|
||||||
|
|
||||||
|
prices = asyncio.run(provider.get_prices(_calc_request()))
|
||||||
|
|
||||||
|
assert [price.price for price in prices] == [Decimal("793.00")]
|
||||||
|
assert [price.service_name for price in prices] == [
|
||||||
|
"Экспресс — Дверь-Дверь"
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_get_prices_uses_configured_service_guid_filter() -> None:
|
||||||
|
client, http_client = _build_client(
|
||||||
|
[
|
||||||
|
httpx.Response(200, text=_DELIVERY_TYPES_RESPONSE),
|
||||||
|
httpx.Response(200, text=_CALC_RESPONSE_WITH_BUYOUT_SERVICE_FIRST),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
provider = CSEProvider(client, delivery_service_guids=("delivery-guid",))
|
||||||
|
|
||||||
|
prices = asyncio.run(provider.get_prices(_calc_request()))
|
||||||
|
|
||||||
|
assert [price.price for price in prices] == [Decimal("793.00")]
|
||||||
|
assert [price.service_name for price in prices] == [
|
||||||
|
"Экспресс — Дверь-Дверь"
|
||||||
|
]
|
||||||
|
content = http_client.calls[1]["content"].decode("utf-8")
|
||||||
|
assert "Service" in content
|
||||||
|
assert "delivery-guid" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_get_prices_requires_delivery_service_guid_configuration() -> None:
|
||||||
|
client, _ = _build_client([httpx.Response(200, text=_DELIVERY_TYPES_RESPONSE)])
|
||||||
|
provider = CSEProvider(client)
|
||||||
|
|
||||||
|
with pytest.raises(CSERequestError):
|
||||||
|
asyncio.run(provider.get_prices(_calc_request()))
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_register_order_returns_document_number() -> None:
|
||||||
|
response = httpx.Response(200, text=_SAVE_RESPONSE)
|
||||||
|
client, http_client = _build_client([response])
|
||||||
|
provider = CSEProvider(client)
|
||||||
|
|
||||||
|
result = asyncio.run(
|
||||||
|
provider.register_order(
|
||||||
|
make_init_payment_request(
|
||||||
|
systemData={
|
||||||
|
"tariff": {
|
||||||
|
"provider": "cse",
|
||||||
|
"serviceName": "Экспресс",
|
||||||
|
"price": 200000,
|
||||||
|
"deliveryDaysMin": 1,
|
||||||
|
"deliveryDaysMax": 2,
|
||||||
|
"tariffCode": "ДоставкаДоДверей|tariff-guid-2|urg-exp",
|
||||||
|
},
|
||||||
|
"parcelType": "parcel",
|
||||||
|
"weight": "1.0",
|
||||||
|
"dimensions": {"length": "10", "width": "10", "height": "10"},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"order-uuid-1",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.order_number == "CSE-000123"
|
||||||
|
assert http_client.calls[0]["url"] == "http://lk-test.cse.ru/1c/ws/web1c.1cws"
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_register_order_raises_document_level_error() -> None:
|
||||||
|
response = httpx.Response(200, text=_SAVE_RESPONSE_DOCUMENT_ERROR)
|
||||||
|
client, _ = _build_client([response])
|
||||||
|
provider = CSEProvider(client)
|
||||||
|
|
||||||
|
with pytest.raises(CSERequestError, match="SenderAddress is invalid"):
|
||||||
|
asyncio.run(
|
||||||
|
provider.register_order(
|
||||||
|
make_init_payment_request(
|
||||||
|
systemData={
|
||||||
|
"tariff": {
|
||||||
|
"provider": "cse",
|
||||||
|
"serviceName": "Экспресс",
|
||||||
|
"price": 200000,
|
||||||
|
"deliveryDaysMin": 1,
|
||||||
|
"deliveryDaysMax": 2,
|
||||||
|
"tariffCode": "ДоставкаДоДверей|tariff-guid-2|urg-exp",
|
||||||
|
},
|
||||||
|
"parcelType": "parcel",
|
||||||
|
"weight": "1.0",
|
||||||
|
"dimensions": {
|
||||||
|
"length": "10",
|
||||||
|
"width": "10",
|
||||||
|
"height": "10",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"order-uuid-1",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_get_order_extracts_waybill_number_from_tracking() -> None:
|
||||||
|
response = httpx.Response(200, text=_TRACKING_RESPONSE)
|
||||||
|
client, http_client = _build_client([response])
|
||||||
|
provider = CSEProvider(client)
|
||||||
|
|
||||||
|
result = asyncio.run(provider.get_order("CSE-000123"))
|
||||||
|
|
||||||
|
assert result.order_number == "CSE-000123"
|
||||||
|
assert result.status_code == "Накладная оформлена."
|
||||||
|
assert result.waybill_number == "496-AA-1676378"
|
||||||
|
content = http_client.calls[0]["content"].decode("utf-8")
|
||||||
|
assert "Tracking" in content
|
||||||
|
assert "DocumentType" in content
|
||||||
|
assert "Order" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_provider_download_waybill_pdf_uses_print_form() -> None:
|
||||||
|
response = httpx.Response(200, text=_FORM_RESPONSE)
|
||||||
|
client, http_client = _build_client([response])
|
||||||
|
provider = CSEProvider(client)
|
||||||
|
|
||||||
|
pdf = asyncio.run(provider.download_waybill_pdf("496-AA-1676378"))
|
||||||
|
|
||||||
|
assert pdf == b"%PDF"
|
||||||
|
content = http_client.calls[0]["content"].decode("utf-8")
|
||||||
|
assert "GetFormsForDocuments" in content
|
||||||
|
assert "DocumentType" in content
|
||||||
|
assert "waybill" in content
|
||||||
|
assert "Type" in content
|
||||||
|
assert "print" in content
|
||||||
|
assert "Format" in content
|
||||||
|
assert "pdf" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_order_request_uses_selected_tariff_urgency() -> None:
|
||||||
|
request = make_init_payment_request(
|
||||||
|
systemData={
|
||||||
|
"tariff": {
|
||||||
|
"provider": "cse",
|
||||||
|
"serviceName": "Экспресс",
|
||||||
|
"price": 200000,
|
||||||
|
"deliveryDaysMin": 1,
|
||||||
|
"deliveryDaysMax": 2,
|
||||||
|
"tariffCode": "ДоставкаДоДверей|tariff-guid-2|urg-exp",
|
||||||
|
},
|
||||||
|
"parcelType": "parcel",
|
||||||
|
"weight": "1.0",
|
||||||
|
"dimensions": {"length": "10", "width": "10", "height": "10"},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
body = order_mapper.map_cse_save_order_request(request, "order-uuid-1", _params())
|
||||||
|
order = body["data"].items[0]
|
||||||
|
|
||||||
|
assert order.field_value("Urgency") == "urg-exp"
|
||||||
|
assert order.field_value("DeliveryOfCargo") == "ДоставкаДоДверей"
|
||||||
|
assert order.field_value("Payer") == "payer-1"
|
||||||
|
assert order.field_value("ShippingMethod") == "sm-1"
|
||||||
|
assert order.field_value("CargoCost") == "50000"
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_order_request_uses_pickup_date_plus_seven_days_as_delivery_date() -> None:
|
||||||
|
request = make_init_payment_request(
|
||||||
|
deliveryDate="2026-05-18T18:00:00.000Z",
|
||||||
|
systemData={
|
||||||
|
"tariff": {
|
||||||
|
"provider": "cse",
|
||||||
|
"serviceName": "Экспресс",
|
||||||
|
"price": 200000,
|
||||||
|
"deliveryDaysMin": 1,
|
||||||
|
"deliveryDaysMax": 2,
|
||||||
|
"tariffCode": "ДоставкаДоДверей|tariff-guid-2|urg-exp",
|
||||||
|
},
|
||||||
|
"parcelType": "parcel",
|
||||||
|
"weight": "1.0",
|
||||||
|
"dimensions": {"length": "10", "width": "10", "height": "10"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
body = order_mapper.map_cse_save_order_request(request, "order-uuid-1", _params())
|
||||||
|
order = body["data"].items[0]
|
||||||
|
|
||||||
|
assert order.field_value("TakeDate") == "2026-05-15T10:00:00"
|
||||||
|
assert order.field_value("DeliveryDate") == "2026-05-22T10:00:00"
|
||||||
|
|
||||||
|
|
||||||
|
def test_application_error_in_response_raises_request_error() -> None:
|
||||||
|
client, _ = _build_client(
|
||||||
|
[
|
||||||
|
httpx.Response(200, text=_DELIVERY_TYPES_RESPONSE),
|
||||||
|
httpx.Response(200, text=_ERROR_RESPONSE),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
provider = CSEProvider(client, delivery_service_guids=("delivery-guid",))
|
||||||
|
|
||||||
|
with pytest.raises(CSERequestError):
|
||||||
|
asyncio.run(provider.get_prices(_calc_request()))
|
||||||
|
|
||||||
|
|
||||||
|
def test_server_error_logs_response_excerpt(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
warning_calls: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def capture_warning(event: str, **kwargs: Any) -> None:
|
||||||
|
warning_calls.append({"event": event, **kwargs})
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"app.adapters.delivery_providers.cse.client.log.warning",
|
||||||
|
capture_warning,
|
||||||
|
)
|
||||||
|
response = httpx.Response(
|
||||||
|
500,
|
||||||
|
text="<fault>\n CSE rejected SaveDocuments because field X is invalid \n</fault>",
|
||||||
|
)
|
||||||
|
client, _ = _build_client([response])
|
||||||
|
provider = CSEProvider(client)
|
||||||
|
|
||||||
|
with pytest.raises(CSEClientError):
|
||||||
|
asyncio.run(
|
||||||
|
provider.register_order(
|
||||||
|
make_init_payment_request(
|
||||||
|
systemData={
|
||||||
|
"tariff": {
|
||||||
|
"provider": "cse",
|
||||||
|
"serviceName": "Экспресс",
|
||||||
|
"price": 200000,
|
||||||
|
"deliveryDaysMin": 1,
|
||||||
|
"deliveryDaysMax": 2,
|
||||||
|
"tariffCode": "ДоставкаДоДверей|tariff-guid-2|urg-exp",
|
||||||
|
},
|
||||||
|
"parcelType": "parcel",
|
||||||
|
"weight": "1.0",
|
||||||
|
"dimensions": {
|
||||||
|
"length": "10",
|
||||||
|
"width": "10",
|
||||||
|
"height": "10",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
"order-uuid-1",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert warning_calls == [
|
||||||
|
{
|
||||||
|
"event": "cse_request_server_error",
|
||||||
|
"operation": "SaveDocuments",
|
||||||
|
"status_code": 500,
|
||||||
|
"response_excerpt": (
|
||||||
|
"<fault> CSE rejected SaveDocuments because field X is invalid "
|
||||||
|
"</fault>"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import asyncio
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.adapters.delivery_providers.registry import (
|
||||||
|
build_delivery_provider_registry,
|
||||||
|
resolve_delivery_provider_timeout_seconds,
|
||||||
|
)
|
||||||
|
from app.config import (
|
||||||
|
CDEKDeliveryProviderConfig,
|
||||||
|
CSEDeliveryProviderConfig,
|
||||||
|
DeliveryProvidersConfig,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_registry_builds_enabled_provider_capability_maps() -> None:
|
||||||
|
config = DeliveryProvidersConfig(
|
||||||
|
cdek=CDEKDeliveryProviderConfig(
|
||||||
|
base_url="https://cdek.test/v2",
|
||||||
|
client_id="id",
|
||||||
|
client_secret="secret",
|
||||||
|
cache_ttl_seconds=111,
|
||||||
|
),
|
||||||
|
cse=CSEDeliveryProviderConfig(
|
||||||
|
enabled=False,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
http_client = httpx.AsyncClient()
|
||||||
|
try:
|
||||||
|
registry = build_delivery_provider_registry(
|
||||||
|
http_client=http_client,
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
asyncio.run(http_client.aclose())
|
||||||
|
|
||||||
|
assert [provider.name for provider in registry.providers] == ["cdek"]
|
||||||
|
assert sorted(registry.payment_price_validation_adapters) == ["cdek"]
|
||||||
|
assert sorted(registry.order_registration_adapters) == ["cdek"]
|
||||||
|
assert registry.providers[0].cache_ttl_seconds == 111
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_delivery_provider_timeout_uses_max_enabled_timeout() -> None:
|
||||||
|
config = DeliveryProvidersConfig(
|
||||||
|
cdek=CDEKDeliveryProviderConfig(timeout_seconds=3.0),
|
||||||
|
cse=CSEDeliveryProviderConfig(timeout_seconds=7.5),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert resolve_delivery_provider_timeout_seconds(config) == 7.5
|
||||||
@@ -164,3 +164,24 @@ def test_send_email_passes_none_when_credentials_blank() -> None:
|
|||||||
call = send.calls[0]
|
call = send.calls[0]
|
||||||
assert call["username"] is None
|
assert call["username"] is None
|
||||||
assert call["password"] is None
|
assert call["password"] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_email_without_attachment_builds_plain_text_message() -> None:
|
||||||
|
send = StubSend()
|
||||||
|
sender = _make_sender(send)
|
||||||
|
|
||||||
|
asyncio.run(
|
||||||
|
sender.send_email(
|
||||||
|
to="client@example.com",
|
||||||
|
subject="Оплата принята",
|
||||||
|
body="Ваша оплата принята.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(send.calls) == 1
|
||||||
|
message: EmailMessage = send.calls[0]["message"]
|
||||||
|
assert message["From"] == "no-reply@test"
|
||||||
|
assert message["To"] == "client@example.com"
|
||||||
|
assert message["Subject"] == "Оплата принята"
|
||||||
|
assert "Ваша оплата принята." in message.get_content()
|
||||||
|
assert list(message.iter_attachments()) == []
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ def test_create_payment_link_posts_signed_payload_and_maps_payment_url() -> None
|
|||||||
"https://example.test/api/v1/delivery/tbank/notifications"
|
"https://example.test/api/v1/delivery/tbank/notifications"
|
||||||
"order-uuid-1"
|
"order-uuid-1"
|
||||||
"test-password"
|
"test-password"
|
||||||
"https://example.test/payment/success"
|
"https://example.test/payment/success/order-uuid-1"
|
||||||
"TBankTest"
|
"TBankTest"
|
||||||
).encode("utf-8")
|
).encode("utf-8")
|
||||||
).hexdigest()
|
).hexdigest()
|
||||||
@@ -109,7 +109,7 @@ def test_create_payment_link_posts_signed_payload_and_maps_payment_url() -> None
|
|||||||
"Amount": 125000,
|
"Amount": 125000,
|
||||||
"OrderId": "order-uuid-1",
|
"OrderId": "order-uuid-1",
|
||||||
"NotificationURL": "https://example.test/api/v1/delivery/tbank/notifications",
|
"NotificationURL": "https://example.test/api/v1/delivery/tbank/notifications",
|
||||||
"SuccessURL": "https://example.test/payment/success",
|
"SuccessURL": "https://example.test/payment/success/order-uuid-1",
|
||||||
"Token": expected_token,
|
"Token": expected_token,
|
||||||
},
|
},
|
||||||
"data": None,
|
"data": None,
|
||||||
@@ -159,7 +159,7 @@ def test_from_config_posts_configured_urls_and_deterministic_token() -> None:
|
|||||||
"https://merchant.test/api/v1/delivery/tbank/notifications"
|
"https://merchant.test/api/v1/delivery/tbank/notifications"
|
||||||
"order-uuid-2"
|
"order-uuid-2"
|
||||||
"config-password"
|
"config-password"
|
||||||
"https://merchant.test/payment/success"
|
"https://merchant.test/payment/success/order-uuid-2"
|
||||||
"ConfigTerminal"
|
"ConfigTerminal"
|
||||||
).encode("utf-8")
|
).encode("utf-8")
|
||||||
).hexdigest()
|
).hexdigest()
|
||||||
@@ -169,7 +169,7 @@ def test_from_config_posts_configured_urls_and_deterministic_token() -> None:
|
|||||||
"Amount": 9900,
|
"Amount": 9900,
|
||||||
"OrderId": "order-uuid-2",
|
"OrderId": "order-uuid-2",
|
||||||
"NotificationURL": "https://merchant.test/api/v1/delivery/tbank/notifications",
|
"NotificationURL": "https://merchant.test/api/v1/delivery/tbank/notifications",
|
||||||
"SuccessURL": "https://merchant.test/payment/success",
|
"SuccessURL": "https://merchant.test/payment/success/order-uuid-2",
|
||||||
"Token": expected_token,
|
"Token": expected_token,
|
||||||
}
|
}
|
||||||
assert http_client.calls[0]["timeout"] == 6.25
|
assert http_client.calls[0]["timeout"] == 6.25
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user