Compare commits
21 Commits
c2757d24fc
..
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 |
+67
-16
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- stage
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
@@ -13,17 +14,53 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Resolve environment
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Тег образа зависит только от коммита (одинаков для stage и prod)
|
||||
echo "IMAGE_TAG=$(git rev-parse --short HEAD)" >> "$GITHUB_ENV"
|
||||
if [[ "${{ gitea.ref_name }}" == "stage" ]]; then
|
||||
{
|
||||
echo "ENV_NAME=stage"
|
||||
echo "CONTAINER_SUFFIX=-stage"
|
||||
echo "APP_PORT=8004"
|
||||
echo "POSTGRES_PORT=5433"
|
||||
echo "REDIS_PORT=6380"
|
||||
echo 'METRICS_FILTER_EXPRESSION=resource.attributes["container.name"] != nil and not IsMatch(resource.attributes["container.name"], "-stage$")'
|
||||
echo "COMPOSE_PROJECT=g2s-aggregator-stage"
|
||||
echo "DEPLOY_DIR=/home/deploy/g2s-aggregator-stage"
|
||||
} >> "$GITHUB_ENV"
|
||||
else
|
||||
{
|
||||
echo "ENV_NAME=prod"
|
||||
echo "CONTAINER_SUFFIX="
|
||||
echo "APP_PORT=8003"
|
||||
echo "POSTGRES_PORT=5432"
|
||||
echo "REDIS_PORT=6379"
|
||||
echo 'METRICS_FILTER_EXPRESSION=resource.attributes["container.name"] != nil and IsMatch(resource.attributes["container.name"], "-stage$")'
|
||||
echo "COMPOSE_PROJECT=g2s-aggregator"
|
||||
echo "DEPLOY_DIR=/home/deploy/g2s-aggregator"
|
||||
} >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Build and push image
|
||||
run: |
|
||||
docker login gitea.p4r4dls.ru \
|
||||
-u ${{ secrets.REGISTRY_USER }} \
|
||||
-p ${{ secrets.REGISTRY_TOKEN }}
|
||||
docker buildx create --use --name multibuilder
|
||||
docker buildx build \
|
||||
--platform linux/amd64 \
|
||||
--push \
|
||||
-t gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.1.0 \
|
||||
.
|
||||
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: |
|
||||
@@ -38,42 +75,56 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
apt update && apt install -y gettext-base
|
||||
|
||||
# Экспортируем все секреты как 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:-}" ]]; then
|
||||
if [[ -z "${!var+x}" ]]; then
|
||||
echo "Error: $var не задан в Gitea Secrets" >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
envsubst < "$src" > "$dst"
|
||||
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:/home/deploy/g2s-aggregator/config.yaml
|
||||
deploy@194.58.121.203:$DEPLOY_DIR/config.yaml
|
||||
scp -i ~/.ssh/deploy_key docker-compose.rendered.yml \
|
||||
deploy@194.58.121.203:/home/deploy/g2s-aggregator/docker-compose.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 /home/deploy/g2s-aggregator &&
|
||||
cd $DEPLOY_DIR &&
|
||||
docker login gitea.p4r4dls.ru \
|
||||
-u ${{ secrets.REGISTRY_USER }} \
|
||||
-p ${{ secrets.REGISTRY_TOKEN }} &&
|
||||
docker compose pull &&
|
||||
docker compose up -d
|
||||
docker compose -p $COMPOSE_PROJECT pull &&
|
||||
docker compose -p $COMPOSE_PROJECT up -d --force-recreate otel-collector &&
|
||||
docker compose -p $COMPOSE_PROJECT up -d
|
||||
"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
*.iml
|
||||
/config.yaml
|
||||
/docker-compose.yml
|
||||
/otel-collector-config.yaml
|
||||
__pycache__
|
||||
http-client.private.env.json
|
||||
scripts
|
||||
|
||||
+10
-7
@@ -1,17 +1,20 @@
|
||||
FROM python:3.14-slim
|
||||
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV PYTHONUNBUFFERED=1 \
|
||||
UV_COMPILE_BYTECODE=1 \
|
||||
UV_LINK_MODE=copy \
|
||||
UV_FROZEN=1 \
|
||||
PATH="/app/.venv/bin:$PATH"
|
||||
|
||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN pip install --no-cache-dir poetry
|
||||
|
||||
COPY pyproject.toml poetry.lock ./
|
||||
RUN poetry config virtualenvs.create false \
|
||||
&& poetry install --no-interaction --no-root
|
||||
COPY pyproject.toml uv.lock ./
|
||||
RUN uv sync --frozen --no-install-project --no-dev
|
||||
|
||||
COPY app ./app
|
||||
COPY alembic.ini ./alembic.ini
|
||||
COPY alembic ./alembic
|
||||
|
||||
CMD ["poetry", "run", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["uv", "run", "--no-sync", "uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
You are Implementer.
|
||||
|
||||
You implement EXACTLY ONE next uncompleted task from spec/tasks/.
|
||||
|
||||
Before starting, you MUST read:
|
||||
- spec/overview.md
|
||||
- the assigned task file in spec/tasks/
|
||||
|
||||
You MUST NOT modify any files in spec/.
|
||||
|
||||
---
|
||||
|
||||
## Your responsibilities
|
||||
|
||||
- Implement the task exactly as specified
|
||||
- Place business rules in Business Logic
|
||||
- Place orchestration in Service
|
||||
- Respect all constraints from the task
|
||||
- Add or update required tests
|
||||
- Keep changes strictly within task scope
|
||||
|
||||
---
|
||||
|
||||
## Mandatory verification steps (BEFORE finishing)
|
||||
|
||||
You MUST:
|
||||
1. Verify all Definition of Done items from the task
|
||||
2. Ensure Business Logic is fully unit-tested WITHOUT mocks
|
||||
3. Ensure Service tests (if any) may use mocks/stubs
|
||||
4. Run all tests specified in the task
|
||||
5. Run all linters / type checks specified in the task
|
||||
6. Verify no unrelated files were modified
|
||||
7. Stage all intended changes:
|
||||
- run: git add -A
|
||||
- DO NOT commit
|
||||
|
||||
If ANY item fails, you MUST report it explicitly.
|
||||
|
||||
---
|
||||
|
||||
## Output format (MANDATORY)
|
||||
|
||||
Output EXACTLY the following sections.
|
||||
|
||||
## Task
|
||||
- Task ID and title
|
||||
|
||||
## Changes made
|
||||
- List of changed files
|
||||
- Short description per file
|
||||
- Explicit layer for each file (Controller / Service / Business Logic / Repository / Adapter / Tests)
|
||||
|
||||
## Definition of Done verification
|
||||
- Checklist copied from the task
|
||||
- Each item marked as PASSED or FAILED
|
||||
|
||||
## Tests
|
||||
- Tests added or updated
|
||||
- Test type:
|
||||
- unit (no mocks)
|
||||
- integration
|
||||
- other
|
||||
- Results
|
||||
|
||||
## Commands executed
|
||||
- Exact commands run
|
||||
- Result (success/failure)
|
||||
|
||||
## Git staging
|
||||
- Confirm: git add -A executed
|
||||
- Confirm: no commits created
|
||||
|
||||
## Notes
|
||||
- Edge cases, trade-offs, or limitations
|
||||
- If none, write "None"
|
||||
|
||||
---
|
||||
|
||||
## Hard rules
|
||||
|
||||
- No scope expansion
|
||||
- No speculative refactoring
|
||||
- No changes outside task scope
|
||||
- No spec changes
|
||||
- No business logic in Service or Adapter
|
||||
@@ -1,99 +0,0 @@
|
||||
You are Planner.
|
||||
|
||||
You analyze a programming task or specification and produce planning artifacts.
|
||||
You do NOT write production code.
|
||||
|
||||
You MUST always read:
|
||||
- spec/overview.md
|
||||
- spec/index.md
|
||||
- existing files in spec/tasks/
|
||||
|
||||
You are the ONLY role allowed to create or modify files in spec/tasks/.
|
||||
Write tasks in russian except headings, frontmatter and terms
|
||||
|
||||
Follow AGENTS.md strictly.
|
||||
|
||||
---
|
||||
|
||||
## Your responsibilities
|
||||
|
||||
Depending on the input, you may:
|
||||
- create one or more new task files in spec/tasks/
|
||||
- update existing task files in spec/tasks/
|
||||
|
||||
For every change in spec/tasks/, you MUST:
|
||||
- use the minimal task format defined below
|
||||
- update spec/index.md via the generator workflow
|
||||
|
||||
You MUST NOT:
|
||||
- modify any source code
|
||||
- change files outside spec/
|
||||
- introduce architectural decisions not requested
|
||||
|
||||
---
|
||||
|
||||
## Minimal task format (MANDATORY)
|
||||
|
||||
Every task file in spec/tasks/ MUST follow this structure.
|
||||
|
||||
### Front matter (required)
|
||||
|
||||
---
|
||||
id: <numeric, zero-padded, unique>
|
||||
title: <short, precise, action-oriented>
|
||||
status: TODO
|
||||
created: <YYYY-MM-DD>
|
||||
---
|
||||
|
||||
### Body (required sections, minimal)
|
||||
|
||||
## Context
|
||||
Why this task exists. Short and factual.
|
||||
|
||||
## Goal
|
||||
What must be implemented or changed.
|
||||
|
||||
## Constraints
|
||||
Hard limits:
|
||||
- architectural
|
||||
- scope
|
||||
- forbidden changes
|
||||
|
||||
## Acceptance criteria
|
||||
- Bullet list
|
||||
- Objective and testable
|
||||
|
||||
## Definition of Done
|
||||
Checklist. Task is DONE only if ALL items are satisfied.
|
||||
|
||||
## Tests
|
||||
What tests must exist or be updated.
|
||||
|
||||
## Commands
|
||||
Exact commands to verify completion
|
||||
(e.g. pytest, ruff, mypy, docker compose).
|
||||
|
||||
---
|
||||
|
||||
## Planner output rules
|
||||
|
||||
When creating or updating tasks, output MUST include:
|
||||
|
||||
### Tasks created
|
||||
- List of new spec/tasks/*.md files with full content
|
||||
|
||||
### Tasks updated
|
||||
- List of modified spec/tasks/*.md files with changes
|
||||
|
||||
### Index update
|
||||
- Explicit instruction to run:
|
||||
python3 spec/gen_spec_index.py
|
||||
|
||||
If no tasks are created or updated, explicitly state:
|
||||
- "No task changes required"
|
||||
|
||||
Rules:
|
||||
- No code blocks outside task files
|
||||
- No speculative tasks
|
||||
- No refactoring-only tasks
|
||||
- No vague goals
|
||||
@@ -1,93 +0,0 @@
|
||||
You are Reviewer.
|
||||
|
||||
You review code changes produced by Implementer.
|
||||
|
||||
Before reviewing, you MUST read:
|
||||
- spec/overview.md
|
||||
- the assigned task file in spec/tasks/
|
||||
|
||||
You MUST review ONLY staged changes:
|
||||
- git diff --staged
|
||||
|
||||
You MUST NOT modify source code.
|
||||
|
||||
---
|
||||
|
||||
## Review scope (MANDATORY)
|
||||
|
||||
You must verify:
|
||||
|
||||
1. Task compliance
|
||||
- Implementation matches task requirements
|
||||
- No scope expansion
|
||||
|
||||
2. Architecture compliance
|
||||
- Correct layer separation
|
||||
- Business Logic purity
|
||||
- Proper Service orchestration
|
||||
- No business decisions in Repository or Adapter
|
||||
|
||||
3. Testing strategy
|
||||
- Business Logic has unit tests WITHOUT mocks
|
||||
- Tests cover task Definition of Done
|
||||
- No missing critical tests
|
||||
|
||||
4. Project compliance
|
||||
- Matches constraints and invariants from spec/overview.md
|
||||
|
||||
---
|
||||
|
||||
## Output format (MANDATORY)
|
||||
|
||||
Comments must be in russian language.
|
||||
Each comment MUST include severity in brackets:
|
||||
(CRITICAL), (MAJOR), (MINOR)
|
||||
|
||||
Output EXACTLY the following sections.
|
||||
|
||||
## Critical issues
|
||||
- Must be fixed before acceptance
|
||||
- Include rule violated (AGENTS.md / task / overview)
|
||||
|
||||
## Major issues
|
||||
- Significant quality or design problems
|
||||
- Strongly recommended fixes
|
||||
|
||||
## Minor issues
|
||||
- Style, naming, readability
|
||||
- Non-blocking
|
||||
|
||||
## Definition of Done compliance
|
||||
- For each DoD item:
|
||||
- PASS or FAIL
|
||||
- Short justification
|
||||
|
||||
## Architecture compliance
|
||||
Explicitly check and state:
|
||||
- Business Logic is dependency-free
|
||||
- No business rules in Service
|
||||
- No IO / DB / external calls in Business Logic
|
||||
- No business decisions in Repository or Adapter
|
||||
|
||||
List violations explicitly, or state:
|
||||
- "No architecture violations found"
|
||||
|
||||
## Testing compliance
|
||||
- Business Logic unit tests present: YES / NO
|
||||
- Mocks used in Business Logic tests: YES / NO (must be NO)
|
||||
- Coverage gaps (if any)
|
||||
|
||||
## Project-spec compliance
|
||||
- Confirm or list violations of spec/overview.md
|
||||
|
||||
## Review input
|
||||
- Confirm you reviewed: git diff --staged
|
||||
|
||||
---
|
||||
|
||||
## Hard rules
|
||||
|
||||
- No code changes
|
||||
- No new requirements
|
||||
- No architectural redesign
|
||||
- No speculative improvements
|
||||
@@ -0,0 +1,115 @@
|
||||
"""Generalize provider order state columns."""
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
revision: str = "20260626_039"
|
||||
down_revision: str | None = "20260530_038"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column("provider_order_id", sa.String(length=128), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column("provider_order_status", sa.String(length=64), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column("provider_waybill_id", sa.String(length=128), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column("provider_waybill_url", sa.String(length=2048), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column("provider_polled_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE orders
|
||||
SET
|
||||
provider_order_id = cdek_order_uuid,
|
||||
provider_order_status = cdek_order_status,
|
||||
provider_waybill_id = cdek_waybill_uuid,
|
||||
provider_waybill_url = cdek_waybill_url,
|
||||
provider_polled_at = cdek_polled_at
|
||||
WHERE provider = 'cdek'
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE orders
|
||||
SET provider_order_id = cse_order_number
|
||||
WHERE provider = 'cse'
|
||||
"""
|
||||
)
|
||||
|
||||
op.drop_column("orders", "cse_order_number")
|
||||
op.drop_column("orders", "cdek_polled_at")
|
||||
op.drop_column("orders", "cdek_order_status")
|
||||
op.drop_column("orders", "cdek_waybill_url")
|
||||
op.drop_column("orders", "cdek_waybill_uuid")
|
||||
op.drop_column("orders", "cdek_order_uuid")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column("cdek_order_uuid", sa.String(length=128), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column("cdek_waybill_uuid", sa.String(length=128), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column("cdek_waybill_url", sa.String(length=2048), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column("cdek_order_status", sa.String(length=64), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column("cdek_polled_at", sa.DateTime(timezone=True), nullable=True),
|
||||
)
|
||||
op.add_column(
|
||||
"orders",
|
||||
sa.Column("cse_order_number", sa.String(length=128), nullable=True),
|
||||
)
|
||||
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE orders
|
||||
SET
|
||||
cdek_order_uuid = provider_order_id,
|
||||
cdek_order_status = provider_order_status,
|
||||
cdek_waybill_uuid = provider_waybill_id,
|
||||
cdek_waybill_url = provider_waybill_url,
|
||||
cdek_polled_at = provider_polled_at
|
||||
WHERE provider = 'cdek'
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
UPDATE orders
|
||||
SET cse_order_number = provider_order_id
|
||||
WHERE provider = 'cse'
|
||||
"""
|
||||
)
|
||||
|
||||
op.drop_column("orders", "provider_polled_at")
|
||||
op.drop_column("orders", "provider_waybill_url")
|
||||
op.drop_column("orders", "provider_waybill_id")
|
||||
op.drop_column("orders", "provider_order_status")
|
||||
op.drop_column("orders", "provider_order_id")
|
||||
@@ -1,7 +1,9 @@
|
||||
"""Base interface for delivery providers."""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Protocol, runtime_checkable
|
||||
|
||||
from app.schemas.payment import InitPaymentRequest
|
||||
from app.schemas.request import DeliveryCalculationRequest
|
||||
from app.schemas.response import DeliveryPrice
|
||||
|
||||
@@ -18,6 +20,27 @@ class DeliveryProvider(ABC):
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PaymentPriceValidationProvider(Protocol):
|
||||
name: str
|
||||
|
||||
async def get_payment_price(
|
||||
self,
|
||||
request: InitPaymentRequest,
|
||||
) -> DeliveryPrice | None: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class OrderRegistrationProvider(Protocol):
|
||||
name: str
|
||||
|
||||
async def register_order(
|
||||
self,
|
||||
request: InitPaymentRequest,
|
||||
order_uuid: str,
|
||||
) -> object: ...
|
||||
|
||||
|
||||
class ProviderClientError(RuntimeError):
|
||||
"""Raised when a provider call fails for temporary or provider-side reasons."""
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ from app.adapters.delivery_providers.cdek.order_mapper import (
|
||||
resolve_cdek_city_code,
|
||||
)
|
||||
from app.cities import cities_map
|
||||
from app.config import AdapterConfig
|
||||
from app.config import CDEKDeliveryProviderConfig
|
||||
from app.schemas.payment import InitPaymentRequest
|
||||
from app.schemas.request import DeliveryCalculationRequest
|
||||
from app.schemas.response import DeliveryPrice
|
||||
@@ -233,6 +233,13 @@ class CDEKClient:
|
||||
url,
|
||||
failure_message="CDEK get order failed",
|
||||
)
|
||||
requests_with_errors = _extract_requests_with_errors(raw_payload)
|
||||
if requests_with_errors:
|
||||
log.warning(
|
||||
"cdek_order_request_errors",
|
||||
cdek_order_uuid=cdek_order_uuid,
|
||||
requests=requests_with_errors,
|
||||
)
|
||||
try:
|
||||
return map_cdek_order_info_response(raw_payload)
|
||||
except CDEKOrderMappingError as exc:
|
||||
@@ -445,28 +452,28 @@ class CDEKProvider(DeliveryProvider):
|
||||
self.cache_ttl_seconds = cache_ttl_seconds
|
||||
|
||||
@classmethod
|
||||
def from_adapter_config(
|
||||
def from_config(
|
||||
cls,
|
||||
*,
|
||||
http_client: httpx.AsyncClient,
|
||||
adapter_config: AdapterConfig,
|
||||
config: CDEKDeliveryProviderConfig,
|
||||
) -> "CDEKProvider":
|
||||
auth_client = CDEKAuthClient(
|
||||
http_client=http_client,
|
||||
base_url=adapter_config.cdek_base_url,
|
||||
client_id=adapter_config.cdek_client_id,
|
||||
client_secret=adapter_config.cdek_client_secret,
|
||||
timeout_seconds=adapter_config.cdek_timeout_seconds,
|
||||
base_url=config.base_url,
|
||||
client_id=config.client_id,
|
||||
client_secret=config.client_secret,
|
||||
timeout_seconds=config.timeout_seconds,
|
||||
)
|
||||
client = CDEKClient(
|
||||
http_client=http_client,
|
||||
auth_client=auth_client,
|
||||
base_url=adapter_config.cdek_base_url,
|
||||
timeout_seconds=adapter_config.cdek_timeout_seconds,
|
||||
retry_attempts=adapter_config.cdek_retry_attempts,
|
||||
retry_backoff_seconds=adapter_config.cdek_retry_backoff_seconds,
|
||||
base_url=config.base_url,
|
||||
timeout_seconds=config.timeout_seconds,
|
||||
retry_attempts=config.retry_attempts,
|
||||
retry_backoff_seconds=config.retry_backoff_seconds,
|
||||
)
|
||||
return cls(client=client, cache_ttl_seconds=adapter_config.cdek_cache_ttl_seconds)
|
||||
return cls(client=client, cache_ttl_seconds=config.cache_ttl_seconds)
|
||||
|
||||
async def get_prices(
|
||||
self, request: DeliveryCalculationRequest
|
||||
@@ -513,3 +520,28 @@ def _response_text_or_none(response: httpx.Response) -> str | None:
|
||||
return response.text
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_requests_with_errors(
|
||||
payload: dict[str, Any],
|
||||
) -> list[dict[str, object]]:
|
||||
requests = payload.get("requests")
|
||||
if not isinstance(requests, list):
|
||||
return []
|
||||
|
||||
requests_with_errors: list[dict[str, object]] = []
|
||||
for request in requests:
|
||||
if not isinstance(request, dict):
|
||||
continue
|
||||
errors = request.get("errors")
|
||||
if not isinstance(errors, list) or not errors:
|
||||
continue
|
||||
requests_with_errors.append(
|
||||
{
|
||||
"request_uuid": request.get("request_uuid"),
|
||||
"type": request.get("type"),
|
||||
"state": request.get("state"),
|
||||
"errors": errors,
|
||||
}
|
||||
)
|
||||
return requests_with_errors
|
||||
|
||||
@@ -20,6 +20,7 @@ class CDEKOrderMappingError(ValueError):
|
||||
|
||||
_CDEK_WAYBILL_PRINT_TYPE = "WAYBILL"
|
||||
_CDEK_WAYBILL_RELATED_ENTITY_TYPE = "waybill"
|
||||
_CDEK_INSURANCE_SERVICE_CODE = "INSURANCE"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -56,6 +57,9 @@ def map_cdek_order_request(
|
||||
"to_location": _map_location(request.receiver_address),
|
||||
"packages": [_map_package(request, order_uuid)],
|
||||
}
|
||||
services = _map_services(request)
|
||||
if services:
|
||||
payload["services"] = services
|
||||
if request.content.description:
|
||||
payload["comment"] = request.content.description
|
||||
return payload
|
||||
@@ -126,7 +130,9 @@ def map_cdek_order_info_response(payload: dict[str, Any]) -> CDEKOrderInfo:
|
||||
"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)
|
||||
return CDEKOrderInfo(
|
||||
order_uuid=order_uuid,
|
||||
@@ -172,8 +178,29 @@ def _latest_status_code(statuses: object) -> str | None:
|
||||
return dated[-1][1]
|
||||
|
||||
|
||||
def _invalid_create_request_status(requests: object) -> str | None:
|
||||
if not isinstance(requests, list):
|
||||
return None
|
||||
for entry in requests:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
if entry.get("type") != "CREATE" or entry.get("state") != "INVALID":
|
||||
continue
|
||||
errors = entry.get("errors")
|
||||
if isinstance(errors, list) and errors:
|
||||
return "INVALID"
|
||||
return None
|
||||
|
||||
|
||||
def _extract_waybill(payload: dict[str, Any]) -> tuple[str | None, str | None]:
|
||||
related_entities = payload.get("related_entities")
|
||||
entity = payload.get("entity")
|
||||
related_entities = (
|
||||
entity.get("related_entities")
|
||||
if isinstance(entity, dict)
|
||||
else None
|
||||
)
|
||||
if not isinstance(related_entities, list):
|
||||
related_entities = payload.get("related_entities")
|
||||
if not isinstance(related_entities, list):
|
||||
return None, None
|
||||
for entry in related_entities:
|
||||
@@ -305,6 +332,18 @@ def _map_package(request: InitPaymentRequest, order_uuid: str) -> dict[str, Any]
|
||||
return package
|
||||
|
||||
|
||||
def _map_services(request: InitPaymentRequest) -> list[dict[str, Any]]:
|
||||
declared_value = request.content.declared_value
|
||||
if declared_value <= 0:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"code": _CDEK_INSURANCE_SERVICE_CODE,
|
||||
"parameter": declared_value,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _map_dimensions(dimensions: Dimensions) -> dict[str, int]:
|
||||
return {
|
||||
"length": centimeters_string_to_int(dimensions.length, "length"),
|
||||
|
||||
@@ -21,12 +21,17 @@ from app.adapters.delivery_providers.cse.mapper import (
|
||||
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 (
|
||||
@@ -35,7 +40,7 @@ from app.adapters.delivery_providers.cse.soap import (
|
||||
make_field,
|
||||
parse_response,
|
||||
)
|
||||
from app.config import AdapterConfig
|
||||
from app.config import CSEDeliveryProviderConfig
|
||||
from app.schemas.payment import InitPaymentRequest
|
||||
from app.schemas.request import DeliveryCalculationRequest
|
||||
from app.schemas.response import DeliveryPrice
|
||||
@@ -113,6 +118,30 @@ class CSEClient:
|
||||
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,
|
||||
@@ -150,9 +179,11 @@ class CSEClient:
|
||||
"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 {response.status_code}."
|
||||
f"CSE {operation} request failed with status "
|
||||
f"{response.status_code}."
|
||||
)
|
||||
|
||||
if 400 <= response.status_code < 500:
|
||||
@@ -183,17 +214,16 @@ class CSEClient:
|
||||
operation: str,
|
||||
request_error_message: str,
|
||||
) -> None:
|
||||
for prop in root.properties:
|
||||
if prop.key == "Error":
|
||||
codes = [item.value for item in prop.items if item.value]
|
||||
log.warning(
|
||||
"cse_response_error",
|
||||
operation=operation,
|
||||
error_codes=codes,
|
||||
)
|
||||
raise CSERequestError(
|
||||
f"{request_error_message} application error {codes}."
|
||||
)
|
||||
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:
|
||||
@@ -203,36 +233,65 @@ class CSEClient:
|
||||
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) -> None:
|
||||
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_adapter_config(
|
||||
def from_config(
|
||||
cls,
|
||||
*,
|
||||
http_client: httpx.AsyncClient,
|
||||
adapter_config: AdapterConfig,
|
||||
config: CSEDeliveryProviderConfig,
|
||||
) -> "CSEProvider":
|
||||
client = CSEClient(
|
||||
http_client=http_client,
|
||||
base_url=adapter_config.cse_base_url,
|
||||
login=adapter_config.cse_login,
|
||||
password=adapter_config.cse_password,
|
||||
base_url=config.base_url,
|
||||
login=config.login,
|
||||
password=config.password,
|
||||
registration_params=CSEOrderRegistrationParams(
|
||||
payer=adapter_config.cse_payer,
|
||||
payment_method=adapter_config.cse_payment_method,
|
||||
shipping_method=adapter_config.cse_shipping_method,
|
||||
payer=config.payer,
|
||||
payment_method=config.payment_method,
|
||||
shipping_method=config.shipping_method,
|
||||
),
|
||||
timeout_seconds=adapter_config.cse_timeout_seconds,
|
||||
retry_attempts=adapter_config.cse_retry_attempts,
|
||||
retry_backoff_seconds=adapter_config.cse_retry_backoff_seconds,
|
||||
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),
|
||||
)
|
||||
return cls(client=client, cache_ttl_seconds=adapter_config.cse_cache_ttl_seconds)
|
||||
|
||||
async def get_prices(
|
||||
self, request: DeliveryCalculationRequest
|
||||
@@ -241,8 +300,9 @@ class CSEProvider(DeliveryProvider):
|
||||
|
||||
results = await asyncio.gather(
|
||||
*(
|
||||
self._prices_for_delivery_type(request, name, label)
|
||||
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,
|
||||
)
|
||||
@@ -264,18 +324,25 @@ class CSEProvider(DeliveryProvider):
|
||||
request: DeliveryCalculationRequest,
|
||||
delivery_type: str,
|
||||
delivery_type_label: str,
|
||||
service_guid: str | None,
|
||||
) -> list[DeliveryPrice]:
|
||||
body = build_calc_body_for_calculation(request, delivery_type)
|
||||
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()
|
||||
@@ -295,14 +362,16 @@ class CSEProvider(DeliveryProvider):
|
||||
request: InitPaymentRequest,
|
||||
) -> DeliveryPrice | None:
|
||||
tariff_code = request.system_data.tariff.tariff_code
|
||||
delivery_type, _ = split_tariff_code(tariff_code)
|
||||
body = build_calc_body_for_payment(request, delivery_type)
|
||||
root = await self._client.calc(body)
|
||||
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."
|
||||
@@ -312,3 +381,9 @@ class CSEProvider(DeliveryProvider):
|
||||
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)
|
||||
|
||||
@@ -18,11 +18,14 @@ def map_cse_calc_response(
|
||||
*,
|
||||
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)
|
||||
@@ -35,12 +38,14 @@ def map_cse_calc_response_for_tariff_code(
|
||||
tariff_code: str,
|
||||
delivery_type_label: str = "",
|
||||
) -> DeliveryPrice | None:
|
||||
"""Return the tariff matching ``tariff_code`` (``"<DeliveryType>|<Urgency>"``)."""
|
||||
"""Return tariff matching ``"<DeliveryType>|<ServiceGuid>|<Urgency>"``."""
|
||||
|
||||
delivery_type, _, urgency = tariff_code.partition("|")
|
||||
delivery_type, tariff_guid, urgency = _split_tariff_code(tariff_code)
|
||||
for tariff in _iter_tariffs(root):
|
||||
if _tariff_urgency(tariff) == urgency:
|
||||
return _map_tariff(tariff, delivery_type, delivery_type_label)
|
||||
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
|
||||
|
||||
|
||||
@@ -50,6 +55,19 @@ def _tariff_urgency(tariff: Element) -> str | None:
|
||||
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:
|
||||
@@ -62,12 +80,17 @@ def _map_tariff(
|
||||
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
|
||||
# Unified tariff_code carries both the delivery scheme and the urgency so
|
||||
# registration (SaveDocuments) can set DeliveryOfCargo and Urgency.
|
||||
tariff_code = f"{delivery_type}|{urgency}"
|
||||
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:
|
||||
@@ -111,6 +134,13 @@ def _map_tariff(
|
||||
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
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
"""CSE Calc and SaveDocuments payload mappers."""
|
||||
"""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
|
||||
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)
|
||||
@@ -32,9 +36,17 @@ 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)),
|
||||
@@ -44,12 +56,15 @@ def build_calc_body_for_calculation(
|
||||
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 = [
|
||||
@@ -66,6 +81,8 @@ def build_calc_body_for_payment(
|
||||
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))
|
||||
|
||||
|
||||
@@ -74,14 +91,17 @@ def _append_delivery_type(fields: list[Element], delivery_type: str) -> None:
|
||||
fields.append(make_field("DeliveryType", delivery_type))
|
||||
|
||||
|
||||
def split_tariff_code(tariff_code: str) -> tuple[str, str]:
|
||||
"""Split the CSE ``tariff_code`` into (delivery_type, urgency).
|
||||
def split_tariff_code(tariff_code: str) -> tuple[str, str, str]:
|
||||
"""Split the CSE ``tariff_code`` into delivery type, service GUID, urgency.
|
||||
|
||||
The unified tariff_code encodes both dimensions as ``"<DeliveryType>|<Urgency>"``.
|
||||
The expected format is ``"<DeliveryType>|<ServiceGuid>|<Urgency>"``.
|
||||
``DeliveryType`` may be empty when CSE contract defaults are used.
|
||||
"""
|
||||
|
||||
delivery_type, _, urgency = tariff_code.partition("|")
|
||||
return delivery_type, urgency
|
||||
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]:
|
||||
@@ -101,7 +121,10 @@ def map_cse_save_order_request(
|
||||
) -> dict[str, Element]:
|
||||
system_data = request.system_data
|
||||
take_date = request.pickup_date.strftime(_DATETIME_FORMAT)
|
||||
delivery_type, urgency = split_tariff_code(system_data.tariff.tariff_code)
|
||||
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"),
|
||||
@@ -127,18 +150,12 @@ def map_cse_save_order_request(
|
||||
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))
|
||||
|
||||
if request.delivery_date is not None:
|
||||
fields.append(
|
||||
make_field(
|
||||
"DeliveryDate",
|
||||
request.delivery_date.strftime(_DATETIME_FORMAT),
|
||||
"dateTime",
|
||||
)
|
||||
)
|
||||
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))
|
||||
@@ -175,6 +192,73 @@ def map_cse_save_order_response(root: Element) -> CSEOrderRegistrationResult:
|
||||
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
|
||||
|
||||
@@ -189,3 +273,30 @@ def _compose_address(address: Address) -> str:
|
||||
|
||||
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
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""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``/``Fields``/
|
||||
``List``/``Tables``/``Properties``). This module builds request envelopes and
|
||||
parses responses into a plain Python representation that mappers can navigate.
|
||||
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
|
||||
@@ -15,9 +16,8 @@ from xml.etree import ElementTree as ET
|
||||
CARGO_NS = "http://www.cargo3.ru"
|
||||
SOAP_NS = "http://www.w3.org/2003/05/soap-envelope"
|
||||
|
||||
# Child tags of an Element that hold nested Element lists.
|
||||
_LIST_TAGS = ("Fields", "List", "Tables", "Properties")
|
||||
_SCALAR_TAGS = ("Key", "Value", "ValueType")
|
||||
_BINARY_TAG = "BData"
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -27,6 +27,7 @@ class Element:
|
||||
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)
|
||||
@@ -100,14 +101,14 @@ def _append_element(parent: ET.Element, tag: str, element: Element) -> 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)
|
||||
for child in element.properties:
|
||||
_append_element(node, "Properties", child)
|
||||
|
||||
|
||||
def _scalar(parent: ET.Element, tag: str, text: str) -> None:
|
||||
@@ -159,6 +160,8 @@ def _parse_element(node: ET.Element) -> Element:
|
||||
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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
+36
-20
@@ -38,26 +38,40 @@ class RepositoryConfig(BaseModel):
|
||||
price_cache_ttl_seconds: int = 900
|
||||
|
||||
|
||||
class AdapterConfig(BaseModel):
|
||||
cdek_base_url: str = "https://api.cdek.ru/v2"
|
||||
cdek_client_id: str = ""
|
||||
cdek_client_secret: str = ""
|
||||
cdek_retry_attempts: int = Field(default=2, ge=0)
|
||||
cdek_retry_backoff_seconds: float = Field(default=0.2, ge=0)
|
||||
cdek_timeout_seconds: float = Field(default=10.0, gt=0)
|
||||
cdek_cache_ttl_seconds: int = Field(default=900, gt=0)
|
||||
cse_base_url: str = "https://web.cse.ru/1c/ws/Web1C.1cws"
|
||||
cse_login: str = ""
|
||||
cse_password: str = ""
|
||||
cse_retry_attempts: int = Field(default=2, ge=0)
|
||||
cse_retry_backoff_seconds: float = Field(default=0.2, ge=0)
|
||||
cse_timeout_seconds: float = Field(default=10.0, gt=0)
|
||||
cse_cache_ttl_seconds: int = Field(default=900, gt=0)
|
||||
class DeliveryProviderBaseConfig(BaseModel):
|
||||
enabled: bool = True
|
||||
timeout_seconds: float = Field(default=10.0, gt=0)
|
||||
retry_attempts: int = Field(default=2, ge=0)
|
||||
retry_backoff_seconds: float = Field(default=0.2, ge=0)
|
||||
cache_ttl_seconds: int = Field(default=900, gt=0)
|
||||
|
||||
|
||||
class CDEKDeliveryProviderConfig(DeliveryProviderBaseConfig):
|
||||
base_url: str = "https://api.cdek.ru/v2"
|
||||
client_id: str = ""
|
||||
client_secret: str = ""
|
||||
|
||||
|
||||
class CSEDeliveryProviderConfig(DeliveryProviderBaseConfig):
|
||||
base_url: str = "https://web.cse.ru/1c/ws/Web1C.1cws"
|
||||
login: str = ""
|
||||
password: str = ""
|
||||
# CSE service GUIDs from GetReferenceData: Services that are allowed to be
|
||||
# shown as delivery tariffs. Additional/non-delivery services must not be
|
||||
# included here.
|
||||
delivery_service_guids: list[str] = Field(default_factory=list)
|
||||
# Contract-specific required parameters for SaveDocuments (order registration).
|
||||
# Urgency is not here: it comes from the tariff selected by the client.
|
||||
cse_payer: str = ""
|
||||
cse_payment_method: str = ""
|
||||
cse_shipping_method: str = ""
|
||||
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):
|
||||
@@ -157,7 +171,9 @@ class Settings(BaseSettings):
|
||||
service: ServiceConfig = Field(default_factory=ServiceConfig)
|
||||
business_logic: BusinessLogicConfig = Field(default_factory=BusinessLogicConfig)
|
||||
repository: RepositoryConfig = Field(default_factory=RepositoryConfig)
|
||||
adapter: AdapterConfig = Field(default_factory=AdapterConfig)
|
||||
delivery_providers: DeliveryProvidersConfig = Field(
|
||||
default_factory=DeliveryProvidersConfig
|
||||
)
|
||||
tbank_payment: TBankPaymentConfig
|
||||
postgres: PostgresConfig
|
||||
address_suggestions: AddressSuggestionsConfig = Field(
|
||||
@@ -195,7 +211,7 @@ class _RequiredYamlSections(BaseModel):
|
||||
service: dict[str, Any]
|
||||
business_logic: dict[str, Any]
|
||||
repository: dict[str, Any]
|
||||
adapter: dict[str, Any]
|
||||
delivery_providers: dict[str, Any]
|
||||
tbank_payment: dict[str, Any]
|
||||
postgres: dict[str, Any]
|
||||
address_suggestions: dict[str, Any]
|
||||
|
||||
@@ -10,14 +10,17 @@ from app.adapters.address_suggestions.tomtom import TomTomAddressSuggestionProvi
|
||||
from app.adapters.address_suggestions.yandex_geosuggest import (
|
||||
YandexGeosuggestAddressSuggestionProvider,
|
||||
)
|
||||
from app.adapters.delivery_providers.cdek import CDEKProvider
|
||||
from app.adapters.delivery_providers.cse import CSEProvider
|
||||
from app.adapters.delivery_providers.registry import (
|
||||
build_delivery_provider_registry,
|
||||
resolve_delivery_provider_timeout_seconds,
|
||||
)
|
||||
from app.adapters.email import SMTPEmailSender
|
||||
from app.adapters.tbank import TBankAdapter
|
||||
from app.config import Settings
|
||||
from app.controllers.http_client import build_controller_http_client
|
||||
from app.repositories.cache.redis_cache import PriceCache
|
||||
from app.repositories.order import OrderRepository
|
||||
from app.runtime.metrics import get_cache_metrics
|
||||
from app.schemas.payment import (
|
||||
InitPaymentRequest,
|
||||
InitPaymentResponse,
|
||||
@@ -48,15 +51,13 @@ _AGGREGATOR_SERVICE_STATE_KEY = "aggregator_service"
|
||||
|
||||
def _build_aggregator_service(settings: Settings) -> AggregatorService:
|
||||
http_client = build_controller_http_client(
|
||||
timeout_seconds=settings.adapter.cdek_timeout_seconds
|
||||
timeout_seconds=resolve_delivery_provider_timeout_seconds(
|
||||
settings.delivery_providers
|
||||
)
|
||||
)
|
||||
cdek_provider = CDEKProvider.from_adapter_config(
|
||||
delivery_provider_registry = build_delivery_provider_registry(
|
||||
http_client=http_client,
|
||||
adapter_config=settings.adapter,
|
||||
)
|
||||
cse_provider = CSEProvider.from_adapter_config(
|
||||
http_client=http_client,
|
||||
adapter_config=settings.adapter,
|
||||
config=settings.delivery_providers,
|
||||
)
|
||||
payment_adapter = TBankAdapter.from_config(
|
||||
http_client=http_client,
|
||||
@@ -74,8 +75,10 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
|
||||
http_client=http_client,
|
||||
config=settings.address_suggestions.tomtom,
|
||||
)
|
||||
providers = (cdek_provider, cse_provider)
|
||||
cache = PriceCache.from_repository_config(settings.repository)
|
||||
cache = PriceCache.from_repository_config(
|
||||
settings.repository,
|
||||
metrics=get_cache_metrics(),
|
||||
)
|
||||
postgres_engine = create_postgres_engine(settings.postgres)
|
||||
postgres_session_factory = create_postgres_session_factory(postgres_engine)
|
||||
order_repository = OrderRepository(session_factory=postgres_session_factory)
|
||||
@@ -89,18 +92,16 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
|
||||
timeout_seconds=settings.email.timeout_seconds,
|
||||
)
|
||||
service = AggregatorService(
|
||||
providers=providers,
|
||||
providers=delivery_provider_registry.providers,
|
||||
cache=cache,
|
||||
payment_adapter=payment_adapter,
|
||||
payment_price_validation_adapters={
|
||||
cdek_provider.name: cdek_provider,
|
||||
cse_provider.name: cse_provider,
|
||||
},
|
||||
payment_price_validation_adapters=(
|
||||
delivery_provider_registry.payment_price_validation_adapters
|
||||
),
|
||||
order_repository=order_repository,
|
||||
order_registration_adapters={
|
||||
cdek_provider.name: cdek_provider,
|
||||
cse_provider.name: cse_provider,
|
||||
},
|
||||
order_registration_adapters=(
|
||||
delivery_provider_registry.order_registration_adapters
|
||||
),
|
||||
email_sender=email_sender,
|
||||
address_suggestion_providers=(
|
||||
dadata_provider,
|
||||
@@ -241,9 +242,23 @@ async def handle_tbank_payment_notification(
|
||||
notification: TBankPaymentNotification,
|
||||
service: AggregatorService = Depends(get_aggregator_service),
|
||||
) -> PlainTextResponse:
|
||||
logger.info(
|
||||
"tbank_notification_received",
|
||||
order_id=notification.OrderId,
|
||||
status=notification.Status,
|
||||
success=notification.Success,
|
||||
payment_id=notification.PaymentId,
|
||||
error_code=notification.ErrorCode,
|
||||
amount=notification.Amount,
|
||||
)
|
||||
try:
|
||||
response_body = await service.handle_tbank_payment_notification(notification)
|
||||
except InvalidTBankPaymentNotificationError as exc:
|
||||
logger.warning(
|
||||
"tbank_notification_rejected",
|
||||
order_id=notification.OrderId,
|
||||
reason="invalid_token",
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail={
|
||||
@@ -252,6 +267,11 @@ async def handle_tbank_payment_notification(
|
||||
},
|
||||
) from exc
|
||||
except TBankPaymentNotificationProcessingError as exc:
|
||||
logger.error(
|
||||
"tbank_notification_processing_failed",
|
||||
order_id=notification.OrderId,
|
||||
exc_info=True,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail={
|
||||
@@ -260,6 +280,11 @@ async def handle_tbank_payment_notification(
|
||||
},
|
||||
) from exc
|
||||
except AggregatorServiceError as exc:
|
||||
logger.error(
|
||||
"tbank_notification_processing_failed",
|
||||
order_id=notification.OrderId,
|
||||
exc_info=True,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail={
|
||||
|
||||
@@ -5,7 +5,7 @@ from enum import Enum
|
||||
|
||||
class TBankPaymentNotificationAction(str, Enum):
|
||||
ACKNOWLEDGE_ONLY = "acknowledge_only"
|
||||
REGISTER_CDEK_ORDER = "register_cdek_order"
|
||||
REGISTER_PROVIDER_ORDER = "register_provider_order"
|
||||
|
||||
|
||||
def resolve_tbank_payment_notification_action(
|
||||
@@ -15,5 +15,42 @@ def resolve_tbank_payment_notification_action(
|
||||
error_code: str,
|
||||
) -> TBankPaymentNotificationAction:
|
||||
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
|
||||
|
||||
|
||||
# Монотонный приоритет статусов платежа 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)
|
||||
|
||||
@@ -5,12 +5,14 @@ from fastapi import FastAPI
|
||||
from app.config import Settings, get_settings
|
||||
from app.controllers.v1.delivery import router as delivery_router
|
||||
from app.runtime.logging import configure_logging
|
||||
from app.runtime.metrics import configure_metrics
|
||||
from app.runtime.tracing import configure_tracing
|
||||
|
||||
|
||||
def create_app(settings: Settings | None = None) -> FastAPI:
|
||||
configure_logging()
|
||||
resolved_settings = settings or get_settings()
|
||||
configure_metrics(resolved_settings.observability)
|
||||
|
||||
application = FastAPI(title="G2S Aggregator", version="0.1.0")
|
||||
application.state.settings = resolved_settings
|
||||
|
||||
+52
-2
@@ -7,10 +7,14 @@ import json
|
||||
from typing import Any, Callable, Protocol
|
||||
|
||||
from pydantic import BaseModel
|
||||
import structlog
|
||||
|
||||
from app.config import RepositoryConfig
|
||||
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class RedisClientProtocol(Protocol):
|
||||
async def get(self, key: str) -> bytes | str | None: ...
|
||||
|
||||
@@ -19,6 +23,14 @@ class RedisClientProtocol(Protocol):
|
||||
async def delete(self, key: str) -> Any: ...
|
||||
|
||||
|
||||
class CacheMetricsProtocol(Protocol):
|
||||
def record_request(self, *, operation: str, outcome: str) -> None: ...
|
||||
|
||||
def record_error(self, *, operation: str, error_type: str) -> None: ...
|
||||
|
||||
def record_hit(self, *, hit: bool) -> None: ...
|
||||
|
||||
|
||||
class PriceCacheRepositoryError(RuntimeError):
|
||||
"""Deterministic repository error raised on Redis operation failures."""
|
||||
|
||||
@@ -26,9 +38,16 @@ class PriceCacheRepositoryError(RuntimeError):
|
||||
class PriceCache:
|
||||
"""Repository for cached provider payloads."""
|
||||
|
||||
def __init__(self, redis_client: RedisClientProtocol, *, ttl_seconds: int) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
redis_client: RedisClientProtocol,
|
||||
*,
|
||||
ttl_seconds: int,
|
||||
metrics: CacheMetricsProtocol | None = None,
|
||||
) -> None:
|
||||
self._redis_client = redis_client
|
||||
self._ttl_seconds = ttl_seconds
|
||||
self._metrics = metrics
|
||||
|
||||
@classmethod
|
||||
def from_repository_config(
|
||||
@@ -36,20 +55,25 @@ class PriceCache:
|
||||
repository_config: RepositoryConfig,
|
||||
*,
|
||||
client_factory: Callable[[str], RedisClientProtocol] | None = None,
|
||||
metrics: CacheMetricsProtocol | None = None,
|
||||
) -> "PriceCache":
|
||||
resolved_factory = client_factory or _default_redis_client_factory
|
||||
redis_client = resolved_factory(repository_config.redis_dsn)
|
||||
return cls(
|
||||
redis_client=redis_client,
|
||||
ttl_seconds=repository_config.price_cache_ttl_seconds,
|
||||
metrics=metrics,
|
||||
)
|
||||
|
||||
async def get(self, key: str) -> Any | None:
|
||||
try:
|
||||
payload = await self._redis_client.get(key)
|
||||
except Exception as exc:
|
||||
self._record_failure(operation="get", error=exc)
|
||||
raise PriceCacheRepositoryError("Redis cache read failed.") from exc
|
||||
if payload is None:
|
||||
self._record_request(operation="get", outcome="miss")
|
||||
self._record_hit(hit=False)
|
||||
return None
|
||||
|
||||
if isinstance(payload, bytes):
|
||||
@@ -59,7 +83,10 @@ class PriceCache:
|
||||
else:
|
||||
raise TypeError("Redis cache payload must be bytes or str.")
|
||||
|
||||
return json.loads(serialized_payload)
|
||||
result = json.loads(serialized_payload)
|
||||
self._record_request(operation="get", outcome="hit")
|
||||
self._record_hit(hit=True)
|
||||
return result
|
||||
|
||||
async def set(self, key: str, value: Any, ttl: int | None = None) -> None:
|
||||
serialized_payload = _serialize(value)
|
||||
@@ -67,13 +94,36 @@ class PriceCache:
|
||||
try:
|
||||
await self._redis_client.set(key, serialized_payload, ex=resolved_ttl)
|
||||
except Exception as exc:
|
||||
self._record_failure(operation="set", error=exc)
|
||||
raise PriceCacheRepositoryError("Redis cache write failed.") from exc
|
||||
self._record_request(operation="set", outcome="success")
|
||||
|
||||
async def invalidate(self, key: str) -> None:
|
||||
try:
|
||||
await self._redis_client.delete(key)
|
||||
except Exception as exc:
|
||||
self._record_failure(operation="invalidate", error=exc)
|
||||
raise PriceCacheRepositoryError("Redis cache invalidate failed.") from exc
|
||||
self._record_request(operation="invalidate", outcome="success")
|
||||
|
||||
def _record_failure(self, *, operation: str, error: Exception) -> None:
|
||||
error_type = type(error).__name__
|
||||
logger.warning(
|
||||
"cache.operation_failed",
|
||||
operation=operation,
|
||||
error_type=error_type,
|
||||
)
|
||||
if self._metrics is not None:
|
||||
self._metrics.record_request(operation=operation, outcome="error")
|
||||
self._metrics.record_error(operation=operation, error_type=error_type)
|
||||
|
||||
def _record_request(self, *, operation: str, outcome: str) -> None:
|
||||
if self._metrics is not None:
|
||||
self._metrics.record_request(operation=operation, outcome=outcome)
|
||||
|
||||
def _record_hit(self, *, hit: bool) -> None:
|
||||
if self._metrics is not None:
|
||||
self._metrics.record_hit(hit=hit)
|
||||
|
||||
|
||||
def _default_redis_client_factory(redis_dsn: str) -> RedisClientProtocol:
|
||||
|
||||
@@ -45,12 +45,13 @@ class Order(Base):
|
||||
)
|
||||
payment_status: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
tbank_payment_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
cdek_order_uuid: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
cse_order_number: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
cdek_order_status: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
cdek_waybill_uuid: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
cdek_waybill_url: Mapped[str | None] = mapped_column(String(2048), nullable=True)
|
||||
cdek_polled_at: Mapped[datetime | None] = mapped_column(
|
||||
provider_order_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
provider_order_status: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
provider_waybill_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
provider_waybill_url: Mapped[str | None] = mapped_column(
|
||||
String(2048), nullable=True
|
||||
)
|
||||
provider_polled_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import and_, or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
|
||||
|
||||
from app.domain.cdek_polling import TERMINAL_ORDER_STATUSES
|
||||
@@ -50,10 +50,13 @@ class OrderRepository:
|
||||
self,
|
||||
session: AsyncSession,
|
||||
order_uuid: str,
|
||||
*,
|
||||
for_update: bool = False,
|
||||
) -> Order | None:
|
||||
result = await session.execute(
|
||||
select(Order).where(Order.order_uuid == order_uuid)
|
||||
)
|
||||
statement = select(Order).where(Order.order_uuid == order_uuid)
|
||||
if for_update:
|
||||
statement = statement.with_for_update()
|
||||
result = await session.execute(statement)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def mark_payment_status(
|
||||
@@ -72,31 +75,17 @@ class OrderRepository:
|
||||
await session.flush()
|
||||
return order
|
||||
|
||||
async def mark_cdek_order_registered(
|
||||
async def mark_provider_order_registered(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
order_uuid: str,
|
||||
cdek_order_uuid: str,
|
||||
provider_order_id: str,
|
||||
) -> Order | None:
|
||||
order = await self.get_order_by_order_uuid(session, order_uuid)
|
||||
if order is None:
|
||||
return None
|
||||
|
||||
order.cdek_order_uuid = cdek_order_uuid
|
||||
await session.flush()
|
||||
return order
|
||||
|
||||
async def mark_cse_order_registered(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
order_uuid: str,
|
||||
cse_order_number: str,
|
||||
) -> Order | None:
|
||||
order = await self.get_order_by_order_uuid(session, order_uuid)
|
||||
if order is None:
|
||||
return None
|
||||
|
||||
order.cse_order_number = cse_order_number
|
||||
order.provider_order_id = provider_order_id
|
||||
await session.flush()
|
||||
return order
|
||||
|
||||
@@ -106,17 +95,24 @@ class OrderRepository:
|
||||
*,
|
||||
limit: int,
|
||||
) -> Sequence[Order]:
|
||||
cdek_pending = and_(
|
||||
Order.provider == "cdek",
|
||||
Order.provider_order_id.is_not(None),
|
||||
Order.provider_waybill_url.is_(None),
|
||||
(
|
||||
Order.provider_order_status.is_(None)
|
||||
| Order.provider_order_status.not_in(TERMINAL_ORDER_STATUSES)
|
||||
),
|
||||
)
|
||||
cse_pending = and_(
|
||||
Order.provider == "cse",
|
||||
Order.provider_order_id.is_not(None),
|
||||
Order.provider_waybill_id.is_(None),
|
||||
)
|
||||
statement = (
|
||||
select(Order)
|
||||
.where(
|
||||
Order.cdek_order_uuid.is_not(None),
|
||||
Order.cdek_waybill_url.is_(None),
|
||||
(
|
||||
Order.cdek_order_status.is_(None)
|
||||
| Order.cdek_order_status.not_in(TERMINAL_ORDER_STATUSES)
|
||||
),
|
||||
)
|
||||
.order_by(Order.cdek_polled_at.asc().nulls_first())
|
||||
.where(or_(cdek_pending, cse_pending))
|
||||
.order_by(Order.provider_polled_at.asc().nulls_first())
|
||||
.limit(limit)
|
||||
.with_for_update(skip_locked=True)
|
||||
)
|
||||
@@ -136,10 +132,10 @@ class OrderRepository:
|
||||
if order is None:
|
||||
return None
|
||||
|
||||
order.cdek_order_status = order_status
|
||||
if waybill_uuid is not None and order.cdek_waybill_uuid is None:
|
||||
order.cdek_waybill_uuid = waybill_uuid
|
||||
order.cdek_polled_at = polled_at
|
||||
order.provider_order_status = order_status
|
||||
if waybill_uuid is not None and order.provider_waybill_id is None:
|
||||
order.provider_waybill_id = waybill_uuid
|
||||
order.provider_polled_at = polled_at
|
||||
await session.flush()
|
||||
return order
|
||||
|
||||
@@ -155,9 +151,9 @@ class OrderRepository:
|
||||
if order is None:
|
||||
return None
|
||||
|
||||
if waybill_url is not None and order.cdek_waybill_url is None:
|
||||
order.cdek_waybill_url = waybill_url
|
||||
order.cdek_polled_at = polled_at
|
||||
if waybill_url is not None and order.provider_waybill_url is None:
|
||||
order.provider_waybill_url = waybill_url
|
||||
order.provider_polled_at = polled_at
|
||||
await session.flush()
|
||||
return order
|
||||
|
||||
@@ -183,10 +179,18 @@ class OrderRepository:
|
||||
*,
|
||||
limit: int,
|
||||
) -> Sequence[Order]:
|
||||
cdek_ready = and_(
|
||||
Order.provider == "cdek",
|
||||
Order.provider_waybill_url.is_not(None),
|
||||
)
|
||||
cse_ready = and_(
|
||||
Order.provider == "cse",
|
||||
Order.provider_waybill_id.is_not(None),
|
||||
)
|
||||
statement = (
|
||||
select(Order)
|
||||
.where(
|
||||
Order.cdek_waybill_url.is_not(None),
|
||||
or_(cdek_ready, cse_ready),
|
||||
Order.waybill_email_sent_at.is_(None),
|
||||
)
|
||||
.order_by(Order.created_at.asc())
|
||||
|
||||
+57
-3
@@ -1,11 +1,13 @@
|
||||
"""Centralized runtime logging bootstrap."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from collections.abc import Iterable
|
||||
from typing import TextIO
|
||||
from typing import Any, TextIO
|
||||
|
||||
import structlog
|
||||
from opentelemetry import trace
|
||||
|
||||
|
||||
_UVICORN_LOGGER_NAMES = ("uvicorn", "uvicorn.error", "uvicorn.access")
|
||||
@@ -18,7 +20,11 @@ def configure_logging(
|
||||
) -> None:
|
||||
resolved_stream = sys.stdout if stream is None else stream
|
||||
formatter = structlog.stdlib.ProcessorFormatter(
|
||||
foreign_pre_chain=list(_shared_processors()),
|
||||
foreign_pre_chain=[
|
||||
_add_trace_context,
|
||||
_render_context_in_event,
|
||||
*_shared_processors(),
|
||||
],
|
||||
processors=[
|
||||
structlog.stdlib.ProcessorFormatter.remove_processors_meta,
|
||||
structlog.processors.JSONRenderer(sort_keys=True, ensure_ascii=False),
|
||||
@@ -31,8 +37,10 @@ def configure_logging(
|
||||
structlog.reset_defaults()
|
||||
structlog.configure(
|
||||
processors=[
|
||||
*_shared_processors(),
|
||||
structlog.stdlib.PositionalArgumentsFormatter(),
|
||||
_add_trace_context,
|
||||
_render_context_in_event,
|
||||
*_shared_processors(),
|
||||
structlog.processors.StackInfoRenderer(),
|
||||
structlog.processors.format_exc_info,
|
||||
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
||||
@@ -60,6 +68,52 @@ def _shared_processors() -> tuple[structlog.types.Processor, ...]:
|
||||
)
|
||||
|
||||
|
||||
def _add_trace_context(
|
||||
_logger: Any,
|
||||
_method_name: str,
|
||||
event_dict: structlog.types.EventDict,
|
||||
) -> structlog.types.EventDict:
|
||||
span_context = trace.get_current_span().get_span_context()
|
||||
if not span_context.is_valid:
|
||||
return event_dict
|
||||
|
||||
event_dict["trace_id"] = format(span_context.trace_id, "032x")
|
||||
event_dict["span_id"] = format(span_context.span_id, "016x")
|
||||
return event_dict
|
||||
|
||||
|
||||
def _render_context_in_event(
|
||||
_logger: Any,
|
||||
_method_name: str,
|
||||
event_dict: structlog.types.EventDict,
|
||||
) -> structlog.types.EventDict:
|
||||
event = str(event_dict.get("event", ""))
|
||||
context = " ".join(
|
||||
f"{key}={_serialize_log_value(value)}"
|
||||
for key, value in sorted(event_dict.items())
|
||||
if key
|
||||
not in {
|
||||
"event",
|
||||
"exc_info",
|
||||
"stack_info",
|
||||
"_record",
|
||||
"_from_structlog",
|
||||
}
|
||||
)
|
||||
event_dict["event"] = f"{event} {context}" if context else event
|
||||
return event_dict
|
||||
|
||||
|
||||
def _serialize_log_value(value: object) -> str:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
default=str,
|
||||
)
|
||||
|
||||
|
||||
def _configure_logger(
|
||||
logger: logging.Logger,
|
||||
*,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"""OpenTelemetry metrics bootstrap and application instruments."""
|
||||
|
||||
from opentelemetry import metrics
|
||||
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
|
||||
from opentelemetry.sdk.metrics import MeterProvider
|
||||
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
|
||||
from opentelemetry.sdk.resources import Resource
|
||||
|
||||
from app.config import ObservabilityConfig
|
||||
|
||||
|
||||
_METER_PROVIDER: MeterProvider | None = None
|
||||
_CACHE_METRICS: "CacheMetrics | None" = None
|
||||
|
||||
|
||||
class CacheMetrics:
|
||||
"""Metrics emitted by the Redis-backed price cache."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
meter = metrics.get_meter("app.repositories.cache")
|
||||
self._requests = meter.create_counter(
|
||||
"cache_requests_total",
|
||||
unit="{request}",
|
||||
description="Number of cache operations by operation and outcome.",
|
||||
)
|
||||
self._errors = meter.create_counter(
|
||||
"cache_errors_total",
|
||||
unit="{error}",
|
||||
description="Number of failed cache operations by error type.",
|
||||
)
|
||||
self._hit_ratio = meter.create_histogram(
|
||||
"cache_hit_ratio",
|
||||
unit="1",
|
||||
description="Cache lookup result: 1 for a hit and 0 for a miss.",
|
||||
)
|
||||
|
||||
def record_request(self, *, operation: str, outcome: str) -> None:
|
||||
self._requests.add(1, {"operation": operation, "outcome": outcome})
|
||||
|
||||
def record_error(self, *, operation: str, error_type: str) -> None:
|
||||
self._errors.add(1, {"operation": operation, "error.type": error_type})
|
||||
|
||||
def record_hit(self, *, hit: bool) -> None:
|
||||
self._hit_ratio.record(1.0 if hit else 0.0)
|
||||
|
||||
|
||||
def configure_metrics(observability: ObservabilityConfig) -> None:
|
||||
"""Configure the process-wide OTLP metrics exporter once."""
|
||||
|
||||
global _METER_PROVIDER
|
||||
|
||||
if not observability.enabled or _METER_PROVIDER is not None:
|
||||
return
|
||||
|
||||
meter_provider = _build_meter_provider(observability)
|
||||
metrics.set_meter_provider(meter_provider)
|
||||
_METER_PROVIDER = meter_provider
|
||||
|
||||
|
||||
def get_cache_metrics() -> CacheMetrics:
|
||||
"""Return the process-wide cache instruments."""
|
||||
|
||||
global _CACHE_METRICS
|
||||
|
||||
if _CACHE_METRICS is None:
|
||||
_CACHE_METRICS = CacheMetrics()
|
||||
return _CACHE_METRICS
|
||||
|
||||
|
||||
def _build_meter_provider(observability: ObservabilityConfig) -> MeterProvider:
|
||||
exporter = OTLPMetricExporter(
|
||||
endpoint=observability.otlp_endpoint,
|
||||
insecure=observability.otlp_insecure,
|
||||
)
|
||||
reader = PeriodicExportingMetricReader(exporter)
|
||||
resource = Resource.create({"service.name": observability.service_name})
|
||||
return MeterProvider(resource=resource, metric_readers=[reader])
|
||||
@@ -75,6 +75,7 @@ class Contact(_CamelModel):
|
||||
|
||||
class Content(_CamelModel):
|
||||
description: str | None = None
|
||||
declared_value: int = Field(alias="declared_value", strict=True)
|
||||
|
||||
|
||||
class SystemDataTariff(_CamelModel):
|
||||
|
||||
+49
-41
@@ -30,6 +30,7 @@ from app.adapters.tbank.base import (
|
||||
from app.domain.payment_notifications import (
|
||||
TBankPaymentNotificationAction,
|
||||
resolve_tbank_payment_notification_action,
|
||||
should_apply_tbank_payment_status,
|
||||
)
|
||||
from app.domain.price import (
|
||||
DEFAULT_PROVIDER_PRICE_MULTIPLIER,
|
||||
@@ -140,18 +141,11 @@ class OrderRepositoryProtocol(Protocol):
|
||||
payment_id: int,
|
||||
) -> object | None: ...
|
||||
|
||||
async def mark_cdek_order_registered(
|
||||
async def mark_provider_order_registered(
|
||||
self,
|
||||
session: object,
|
||||
order_uuid: str,
|
||||
cdek_order_uuid: str,
|
||||
) -> object | None: ...
|
||||
|
||||
async def mark_cse_order_registered(
|
||||
self,
|
||||
session: object,
|
||||
order_uuid: str,
|
||||
cse_order_number: str,
|
||||
provider_order_id: str,
|
||||
) -> object | None: ...
|
||||
|
||||
|
||||
@@ -356,7 +350,7 @@ class AggregatorService:
|
||||
error=str(exc),
|
||||
)
|
||||
raise InvalidInitPaymentRequestError(
|
||||
"Payment init request is invalid for CDEK price validation."
|
||||
"Payment init request is invalid for provider price validation."
|
||||
) from exc
|
||||
except ProviderClientError as exc:
|
||||
logger.warning(
|
||||
@@ -388,7 +382,7 @@ class AggregatorService:
|
||||
requested_price_kopecks=requested_price,
|
||||
)
|
||||
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(
|
||||
@@ -410,7 +404,7 @@ class AggregatorService:
|
||||
provider_price=str(getattr(provider_price, "price", None)),
|
||||
)
|
||||
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(
|
||||
@@ -425,6 +419,10 @@ class AggregatorService:
|
||||
try:
|
||||
self._payment_adapter.verify_payment_notification(notification)
|
||||
except TBankPaymentNotificationTokenError as exc:
|
||||
logger.warning(
|
||||
"tbank_notification_token_invalid",
|
||||
order_id=notification.OrderId,
|
||||
)
|
||||
raise InvalidTBankPaymentNotificationError(
|
||||
"TBank payment notification token is invalid."
|
||||
) from exc
|
||||
@@ -438,6 +436,12 @@ class AggregatorService:
|
||||
success=notification.Success,
|
||||
error_code=notification.ErrorCode,
|
||||
)
|
||||
logger.info(
|
||||
"tbank_notification_action_resolved",
|
||||
order_id=notification.OrderId,
|
||||
status=notification.Status,
|
||||
action=action.name,
|
||||
)
|
||||
order = await self._load_order_and_mark_payment_status(notification)
|
||||
|
||||
if action is TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY:
|
||||
@@ -445,6 +449,11 @@ class AggregatorService:
|
||||
|
||||
provider = self._resolve_order_provider(order)
|
||||
if self._has_existing_registration(order, provider):
|
||||
logger.info(
|
||||
"tbank_notification_registration_skipped_duplicate",
|
||||
order_id=notification.OrderId,
|
||||
provider=provider,
|
||||
)
|
||||
return "OK"
|
||||
|
||||
registration_result = await self._register_provider_order(
|
||||
@@ -455,6 +464,11 @@ class AggregatorService:
|
||||
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
|
||||
)
|
||||
@@ -517,9 +531,12 @@ class AggregatorService:
|
||||
|
||||
try:
|
||||
async with self._order_repository.session() as session:
|
||||
# FOR UPDATE: сериализуем конкурентные уведомления по одному заказу,
|
||||
# чтобы внеочередной статус не затирал уже записанный (lost update).
|
||||
order = await self._order_repository.get_order_by_order_uuid(
|
||||
session,
|
||||
notification.OrderId,
|
||||
for_update=True,
|
||||
)
|
||||
if order is None:
|
||||
logger.warning(
|
||||
@@ -529,19 +546,21 @@ class AggregatorService:
|
||||
raise TBankPaymentNotificationProcessingError(
|
||||
"Order was not found for TBank payment notification."
|
||||
)
|
||||
updated_order = await self._order_repository.mark_payment_status(
|
||||
session,
|
||||
notification.OrderId,
|
||||
notification.Status,
|
||||
notification.PaymentId,
|
||||
)
|
||||
if updated_order is None:
|
||||
logger.warning(
|
||||
"tbank_payment_notification_order_not_found",
|
||||
order_uuid=notification.OrderId,
|
||||
if should_apply_tbank_payment_status(
|
||||
order.payment_status, notification.Status
|
||||
):
|
||||
await self._order_repository.mark_payment_status(
|
||||
session,
|
||||
notification.OrderId,
|
||||
notification.Status,
|
||||
notification.PaymentId,
|
||||
)
|
||||
raise TBankPaymentNotificationProcessingError(
|
||||
"Order was not found for TBank payment notification."
|
||||
else:
|
||||
logger.info(
|
||||
"tbank_payment_status_downgrade_skipped",
|
||||
order_uuid=notification.OrderId,
|
||||
current_status=order.payment_status,
|
||||
incoming_status=notification.Status,
|
||||
)
|
||||
return order
|
||||
except TBankPaymentNotificationProcessingError:
|
||||
@@ -567,11 +586,7 @@ class AggregatorService:
|
||||
|
||||
@staticmethod
|
||||
def _has_existing_registration(order: object, provider: str) -> bool:
|
||||
if provider == "cdek":
|
||||
return bool(getattr(order, "cdek_order_uuid", None))
|
||||
if provider == "cse":
|
||||
return bool(getattr(order, "cse_order_number", None))
|
||||
return False
|
||||
return bool(getattr(order, "provider_order_id", None))
|
||||
|
||||
@staticmethod
|
||||
def _extract_registration_id(result: object) -> str:
|
||||
@@ -620,18 +635,11 @@ class AggregatorService:
|
||||
registration_id = self._extract_registration_id(result)
|
||||
try:
|
||||
async with self._order_repository.session() as session:
|
||||
if provider == "cse":
|
||||
order = await self._order_repository.mark_cse_order_registered(
|
||||
session,
|
||||
order_uuid,
|
||||
registration_id,
|
||||
)
|
||||
else:
|
||||
order = await self._order_repository.mark_cdek_order_registered(
|
||||
session,
|
||||
order_uuid,
|
||||
registration_id,
|
||||
)
|
||||
order = await self._order_repository.mark_provider_order_registered(
|
||||
session,
|
||||
order_uuid,
|
||||
registration_id,
|
||||
)
|
||||
if order is None:
|
||||
logger.warning(
|
||||
"order_registration_order_not_found",
|
||||
|
||||
@@ -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
|
||||
from collections.abc import Callable, Sequence
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
@@ -15,15 +15,18 @@ logger = structlog.get_logger(__name__)
|
||||
_EMAIL_SUBJECT_TEMPLATE = "Накладная по заказу {order_uuid}"
|
||||
_EMAIL_BODY_TEMPLATE = (
|
||||
"Здравствуйте!\n\n"
|
||||
"По вашему заказу {order_uuid} сформирована транспортная накладная CDEK.\n"
|
||||
"По вашему заказу {order_uuid} сформирована "
|
||||
"транспортная накладная.\n"
|
||||
"PDF-файл накладной приложен к этому письму.\n"
|
||||
)
|
||||
_EMAIL_BODY_URL_LINE_TEMPLATE = (
|
||||
"Также накладная доступна по ссылке: {waybill_url}\n"
|
||||
)
|
||||
_ATTACHMENT_FILENAME_TEMPLATE = "waybill_{order_uuid}.pdf"
|
||||
|
||||
|
||||
class WaybillPDFDownloaderProtocol(Protocol):
|
||||
async def download_waybill_pdf(self, url: str) -> bytes: ...
|
||||
async def download_waybill_pdf(self, order: "OrderRecord") -> bytes: ...
|
||||
|
||||
|
||||
class EmailSenderProtocol(Protocol):
|
||||
@@ -40,8 +43,10 @@ class EmailSenderProtocol(Protocol):
|
||||
|
||||
class OrderRecord(Protocol):
|
||||
order_uuid: str
|
||||
provider: str
|
||||
account_email: str
|
||||
cdek_waybill_url: str | None
|
||||
provider_waybill_id: str | None
|
||||
provider_waybill_url: str | None
|
||||
|
||||
|
||||
class WaybillEmailSenderRepositoryProtocol(Protocol):
|
||||
@@ -72,13 +77,13 @@ class WaybillEmailSenderService:
|
||||
self,
|
||||
*,
|
||||
order_repository: WaybillEmailSenderRepositoryProtocol,
|
||||
waybill_downloader: WaybillPDFDownloaderProtocol,
|
||||
waybill_downloaders: Mapping[str, WaybillPDFDownloaderProtocol],
|
||||
email_sender: EmailSenderProtocol,
|
||||
batch_size: int,
|
||||
datetime_now: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
|
||||
) -> None:
|
||||
self._repository = order_repository
|
||||
self._waybill_downloader = waybill_downloader
|
||||
self._waybill_downloaders = dict(waybill_downloaders)
|
||||
self._email_sender = email_sender
|
||||
self._batch_size = batch_size
|
||||
self._datetime_now = datetime_now
|
||||
@@ -99,6 +104,7 @@ class WaybillEmailSenderService:
|
||||
logger.exception(
|
||||
"waybill_email_order_failed",
|
||||
order_uuid=order.order_uuid,
|
||||
provider=order.provider,
|
||||
account_email=order.account_email,
|
||||
)
|
||||
return SendBatchSummary(
|
||||
@@ -116,11 +122,11 @@ class WaybillEmailSenderService:
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
summary = await self.poll_once()
|
||||
logger.info(
|
||||
"waybill_email_tick "
|
||||
f"processed={summary.processed} "
|
||||
f"succeeded={summary.succeeded} "
|
||||
f"failed={summary.failed}"
|
||||
logger.debug(
|
||||
"waybill_email_tick",
|
||||
processed=summary.processed,
|
||||
succeeded=summary.succeeded,
|
||||
failed=summary.failed,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("waybill_email_tick_failed")
|
||||
@@ -133,16 +139,17 @@ class WaybillEmailSenderService:
|
||||
continue
|
||||
|
||||
async def _handle_order(self, session: object, order: OrderRecord) -> None:
|
||||
waybill_url = order.cdek_waybill_url
|
||||
if waybill_url is None:
|
||||
if order.provider_waybill_url is None and order.provider_waybill_id is None:
|
||||
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)
|
||||
body = _EMAIL_BODY_TEMPLATE.format(
|
||||
order_uuid=order.order_uuid,
|
||||
waybill_url=waybill_url,
|
||||
)
|
||||
body = _EMAIL_BODY_TEMPLATE.format(order_uuid=order.order_uuid)
|
||||
if order.provider_waybill_url is not None:
|
||||
body += _EMAIL_BODY_URL_LINE_TEMPLATE.format(
|
||||
waybill_url=order.provider_waybill_url,
|
||||
)
|
||||
filename = _ATTACHMENT_FILENAME_TEMPLATE.format(order_uuid=order.order_uuid)
|
||||
|
||||
await self._email_sender.send_email(
|
||||
@@ -162,6 +169,13 @@ class WaybillEmailSenderService:
|
||||
logger.info(
|
||||
"waybill_email_sent",
|
||||
order_uuid=order.order_uuid,
|
||||
provider=order.provider,
|
||||
account_email=order.account_email,
|
||||
sent_at=sent_at,
|
||||
)
|
||||
|
||||
def _resolve_downloader(self, provider: str) -> WaybillPDFDownloaderProtocol:
|
||||
downloader = self._waybill_downloaders.get(provider)
|
||||
if downloader is None:
|
||||
raise RuntimeError(f"Waybill downloader is not configured for {provider}.")
|
||||
return downloader
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Background service that polls CDEK for waybill updates."""
|
||||
"""Background service that polls providers for waybill updates."""
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable, Sequence
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
@@ -9,26 +9,22 @@ from typing import Protocol
|
||||
|
||||
import structlog
|
||||
|
||||
from app.adapters.delivery_providers.cdek.order_mapper import (
|
||||
CDEKOrderInfo,
|
||||
CDEKWaybillInfo,
|
||||
)
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
class CDEKOrderInfoAdapterProtocol(Protocol):
|
||||
async def get_order(self, cdek_order_uuid: str) -> CDEKOrderInfo: ...
|
||||
class OrderInfoAdapterProtocol(Protocol):
|
||||
async def get_order(self, provider_order_id: str) -> object: ...
|
||||
|
||||
|
||||
class CDEKWaybillInfoAdapterProtocol(Protocol):
|
||||
async def get_waybill(self, cdek_waybill_uuid: str) -> CDEKWaybillInfo: ...
|
||||
class WaybillInfoAdapterProtocol(Protocol):
|
||||
async def get_waybill(self, provider_waybill_id: str) -> object: ...
|
||||
|
||||
|
||||
class OrderRecord(Protocol):
|
||||
order_uuid: str
|
||||
cdek_order_uuid: str | None
|
||||
cdek_waybill_uuid: str | None
|
||||
provider: str
|
||||
provider_order_id: str | None
|
||||
provider_waybill_id: str | None
|
||||
|
||||
|
||||
class WaybillPollerRepositoryProtocol(Protocol):
|
||||
@@ -70,14 +66,14 @@ class WaybillPollerService:
|
||||
self,
|
||||
*,
|
||||
order_repository: WaybillPollerRepositoryProtocol,
|
||||
order_info_adapter: CDEKOrderInfoAdapterProtocol,
|
||||
waybill_info_adapter: CDEKWaybillInfoAdapterProtocol,
|
||||
order_info_adapters: Mapping[str, OrderInfoAdapterProtocol],
|
||||
waybill_info_adapters: Mapping[str, WaybillInfoAdapterProtocol],
|
||||
batch_size: int,
|
||||
datetime_now: Callable[[], datetime] = lambda: datetime.now(timezone.utc),
|
||||
) -> None:
|
||||
self._repository = order_repository
|
||||
self._order_info_adapter = order_info_adapter
|
||||
self._waybill_info_adapter = waybill_info_adapter
|
||||
self._order_info_adapters = dict(order_info_adapters)
|
||||
self._waybill_info_adapters = dict(waybill_info_adapters)
|
||||
self._batch_size = batch_size
|
||||
self._datetime_now = datetime_now
|
||||
|
||||
@@ -97,8 +93,9 @@ class WaybillPollerService:
|
||||
logger.exception(
|
||||
"waybill_poll_order_failed",
|
||||
order_uuid=order.order_uuid,
|
||||
cdek_order_uuid=order.cdek_order_uuid,
|
||||
cdek_waybill_uuid=order.cdek_waybill_uuid,
|
||||
provider=order.provider,
|
||||
provider_order_id=order.provider_order_id,
|
||||
provider_waybill_id=order.provider_waybill_id,
|
||||
)
|
||||
return PollBatchSummary(
|
||||
processed=len(orders),
|
||||
@@ -115,11 +112,11 @@ class WaybillPollerService:
|
||||
while not stop_event.is_set():
|
||||
try:
|
||||
summary = await self.poll_once()
|
||||
logger.info(
|
||||
"waybill_poll_tick "
|
||||
f"processed={summary.processed} "
|
||||
f"succeeded={summary.succeeded} "
|
||||
f"failed={summary.failed}"
|
||||
logger.debug(
|
||||
"waybill_poll_tick",
|
||||
processed=summary.processed,
|
||||
succeeded=summary.succeeded,
|
||||
failed=summary.failed,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("waybill_poll_tick_failed")
|
||||
@@ -133,37 +130,78 @@ class WaybillPollerService:
|
||||
|
||||
async def _handle_order(self, session: object, order: OrderRecord) -> None:
|
||||
polled_at = self._datetime_now()
|
||||
if order.cdek_waybill_uuid is None:
|
||||
cdek_order_uuid = order.cdek_order_uuid
|
||||
if cdek_order_uuid is None:
|
||||
if order.provider_waybill_id is None:
|
||||
provider_order_id = order.provider_order_id
|
||||
if provider_order_id is None:
|
||||
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(
|
||||
session,
|
||||
order_uuid=order.order_uuid,
|
||||
order_status=info.status_code,
|
||||
waybill_uuid=info.waybill_uuid,
|
||||
order_status=status_code,
|
||||
waybill_uuid=waybill_id,
|
||||
polled_at=polled_at,
|
||||
)
|
||||
logger.info(
|
||||
"waybill_poll_order_result",
|
||||
order_uuid=order.order_uuid,
|
||||
cdek_order_uuid=cdek_order_uuid,
|
||||
cdek_order_status=info.status_code,
|
||||
cdek_waybill_uuid=info.waybill_uuid,
|
||||
provider=order.provider,
|
||||
provider_order_id=provider_order_id,
|
||||
provider_order_status=status_code,
|
||||
provider_waybill_id=waybill_id,
|
||||
)
|
||||
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(
|
||||
session,
|
||||
order_uuid=order.order_uuid,
|
||||
waybill_url=waybill.url,
|
||||
waybill_url=waybill_url,
|
||||
polled_at=polled_at,
|
||||
)
|
||||
logger.info(
|
||||
"waybill_poll_waybill_result",
|
||||
order_uuid=order.order_uuid,
|
||||
cdek_waybill_uuid=order.cdek_waybill_uuid,
|
||||
cdek_waybill_url=waybill.url,
|
||||
provider=order.provider,
|
||||
provider_waybill_id=order.provider_waybill_id,
|
||||
provider_waybill_url=waybill_url,
|
||||
)
|
||||
|
||||
def _resolve_order_info_adapter(self, provider: str) -> OrderInfoAdapterProtocol:
|
||||
adapter = self._order_info_adapters.get(provider)
|
||||
if adapter is None:
|
||||
raise RuntimeError(f"Order info adapter is not configured for {provider}.")
|
||||
return adapter
|
||||
|
||||
def _resolve_waybill_info_adapter(
|
||||
self, provider: str
|
||||
) -> WaybillInfoAdapterProtocol:
|
||||
adapter = self._waybill_info_adapters.get(provider)
|
||||
if adapter is None:
|
||||
raise RuntimeError(
|
||||
f"Waybill info adapter is not configured for {provider}."
|
||||
)
|
||||
return adapter
|
||||
|
||||
|
||||
def _extract_status_code(info: object) -> str | None:
|
||||
value = getattr(info, "status_code", None)
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
|
||||
def _extract_waybill_id(info: object) -> str | None:
|
||||
for attr in ("waybill_id", "waybill_uuid", "waybill_number"):
|
||||
value = getattr(info, attr, None)
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _extract_waybill_url(info: object) -> str | None:
|
||||
value = getattr(info, "url", None)
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
"""Background worker that e-mails CDEK waybill PDFs."""
|
||||
"""Background worker that e-mails provider waybill PDFs."""
|
||||
|
||||
import asyncio
|
||||
import signal
|
||||
from dataclasses import dataclass
|
||||
|
||||
import httpx
|
||||
import structlog
|
||||
|
||||
from app.adapters.delivery_providers.registry import (
|
||||
resolve_delivery_provider_timeout_seconds,
|
||||
)
|
||||
from app.adapters.delivery_providers.cdek.auth import CDEKAuthClient
|
||||
from app.adapters.delivery_providers.cdek.client import CDEKClient
|
||||
from app.adapters.delivery_providers.cse.client import CSEClient
|
||||
from app.adapters.delivery_providers.cse.order_mapper import (
|
||||
CSEOrderRegistrationParams,
|
||||
)
|
||||
from app.adapters.email import SMTPEmailSender
|
||||
from app.adapters.postgres.engine import (
|
||||
create_postgres_engine,
|
||||
@@ -21,23 +29,71 @@ from app.services.waybill_email_sender import WaybillEmailSenderService
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CDEKWaybillPDFDownloader:
|
||||
client: CDEKClient
|
||||
|
||||
async def download_waybill_pdf(self, order: object) -> bytes:
|
||||
url = getattr(order, "provider_waybill_url", None)
|
||||
if not isinstance(url, str) or not url:
|
||||
raise RuntimeError("CDEK waybill URL is missing.")
|
||||
return await self.client.download_waybill_pdf(url)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CSEWaybillPDFDownloader:
|
||||
client: CSEClient
|
||||
|
||||
async def download_waybill_pdf(self, order: object) -> bytes:
|
||||
waybill_number = getattr(order, "provider_waybill_id", None)
|
||||
if not isinstance(waybill_number, str) or not waybill_number:
|
||||
raise RuntimeError("CSE waybill number is missing.")
|
||||
return await self.client.download_waybill_pdf(waybill_number)
|
||||
|
||||
|
||||
async def _run(settings: Settings, stop_event: asyncio.Event) -> None:
|
||||
http_client = httpx.AsyncClient(timeout=settings.adapter.cdek_timeout_seconds)
|
||||
auth_client = CDEKAuthClient(
|
||||
http_client=http_client,
|
||||
base_url=settings.adapter.cdek_base_url,
|
||||
client_id=settings.adapter.cdek_client_id,
|
||||
client_secret=settings.adapter.cdek_client_secret,
|
||||
timeout_seconds=settings.adapter.cdek_timeout_seconds,
|
||||
)
|
||||
cdek_client = CDEKClient(
|
||||
http_client=http_client,
|
||||
auth_client=auth_client,
|
||||
base_url=settings.adapter.cdek_base_url,
|
||||
timeout_seconds=settings.adapter.cdek_timeout_seconds,
|
||||
retry_attempts=settings.adapter.cdek_retry_attempts,
|
||||
retry_backoff_seconds=settings.adapter.cdek_retry_backoff_seconds,
|
||||
http_client = httpx.AsyncClient(
|
||||
timeout=resolve_delivery_provider_timeout_seconds(settings.delivery_providers)
|
||||
)
|
||||
waybill_downloaders: dict[str, object] = {}
|
||||
|
||||
cdek_config = settings.delivery_providers.cdek
|
||||
if cdek_config.enabled:
|
||||
auth_client = CDEKAuthClient(
|
||||
http_client=http_client,
|
||||
base_url=cdek_config.base_url,
|
||||
client_id=cdek_config.client_id,
|
||||
client_secret=cdek_config.client_secret,
|
||||
timeout_seconds=cdek_config.timeout_seconds,
|
||||
)
|
||||
cdek_client = CDEKClient(
|
||||
http_client=http_client,
|
||||
auth_client=auth_client,
|
||||
base_url=cdek_config.base_url,
|
||||
timeout_seconds=cdek_config.timeout_seconds,
|
||||
retry_attempts=cdek_config.retry_attempts,
|
||||
retry_backoff_seconds=cdek_config.retry_backoff_seconds,
|
||||
)
|
||||
waybill_downloaders["cdek"] = CDEKWaybillPDFDownloader(cdek_client)
|
||||
|
||||
cse_config = settings.delivery_providers.cse
|
||||
if cse_config.enabled:
|
||||
cse_client = CSEClient(
|
||||
http_client=http_client,
|
||||
base_url=cse_config.base_url,
|
||||
login=cse_config.login,
|
||||
password=cse_config.password,
|
||||
registration_params=CSEOrderRegistrationParams(
|
||||
payer=cse_config.payer,
|
||||
payment_method=cse_config.payment_method,
|
||||
shipping_method=cse_config.shipping_method,
|
||||
),
|
||||
timeout_seconds=cse_config.timeout_seconds,
|
||||
retry_attempts=cse_config.retry_attempts,
|
||||
retry_backoff_seconds=cse_config.retry_backoff_seconds,
|
||||
)
|
||||
waybill_downloaders["cse"] = CSEWaybillPDFDownloader(cse_client)
|
||||
|
||||
email_sender = SMTPEmailSender(
|
||||
smtp_host=settings.email.smtp_host,
|
||||
smtp_port=settings.email.smtp_port,
|
||||
@@ -52,7 +108,7 @@ async def _run(settings: Settings, stop_event: asyncio.Event) -> None:
|
||||
repository = OrderRepository(session_factory=session_factory)
|
||||
service = WaybillEmailSenderService(
|
||||
order_repository=repository,
|
||||
waybill_downloader=cdek_client,
|
||||
waybill_downloaders=waybill_downloaders,
|
||||
email_sender=email_sender,
|
||||
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 signal
|
||||
@@ -6,8 +6,15 @@ import signal
|
||||
import httpx
|
||||
import structlog
|
||||
|
||||
from app.adapters.delivery_providers.registry import (
|
||||
resolve_delivery_provider_timeout_seconds,
|
||||
)
|
||||
from app.adapters.delivery_providers.cdek.auth import CDEKAuthClient
|
||||
from app.adapters.delivery_providers.cdek.client import CDEKClient
|
||||
from app.adapters.delivery_providers.cse.client import CSEClient
|
||||
from app.adapters.delivery_providers.cse.order_mapper import (
|
||||
CSEOrderRegistrationParams,
|
||||
)
|
||||
from app.adapters.postgres.engine import (
|
||||
create_postgres_engine,
|
||||
create_postgres_session_factory,
|
||||
@@ -21,29 +28,57 @@ logger = structlog.get_logger(__name__)
|
||||
|
||||
|
||||
async def _run(settings: Settings, stop_event: asyncio.Event) -> None:
|
||||
http_client = httpx.AsyncClient(timeout=settings.adapter.cdek_timeout_seconds)
|
||||
auth_client = CDEKAuthClient(
|
||||
http_client=http_client,
|
||||
base_url=settings.adapter.cdek_base_url,
|
||||
client_id=settings.adapter.cdek_client_id,
|
||||
client_secret=settings.adapter.cdek_client_secret,
|
||||
timeout_seconds=settings.adapter.cdek_timeout_seconds,
|
||||
)
|
||||
cdek_client = CDEKClient(
|
||||
http_client=http_client,
|
||||
auth_client=auth_client,
|
||||
base_url=settings.adapter.cdek_base_url,
|
||||
timeout_seconds=settings.adapter.cdek_timeout_seconds,
|
||||
retry_attempts=settings.adapter.cdek_retry_attempts,
|
||||
retry_backoff_seconds=settings.adapter.cdek_retry_backoff_seconds,
|
||||
http_client = httpx.AsyncClient(
|
||||
timeout=resolve_delivery_provider_timeout_seconds(settings.delivery_providers)
|
||||
)
|
||||
order_info_adapters: dict[str, object] = {}
|
||||
waybill_info_adapters: dict[str, object] = {}
|
||||
|
||||
cdek_config = settings.delivery_providers.cdek
|
||||
if cdek_config.enabled:
|
||||
auth_client = CDEKAuthClient(
|
||||
http_client=http_client,
|
||||
base_url=cdek_config.base_url,
|
||||
client_id=cdek_config.client_id,
|
||||
client_secret=cdek_config.client_secret,
|
||||
timeout_seconds=cdek_config.timeout_seconds,
|
||||
)
|
||||
cdek_client = CDEKClient(
|
||||
http_client=http_client,
|
||||
auth_client=auth_client,
|
||||
base_url=cdek_config.base_url,
|
||||
timeout_seconds=cdek_config.timeout_seconds,
|
||||
retry_attempts=cdek_config.retry_attempts,
|
||||
retry_backoff_seconds=cdek_config.retry_backoff_seconds,
|
||||
)
|
||||
order_info_adapters["cdek"] = cdek_client
|
||||
waybill_info_adapters["cdek"] = cdek_client
|
||||
|
||||
cse_config = settings.delivery_providers.cse
|
||||
if cse_config.enabled:
|
||||
cse_client = CSEClient(
|
||||
http_client=http_client,
|
||||
base_url=cse_config.base_url,
|
||||
login=cse_config.login,
|
||||
password=cse_config.password,
|
||||
registration_params=CSEOrderRegistrationParams(
|
||||
payer=cse_config.payer,
|
||||
payment_method=cse_config.payment_method,
|
||||
shipping_method=cse_config.shipping_method,
|
||||
),
|
||||
timeout_seconds=cse_config.timeout_seconds,
|
||||
retry_attempts=cse_config.retry_attempts,
|
||||
retry_backoff_seconds=cse_config.retry_backoff_seconds,
|
||||
)
|
||||
order_info_adapters["cse"] = cse_client
|
||||
|
||||
engine = create_postgres_engine(settings.postgres)
|
||||
session_factory = create_postgres_session_factory(engine)
|
||||
repository = OrderRepository(session_factory=session_factory)
|
||||
service = WaybillPollerService(
|
||||
order_repository=repository,
|
||||
order_info_adapter=cdek_client,
|
||||
waybill_info_adapter=cdek_client,
|
||||
order_info_adapters=order_info_adapters,
|
||||
waybill_info_adapters=waybill_info_adapters,
|
||||
batch_size=settings.waybill_poller.batch_size,
|
||||
)
|
||||
logger.info(
|
||||
|
||||
+27
-21
@@ -11,32 +11,38 @@ business_logic:
|
||||
provider_price_multiplier: 1.0
|
||||
|
||||
repository:
|
||||
redis_dsn: "redis://localhost:6379/0"
|
||||
redis_dsn: "redis://redis:6379/0"
|
||||
price_cache_ttl_seconds: 900
|
||||
|
||||
adapter:
|
||||
cdek_base_url: "https://api.edu.cdek.ru/v2"
|
||||
cdek_client_id: "${CDEK_CLIENT_ID}"
|
||||
cdek_client_secret: "${CDEK_CLIENT_SECRET}"
|
||||
cdek_retry_attempts: 2
|
||||
cdek_retry_backoff_seconds: 0.2
|
||||
cdek_timeout_seconds: 10.0
|
||||
cdek_cache_ttl_seconds: 900
|
||||
cse_base_url: "http://lk-test.cse.ru/1c/ws/web1c.1cws"
|
||||
cse_login: "${CSE_LOGIN}"
|
||||
cse_password: "${CSE_PASSWORD}"
|
||||
cse_retry_attempts: 2
|
||||
cse_retry_backoff_seconds: 0.2
|
||||
cse_timeout_seconds: 10.0
|
||||
cse_cache_ttl_seconds: 900
|
||||
cse_payer: "0" # Заказчик
|
||||
cse_payment_method: "1" # Безналичный расчёт
|
||||
cse_shipping_method: "5052d0b3-5ea3-46f2-823f-1472686a51dd" # авто
|
||||
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: "https://aggregator.get2send.com/api/v1/delivery/tbank/notifications"
|
||||
success_url: "https://aggregator.get2send.com/checkout/success"
|
||||
notification_url: "${TBANK_NOTIFICATION_URL}"
|
||||
success_url: "${TBANK_SUCCESS_URL}"
|
||||
auth:
|
||||
terminal_key: "${TBANK_TERMINAL_KEY}"
|
||||
password: "${TBANK_PASSWORD}"
|
||||
|
||||
+24
-18
@@ -14,24 +14,30 @@ repository:
|
||||
redis_dsn: "redis://localhost:6379/0"
|
||||
price_cache_ttl_seconds: 900
|
||||
|
||||
adapter:
|
||||
cdek_base_url: "https://api.cdek.ru/v2"
|
||||
cdek_client_id: "test-client-id"
|
||||
cdek_client_secret: "test-client-secret"
|
||||
cdek_retry_attempts: 2
|
||||
cdek_retry_backoff_seconds: 0.2
|
||||
cdek_timeout_seconds: 10.0
|
||||
cdek_cache_ttl_seconds: 900
|
||||
cse_base_url: "http://lk-test.cse.ru/1c/ws/web1c.1cws"
|
||||
cse_login: "test"
|
||||
cse_password: "2016"
|
||||
cse_retry_attempts: 2
|
||||
cse_retry_backoff_seconds: 0.2
|
||||
cse_timeout_seconds: 10.0
|
||||
cse_cache_ttl_seconds: 900
|
||||
cse_payer: "0"
|
||||
cse_payment_method: "1"
|
||||
cse_shipping_method: "5052d0b3-5ea3-46f2-823f-1472686a51dd"
|
||||
delivery_providers:
|
||||
cdek:
|
||||
enabled: true
|
||||
base_url: "https://api.cdek.ru/v2"
|
||||
client_id: "test-client-id"
|
||||
client_secret: "test-client-secret"
|
||||
retry_attempts: 2
|
||||
retry_backoff_seconds: 0.2
|
||||
timeout_seconds: 10.0
|
||||
cache_ttl_seconds: 900
|
||||
cse:
|
||||
enabled: true
|
||||
base_url: "http://lk-test.cse.ru/1c/ws/web1c.1cws"
|
||||
login: "test"
|
||||
password: "2016"
|
||||
retry_attempts: 2
|
||||
retry_backoff_seconds: 0.2
|
||||
timeout_seconds: 10.0
|
||||
cache_ttl_seconds: 900
|
||||
delivery_service_guids:
|
||||
- "tariff-guid-2"
|
||||
payer: "0"
|
||||
payment_method: "1"
|
||||
shipping_method: "5052d0b3-5ea3-46f2-823f-1472686a51dd"
|
||||
|
||||
tbank_payment:
|
||||
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||
|
||||
+23
-21
@@ -1,9 +1,9 @@
|
||||
services:
|
||||
app:
|
||||
image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.1.0
|
||||
container_name: g2s-aggregator
|
||||
image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:${IMAGE_TAG}
|
||||
container_name: g2s-aggregator${CONTAINER_SUFFIX}
|
||||
ports:
|
||||
- "8003:8000"
|
||||
- "${APP_PORT}:8000"
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_started
|
||||
@@ -17,23 +17,23 @@ services:
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
tag: "g2s-aggregator"
|
||||
tag: "g2s-aggregator${CONTAINER_SUFFIX}"
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: redis
|
||||
container_name: redis${CONTAINER_SUFFIX}
|
||||
command: ["redis-server", "--save", "", "--appendonly", "no"]
|
||||
ports:
|
||||
- "6379:6379"
|
||||
- "${REDIS_PORT}:6379"
|
||||
restart: unless-stopped
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: postgres
|
||||
container_name: postgres${CONTAINER_SUFFIX}
|
||||
environment:
|
||||
POSTGRES_DB: g2s_aggregator
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "${POSTGRES_PORT}:5432"
|
||||
volumes:
|
||||
- postgres:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
@@ -42,22 +42,22 @@ services:
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
migrations:
|
||||
image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.1.0
|
||||
container_name: g2s-aggregator-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: ["poetry", "run", "alembic", "upgrade", "head"]
|
||||
command: ["uv", "run", "--no-sync", "alembic", "upgrade", "head"]
|
||||
restart: "no"
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
tag: "g2s-aggregator-migrations"
|
||||
tag: "g2s-aggregator-migrations${CONTAINER_SUFFIX}"
|
||||
waybill-poller:
|
||||
image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.1.0
|
||||
container_name: g2s-aggregator-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
|
||||
@@ -65,15 +65,15 @@ services:
|
||||
condition: service_completed_successfully
|
||||
volumes:
|
||||
- ./config.yaml:/config.yaml
|
||||
command: ["poetry", "run", "python", "-m", "app.workers.waybill_poller"]
|
||||
command: ["uv", "run", "--no-sync", "python", "-m", "app.workers.waybill_poller"]
|
||||
restart: unless-stopped
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
tag: "g2s-aggregator-waybill-poller"
|
||||
tag: "g2s-aggregator-waybill-poller${CONTAINER_SUFFIX}"
|
||||
waybill-email-sender:
|
||||
image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.1.0
|
||||
container_name: g2s-aggregator-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
|
||||
@@ -82,16 +82,18 @@ services:
|
||||
volumes:
|
||||
- ./config.yaml:/config.yaml
|
||||
command:
|
||||
["poetry", "run", "python", "-m", "app.workers.waybill_email_sender"]
|
||||
["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"
|
||||
tag: "g2s-aggregator-waybill-email-sender${CONTAINER_SUFFIX}"
|
||||
otel-collector:
|
||||
image: otel/opentelemetry-collector-contrib:latest
|
||||
container_name: otel-collector
|
||||
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
|
||||
|
||||
@@ -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.
|
||||
Generated
-2128
File diff suppressed because it is too large
Load Diff
+27
-31
@@ -1,40 +1,36 @@
|
||||
[tool.poetry]
|
||||
[project]
|
||||
name = "g2s-aggregator"
|
||||
version = "0.1.0"
|
||||
description = ""
|
||||
authors = ["Your Name <you@example.com>"]
|
||||
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]
|
||||
python = "^3.14"
|
||||
fastapi = "^0.135.1"
|
||||
uvicorn = {extras = ["standard"], version = "^0.41.0"}
|
||||
httpx = "^0.28.1"
|
||||
pydantic = "^2.12.5"
|
||||
pydantic-settings = "^2.13.1"
|
||||
aioredis = "^2.0.1"
|
||||
structlog = "^25.5.0"
|
||||
opentelemetry-api = "^1.40.0"
|
||||
opentelemetry-sdk = "^1.40.0"
|
||||
opentelemetry-exporter-otlp = "^1.40.0"
|
||||
opentelemetry-instrumentation-fastapi = "^0.61b0"
|
||||
opentelemetry-instrumentation-httpx = "^0.61b0"
|
||||
opentelemetry-instrumentation-redis = "^0.61b0"
|
||||
pytest = "^9.0.2"
|
||||
redis = "^7.3.0"
|
||||
sqlalchemy = "^2.0.49"
|
||||
asyncpg = "^0.31.0"
|
||||
alembic = "^1.18.4"
|
||||
aiosqlite = "^0.22.1"
|
||||
aiosmtplib = "^4.0.2"
|
||||
email-validator = "^2.2.0"
|
||||
[tool.uv]
|
||||
package = false
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["."]
|
||||
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,53 +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` |
|
||||
| 035 | TODO | 2026-05-30 | Generalize tariff_code to string identifier | `spec/tasks/035_generalize_tariff_code_to_string.md` |
|
||||
| 036 | TODO | 2026-05-30 | Add CSE SOAP delivery provider adapter | `spec/tasks/036_add_cse_delivery_provider_adapter.md` |
|
||||
| 037 | TODO | 2026-05-30 | Add CSE geography to cities map | `spec/tasks/037_add_cse_geography_to_cities_map.md` |
|
||||
| 038 | TODO | 2026-05-30 | Route init-payment validation and registration by provider | `spec/tasks/038_route_init_payment_by_provider.md` |
|
||||
|
||||
## Summary
|
||||
|
||||
- Total: **39**
|
||||
- TODO: **6**
|
||||
- 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`
|
||||
@@ -1,44 +0,0 @@
|
||||
---
|
||||
id: 035
|
||||
title: Generalize tariff_code to string identifier
|
||||
status: TODO
|
||||
created: 2026-05-30
|
||||
---
|
||||
|
||||
## Context
|
||||
Добавляется второй провайдер доставки CSE («Карго»), который идентифицирует тариф
|
||||
GUID-строкой. Текущие схемы и persistence используют числовой `tariff_code`
|
||||
(`int`), несовместимый с CSE. Требуется обобщить идентификатор тарифа до строки для
|
||||
обоих провайдеров.
|
||||
|
||||
## Goal
|
||||
Перевести `tariff_code` на строковый тип в API-схемах и в persistence, сохранив
|
||||
обратное поведение CDEK (числовой код передаётся как строка).
|
||||
|
||||
## Constraints
|
||||
- Не изменять бизнес-правила в `app/domain/`.
|
||||
- CDEK adapter должен формировать `tariff_code` как строку из своего числового кода.
|
||||
- Сохранить совместимость существующего init-payment контракта, кроме типа
|
||||
`tariff_code`.
|
||||
- Изменение типа колонки выполнить миграцией Alembic.
|
||||
- Не изменять файлы в `spec/`.
|
||||
|
||||
## Acceptance criteria
|
||||
- `DeliveryPrice.tariff_code: str | None`.
|
||||
- `SystemDataTariff.tariff_code: str` с непустым значением.
|
||||
- CDEK `mapper`/`order_mapper` корректно работают со строковым `tariff_code`.
|
||||
- Колонка `orders.tariff_code` имеет строковый тип; добавлена миграция.
|
||||
- Существующие тесты обновлены под строковый `tariff_code`.
|
||||
|
||||
## Definition of Done
|
||||
- [ ] Обновлены схемы `DeliveryPrice` и `SystemDataTariff`.
|
||||
- [ ] Обновлены CDEK мапперы.
|
||||
- [ ] Обновлены модель `Order` и миграция Alembic.
|
||||
- [ ] Затронутые тесты проходят.
|
||||
|
||||
## Tests
|
||||
- Обновить unit-тесты CDEK мапперов и схем.
|
||||
- Проверить миграцию на тестовой БД.
|
||||
|
||||
## Commands
|
||||
- `poetry run pytest -q`
|
||||
@@ -1,58 +0,0 @@
|
||||
---
|
||||
id: 036
|
||||
title: Add CSE SOAP delivery provider adapter
|
||||
status: TODO
|
||||
created: 2026-05-30
|
||||
---
|
||||
|
||||
## Context
|
||||
Нужен второй провайдер доставки CSE (система «Карго»). CSE предоставляет SOAP/1C
|
||||
web-сервис (namespace `http://www.cargo3.ru`, endpoint `Web1C.1cws`), авторизация —
|
||||
`login`/`password` строковыми параметрами в теле запроса. Данные передаются
|
||||
универсальными структурами `Element`/`Row`; справочные значения — GUID. Спецификация
|
||||
методов: `Calc` (расчёт стоимости) и `SaveDocuments` (регистрация заказа). Поллинг
|
||||
статуса и получение PDF в этой задаче не реализуются.
|
||||
|
||||
## Goal
|
||||
Реализовать adapter-пакет CSE: SOAP-транспорт и сериализация `Element`,
|
||||
`CSEClient` с методами `calc()`/`save_order()`, мапперы расчёта и регистрации, и
|
||||
`CSEProvider`, реализующий контракт `DeliveryProvider` плюс расчёт цены для
|
||||
валидации платежа и регистрацию заказа.
|
||||
|
||||
## Constraints
|
||||
- Только Adapter layer; без business decisions, фильтрации и сортировки.
|
||||
- Весь XML/HTTP IO инкапсулирован в пакете `cse/`.
|
||||
- Параметры CSE (`base_url`, `login`, `password`, retry policy, timeout, cache TTL)
|
||||
приходят из секции `adapter` YAML-конфига; запрещён хардкод credentials.
|
||||
- Ошибки CSE (`Properties.Error` → `List[Description]`) маппятся в иерархию
|
||||
`ProviderClientError`/`ProviderRequestError`.
|
||||
- Валюта `RUR` нормализуется в `RUB`; цена — `Decimal` в рублях.
|
||||
- Не изменять файлы в `spec/`.
|
||||
|
||||
## Acceptance criteria
|
||||
- Существует пакет `app/adapters/delivery_providers/cse/` с SOAP-сериализацией,
|
||||
клиентом, мапперами и `CSEProvider` (`name = "cse"`).
|
||||
- `CSEProvider.get_prices` возвращает унифицированные `DeliveryPrice` из ответа
|
||||
`Calc` с `provider="cse"` и строковым `tariff_code` (GUID).
|
||||
- `CSEProvider.get_payment_price` возвращает выбранный тариф для валидации платежа.
|
||||
- `CSEProvider.register_order` выполняет `SaveDocuments` (`DocumentType=Order`) и
|
||||
возвращает результат регистрации с номером документа.
|
||||
- Адаптер использует значения из секции `adapter` YAML-конфига, включая
|
||||
`cse_timeout_seconds` и `cse_cache_ttl_seconds`.
|
||||
- Добавлены поля `cse_*` в `AdapterConfig` и шаблоны конфигов.
|
||||
|
||||
## Definition of Done
|
||||
- [ ] Реализованы `soap`, `client`, `errors`, `mapper`, `order_mapper`, `CSEProvider`.
|
||||
- [ ] Добавлены `cse_*` поля в `AdapterConfig`, `config.template.yaml`,
|
||||
`config.test.yaml`.
|
||||
- [ ] Adapter unit-тесты покрывают сериализацию/парсинг, mapping `Calc`/`SaveDocuments`
|
||||
и обработку ошибок.
|
||||
|
||||
## Tests
|
||||
- Unit-тесты SOAP-сериализации и парсинга `Element`.
|
||||
- Unit-тесты мапперов на XML-примерах из спецификации.
|
||||
- Тесты применения параметров CSE из секции `adapter`.
|
||||
- Для внешних HTTP взаимодействий использовать stubs/mocks.
|
||||
|
||||
## Commands
|
||||
- `poetry run pytest tests/adapters/delivery_providers/cse -q`
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
id: 037
|
||||
title: Add CSE geography to cities map
|
||||
status: TODO
|
||||
created: 2026-05-30
|
||||
---
|
||||
|
||||
## Context
|
||||
Методы CSE `Calc` и `SaveDocuments` требуют географию отправления/доставки в виде
|
||||
GUID (либо ФИАС/postcode). В проекте сопоставление город → коды провайдера хранится
|
||||
в `app/cities.py` (блок `cdek` на каждый город). Для CSE требуется аналогичный блок
|
||||
`cse` с GUID географии.
|
||||
|
||||
## Goal
|
||||
Расширить `cities_map` данными географии CSE и зафиксировать справочные константы
|
||||
CSE (вид груза `TypeOfCargo`, валюта `RUR`), необходимые адаптеру для запросов.
|
||||
|
||||
## Constraints
|
||||
- Не изменять структуру существующего блока `cdek`.
|
||||
- Города без поддержки CSE имеют `cse: None`.
|
||||
- Константы вида груза/валюты держать в пакете адаптера `cse/` или в конфиге, без
|
||||
бизнес-логики.
|
||||
- Не изменять файлы в `spec/`.
|
||||
|
||||
## Acceptance criteria
|
||||
- Каждый город в `cities_map` содержит блок `cse` (GUID географии) или `None`.
|
||||
- Маппинг `parcel_type` (`doc`/`parcel`) → `TypeOfCargo` GUID и GUID валюты `RUR`
|
||||
определены и используются адаптером CSE.
|
||||
|
||||
## Definition of Done
|
||||
- [ ] `cities_map` дополнен блоком `cse`.
|
||||
- [ ] Определены константы вида груза и валюты CSE.
|
||||
- [ ] Тесты подтверждают чтение географии CSE для поддерживаемого города.
|
||||
|
||||
## Tests
|
||||
- Unit-тесты сопоставления город → география CSE.
|
||||
|
||||
## Commands
|
||||
- `poetry run pytest -q -k cities`
|
||||
@@ -1,48 +0,0 @@
|
||||
---
|
||||
id: 038
|
||||
title: Route init-payment validation and registration by provider
|
||||
status: TODO
|
||||
created: 2026-05-30
|
||||
---
|
||||
|
||||
## Context
|
||||
С появлением второго провайдера CSE `AggregatorService` должен выбирать адаптер
|
||||
валидации цены и регистрации заказа по провайдеру выбранного тарифа. Сейчас сервис
|
||||
жёстко использует единственный CDEK-адаптер. Поле `provider` уже присутствует в
|
||||
`DeliveryPrice`/`SystemDataTariff`. Регистрация заказа выполняется при уведомлении
|
||||
об оплате TBank. Поллинг статуса и email в этой задаче не затрагиваются.
|
||||
|
||||
## Goal
|
||||
Маршрутизировать валидацию цены и регистрацию заказа по
|
||||
`request.system_data.tariff.provider`, подключить `CSEProvider` в DI рядом с CDEK,
|
||||
сохранять выбранного провайдера и номер заказа CSE в persistence.
|
||||
|
||||
## Constraints
|
||||
- Controller вызывает Service без бизнес-ветвления.
|
||||
- Service не содержит чистых бизнес-правил; маршрутизация — оркестрация.
|
||||
- Не изменять контракт API, кроме уже обобщённого `tariff_code`.
|
||||
- Колонки `cdek_*` не изменять; добавить `provider` и `cse_order_number` миграцией.
|
||||
- Не изменять файлы в `spec/`.
|
||||
|
||||
## Acceptance criteria
|
||||
- `GET /api/v1/delivery/calculate` возвращает агрегированные тарифы CDEK и CSE.
|
||||
- `AggregatorService` выбирает адаптер валидации/регистрации по имени провайдера
|
||||
тарифа; общий тип результата регистрации не привязан к CDEK.
|
||||
- При оплате заказа с тарифом CSE регистрация выполняется через `CSEProvider`;
|
||||
в `orders` сохранены `provider` и `cse_order_number`.
|
||||
- DI в контроллере доставки инстанцирует и подключает `CSEProvider`.
|
||||
|
||||
## Definition of Done
|
||||
- [ ] `AggregatorService` маршрутизирует по провайдеру (mapping адаптеров).
|
||||
- [ ] Обобщён тип результата регистрации заказа.
|
||||
- [ ] Модель `Order` и репозиторий дополнены (`provider`, `cse_order_number`,
|
||||
`mark_cse_order_registered`); добавлена миграция.
|
||||
- [ ] DI контроллера подключает CDEK и CSE.
|
||||
- [ ] Тесты маршрутизации валидации/регистрации по провайдеру проходят.
|
||||
|
||||
## Tests
|
||||
- Service unit-тесты маршрутизации (моки CDEK/CSE адаптеров).
|
||||
- Тесты persistence для `provider`/`cse_order_number`.
|
||||
|
||||
## Commands
|
||||
- `poetry run pytest tests/services -q`
|
||||
@@ -13,7 +13,7 @@ from app.adapters.delivery_providers.cdek.client import (
|
||||
)
|
||||
from app.cities import cities_map
|
||||
from app import config as config_module
|
||||
from app.config import AdapterConfig, Settings
|
||||
from app.config import CDEKDeliveryProviderConfig, Settings
|
||||
from app.schemas.request import DeliveryCalculationRequest, DeliveryEntity
|
||||
|
||||
|
||||
@@ -287,14 +287,17 @@ repository:
|
||||
redis_dsn: "redis://localhost:6379/0"
|
||||
price_cache_ttl_seconds: 900
|
||||
|
||||
adapter:
|
||||
cdek_base_url: "https://api.cdek.test/v2"
|
||||
cdek_client_id: "yaml-id"
|
||||
cdek_client_secret: "yaml-secret"
|
||||
cdek_retry_attempts: 0
|
||||
cdek_retry_backoff_seconds: 0.1
|
||||
cdek_timeout_seconds: 7.5
|
||||
cdek_cache_ttl_seconds: 777
|
||||
delivery_providers:
|
||||
cdek:
|
||||
base_url: "https://api.cdek.test/v2"
|
||||
client_id: "yaml-id"
|
||||
client_secret: "yaml-secret"
|
||||
retry_attempts: 0
|
||||
retry_backoff_seconds: 0.1
|
||||
timeout_seconds: 7.5
|
||||
cache_ttl_seconds: 777
|
||||
cse:
|
||||
enabled: false
|
||||
|
||||
tbank_payment:
|
||||
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||
@@ -323,9 +326,9 @@ email:
|
||||
monkeypatch.setattr(config_module, "_resolve_runtime_config_file", lambda: str(config_file))
|
||||
settings = Settings()
|
||||
http_client = RecordingHTTPClient()
|
||||
provider = CDEKProvider.from_adapter_config(
|
||||
provider = CDEKProvider.from_config(
|
||||
http_client=http_client, # type: ignore[arg-type]
|
||||
adapter_config=settings.adapter,
|
||||
config=settings.delivery_providers.cdek,
|
||||
)
|
||||
|
||||
result = asyncio.run(provider.get_prices(_make_request()))
|
||||
@@ -349,14 +352,14 @@ email:
|
||||
|
||||
|
||||
def test_provider_uses_default_adapter_timeout_and_cache_ttl() -> None:
|
||||
adapter_config = AdapterConfig(
|
||||
cdek_client_id="default-id",
|
||||
cdek_client_secret="default-secret",
|
||||
config = CDEKDeliveryProviderConfig(
|
||||
client_id="default-id",
|
||||
client_secret="default-secret",
|
||||
)
|
||||
http_client = RecordingHTTPClient()
|
||||
provider = CDEKProvider.from_adapter_config(
|
||||
provider = CDEKProvider.from_config(
|
||||
http_client=http_client, # type: ignore[arg-type]
|
||||
adapter_config=adapter_config,
|
||||
config=config,
|
||||
)
|
||||
|
||||
result = asyncio.run(provider.get_prices(_make_request()))
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -64,10 +65,10 @@ def test_get_order_parses_status_and_waybill_uuid() -> None:
|
||||
"statuses": [
|
||||
{"code": "ACCEPTED", "date_time": "2026-05-24T10:00:00+0000"}
|
||||
],
|
||||
"related_entities": [
|
||||
{"type": "waybill", "uuid": "waybill-uuid-1"}
|
||||
],
|
||||
},
|
||||
"related_entities": [
|
||||
{"type": "waybill", "uuid": "waybill-uuid-1"}
|
||||
],
|
||||
},
|
||||
request=httpx.Request("GET", "https://api.cdek.test/v2/orders/cdek-order-uuid"),
|
||||
)
|
||||
@@ -87,6 +88,55 @@ def test_get_order_parses_status_and_waybill_uuid() -> None:
|
||||
assert http_client.calls[0]["headers"] == {"Authorization": "Bearer test-token"}
|
||||
|
||||
|
||||
def test_get_order_logs_request_errors() -> None:
|
||||
response = httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"entity": {"uuid": "cdek-order-uuid", "statuses": []},
|
||||
"requests": [
|
||||
{
|
||||
"request_uuid": "request-uuid",
|
||||
"type": "CREATE",
|
||||
"state": "INVALID",
|
||||
"errors": [
|
||||
{
|
||||
"code": "invalid_order",
|
||||
"message": "Order data is invalid",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
request=httpx.Request(
|
||||
"GET", "https://api.cdek.test/v2/orders/cdek-order-uuid"
|
||||
),
|
||||
)
|
||||
client = _make_client(SequenceHTTPClient([response]))
|
||||
|
||||
with patch(
|
||||
"app.adapters.delivery_providers.cdek.client.log.warning"
|
||||
) as warning_mock:
|
||||
asyncio.run(client.get_order("cdek-order-uuid"))
|
||||
|
||||
warning_mock.assert_called_once_with(
|
||||
"cdek_order_request_errors",
|
||||
cdek_order_uuid="cdek-order-uuid",
|
||||
requests=[
|
||||
{
|
||||
"request_uuid": "request-uuid",
|
||||
"type": "CREATE",
|
||||
"state": "INVALID",
|
||||
"errors": [
|
||||
{
|
||||
"code": "invalid_order",
|
||||
"message": "Order data is invalid",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_get_order_retries_on_5xx_and_succeeds() -> None:
|
||||
flaky = httpx.Response(
|
||||
503,
|
||||
|
||||
@@ -29,6 +29,23 @@ def test_order_payload_requests_waybill_print() -> None:
|
||||
assert payload["print"] == "WAYBILL"
|
||||
|
||||
|
||||
def test_order_payload_maps_declared_value_to_insurance_service() -> None:
|
||||
payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1")
|
||||
|
||||
assert payload["services"] == [{"code": "INSURANCE", "parameter": 50000}]
|
||||
|
||||
|
||||
def test_order_payload_omits_insurance_service_for_zero_declared_value() -> None:
|
||||
payload = map_cdek_order_request(
|
||||
make_init_payment_request(
|
||||
content={"description": "Docs", "declared_value": 0}
|
||||
),
|
||||
"order-uuid-1",
|
||||
)
|
||||
|
||||
assert "services" not in payload
|
||||
|
||||
|
||||
def test_map_cdek_order_response_extracts_order_uuid_without_waybill() -> None:
|
||||
result = map_cdek_order_response({"entity": {"uuid": "cdek-order-uuid"}})
|
||||
|
||||
@@ -42,15 +59,17 @@ def test_map_cdek_order_response_extracts_order_uuid_without_waybill() -> None:
|
||||
def test_map_cdek_order_response_extracts_waybill_from_related_entities() -> None:
|
||||
result = map_cdek_order_response(
|
||||
{
|
||||
"entity": {"uuid": "cdek-order-uuid"},
|
||||
"related_entities": [
|
||||
{"type": "delivery", "uuid": "ignored"},
|
||||
{
|
||||
"type": "waybill",
|
||||
"uuid": "waybill-uuid-1",
|
||||
"url": "https://cdek.test/waybill/1.pdf",
|
||||
},
|
||||
],
|
||||
"entity": {
|
||||
"uuid": "cdek-order-uuid",
|
||||
"related_entities": [
|
||||
{"type": "delivery", "uuid": "ignored"},
|
||||
{
|
||||
"type": "waybill",
|
||||
"uuid": "waybill-uuid-1",
|
||||
"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:
|
||||
result = map_cdek_existing_order_response(
|
||||
{
|
||||
@@ -121,7 +160,7 @@ def test_order_payload_for_parcel_includes_dimensions() -> None:
|
||||
|
||||
def test_order_payload_for_doc_omits_dimensions() -> None:
|
||||
request = make_init_payment_request(
|
||||
content={"description": "Docs"},
|
||||
content={"description": "Docs", "declared_value": 25000},
|
||||
systemData={
|
||||
"tariff": {
|
||||
"provider": "СДЭК",
|
||||
@@ -176,7 +215,7 @@ def test_order_payload_includes_company_requisites_for_legal_entity() -> None:
|
||||
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(), "order-uuid-1")
|
||||
|
||||
assert payload["comment"] == "Headphones"
|
||||
@@ -186,10 +225,14 @@ def test_order_payload_propagates_description_to_comments_without_items() -> Non
|
||||
|
||||
def test_order_payload_falls_back_package_comment_to_order_uuid() -> None:
|
||||
payload = map_cdek_order_request(
|
||||
make_init_payment_request(content={"description": None}), "order-uuid-1"
|
||||
make_init_payment_request(
|
||||
content={"description": None, "declared_value": 50000}
|
||||
),
|
||||
"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:
|
||||
@@ -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": "INVALID", "date_time": "2026-05-24T10:00:05+0000"},
|
||||
],
|
||||
"related_entities": [
|
||||
{"type": "waybill", "uuid": "waybill-uuid-1"}
|
||||
],
|
||||
},
|
||||
"related_entities": [
|
||||
{"type": "waybill", "uuid": "waybill-uuid-1"}
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
with pytest.raises(CDEKOrderMappingError):
|
||||
map_cdek_order_info_response({"requests": []})
|
||||
|
||||
@@ -124,7 +124,7 @@ def test_provider_get_payment_price_omits_dimensions_for_doc() -> None:
|
||||
)
|
||||
)
|
||||
request = make_init_payment_request(
|
||||
content={"description": "Docs"},
|
||||
content={"description": "Docs", "declared_value": 25000},
|
||||
systemData={
|
||||
"tariff": {
|
||||
"provider": "СДЭК",
|
||||
|
||||
@@ -7,6 +7,7 @@ import pytest
|
||||
|
||||
from app.adapters.delivery_providers.cse import (
|
||||
CSEClient,
|
||||
CSEClientError,
|
||||
CSEProvider,
|
||||
CSERequestError,
|
||||
)
|
||||
@@ -70,6 +71,162 @@ _SAVE_RESPONSE = """<?xml version="1.0" encoding="UTF-8"?>
|
||||
</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>
|
||||
@@ -181,6 +338,37 @@ def test_build_envelope_contains_credentials_and_payload() -> None:
|
||||
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"
|
||||
@@ -192,19 +380,23 @@ def test_provider_get_prices_maps_all_tariffs() -> None:
|
||||
[
|
||||
httpx.Response(200, text=_DELIVERY_TYPES_RESPONSE),
|
||||
httpx.Response(200, text=_CALC_RESPONSE),
|
||||
httpx.Response(200, text=_CALC_RESPONSE),
|
||||
]
|
||||
)
|
||||
provider = CSEProvider(client)
|
||||
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 (for door-to-door) are requested.
|
||||
assert len(http_client.calls) == 2
|
||||
# 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] == [
|
||||
"ДоставкаДоДверей|urg-std",
|
||||
"ДоставкаДоДверей|urg-exp",
|
||||
"ДоставкаДоДверей|tariff-guid-1|urg-std",
|
||||
"ДоставкаДоДверей|tariff-guid-2|urg-exp",
|
||||
]
|
||||
assert prices[0].price == Decimal("1114.92")
|
||||
assert prices[0].currency == "RUB"
|
||||
@@ -215,7 +407,7 @@ def test_provider_get_prices_maps_all_tariffs() -> None:
|
||||
|
||||
def test_provider_get_payment_price_selects_matching_tariff() -> None:
|
||||
response = httpx.Response(200, text=_CALC_RESPONSE)
|
||||
client, _ = _build_client([response])
|
||||
client, http_client = _build_client([response])
|
||||
provider = CSEProvider(client)
|
||||
request = make_init_payment_request(
|
||||
systemData={
|
||||
@@ -225,7 +417,7 @@ def test_provider_get_payment_price_selects_matching_tariff() -> None:
|
||||
"price": 200000,
|
||||
"deliveryDaysMin": 1,
|
||||
"deliveryDaysMax": 2,
|
||||
"tariffCode": "ДоставкаДоДверей|urg-exp",
|
||||
"tariffCode": "ДоставкаДоДверей|tariff-guid-2|urg-exp",
|
||||
},
|
||||
"parcelType": "parcel",
|
||||
"weight": "1.0",
|
||||
@@ -236,8 +428,110 @@ def test_provider_get_payment_price_selects_matching_tariff() -> None:
|
||||
price = asyncio.run(provider.get_payment_price(request))
|
||||
|
||||
assert price is not None
|
||||
assert price.tariff_code == "ДоставкаДоДверей|urg-exp"
|
||||
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:
|
||||
@@ -246,13 +540,96 @@ def test_provider_register_order_returns_document_number() -> None:
|
||||
provider = CSEProvider(client)
|
||||
|
||||
result = asyncio.run(
|
||||
provider.register_order(make_init_payment_request(), "order-uuid-1")
|
||||
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={
|
||||
@@ -262,7 +639,7 @@ def test_save_order_request_uses_selected_tariff_urgency() -> None:
|
||||
"price": 200000,
|
||||
"deliveryDaysMin": 1,
|
||||
"deliveryDaysMax": 2,
|
||||
"tariffCode": "ДоставкаДоДверей|urg-exp",
|
||||
"tariffCode": "ДоставкаДоДверей|tariff-guid-2|urg-exp",
|
||||
},
|
||||
"parcelType": "parcel",
|
||||
"weight": "1.0",
|
||||
@@ -276,6 +653,32 @@ def test_save_order_request_uses_selected_tariff_urgency() -> None:
|
||||
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:
|
||||
@@ -285,7 +688,63 @@ def test_application_error_in_response_raises_request_error() -> None:
|
||||
httpx.Response(200, text=_ERROR_RESPONSE),
|
||||
]
|
||||
)
|
||||
provider = CSEProvider(client)
|
||||
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
|
||||
@@ -11,9 +11,12 @@ business_logic:
|
||||
repository:
|
||||
redis_dsn: "redis://localhost:6379/0"
|
||||
|
||||
adapter:
|
||||
cdek_client_id: "yaml-id"
|
||||
cdek_client_secret: "yaml-secret"
|
||||
delivery_providers:
|
||||
cdek:
|
||||
client_id: "yaml-id"
|
||||
client_secret: "yaml-secret"
|
||||
cse:
|
||||
enabled: false
|
||||
|
||||
tbank_payment:
|
||||
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||
|
||||
@@ -14,14 +14,17 @@ repository:
|
||||
redis_dsn: "redis://localhost:6379/0"
|
||||
price_cache_ttl_seconds: 900
|
||||
|
||||
adapter:
|
||||
cdek_base_url: "https://api.cdek.ru/v2"
|
||||
cdek_client_id: "test-client-id"
|
||||
cdek_client_secret: "test-client-secret"
|
||||
cdek_retry_attempts: 2
|
||||
cdek_retry_backoff_seconds: 0.2
|
||||
cdek_timeout_seconds: 10.0
|
||||
cdek_cache_ttl_seconds: 900
|
||||
delivery_providers:
|
||||
cdek:
|
||||
base_url: "https://api.cdek.ru/v2"
|
||||
client_id: "test-client-id"
|
||||
client_secret: "test-client-secret"
|
||||
retry_attempts: 2
|
||||
retry_backoff_seconds: 0.2
|
||||
timeout_seconds: 10.0
|
||||
cache_ttl_seconds: 900
|
||||
cse:
|
||||
enabled: false
|
||||
|
||||
tbank_payment:
|
||||
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||
|
||||
@@ -14,14 +14,17 @@ repository:
|
||||
redis_dsn: "redis://localhost:6379/0"
|
||||
price_cache_ttl_seconds: 900
|
||||
|
||||
adapter:
|
||||
cdek_base_url: "https://api.cdek.ru/v2"
|
||||
cdek_client_id: "test-client-id"
|
||||
cdek_client_secret: "test-client-secret"
|
||||
cdek_retry_attempts: 2
|
||||
cdek_retry_backoff_seconds: 0.2
|
||||
cdek_timeout_seconds: 10.0
|
||||
cdek_cache_ttl_seconds: 900
|
||||
delivery_providers:
|
||||
cdek:
|
||||
base_url: "https://api.cdek.ru/v2"
|
||||
client_id: "test-client-id"
|
||||
client_secret: "test-client-secret"
|
||||
retry_attempts: 2
|
||||
retry_backoff_seconds: 0.2
|
||||
timeout_seconds: 10.0
|
||||
cache_ttl_seconds: 900
|
||||
cse:
|
||||
enabled: false
|
||||
|
||||
tbank_payment:
|
||||
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||
|
||||
@@ -14,14 +14,17 @@ repository:
|
||||
redis_dsn: "redis://localhost:6379/0"
|
||||
price_cache_ttl_seconds: 900
|
||||
|
||||
adapter:
|
||||
cdek_base_url: "https://api.cdek.ru/v2"
|
||||
cdek_client_id: "test-client-id"
|
||||
cdek_client_secret: "test-client-secret"
|
||||
cdek_retry_attempts: 2
|
||||
cdek_retry_backoff_seconds: 0.2
|
||||
cdek_timeout_seconds: 10.0
|
||||
cdek_cache_ttl_seconds: 900
|
||||
delivery_providers:
|
||||
cdek:
|
||||
base_url: "https://api.cdek.ru/v2"
|
||||
client_id: "test-client-id"
|
||||
client_secret: "test-client-secret"
|
||||
retry_attempts: 2
|
||||
retry_backoff_seconds: 0.2
|
||||
timeout_seconds: 10.0
|
||||
cache_ttl_seconds: 900
|
||||
cse:
|
||||
enabled: false
|
||||
|
||||
tbank_payment:
|
||||
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||
|
||||
@@ -14,14 +14,17 @@ repository:
|
||||
redis_dsn: "redis://localhost:6379/0"
|
||||
price_cache_ttl_seconds: 900
|
||||
|
||||
adapter:
|
||||
cdek_base_url: "https://api.cdek.ru/v2"
|
||||
cdek_client_id: "test-client-id"
|
||||
cdek_client_secret: "test-client-secret"
|
||||
cdek_retry_attempts: 2
|
||||
cdek_retry_backoff_seconds: 0.2
|
||||
cdek_timeout_seconds: 10.0
|
||||
cdek_cache_ttl_seconds: 900
|
||||
delivery_providers:
|
||||
cdek:
|
||||
base_url: "https://api.cdek.ru/v2"
|
||||
client_id: "test-client-id"
|
||||
client_secret: "test-client-secret"
|
||||
retry_attempts: 2
|
||||
retry_backoff_seconds: 0.2
|
||||
timeout_seconds: 10.0
|
||||
cache_ttl_seconds: 900
|
||||
cse:
|
||||
enabled: false
|
||||
|
||||
tbank_payment:
|
||||
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||
|
||||
@@ -13,14 +13,17 @@ repository:
|
||||
redis_dsn: "redis://localhost:6379/0"
|
||||
price_cache_ttl_seconds: 900
|
||||
|
||||
adapter:
|
||||
cdek_base_url: "https://api.cdek.ru/v2"
|
||||
cdek_client_id: "test-client-id"
|
||||
cdek_client_secret: "test-client-secret"
|
||||
cdek_retry_attempts: 2
|
||||
cdek_retry_backoff_seconds: 0.2
|
||||
cdek_timeout_seconds: 10.0
|
||||
cdek_cache_ttl_seconds: 900
|
||||
delivery_providers:
|
||||
cdek:
|
||||
base_url: "https://api.cdek.ru/v2"
|
||||
client_id: "test-client-id"
|
||||
client_secret: "test-client-secret"
|
||||
retry_attempts: 2
|
||||
retry_backoff_seconds: 0.2
|
||||
timeout_seconds: 10.0
|
||||
cache_ttl_seconds: 900
|
||||
cse:
|
||||
enabled: false
|
||||
|
||||
tbank_payment:
|
||||
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||
|
||||
@@ -11,9 +11,12 @@ business_logic:
|
||||
repository:
|
||||
redis_dsn: "redis://localhost:6379/0"
|
||||
|
||||
adapter:
|
||||
cdek_client_id: "test-id"
|
||||
cdek_client_secret: "test-secret"
|
||||
delivery_providers:
|
||||
cdek:
|
||||
client_id: "test-id"
|
||||
client_secret: "test-secret"
|
||||
cse:
|
||||
enabled: false
|
||||
|
||||
tbank_payment:
|
||||
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||
|
||||
@@ -132,7 +132,7 @@ business_logic:
|
||||
|
||||
repository: {{}}
|
||||
|
||||
adapter: {{}}
|
||||
delivery_providers: {{}}
|
||||
|
||||
tbank_payment:
|
||||
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||
@@ -174,13 +174,32 @@ def test_configuration_sections_are_loaded_from_yaml_file(
|
||||
assert settings.business_logic.provider_price_multiplier == Decimal("1.0")
|
||||
assert settings.repository.redis_dsn == "redis://localhost:6379/0"
|
||||
assert settings.repository.price_cache_ttl_seconds == 900
|
||||
assert settings.adapter.cdek_base_url == "https://api.cdek.ru/v2"
|
||||
assert settings.adapter.cdek_client_id == "test-client-id"
|
||||
assert settings.adapter.cdek_client_secret == "test-client-secret"
|
||||
assert settings.adapter.cdek_retry_attempts == 2
|
||||
assert settings.adapter.cdek_retry_backoff_seconds == 0.2
|
||||
assert settings.adapter.cdek_timeout_seconds == 10.0
|
||||
assert settings.adapter.cdek_cache_ttl_seconds == 900
|
||||
assert settings.delivery_providers.cdek.enabled is True
|
||||
assert settings.delivery_providers.cdek.base_url == "https://api.cdek.ru/v2"
|
||||
assert settings.delivery_providers.cdek.client_id == "test-client-id"
|
||||
assert settings.delivery_providers.cdek.client_secret == "test-client-secret"
|
||||
assert settings.delivery_providers.cdek.retry_attempts == 2
|
||||
assert settings.delivery_providers.cdek.retry_backoff_seconds == 0.2
|
||||
assert settings.delivery_providers.cdek.timeout_seconds == 10.0
|
||||
assert settings.delivery_providers.cdek.cache_ttl_seconds == 900
|
||||
assert settings.delivery_providers.cse.enabled is True
|
||||
assert (
|
||||
settings.delivery_providers.cse.base_url
|
||||
== "http://lk-test.cse.ru/1c/ws/web1c.1cws"
|
||||
)
|
||||
assert settings.delivery_providers.cse.login == "test"
|
||||
assert settings.delivery_providers.cse.password == "2016"
|
||||
assert settings.delivery_providers.cse.retry_attempts == 2
|
||||
assert settings.delivery_providers.cse.retry_backoff_seconds == 0.2
|
||||
assert settings.delivery_providers.cse.timeout_seconds == 10.0
|
||||
assert settings.delivery_providers.cse.cache_ttl_seconds == 900
|
||||
assert settings.delivery_providers.cse.delivery_service_guids == ["tariff-guid-2"]
|
||||
assert settings.delivery_providers.cse.payer == "0"
|
||||
assert settings.delivery_providers.cse.payment_method == "1"
|
||||
assert (
|
||||
settings.delivery_providers.cse.shipping_method
|
||||
== "5052d0b3-5ea3-46f2-823f-1472686a51dd"
|
||||
)
|
||||
assert settings.tbank_payment.init_url == "https://securepay.tinkoff.ru/v2/Init"
|
||||
assert (
|
||||
settings.tbank_payment.notification_url
|
||||
@@ -233,7 +252,7 @@ def test_get_settings_fails_when_required_yaml_section_is_missing(
|
||||
get_settings()
|
||||
|
||||
locations = {tuple(item["loc"]) for item in error.value.errors()}
|
||||
assert ("adapter",) in locations
|
||||
assert ("delivery_providers",) in locations
|
||||
get_settings.cache_clear()
|
||||
|
||||
|
||||
@@ -287,7 +306,7 @@ def test_get_settings_uses_config_test_yaml_in_pytest_environment(
|
||||
assert settings.service.provider_timeout_seconds == 17.0
|
||||
assert settings.controller.api_prefix == "/from-config-test-yaml"
|
||||
assert settings.business_logic.provider_price_multiplier == Decimal("1.25")
|
||||
assert settings.adapter.cdek_client_id == "test-id"
|
||||
assert settings.delivery_providers.cdek.client_id == "test-id"
|
||||
assert settings.tbank_payment.auth.terminal_key == "override-terminal-key"
|
||||
assert settings.tbank_payment.auth.password == "override-password"
|
||||
assert (
|
||||
|
||||
@@ -5,6 +5,7 @@ import pytest
|
||||
|
||||
from app.controllers.v1 import delivery as delivery_controller
|
||||
from app.controllers.v1.delivery import get_aggregator_service
|
||||
from app.adapters.delivery_providers.registry import DeliveryProviderRegistry
|
||||
from app.main import create_app
|
||||
from app.schemas.request import AddressSuggestRequest, SuggestAddressRequest
|
||||
from app.schemas.response import AddressSuggestion
|
||||
@@ -52,16 +53,6 @@ def test_post_address_suggest_uses_registered_provider_in_default_dependency(
|
||||
async def aclose(self) -> None:
|
||||
return None
|
||||
|
||||
class StubCDEKProvider:
|
||||
name = "stub-cdek"
|
||||
cache_ttl_seconds = 900
|
||||
|
||||
@classmethod
|
||||
def from_adapter_config(cls, *, http_client, adapter_config) -> "StubCDEKProvider":
|
||||
assert http_client is stub_http_client
|
||||
_ = adapter_config
|
||||
return cls()
|
||||
|
||||
class StubAddressProvider:
|
||||
def __init__(self, name: str, response: list[AddressSuggestion]) -> None:
|
||||
self.name = name
|
||||
@@ -122,8 +113,11 @@ def test_post_address_suggest_uses_registered_provider_in_default_dependency(
|
||||
|
||||
class StubPriceCache:
|
||||
@classmethod
|
||||
def from_repository_config(cls, repository_config) -> StubCache:
|
||||
def from_repository_config(
|
||||
cls, repository_config, *, metrics
|
||||
) -> StubCache:
|
||||
_ = repository_config
|
||||
assert metrics is delivery_controller.get_cache_metrics()
|
||||
return StubCache()
|
||||
|
||||
stub_http_client = StubHttpClient()
|
||||
@@ -166,12 +160,38 @@ def test_post_address_suggest_uses_registered_provider_in_default_dependency(
|
||||
http_client_timeouts.append(timeout_seconds)
|
||||
return stub_http_client
|
||||
|
||||
def fake_resolve_delivery_provider_timeout_seconds(config: object) -> float:
|
||||
_ = config
|
||||
return 10.0
|
||||
|
||||
def fake_build_delivery_provider_registry(
|
||||
*,
|
||||
http_client: object,
|
||||
config: object,
|
||||
) -> DeliveryProviderRegistry:
|
||||
assert http_client is stub_http_client
|
||||
_ = config
|
||||
return DeliveryProviderRegistry(
|
||||
providers=(),
|
||||
payment_price_validation_adapters={},
|
||||
order_registration_adapters={},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
delivery_controller,
|
||||
"build_controller_http_client",
|
||||
fake_build_controller_http_client,
|
||||
)
|
||||
monkeypatch.setattr(delivery_controller, "CDEKProvider", StubCDEKProvider)
|
||||
monkeypatch.setattr(
|
||||
delivery_controller,
|
||||
"resolve_delivery_provider_timeout_seconds",
|
||||
fake_resolve_delivery_provider_timeout_seconds,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
delivery_controller,
|
||||
"build_delivery_provider_registry",
|
||||
fake_build_delivery_provider_registry,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
delivery_controller,
|
||||
"DadataAddressSuggestionProvider",
|
||||
|
||||
@@ -9,6 +9,7 @@ from app.controllers.v1.delivery import get_aggregator_service
|
||||
from app.main import create_app
|
||||
from app.schemas.request import DeliveryCalculationRequest, DeliveryEntity, ParcelType
|
||||
from app.schemas.response import DeliveryPrice
|
||||
from app.adapters.delivery_providers.registry import DeliveryProviderRegistry
|
||||
from app.services.aggregator import (
|
||||
AggregatorService,
|
||||
AggregatorServiceError,
|
||||
@@ -118,13 +119,6 @@ def test_post_delivery_price_uses_registered_provider_in_default_dependency(
|
||||
),
|
||||
]
|
||||
|
||||
class StubCDEKProvider:
|
||||
@classmethod
|
||||
def from_adapter_config(cls, *, http_client, adapter_config) -> StubProvider:
|
||||
assert http_client is stub_http_client
|
||||
_ = adapter_config
|
||||
return stub_provider
|
||||
|
||||
class StubCache:
|
||||
def __init__(self) -> None:
|
||||
self.storage: dict[str, object] = {}
|
||||
@@ -138,8 +132,11 @@ def test_post_delivery_price_uses_registered_provider_in_default_dependency(
|
||||
|
||||
class StubPriceCache:
|
||||
@classmethod
|
||||
def from_repository_config(cls, repository_config) -> StubCache:
|
||||
def from_repository_config(
|
||||
cls, repository_config, *, metrics
|
||||
) -> StubCache:
|
||||
_ = repository_config
|
||||
assert metrics is delivery_controller.get_cache_metrics()
|
||||
return stub_cache
|
||||
|
||||
stub_http_client = StubHttpClient()
|
||||
@@ -151,12 +148,38 @@ def test_post_delivery_price_uses_registered_provider_in_default_dependency(
|
||||
http_client_timeouts.append(timeout_seconds)
|
||||
return stub_http_client
|
||||
|
||||
def fake_resolve_delivery_provider_timeout_seconds(config: object) -> float:
|
||||
_ = config
|
||||
return 12.5
|
||||
|
||||
def fake_build_delivery_provider_registry(
|
||||
*,
|
||||
http_client: object,
|
||||
config: object,
|
||||
) -> DeliveryProviderRegistry:
|
||||
assert http_client is stub_http_client
|
||||
_ = config
|
||||
return DeliveryProviderRegistry(
|
||||
providers=(stub_provider,),
|
||||
payment_price_validation_adapters={},
|
||||
order_registration_adapters={},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
delivery_controller,
|
||||
"build_controller_http_client",
|
||||
fake_build_controller_http_client,
|
||||
)
|
||||
monkeypatch.setattr(delivery_controller, "CDEKProvider", StubCDEKProvider)
|
||||
monkeypatch.setattr(
|
||||
delivery_controller,
|
||||
"resolve_delivery_provider_timeout_seconds",
|
||||
fake_resolve_delivery_provider_timeout_seconds,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
delivery_controller,
|
||||
"build_delivery_provider_registry",
|
||||
fake_build_delivery_provider_registry,
|
||||
)
|
||||
monkeypatch.setattr(delivery_controller, "PriceCache", StubPriceCache)
|
||||
|
||||
app = create_app()
|
||||
@@ -196,7 +219,7 @@ def test_post_delivery_price_uses_registered_provider_in_default_dependency(
|
||||
}
|
||||
]
|
||||
assert second_response.json() == first_response.json()
|
||||
assert len(http_client_timeouts) == 1
|
||||
assert http_client_timeouts == [12.5]
|
||||
assert len(stub_provider.calls) == 1
|
||||
|
||||
|
||||
|
||||
@@ -164,6 +164,47 @@ def test_post_init_payment_rejects_non_integer_price() -> None:
|
||||
assert service.calls == []
|
||||
|
||||
|
||||
def test_post_init_payment_rejects_missing_declared_value() -> None:
|
||||
service = StubAggregatorService(response=None)
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
invalid_payload = make_init_payment_payload()
|
||||
del invalid_payload["content"]["declared_value"]
|
||||
|
||||
response = _post(app, invalid_payload)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert service.calls == []
|
||||
|
||||
|
||||
def test_post_init_payment_rejects_non_integer_declared_value() -> None:
|
||||
service = StubAggregatorService(response=None)
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
invalid_payload = make_init_payment_payload()
|
||||
invalid_payload["content"]["declared_value"] = "50000"
|
||||
|
||||
response = _post(app, invalid_payload)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert service.calls == []
|
||||
|
||||
|
||||
def test_post_init_payment_rejects_camel_case_declared_value() -> None:
|
||||
service = StubAggregatorService(response=None)
|
||||
app = create_app()
|
||||
_install_service_override(app, service)
|
||||
invalid_payload = make_init_payment_payload()
|
||||
invalid_payload["content"]["declaredValue"] = invalid_payload["content"].pop(
|
||||
"declared_value"
|
||||
)
|
||||
|
||||
response = _post(app, invalid_payload)
|
||||
|
||||
assert response.status_code == 422
|
||||
assert service.calls == []
|
||||
|
||||
|
||||
def test_post_init_payment_requires_company_fields_when_is_company_true() -> None:
|
||||
service = StubAggregatorService(response=None)
|
||||
app = create_app()
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
from app.domain.payment_notifications import (
|
||||
TBankPaymentNotificationAction,
|
||||
resolve_tbank_payment_notification_action,
|
||||
should_apply_tbank_payment_status,
|
||||
)
|
||||
|
||||
|
||||
def test_confirmed_success_zero_error_code_registers_cdek_order() -> None:
|
||||
def test_confirmed_success_zero_error_code_registers_provider_order() -> None:
|
||||
result = resolve_tbank_payment_notification_action(
|
||||
status="CONFIRMED",
|
||||
success=True,
|
||||
error_code="0",
|
||||
)
|
||||
|
||||
assert result is TBankPaymentNotificationAction.REGISTER_CDEK_ORDER
|
||||
assert result is TBankPaymentNotificationAction.REGISTER_PROVIDER_ORDER
|
||||
|
||||
|
||||
def test_confirmed_without_success_acknowledges_only() -> None:
|
||||
@@ -53,3 +54,27 @@ def test_unknown_status_acknowledges_only() -> None:
|
||||
)
|
||||
|
||||
assert result is TBankPaymentNotificationAction.ACKNOWLEDGE_ONLY
|
||||
|
||||
|
||||
def test_authorized_does_not_downgrade_confirmed() -> None:
|
||||
assert should_apply_tbank_payment_status("CONFIRMED", "AUTHORIZED") is False
|
||||
|
||||
|
||||
def test_confirmed_overwrites_authorized() -> None:
|
||||
assert should_apply_tbank_payment_status("AUTHORIZED", "CONFIRMED") is True
|
||||
|
||||
|
||||
def test_first_status_always_applies() -> None:
|
||||
assert should_apply_tbank_payment_status(None, "AUTHORIZED") is True
|
||||
|
||||
|
||||
def test_same_status_applies() -> None:
|
||||
assert should_apply_tbank_payment_status("CONFIRMED", "CONFIRMED") is True
|
||||
|
||||
|
||||
def test_refund_overwrites_confirmed() -> None:
|
||||
assert should_apply_tbank_payment_status("CONFIRMED", "REFUNDED") is True
|
||||
|
||||
|
||||
def test_unknown_status_does_not_overwrite_known() -> None:
|
||||
assert should_apply_tbank_payment_status("CONFIRMED", "WAT") is False
|
||||
|
||||
@@ -3,6 +3,8 @@ import logging
|
||||
from io import StringIO
|
||||
|
||||
import structlog
|
||||
from opentelemetry import trace
|
||||
from opentelemetry.trace import NonRecordingSpan, SpanContext, TraceFlags
|
||||
|
||||
from app.runtime.logging import configure_logging
|
||||
|
||||
@@ -20,6 +22,35 @@ def test_application_log_is_serialized_as_json() -> None:
|
||||
assert payload["level"] == "info"
|
||||
assert payload["logger"] == "app.test"
|
||||
assert isinstance(payload["timestamp"], str)
|
||||
assert "trace_id" not in payload
|
||||
assert "span_id" not in payload
|
||||
|
||||
_reset_logging()
|
||||
|
||||
|
||||
def test_structured_context_is_preserved_and_rendered_in_event() -> None:
|
||||
stream = StringIO()
|
||||
|
||||
configure_logging(stream=stream)
|
||||
|
||||
structlog.get_logger("app.test").warning(
|
||||
"application_event",
|
||||
order_uuid="order-1",
|
||||
attempt=2,
|
||||
retryable=True,
|
||||
details={"code": "invalid"},
|
||||
)
|
||||
|
||||
payload = json.loads(stream.getvalue().splitlines()[0])
|
||||
|
||||
assert payload["event"] == (
|
||||
'application_event attempt=2 details={"code":"invalid"} '
|
||||
'order_uuid="order-1" retryable=true'
|
||||
)
|
||||
assert payload["order_uuid"] == "order-1"
|
||||
assert payload["attempt"] == 2
|
||||
assert payload["retryable"] is True
|
||||
assert payload["details"] == {"code": "invalid"}
|
||||
|
||||
_reset_logging()
|
||||
|
||||
@@ -48,6 +79,49 @@ def test_uvicorn_loggers_use_shared_json_logging_setup() -> None:
|
||||
_reset_logging()
|
||||
|
||||
|
||||
def test_active_trace_context_is_added_to_structlog_and_standard_logs() -> None:
|
||||
stream = StringIO()
|
||||
trace_id = 0x1234567890ABCDEF1234567890ABCDEF
|
||||
span_id = 0x1234567890ABCDEF
|
||||
span = NonRecordingSpan(
|
||||
SpanContext(
|
||||
trace_id=trace_id,
|
||||
span_id=span_id,
|
||||
is_remote=False,
|
||||
trace_flags=TraceFlags.SAMPLED,
|
||||
)
|
||||
)
|
||||
|
||||
configure_logging(stream=stream)
|
||||
|
||||
with trace.use_span(span, end_on_exit=False):
|
||||
structlog.get_logger("app.test").info("structured_event")
|
||||
logging.getLogger("app.test").info("standard_event")
|
||||
|
||||
records = [json.loads(line) for line in stream.getvalue().splitlines()]
|
||||
expected_trace_id = "1234567890abcdef1234567890abcdef"
|
||||
expected_span_id = "1234567890abcdef"
|
||||
|
||||
assert [record["trace_id"] for record in records] == [
|
||||
expected_trace_id,
|
||||
expected_trace_id,
|
||||
]
|
||||
assert [record["span_id"] for record in records] == [
|
||||
expected_span_id,
|
||||
expected_span_id,
|
||||
]
|
||||
assert records[0]["event"] == (
|
||||
f'structured_event span_id="{expected_span_id}" '
|
||||
f'trace_id="{expected_trace_id}"'
|
||||
)
|
||||
assert records[1]["event"] == (
|
||||
f'standard_event span_id="{expected_span_id}" '
|
||||
f'trace_id="{expected_trace_id}"'
|
||||
)
|
||||
|
||||
_reset_logging()
|
||||
|
||||
|
||||
def _reset_logging() -> None:
|
||||
structlog.reset_defaults()
|
||||
root_logger = logging.getLogger()
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import importlib
|
||||
|
||||
from app.config import ObservabilityConfig
|
||||
|
||||
|
||||
def _reload_metrics_module():
|
||||
metrics_module = importlib.import_module("app.runtime.metrics")
|
||||
return importlib.reload(metrics_module)
|
||||
|
||||
|
||||
def _make_observability_config(**overrides) -> ObservabilityConfig:
|
||||
payload = {
|
||||
"enabled": True,
|
||||
"service_name": "g2s-aggregator",
|
||||
"otlp_endpoint": "http://collector:4317",
|
||||
"otlp_insecure": True,
|
||||
}
|
||||
payload.update(overrides)
|
||||
return ObservabilityConfig(**payload)
|
||||
|
||||
|
||||
def test_configure_metrics_skips_bootstrap_when_disabled(monkeypatch) -> None:
|
||||
metrics_module = _reload_metrics_module()
|
||||
calls: list[object] = []
|
||||
monkeypatch.setattr(
|
||||
metrics_module,
|
||||
"_build_meter_provider",
|
||||
lambda observability: calls.append(observability),
|
||||
)
|
||||
|
||||
metrics_module.configure_metrics(_make_observability_config(enabled=False))
|
||||
|
||||
assert calls == []
|
||||
|
||||
|
||||
def test_build_meter_provider_uses_otlp_exporter_and_service_name(monkeypatch) -> None:
|
||||
metrics_module = _reload_metrics_module()
|
||||
created: dict[str, object] = {}
|
||||
|
||||
class FakeExporter:
|
||||
def __init__(self, *, endpoint: str, insecure: bool) -> None:
|
||||
created["endpoint"] = endpoint
|
||||
created["insecure"] = insecure
|
||||
|
||||
class FakeReader:
|
||||
def __init__(self, exporter: object) -> None:
|
||||
created["exporter"] = exporter
|
||||
|
||||
class FakeMeterProvider:
|
||||
def __init__(self, *, resource, metric_readers: list[object]) -> None:
|
||||
self.resource = resource
|
||||
self.metric_readers = metric_readers
|
||||
|
||||
monkeypatch.setattr(metrics_module, "OTLPMetricExporter", FakeExporter)
|
||||
monkeypatch.setattr(metrics_module, "PeriodicExportingMetricReader", FakeReader)
|
||||
monkeypatch.setattr(metrics_module, "MeterProvider", FakeMeterProvider)
|
||||
|
||||
provider = metrics_module._build_meter_provider(
|
||||
_make_observability_config(
|
||||
otlp_endpoint="http://metrics-backend:4317",
|
||||
otlp_insecure=False,
|
||||
)
|
||||
)
|
||||
|
||||
assert created["endpoint"] == "http://metrics-backend:4317"
|
||||
assert created["insecure"] is False
|
||||
assert provider.resource.attributes["service.name"] == "g2s-aggregator"
|
||||
assert len(provider.metric_readers) == 1
|
||||
assert created["exporter"] is not None
|
||||
|
||||
|
||||
def test_cache_metrics_create_and_record_expected_instruments(monkeypatch) -> None:
|
||||
metrics_module = _reload_metrics_module()
|
||||
created: dict[str, object] = {}
|
||||
|
||||
class FakeInstrument:
|
||||
def __init__(self, name: str) -> None:
|
||||
self.name = name
|
||||
self.records: list[tuple[float, dict[str, str] | None]] = []
|
||||
|
||||
def add(self, value: float, attributes: dict[str, str]) -> None:
|
||||
self.records.append((value, attributes))
|
||||
|
||||
def record(
|
||||
self, value: float, attributes: dict[str, str] | None = None
|
||||
) -> None:
|
||||
self.records.append((value, attributes))
|
||||
|
||||
class FakeMeter:
|
||||
def create_counter(self, name: str, **kwargs) -> FakeInstrument:
|
||||
del kwargs
|
||||
instrument = FakeInstrument(name)
|
||||
created[name] = instrument
|
||||
return instrument
|
||||
|
||||
def create_histogram(self, name: str, **kwargs) -> FakeInstrument:
|
||||
del kwargs
|
||||
instrument = FakeInstrument(name)
|
||||
created[name] = instrument
|
||||
return instrument
|
||||
|
||||
monkeypatch.setattr(metrics_module.metrics, "get_meter", lambda _name: FakeMeter())
|
||||
|
||||
cache_metrics = metrics_module.CacheMetrics()
|
||||
cache_metrics.record_request(operation="get", outcome="hit")
|
||||
cache_metrics.record_error(operation="set", error_type="ConnectionError")
|
||||
cache_metrics.record_hit(hit=True)
|
||||
cache_metrics.record_hit(hit=False)
|
||||
|
||||
assert set(created) == {
|
||||
"cache_requests_total",
|
||||
"cache_errors_total",
|
||||
"cache_hit_ratio",
|
||||
}
|
||||
assert created["cache_requests_total"].records == [
|
||||
(1, {"operation": "get", "outcome": "hit"})
|
||||
]
|
||||
assert created["cache_errors_total"].records == [
|
||||
(1, {"operation": "set", "error.type": "ConnectionError"})
|
||||
]
|
||||
assert created["cache_hit_ratio"].records == [(1.0, None), (0.0, None)]
|
||||
@@ -49,6 +49,7 @@ def make_init_payment_payload(**overrides: Any) -> dict[str, Any]:
|
||||
},
|
||||
"content": {
|
||||
"description": "Headphones",
|
||||
"declared_value": 50000,
|
||||
},
|
||||
"pickupDate": "2026-05-15T10:00:00.000Z",
|
||||
"deliveryDate": "2026-05-18T18:00:00.000Z",
|
||||
|
||||
+78
-8
@@ -69,6 +69,22 @@ class FailingSetRedisClient:
|
||||
return 1
|
||||
|
||||
|
||||
class RecordingCacheMetrics:
|
||||
def __init__(self) -> None:
|
||||
self.requests: list[tuple[str, str]] = []
|
||||
self.errors: list[tuple[str, str]] = []
|
||||
self.hits: list[bool] = []
|
||||
|
||||
def record_request(self, *, operation: str, outcome: str) -> None:
|
||||
self.requests.append((operation, outcome))
|
||||
|
||||
def record_error(self, *, operation: str, error_type: str) -> None:
|
||||
self.errors.append((operation, error_type))
|
||||
|
||||
def record_hit(self, *, hit: bool) -> None:
|
||||
self.hits.append(hit)
|
||||
|
||||
|
||||
def test_cache_miss_returns_none() -> None:
|
||||
cache = PriceCache(InMemoryRedisClient(), ttl_seconds=120)
|
||||
|
||||
@@ -170,14 +186,17 @@ repository:
|
||||
redis_dsn: "redis://redis.internal:6380/5"
|
||||
price_cache_ttl_seconds: 123
|
||||
|
||||
adapter:
|
||||
cdek_base_url: "https://api.cdek.ru/v2"
|
||||
cdek_client_id: "test-client-id"
|
||||
cdek_client_secret: "test-client-secret"
|
||||
cdek_retry_attempts: 2
|
||||
cdek_retry_backoff_seconds: 0.2
|
||||
cdek_timeout_seconds: 10.0
|
||||
cdek_cache_ttl_seconds: 900
|
||||
delivery_providers:
|
||||
cdek:
|
||||
base_url: "https://api.cdek.ru/v2"
|
||||
client_id: "test-client-id"
|
||||
client_secret: "test-client-secret"
|
||||
retry_attempts: 2
|
||||
retry_backoff_seconds: 0.2
|
||||
timeout_seconds: 10.0
|
||||
cache_ttl_seconds: 900
|
||||
cse:
|
||||
enabled: false
|
||||
|
||||
tbank_payment:
|
||||
init_url: "https://securepay.tinkoff.ru/v2/Init"
|
||||
@@ -263,3 +282,54 @@ def test_set_raises_deterministic_repository_error_on_redis_failure() -> None:
|
||||
|
||||
with pytest.raises(PriceCacheRepositoryError, match="Redis cache write failed."):
|
||||
asyncio.run(cache.set("key", {"v": 1}))
|
||||
|
||||
|
||||
def test_cache_metrics_record_hits_misses_and_successful_writes() -> None:
|
||||
redis_client = InMemoryRedisClient()
|
||||
metrics = RecordingCacheMetrics()
|
||||
cache = PriceCache(redis_client, ttl_seconds=10, metrics=metrics)
|
||||
|
||||
async def scenario() -> None:
|
||||
await cache.get("missing")
|
||||
await cache.set("key", {"v": 1})
|
||||
await cache.get("key")
|
||||
await cache.invalidate("key")
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
assert metrics.requests == [
|
||||
("get", "miss"),
|
||||
("set", "success"),
|
||||
("get", "hit"),
|
||||
("invalidate", "success"),
|
||||
]
|
||||
assert metrics.errors == []
|
||||
assert metrics.hits == [False, True]
|
||||
|
||||
|
||||
def test_cache_failure_records_metrics_and_structured_log(monkeypatch) -> None:
|
||||
events: list[tuple[str, dict[str, object]]] = []
|
||||
metrics = RecordingCacheMetrics()
|
||||
cache = PriceCache(FailingGetRedisClient(), ttl_seconds=10, metrics=metrics)
|
||||
|
||||
class StubLogger:
|
||||
def warning(self, event: str, **context: object) -> None:
|
||||
events.append((event, context))
|
||||
|
||||
monkeypatch.setattr(
|
||||
"app.repositories.cache.redis_cache.logger",
|
||||
StubLogger(),
|
||||
)
|
||||
|
||||
with pytest.raises(PriceCacheRepositoryError, match="Redis cache read failed."):
|
||||
asyncio.run(cache.get("key"))
|
||||
|
||||
assert metrics.requests == [("get", "error")]
|
||||
assert metrics.errors == [("get", "RuntimeError")]
|
||||
assert metrics.hits == []
|
||||
assert events == [
|
||||
(
|
||||
"cache.operation_failed",
|
||||
{"operation": "get", "error_type": "RuntimeError"},
|
||||
)
|
||||
]
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user