Compare commits

...

21 Commits

Author SHA1 Message Date
Раис Юсупалиев c2728ae268 КОСТЫЛЬ перетираем дату доставки
Deploy / deploy (push) Successful in 53s
2026-06-27 22:52:19 +03:00
Раис Юсупалиев c942885abe fix pickup date
Deploy / deploy (push) Successful in 1m2s
2026-06-27 22:41:10 +03:00
Раис Юсупалиев 7287a1398e еще логи
Deploy / deploy (push) Successful in 56s
2026-06-27 18:42:50 +03:00
Раис Юсупалиев a2d9a243f7 add cse logs
Deploy / deploy (push) Successful in 58s
2026-06-27 18:10:20 +03:00
Раис Юсупалиев 4262b8a200 fix ксе tariffs
Deploy / deploy (push) Successful in 57s
2026-06-27 17:34:06 +03:00
Раис Юсупалиев e4d9b581a6 fix ксе prices
Deploy / deploy (push) Successful in 55s
2026-06-27 16:45:58 +03:00
Раис Юсупалиев facdde00c9 добавлено получение накладной в cse
Deploy / deploy (push) Successful in 2m33s
2026-06-27 07:10:31 +03:00
Раис Юсупалиев 2d8f31d3d9 оценочная стоимость отправляется в cdek
Deploy / deploy (push) Successful in 1m1s
2026-06-26 20:59:27 +03:00
Раис Юсупалиев 6906db7739 убрал packages из cdek payload
Deploy / deploy (push) Successful in 52s
2026-06-26 20:46:21 +03:00
Раис Юсупалиев 843175f12e Рефактор
Deploy / deploy (push) Successful in 18m5s
2026-06-26 20:16:04 +03:00
Раис Юсупалиев c6c37640fd добавлен declaredValue
Deploy / deploy (push) Successful in 53s
2026-06-21 22:55:29 +03:00
Раис Юсупалиев 6a3da82ddc span_id и trace_id
Deploy / deploy (push) Successful in 51s
2026-06-20 19:56:48 +03:00
Раис Юсупалиев 798b6e38bd пофикшен redis и логи redis
Deploy / deploy (push) Successful in 52s
2026-06-20 19:35:23 +03:00
Раис Юсупалиев 02f5ef93b0 улучшены логи, otel collector добавлен в git
Deploy / deploy (push) Successful in 50s
2026-06-20 05:24:17 +03:00
Раис Юсупалиев aeee641c6c поправлена гонка статусов от tabnk
Deploy / deploy (push) Successful in 48s
2026-06-20 01:12:16 +03:00
Раис Юсупалиев cbcd9ca1bc логи для нотификации tbank
Deploy / deploy (push) Successful in 56s
2026-06-20 00:26:48 +03:00
Раис Юсупалиев 65c07f1da3 фикс поллинга накладной
Deploy / deploy (push) Successful in 9m37s
2026-06-19 23:52:46 +03:00
Раис Юсупалиев f82a7c7606 fix image tag
Deploy / deploy (push) Successful in 2m59s
2026-06-19 08:22:17 +03:00
Раис Юсупалиев b58fd11966 fix build
Deploy / deploy (push) Successful in 3m23s
2026-06-19 07:18:30 +03:00
Раис Юсупалиев ed33357af2 убран SDD, добавлен uv, фикс билда
Deploy / deploy (push) Failing after 2m28s
2026-06-19 06:07:30 +03:00
Раис Юсупалиев 5f85006e1d добавлен stage
Deploy / deploy (push) Failing after 40s
2026-06-19 05:32:19 +03:00
110 changed files with 4095 additions and 5454 deletions
+63 -12
View File
@@ -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
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 \
--push \
-t gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.1.0 \
--load \
-t "$IMG" \
.
docker push "$IMG"
fi
- name: Setup SSH
run: |
@@ -38,8 +75,6 @@ jobs:
run: |
set -euo pipefail
apt update && apt install -y gettext-base
# Экспортируем все секреты как env-переменные
while IFS= read -r -d '' entry; do
key="${entry%%=*}"
@@ -47,33 +82,49 @@ jobs:
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
"
+1
View File
@@ -2,6 +2,7 @@
*.iml
/config.yaml
/docker-compose.yml
/otel-collector-config.yaml
__pycache__
http-client.private.env.json
scripts
+10 -7
View File
@@ -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"]
-85
View File
@@ -1,85 +0,0 @@
You are Implementer.
You implement EXACTLY ONE next uncompleted task from spec/tasks/.
Before starting, you MUST read:
- spec/overview.md
- the assigned task file in spec/tasks/
You MUST NOT modify any files in spec/.
---
## Your responsibilities
- Implement the task exactly as specified
- Place business rules in Business Logic
- Place orchestration in Service
- Respect all constraints from the task
- Add or update required tests
- Keep changes strictly within task scope
---
## Mandatory verification steps (BEFORE finishing)
You MUST:
1. Verify all Definition of Done items from the task
2. Ensure Business Logic is fully unit-tested WITHOUT mocks
3. Ensure Service tests (if any) may use mocks/stubs
4. Run all tests specified in the task
5. Run all linters / type checks specified in the task
6. Verify no unrelated files were modified
7. Stage all intended changes:
- run: git add -A
- DO NOT commit
If ANY item fails, you MUST report it explicitly.
---
## Output format (MANDATORY)
Output EXACTLY the following sections.
## Task
- Task ID and title
## Changes made
- List of changed files
- Short description per file
- Explicit layer for each file (Controller / Service / Business Logic / Repository / Adapter / Tests)
## Definition of Done verification
- Checklist copied from the task
- Each item marked as PASSED or FAILED
## Tests
- Tests added or updated
- Test type:
- unit (no mocks)
- integration
- other
- Results
## Commands executed
- Exact commands run
- Result (success/failure)
## Git staging
- Confirm: git add -A executed
- Confirm: no commits created
## Notes
- Edge cases, trade-offs, or limitations
- If none, write "None"
---
## Hard rules
- No scope expansion
- No speculative refactoring
- No changes outside task scope
- No spec changes
- No business logic in Service or Adapter
-99
View File
@@ -1,99 +0,0 @@
You are Planner.
You analyze a programming task or specification and produce planning artifacts.
You do NOT write production code.
You MUST always read:
- spec/overview.md
- spec/index.md
- existing files in spec/tasks/
You are the ONLY role allowed to create or modify files in spec/tasks/.
Write tasks in russian except headings, frontmatter and terms
Follow AGENTS.md strictly.
---
## Your responsibilities
Depending on the input, you may:
- create one or more new task files in spec/tasks/
- update existing task files in spec/tasks/
For every change in spec/tasks/, you MUST:
- use the minimal task format defined below
- update spec/index.md via the generator workflow
You MUST NOT:
- modify any source code
- change files outside spec/
- introduce architectural decisions not requested
---
## Minimal task format (MANDATORY)
Every task file in spec/tasks/ MUST follow this structure.
### Front matter (required)
---
id: <numeric, zero-padded, unique>
title: <short, precise, action-oriented>
status: TODO
created: <YYYY-MM-DD>
---
### Body (required sections, minimal)
## Context
Why this task exists. Short and factual.
## Goal
What must be implemented or changed.
## Constraints
Hard limits:
- architectural
- scope
- forbidden changes
## Acceptance criteria
- Bullet list
- Objective and testable
## Definition of Done
Checklist. Task is DONE only if ALL items are satisfied.
## Tests
What tests must exist or be updated.
## Commands
Exact commands to verify completion
(e.g. pytest, ruff, mypy, docker compose).
---
## Planner output rules
When creating or updating tasks, output MUST include:
### Tasks created
- List of new spec/tasks/*.md files with full content
### Tasks updated
- List of modified spec/tasks/*.md files with changes
### Index update
- Explicit instruction to run:
python3 spec/gen_spec_index.py
If no tasks are created or updated, explicitly state:
- "No task changes required"
Rules:
- No code blocks outside task files
- No speculative tasks
- No refactoring-only tasks
- No vague goals
-93
View File
@@ -1,93 +0,0 @@
You are Reviewer.
You review code changes produced by Implementer.
Before reviewing, you MUST read:
- spec/overview.md
- the assigned task file in spec/tasks/
You MUST review ONLY staged changes:
- git diff --staged
You MUST NOT modify source code.
---
## Review scope (MANDATORY)
You must verify:
1. Task compliance
- Implementation matches task requirements
- No scope expansion
2. Architecture compliance
- Correct layer separation
- Business Logic purity
- Proper Service orchestration
- No business decisions in Repository or Adapter
3. Testing strategy
- Business Logic has unit tests WITHOUT mocks
- Tests cover task Definition of Done
- No missing critical tests
4. Project compliance
- Matches constraints and invariants from spec/overview.md
---
## Output format (MANDATORY)
Comments must be in russian language.
Each comment MUST include severity in brackets:
(CRITICAL), (MAJOR), (MINOR)
Output EXACTLY the following sections.
## Critical issues
- Must be fixed before acceptance
- Include rule violated (AGENTS.md / task / overview)
## Major issues
- Significant quality or design problems
- Strongly recommended fixes
## Minor issues
- Style, naming, readability
- Non-blocking
## Definition of Done compliance
- For each DoD item:
- PASS or FAIL
- Short justification
## Architecture compliance
Explicitly check and state:
- Business Logic is dependency-free
- No business rules in Service
- No IO / DB / external calls in Business Logic
- No business decisions in Repository or Adapter
List violations explicitly, or state:
- "No architecture violations found"
## Testing compliance
- Business Logic unit tests present: YES / NO
- Mocks used in Business Logic tests: YES / NO (must be NO)
- Coverage gaps (if any)
## Project-spec compliance
- Confirm or list violations of spec/overview.md
## Review input
- Confirm you reviewed: git diff --staged
---
## Hard rules
- No code changes
- No new requirements
- No architectural redesign
- No speculative improvements
@@ -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")
+23
View File
@@ -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."""
+44 -12
View File
@@ -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,7 +178,28 @@ 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]:
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
@@ -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"),
+100 -25
View File
@@ -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,16 +214,15 @@ 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]
error_codes = _response_error_codes(root)
if error_codes:
log.warning(
"cse_response_error",
operation=operation,
error_codes=codes,
error_codes=error_codes,
)
raise CSERequestError(
f"{request_error_message} application error {codes}."
f"{request_error_message} application error {error_codes}."
)
@staticmethod
@@ -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)
+37 -7
View File
@@ -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
+10 -7
View File
@@ -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
View File
@@ -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]
+44 -19
View File
@@ -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(
http_client=http_client,
adapter_config=settings.adapter,
)
cse_provider = CSEProvider.from_adapter_config(
delivery_provider_registry = build_delivery_provider_registry(
http_client=http_client,
adapter_config=settings.adapter,
config=settings.delivery_providers,
)
payment_adapter = TBankAdapter.from_config(
http_client=http_client,
@@ -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={
+39 -2
View File
@@ -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)
+2
View File
@@ -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
View File
@@ -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:
+7 -6
View File
@@ -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,
)
+41 -37
View File
@@ -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]:
statement = (
select(Order)
.where(
Order.cdek_order_uuid.is_not(None),
Order.cdek_waybill_url.is_(None),
cdek_pending = and_(
Order.provider == "cdek",
Order.provider_order_id.is_not(None),
Order.provider_waybill_url.is_(None),
(
Order.cdek_order_status.is_(None)
| Order.cdek_order_status.not_in(TERMINAL_ORDER_STATUSES)
Order.provider_order_status.is_(None)
| Order.provider_order_status.not_in(TERMINAL_ORDER_STATUSES)
),
)
.order_by(Order.cdek_polled_at.asc().nulls_first())
cse_pending = and_(
Order.provider == "cse",
Order.provider_order_id.is_not(None),
Order.provider_waybill_id.is_(None),
)
statement = (
select(Order)
.where(or_(cdek_pending, cse_pending))
.order_by(Order.provider_polled_at.asc().nulls_first())
.limit(limit)
.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
View File
@@ -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,
*,
+77
View File
@@ -0,0 +1,77 @@
"""OpenTelemetry metrics bootstrap and application instruments."""
from opentelemetry import metrics
from opentelemetry.exporter.otlp.proto.grpc.metric_exporter import OTLPMetricExporter
from opentelemetry.sdk.metrics import MeterProvider
from opentelemetry.sdk.metrics.export import PeriodicExportingMetricReader
from opentelemetry.sdk.resources import Resource
from app.config import ObservabilityConfig
_METER_PROVIDER: MeterProvider | None = None
_CACHE_METRICS: "CacheMetrics | None" = None
class CacheMetrics:
"""Metrics emitted by the Redis-backed price cache."""
def __init__(self) -> None:
meter = metrics.get_meter("app.repositories.cache")
self._requests = meter.create_counter(
"cache_requests_total",
unit="{request}",
description="Number of cache operations by operation and outcome.",
)
self._errors = meter.create_counter(
"cache_errors_total",
unit="{error}",
description="Number of failed cache operations by error type.",
)
self._hit_ratio = meter.create_histogram(
"cache_hit_ratio",
unit="1",
description="Cache lookup result: 1 for a hit and 0 for a miss.",
)
def record_request(self, *, operation: str, outcome: str) -> None:
self._requests.add(1, {"operation": operation, "outcome": outcome})
def record_error(self, *, operation: str, error_type: str) -> None:
self._errors.add(1, {"operation": operation, "error.type": error_type})
def record_hit(self, *, hit: bool) -> None:
self._hit_ratio.record(1.0 if hit else 0.0)
def configure_metrics(observability: ObservabilityConfig) -> None:
"""Configure the process-wide OTLP metrics exporter once."""
global _METER_PROVIDER
if not observability.enabled or _METER_PROVIDER is not None:
return
meter_provider = _build_meter_provider(observability)
metrics.set_meter_provider(meter_provider)
_METER_PROVIDER = meter_provider
def get_cache_metrics() -> CacheMetrics:
"""Return the process-wide cache instruments."""
global _CACHE_METRICS
if _CACHE_METRICS is None:
_CACHE_METRICS = CacheMetrics()
return _CACHE_METRICS
def _build_meter_provider(observability: ObservabilityConfig) -> MeterProvider:
exporter = OTLPMetricExporter(
endpoint=observability.otlp_endpoint,
insecure=observability.otlp_insecure,
)
reader = PeriodicExportingMetricReader(exporter)
resource = Resource.create({"service.name": observability.service_name})
return MeterProvider(resource=resource, metric_readers=[reader])
+1
View File
@@ -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):
+40 -32
View File
@@ -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(
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,
)
if updated_order is None:
logger.warning(
"tbank_payment_notification_order_not_found",
else:
logger.info(
"tbank_payment_status_downgrade_skipped",
order_uuid=notification.OrderId,
)
raise TBankPaymentNotificationProcessingError(
"Order was not found for TBank payment notification."
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,14 +635,7 @@ 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(
order = await self._order_repository.mark_provider_order_registered(
session,
order_uuid,
registration_id,
+32 -18
View File
@@ -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,15 +139,16 @@ 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)
@@ -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
+75 -37
View File
@@ -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
+67 -11
View File
@@ -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)
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=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,
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=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,
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,
)
+47 -12
View File
@@ -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)
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=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,
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=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,
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
View File
@@ -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
View File
@@ -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
View File
@@ -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
+115
View File
@@ -0,0 +1,115 @@
receivers:
otlp:
protocols:
grpc:
endpoint: 0.0.0.0:4317
http:
endpoint: 0.0.0.0:4318
filelog:
include:
- /var/lib/docker/containers/*/*-json.log
include_file_path: true
operators:
- type: json_parser
parse_to: attributes
on_error: send
- type: filter
expr: |
attributes["attrs"] == nil or
attributes["attrs"]["tag"] == nil or
(
attributes["attrs"]["tag"] != "g2s-aggregator${CONTAINER_SUFFIX}" and
attributes["attrs"]["tag"] != "g2s-aggregator-migrations${CONTAINER_SUFFIX}" and
attributes["attrs"]["tag"] != "g2s-aggregator-waybill-poller${CONTAINER_SUFFIX}" and
attributes["attrs"]["tag"] != "g2s-aggregator-waybill-email-sender${CONTAINER_SUFFIX}"
)
on_error: send
- type: regex_parser
parse_from: attributes["attrs"]["tag"]
regex: '^(?P<service_name>g2s-aggregator(?:-migrations|-waybill-poller|-waybill-email-sender)?)(?:-stage)?$'
on_error: send
- type: move
from: attributes["service_name"]
to: resource["service.name"]
on_error: send
- type: regex_parser
parse_from: attributes["log.file.path"]
regex: '^/var/lib/docker/containers/(?P<container_id>[^/]+)/'
on_error: send
- type: move
from: attributes["container_id"]
to: resource["service.instance.id"]
on_error: send
- type: remove
field: attributes["attrs"]
on_error: send
- type: json_parser
parse_from: attributes.log
parse_to: attributes
on_error: send
- type: trace_parser
trace_id:
parse_from: attributes.trace_id
span_id:
parse_from: attributes.span_id
on_error: send
- type: time_parser
parse_from: attributes.timestamp
layout: '%Y-%m-%dT%H:%M:%S.%fZ'
on_error: send
- type: severity_parser
parse_from: attributes.level
on_error: send
- type: move
from: attributes.event
to: body
on_error: send
- type: remove
field: attributes.log
on_error: send
docker_stats:
endpoint: unix:///var/run/docker.sock
collection_interval: 30s
container_labels_as_resource_attributes: true
api_version: "1.43"
processors:
resource/env:
attributes:
- key: deployment.environment
value: "${ENV_NAME}"
action: upsert
- key: service.namespace
value: g2s
action: upsert
resource/version:
attributes:
- key: service.version
value: "${IMAGE_TAG}"
action: upsert
filter/environment:
error_mode: ignore
metrics:
metric:
- '${METRICS_FILTER_EXPRESSION}'
exporters:
otlp:
endpoint: "${SIGNOZ_OTLP_ENDPOINT}"
tls:
insecure: true
service:
pipelines:
traces:
receivers: [otlp]
processors: [resource/env, resource/version]
exporters: [otlp]
logs:
receivers: [filelog]
processors: [resource/env, resource/version]
exporters: [otlp]
metrics:
receivers: [otlp, docker_stats]
processors: [filter/environment, resource/env]
exporters: [otlp]
+1 -1
View File
@@ -1,4 +1,4 @@
# spec/overview.md
# overview.md
Контекст, специфичный для проекта агрегатора служб доставки.
Глобальные правила определены в AGENTS.md.
Generated
-2128
View File
File diff suppressed because it is too large Load Diff
+27 -31
View File
@@ -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"
-229
View File
@@ -1,229 +0,0 @@
#!/usr/bin/env python3
"""
Generate spec/index.md from spec/tasks/*.md
Assumptions:
- Each task file starts with YAML-like front matter:
---
id: 001
title: Add endpoint /reports/addons
status: TODO # TODO | DONE
created: 2026-02-01
---
Notes:
- status lives inside each task file (as requested).
- index.md is fully overwritten and marked as generated.
- No external deps (no PyYAML). Supports simple "key: value" scalars.
Usage:
python3 spec/gen_spec_index.py
python3 spec/gen_spec_index.py --check # verify index is up-to-date (CI)
"""
from __future__ import annotations
import argparse
import datetime as dt
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Dict, List, Optional, Tuple
ROOT = Path(__file__).resolve().parents[1]
SPEC_DIR = ROOT / "spec"
TASKS_DIR = SPEC_DIR / "tasks"
INDEX_PATH = SPEC_DIR / "index.md"
ALLOWED_STATUS = {"TODO", "DONE"}
@dataclass(frozen=True)
class TaskMeta:
file: Path
id: str
title: str
status: str
created: str # YYYY-MM-DD
FRONT_MATTER_RE = re.compile(r"^---\s*$")
def read_front_matter(md_path: Path) -> Tuple[Dict[str, str], int]:
"""
Returns (meta_dict, end_line_index_exclusive).
If no front matter, returns ({}, 0).
"""
text = md_path.read_text(encoding="utf-8")
lines = text.splitlines()
if not lines or not FRONT_MATTER_RE.match(lines[0]):
return {}, 0
meta: Dict[str, str] = {}
i = 1
while i < len(lines):
if FRONT_MATTER_RE.match(lines[i]):
return meta, i + 1
line = lines[i].strip()
i += 1
if not line or line.startswith("#"):
continue
# Very small YAML subset: key: value
if ":" not in line:
continue
key, val = line.split(":", 1)
key = key.strip()
val = val.strip()
# Strip optional quotes
if (val.startswith('"') and val.endswith('"')) or (val.startswith("'") and val.endswith("'")):
val = val[1:-1]
meta[key] = val
# Started front matter but never closed
raise ValueError(f"{md_path}: front matter starts with '---' but missing closing '---'")
def normalize_id(md_path: Path, meta: Dict[str, str]) -> str:
# Prefer explicit id, else derive from filename prefix like "001-something.md"
if "id" in meta and meta["id"].strip():
return meta["id"].strip()
m = re.match(r"^(\d+)", md_path.stem)
if m:
return m.group(1)
raise ValueError(f"{md_path}: missing 'id' in front matter and filename doesn't start with digits")
def normalize_title(md_path: Path, meta: Dict[str, str], content_start_line: int) -> str:
if "title" in meta and meta["title"].strip():
return meta["title"].strip()
# Fallback: first markdown heading after front matter: "# Title"
text = md_path.read_text(encoding="utf-8")
lines = text.splitlines()
for line in lines[content_start_line:]:
line = line.strip()
if line.startswith("# "):
return line[2:].strip()
raise ValueError(f"{md_path}: missing 'title' in front matter and no '# ' heading found")
def normalize_status(md_path: Path, meta: Dict[str, str]) -> str:
status = meta.get("status", "").strip().upper()
if status not in ALLOWED_STATUS:
raise ValueError(f"{md_path}: invalid or missing 'status' (must be one of: {sorted(ALLOWED_STATUS)})")
return status
def normalize_created(md_path: Path, meta: Dict[str, str]) -> str:
created = meta.get("created", "").strip()
if created:
# Validate YYYY-MM-DD
try:
dt.date.fromisoformat(created)
except ValueError:
raise ValueError(f"{md_path}: invalid 'created' date '{created}' (expected YYYY-MM-DD)")
return created
# Fallback: file mtime (not perfect, but better than failing)
mtime = dt.datetime.fromtimestamp(md_path.stat().st_mtime, tz=dt.timezone.utc).date().isoformat()
return mtime
def load_tasks() -> List[TaskMeta]:
if not TASKS_DIR.exists():
raise FileNotFoundError(f"Tasks directory not found: {TASKS_DIR}")
md_files = sorted(TASKS_DIR.glob("*.md"))
tasks: List[TaskMeta] = []
seen_ids: Dict[str, Path] = {}
for f in md_files:
meta, content_start = read_front_matter(f)
task_id = normalize_id(f, meta)
if task_id in seen_ids:
raise ValueError(f"Duplicate task id '{task_id}': {seen_ids[task_id]} and {f}")
seen_ids[task_id] = f
title = normalize_title(f, meta, content_start)
status = normalize_status(f, meta)
created = normalize_created(f, meta)
tasks.append(TaskMeta(file=f, id=task_id, title=title, status=status, created=created))
# Sort by numeric id if possible
def sort_key(t: TaskMeta):
try:
return (0, int(t.id))
except ValueError:
return (1, t.id)
return sorted(tasks, key=sort_key)
def render_index(tasks: List[TaskMeta]) -> str:
lines: List[str] = []
lines.append("# Spec Tasks Index")
lines.append("")
lines.append("> ⚠️ This file is generated. Do not edit manually.")
lines.append("")
lines.append("## Tasks")
lines.append("")
lines.append("| ID | Status | Created | Title | File |")
lines.append("|---:|:------:|:-------:|:------|:-----|")
for t in tasks:
rel = t.file.relative_to(ROOT).as_posix()
title = t.title.replace("|", "\\|")
lines.append(f"| {t.id} | {t.status} | {t.created} | {title} | `{rel}` |")
lines.append("")
lines.append("## Summary")
lines.append("")
todo = sum(1 for t in tasks if t.status == "TODO")
done = sum(1 for t in tasks if t.status == "DONE")
lines.append(f"- Total: **{len(tasks)}**")
lines.append(f"- TODO: **{todo}**")
lines.append(f"- DONE: **{done}**")
lines.append("")
return "\n".join(lines)
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--check", action="store_true", help="fail if spec/index.md is not up-to-date")
args = parser.parse_args()
tasks = load_tasks()
content = render_index(tasks)
if args.check:
existing = INDEX_PATH.read_text(encoding="utf-8") if INDEX_PATH.exists() else ""
if existing != content:
print("spec/index.md is not up-to-date. Run: python3 spec/gen_spec_index.py", file=sys.stderr)
return 2
return 0
SPEC_DIR.mkdir(parents=True, exist_ok=True)
INDEX_PATH.write_text(content, encoding="utf-8")
print(f"Wrote {INDEX_PATH.relative_to(ROOT)} ({len(tasks)} tasks)")
return 0
if __name__ == "__main__":
raise SystemExit(main())
-53
View File
@@ -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**
-34
View File
@@ -1,34 +0,0 @@
---
id: 000
title: Task format reference
status: DONE
created: 2026-02-01
---
## Context
В репозитории хранится эталонная задача, которая показывает обязательный формат задач.
## Goal
Дать каноничный пример минимального формата задачи, обязательного для всех файлов в `spec/tasks/`.
## Constraints
- Содержать только требования к формату, без продуктовой реализации.
- Оставаться совместимой с обязательной структурой задач Planner.
- Не добавлять требований к реализации.
## Acceptance criteria
- Front matter содержит `id`, `title`, `status`, `created`.
- Все обязательные секции присутствуют в требуемом порядке.
- Содержимое остаётся эталонной, неисполняемой задачей.
## Definition of Done
- [x] Front matter использует обязательные поля.
- [x] Обязательные секции присутствуют.
- [x] Файл пригоден только как reference формата.
## Tests
- Runtime-тесты не требуются.
- Формат валидируется успешной генерацией spec index.
## Commands
- `python3 spec/gen_spec_index.py --check`
@@ -1,38 +0,0 @@
---
id: 001
title: Create app skeleton and component configuration
status: DONE
created: 2026-03-07
---
## Context
Сейчас в репозитории есть только specification-файлы, при этом `spec/overview.md` задаёт конкретную структуру приложения и требования к конфигурации компонентов.
## Goal
Создать начальный `app/` skeleton и модели конфигурации из project overview, включая отдельные секции конфигурации для Controller, Service, Business Logic, Repository, Adapter, Observability и Alerts.
## Constraints
- Соблюдать layered architecture из `AGENTS.md`.
- Scope задачи: только scaffolding и configuration, без business workflows.
- Не реализовывать provider HTTP calls, cache behavior или aggregation logic в рамках этой задачи.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Структура `app/` создана согласно `spec/overview.md`.
- `app/config.py` содержит отдельные component configuration sections. Config must be a .yaml-file added to .gitignore.
- FastAPI entrypoint существует и успешно импортирует configuration.
- В файлах Controller, Service, Repository и Adapter отсутствуют business rules.
## Definition of Done
- [ ] Созданы обязательные директории `app/` и базовые файлы.
- [ ] Реализованы раздельные секции конфигурации по компонентам.
- [ ] Проходит app import smoke test.
- [ ] Изменения остаются строго в scope задачи.
## Tests
- Добавить/import smoke test для FastAPI app startup.
- Добавить unit tests, проверяющие загрузку configuration sections из environment variables.
## Commands
- `poetry run pytest tests/smoke/test_app_import.py -q`
- `poetry run pytest tests/config/test_config_sections.py -q`
@@ -1,37 +0,0 @@
---
id: 002
title: Implement pure domain price rules
status: DONE
created: 2026-03-07
---
## Context
`spec/overview.md` требует business rules для фильтрации тарифов, сравнения цен, сортировки и нормализации входных данных в чистом слое Business Logic.
## Goal
Реализовать детерминированные, dependency-free domain functions в `app/domain/price.py` для нормализации запроса и фильтрации/упорядочивания котировок.
## Constraints
- Business Logic должна быть pure и dependency-free.
- Запрещены прямые IO, adapters, repositories, framework imports и внешние вызовы.
- Слои Service и Controller не должны забирать domain rules из этой задачи.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Domain functions нормализуют значения запроса по явным детерминированным правилам.
- Domain functions отфильтровывают невалидные котировки по определённым domain conditions.
- Domain functions сортируют валидные котировки по возрастанию цены.
- Логика реализована только в `app/domain/` и доступна для вызова из Service.
## Definition of Done
- [ ] Реализованы pure domain functions для normalization/filtering/sorting.
- [ ] Поведение функций детерминированное и без side effects.
- [ ] В domain module нет импортов не-domain зависимостей.
- [ ] Unit tests (без mocks) покрывают стандартные и edge cases.
## Tests
- Добавить `tests/domain/test_price.py` только с unit tests.
- Покрыть edge cases: пустой input, невалидные price, одинаковые цены, границы нормализации.
## Commands
- `poetry run pytest tests/domain/test_price.py -q`
@@ -1,43 +0,0 @@
---
id: 003
title: Add CDEK provider adapter
status: DONE
created: 2026-03-07
---
## Context
Phase 1 в `spec/overview.md` требует поддержку provider CDEK с OAuth2 authentication и маппингом расчёта тарифа.
## Goal
Реализовать adapter interfaces и модули CDEK adapter: provider contract, OAuth2 token handling, HTTP client behavior, преобразование ответа в `DeliveryPrice` и чтение параметров CDEK из секции `adapter` YAML-конфига.
## Constraints
- Только Adapter layer; без business decisions и sorting logic.
- Внешний IO должен оставаться внутри adapter modules.
- Timeout для CDEK должен быть 10 секунд согласно specification.
- Параметры CDEK (`base_url`, OAuth2 credentials, retry policy, timeout, cache TTL) должны приходить из секции `adapter` в `.yaml` конфиге.
- Запрещён хардкод credentials и runtime-параметров адаптера.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Существует интерфейс `DeliveryProvider` со стабильным контрактом `get_price()`.
- Модуль CDEK auth получает OAuth2 token через client credentials и переиспользует валидный token до истечения.
- CDEK client выполняет tariff request с timeout и retry behavior.
- CDEK mapper преобразует provider response в унифицированную schema `DeliveryPrice`.
- Adapter наружу предоставляет только provider-facing API, необходимый Service.
- Адаптер использует значения из секции `adapter` YAML-конфига, включая `timeout_seconds=10` и `cache_ttl_seconds=900` по умолчанию.
## Definition of Done
- [ ] Реализован base provider interface.
- [ ] Реализованы модули CDEK auth/client/mapper.
- [ ] CDEK adapter возвращает унифицированную price model при успешном ответе.
- [ ] Adapter tests покрывают auth refresh, timeout/retry и mapping cases.
## Tests
- Добавить adapter unit tests для token lifecycle behavior.
- Добавить adapter tests для response mapping и HTTP error handling.
- Добавить tests, проверяющие применение параметров CDEK из секции `adapter` YAML-конфига.
- Для внешних HTTP взаимодействий использовать stubs/mocks.
## Commands
- `poetry run pytest tests/adapters/delivery_providers/cdek -q`
@@ -1,40 +0,0 @@
---
id: 004
title: Add Redis price cache repository
status: DONE
created: 2026-03-07
---
## Context
Product requirements требуют кеширование ответов provider с configurable TTL, а архитектура закрепляет доступ к данным за слоем Repository.
## Goal
Реализовать `PriceCache` repository на Redis с операциями `get`, `set` и `invalidate`, включая поддержку TTL из секции `repository` YAML-конфига.
## Constraints
- Только Repository layer: data access и persistence mapping.
- В repository code не допускаются business decisions и workflow orchestration.
- Генерация cache key не должна реализовываться в repository.
- Параметры Redis и TTL должны читаться из секции `repository` в `.yaml` конфиге.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Repository module существует по пути `app/repositories/cache/redis_cache.py`.
- `PriceCache` предоставляет async методы `get`, `set`, `invalidate`.
- `set` применяет TTL из configuration.
- Serialization/deserialization cached payload выполняются детерминированно.
- Repository использует только параметры секции `repository` YAML-конфига для подключения к Redis и настройки TTL.
## Definition of Done
- [ ] Реализован Redis-backed cache repository.
- [ ] Интерфейс repository соответствует требуемым операциям.
- [ ] Поведение TTL покрыто тестами.
- [ ] В repository отсутствует business logic.
## Tests
- Добавить repository tests для cache hit/miss, set/get roundtrip, invalidate и TTL expiration.
- Добавить tests, проверяющие чтение TTL и Redis connection settings из секции `repository` YAML-конфига.
- Использовать Redis test double или изолированный test Redis instance.
## Commands
- `poetry run pytest tests/repositories/cache/test_redis_cache.py -q`
@@ -1,39 +0,0 @@
---
id: 005
title: Implement aggregator service workflow
status: DONE
created: 2026-03-07
---
## Context
`spec/overview.md` определяет `AggregatorService.get_all_prices()` как application orchestrator для параллельных provider calls, graceful degradation, caching и отсортированного унифицированного результата.
## Goal
Реализовать `AggregatorService.get_all_prices(request: DeliveryRequest) -> list[DeliveryPrice]` в `app/services/aggregator.py` с параллельным выполнением, cache coordination и делегированием business rules в domain functions.
## Constraints
- Service выполняет только orchestration; pure business rules остаются в Business Logic.
- Использовать dependency injection для providers и repository.
- Для вызовов providers использовать `asyncio.gather(..., return_exceptions=True)`.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Service вызывает все зарегистрированные providers параллельно.
- Failures/timeouts отдельных providers не валят весь запрос; упавшие providers исключаются.
- Cache проверяется до внешнего provider request и обновляется после успешного provider response.
- Фильтрация/сортировка котировок делегируется domain functions.
- Возвращаемый список котировок унифицирован и отсортирован по цене по возрастанию.
## Definition of Done
- [ ] Метод Service реализован с injected dependencies.
- [ ] Параллельная orchestration использует `return_exceptions=True`.
- [ ] Реализована cache coordination для provider responses.
- [ ] Service tests покрывают full-success, partial-failure, all-failure и cache-hit сценарии.
## Tests
- Добавить service tests со stubs/mocks для adapters и repository.
- Проверить orchestration и исключение неуспешных providers.
- Проверить делегирование sorting/filtering в domain logic.
## Commands
- `poetry run pytest tests/services/test_aggregator.py -q`
@@ -1,37 +0,0 @@
---
id: 006
title: Add delivery price controller endpoint
status: DONE
created: 2026-03-07
---
## Context
API contract требует endpoint `POST /api/v1/delivery/price` с DTO validation и HTTP error mapping, при этом Controller должен вызывать ровно один метод Service.
## Goal
Реализовать request/response schemas и endpoint в `app/controllers/v1/delivery.py`, который валидирует входные данные, делегирует в `AggregatorService.get_all_prices()` и маппит service exceptions в HTTP responses.
## Constraints
- Controller не должен содержать business logic или provider-specific branching.
- На каждый запрос Controller должен вызывать ровно один метод Service.
- Validation должна использовать Pydantic models из schema layer.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- `POST /api/v1/delivery/price` принимает payload `DeliveryRequest` и возвращает `list[DeliveryPrice]`.
- Невалидный input возвращает validation error response.
- Controller делегирует обработку в `AggregatorService.get_all_prices()`.
- Service exceptions маппятся в детерминированные HTTP responses.
## Definition of Done
- [ ] Реализованы request и response schemas.
- [ ] Controller endpoint подключён в FastAPI router.
- [ ] Path и HTTP method endpoint соответствуют specification.
- [ ] API tests покрывают успешный ответ, validation failure и mapped service error.
## Tests
- Добавить controller tests для поведения route и делегирования в service.
- Добавить API-level tests для валидации request schema и response schema.
## Commands
- `poetry run pytest tests/controllers/v1/test_delivery.py -q`
@@ -1,47 +0,0 @@
---
id: 007
title: Add observability correlation and telemetry
status: DONE
created: 2026-03-07
---
## Context
Проект требует связать traces, metrics и logs через request correlation (`request_id` и `trace_id`) и добавить manual spans вокруг ключевых операций.
## Goal
Реализовать observability wiring: middleware для `request_id`, propagation structured logging context с корреляцией по `trace_id`, OpenTelemetry instrumentation для FastAPI/httpx, manual spans для операций service/provider/cache и метрики из `spec/overview.md`.
## Constraints
- Все observability concerns держать вне Business Logic.
- При добавлении telemetry не вводить business decision-making.
- Инструментировать только слои и операции, указанные в `spec/overview.md`.
- Конфигурация telemetry должна читаться из секции `observability` в `.yaml` конфиге.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Middleware назначает UUID `request_id` для каждого запроса.
- `request_id` добавляется в structured logs через contextvars.
- Логи коррелируются с трассировкой через `trace_id`.
- Включена OpenTelemetry instrumentation для FastAPI и httpx.
- Есть manual spans для `AggregatorService.get_all_prices`, provider `get_price` и cache operations.
- Span attributes включают `provider`, `from_city`, `to_city`, `weight_kg`, `cache_hit`, `tariffs_found` там, где применимо.
- Реализованы и экспортируются метрики: количество запросов, количество ошибок, latency (включая p50/p99), доступность провайдеров.
## Definition of Done
- [ ] Реализован middleware корреляции запросов.
- [ ] В logging context есть binding `request_id`.
- [ ] Реализована автоматическая и manual tracing instrumentation.
- [ ] Реализованы метрики, соответствующие overview.
- [ ] Observability tests проверяют создание spans, обязательные attributes и метрики.
## Tests
- Добавить middleware tests для генерации и propagation `request_id`.
- Добавить telemetry tests с in-memory span exporter для проверки names/attributes.
- Добавить logging tests, проверяющие наличие `request_id` в log context.
- Добавить tests для проверки экспорта метрик (request count, error count, latency, provider availability).
## Commands
- `poetry run pytest tests/controllers/test_middleware_request_id.py -q`
- `poetry run pytest tests/observability/test_tracing.py -q`
- `poetry run pytest tests/observability/test_logging_context.py -q`
- `poetry run pytest tests/observability/test_metrics.py -q`
@@ -1,41 +0,0 @@
---
id: 008
title: Add SigNoz to Telegram alerting configuration
status: DONE
created: 2026-03-07
---
## Context
`spec/overview.md` требует anomaly alerting через цепочку `SigNoz Alert Rules -> Webhook -> Telegram Bot API` с вынесением token в configuration.
## Goal
Добавить configuration и infrastructure artifacts, необходимые для маршрутизации anomaly alerts из SigNoz в Telegram и для соответствия alert thresholds требованиям проекта через секцию `alerts` в `.yaml` конфиге.
## Constraints
- Alert transport/configuration должны быть отделены от business logic.
- Telegram credentials должны приходить из секции `alerts` YAML-конфига и не быть hardcoded.
- Thresholds должны соответствовать alert conditions из `spec/overview.md`.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Configuration содержит отдельную секцию `alerts` в `.yaml` конфиге с Telegram settings.
- Infrastructure artifact(s) описывают routing от SigNoz alert webhook к Telegram Bot API.
- Представлены alert conditions для:
- provider 5xx: 5 ошибок за 5 минут
- provider p99 latency > 5000ms в течение 10 минут
- provider unavailable более 5 минут
- Setup instructions содержат пример секции `alerts` YAML-конфига и шаги верификации.
## Definition of Done
- [ ] Реализована schema конфигурации Alerts.
- [ ] Добавлены artifact(s) для маршрутизации SigNoz-to-Telegram.
- [ ] Определены все три обязательных anomaly conditions.
- [ ] Инструкции валидации исполнимы в local environment.
## Tests
- Добавить config tests для проверки загрузки alert settings из секции `alerts` YAML-конфига и обязательных полей.
- Добавить tests для alert payload transformation/transport logic, если он реализован в коде.
## Commands
- `poetry run pytest tests/config/test_alerts_config.py -q`
- `docker compose config`
-39
View File
@@ -1,39 +0,0 @@
---
id: 009
title: Add local infrastructure stack
status: DONE
created: 2026-03-07
---
## Context
Overview задаёт локальные infrastructure components `app`, `redis` и `signoz`, необходимые для runtime и observability.
## Goal
Добавить Docker Compose infrastructure definitions для local development, включая application service, Redis cache и SigNoz observability backend.
## Constraints
- Scope задачи: только infrastructure wiring.
- Не переносить business logic в infrastructure scripts.
- Имена services должны соответствовать overview: `app`, `redis`, `signoz`.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Compose file определяет services `app`, `redis`, `signoz`.
- Service `app` содержит environment wiring для cache и observability endpoints.
- Service `redis` достижим из `app` во внутренней сети.
- Service `signoz` достижим для telemetry export при локальном запуске.
## Definition of Done
- [ ] Добавлен compose definition с обязательными services.
- [ ] Задокументированы environment variables для локальной интеграции.
- [ ] `docker compose config` проходит валидацию.
- [ ] Добавлены базовые startup instructions.
## Tests
- Добавить/обновить infrastructure checks для валидации compose syntax.
- Добавить smoke sequence команд для проверки локального запуска.
## Commands
- `docker compose config`
- `docker compose up -d redis signoz`
- `docker compose ps`
@@ -1,39 +0,0 @@
---
id: 010
title: Migrate to YAML-only configuration loading
status: DONE
created: 2026-03-08
---
## Context
В проекте используется смешанный подход конфигурации с переменными окружения. Требуется унифицировать источник конфигурации и оставить только `config.yaml`.
## Goal
Убрать использование переменных окружения из runtime-конфигурации приложения и перевести загрузку всех параметров на чтение из `config.yaml`.
## Constraints
- Соблюдать layered architecture из `AGENTS.md`.
- Scope задачи: только механизм загрузки конфигурации и места её потребления.
- Запрещено менять бизнес-правила и поведение use-case, не связанное с конфигурацией.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Runtime-конфигурация приложения загружается только из `config.yaml`.
- Переменные окружения не используются для чтения параметров Controller, Service, Business Logic, Repository, Adapter, Observability и Alerts.
- При отсутствии обязательных полей в `config.yaml` приложение завершает запуск с детерминированной ошибкой валидации конфигурации.
- Тесты конфигурации больше не опираются на `monkeypatch` переменных окружения и проверяют чтение из YAML.
## Definition of Done
- [ ] Удалено чтение переменных окружения из конфигурационного слоя.
- [ ] Все компонентные секции конфигурации продолжают читаться из `config.yaml`.
- [ ] Обновлены тесты конфигурации под YAML-only поведение.
- [ ] Smoke test старта приложения проходит с валидным `config.yaml`.
## Tests
- Обновить `tests/config/test_config_sections.py` под проверку YAML-only загрузки.
- Добавить/обновить негативные тесты отсутствующих обязательных YAML-полей.
- Проверить app startup smoke test с валидным `config.yaml`.
## Commands
- `poetry run pytest tests/config/test_config_sections.py -q`
- `poetry run pytest tests/smoke/test_app_import.py -q`
@@ -1,41 +0,0 @@
---
id: 011
title: Migrate Redis repository client to aioredis
status: DONE
created: 2026-03-08
---
## Context
Текущая реализация кеш-репозитория использует клиент `redis`. Требуется перейти на `aioredis` для асинхронного доступа к Redis без изменения бизнес-поведения приложения.
## Goal
Перевести слой Repository (`PriceCache`) и связанные точки инициализации/конфигурации на `aioredis`, сохранив существующий контракт репозитория и runtime-поведение кеша.
## Constraints
- Изменения только в слоях Repository и инициализации зависимостей, необходимых для подключения клиента.
- Не изменять бизнес-правила, Controller/API-контракты и оркестрацию Service.
- SQL/внешний IO вне Repository и Adapter не добавлять.
- Публичный интерфейс `PriceCache` (`get`, `set`, `invalidate`) должен остаться совместимым.
- Не изменять файлы в `spec/` кроме этой задачи и сгенерированного `spec/index.md`.
## Acceptance criteria
- В кодовой базе для Redis repository используется `aioredis` вместо `redis`.
- Инициализация Redis-клиента и операции `get/set/invalidate` работают асинхронно через `aioredis`.
- Поведение TTL и сериализации кеша не изменено относительно текущего контракта.
- Конфигурация подключения к Redis продолжает читаться из секции `repository` YAML-конфига.
- Ошибки подключения/операций Redis обрабатываются детерминированно в рамках текущего repository-контракта.
## Definition of Done
- [ ] Обновлены зависимости проекта для использования `aioredis`.
- [ ] Репозиторий кеша переведен на `aioredis` без изменения публичного интерфейса.
- [ ] Обновлены/добавлены тесты репозитория для нового клиента.
- [ ] Все релевантные тесты проходят.
## Tests
- Обновить `tests/repositories/cache/test_redis_cache.py` под использование `aioredis`.
- Добавить/обновить тесты на cache hit/miss, `set/get` roundtrip, `invalidate`, TTL expiration.
- Добавить негативный тест на ошибку Redis-клиента при операции чтения или записи.
## Commands
- `poetry run pytest tests/repositories/cache/test_redis_cache.py -q`
- `poetry run pytest tests/services/test_aggregator.py -q`
@@ -1,47 +0,0 @@
---
id: 012
title: Remove observability and SigNoz stack for phase 1
status: DONE
created: 2026-03-08
---
## Context
На первом этапе принято решение отказаться от логирования, мониторинга, алертинга и инфраструктуры SigNoz. Текущая кодовая база и тесты содержат эти зависимости и сценарии.
## Goal
Удалить из проекта все runtime- и test-артефакты, связанные с логами, мониторингом, алертами, Telegram alerting и SigNoz, сохранив рабочий API расчета доставки и кеширование.
## Constraints
- Соблюдать layered architecture из `AGENTS.md`; не переносить бизнес-правила между слоями.
- Scope задачи ограничен удалением observability/alerting/SigNoz и зависимых конфигураций, инфраструктурных и тестовых артефактов.
- Не изменять бизнес-логику расчета тарифов, provider integration и API-контракт `POST /api/v1/delivery/price`.
- Не добавлять новую систему мониторинга или алертинга в рамках этой задачи.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Из runtime-приложения удалены middleware, instrumentation и иные механизмы, реализующие request/log correlation, tracing, metrics и alerting.
- В коде и конфигурации приложения отсутствуют секции и параметры `observability` и `alerts`, связанные с OpenTelemetry, structlog, SigNoz и Telegram.
- Из инфраструктурных файлов удалены сервис/настройки SigNoz и маршрутизация алертов.
- Удалены или обновлены тесты observability/alerts; тестовый набор для контроллера, сервиса, репозитория и конфигурации остается зеленым.
- По файлам приложения и инфраструктуры нет упоминаний `signoz`, `opentelemetry`, `structlog`, `telegram`.
## Definition of Done
- [ ] Удалены runtime-компоненты observability/alerting.
- [ ] Обновлены YAML-конфиги и config schemas без секций observability/alerts.
- [ ] Обновлены инфраструктурные файлы без SigNoz.
- [ ] Удалены/обновлены тесты observability и alerts.
- [ ] Пройдены все команды из раздела Commands.
## Tests
- Обновить `tests/config/test_config_sections.py` под конфигурацию без observability/alerts.
- Удалить или заменить `tests/config/test_alerts_config.py` и `tests/observability/*` в соответствии с новым scope.
- Обновить `tests/smoke/test_local_infra_stack.py` под инфраструктуру без SigNoz.
- Подтвердить, что `tests/controllers/v1/test_delivery.py`, `tests/services/test_aggregator.py`, `tests/repositories/cache/test_redis_cache.py` проходят без observability-зависимостей.
## Commands
- `poetry run pytest tests/controllers/v1/test_delivery.py -q`
- `poetry run pytest tests/services/test_aggregator.py -q`
- `poetry run pytest tests/repositories/cache/test_redis_cache.py -q`
- `poetry run pytest tests/config/test_config_sections.py -q`
- `poetry run pytest tests/smoke/test_local_infra_stack.py -q`
- `! rg -n "(signoz|opentelemetry|structlog|telegram)" app tests docker-compose.yml pyproject.toml config.yaml config.example.yaml config.test.yaml infra`
@@ -1,47 +0,0 @@
---
id: 013
title: Add configurable provider price multiplier in domain logic
status: DONE
created: 2026-03-09
---
## Context
Сейчас `app/domain/price.py` валидирует и сортирует тарифы провайдеров без дополнительной постобработки цены. Появилось новое требование: перед возвратом результата применять к цене провайдера конфигурируемый multiplier из YAML-конфига и округлять итог до целого числа.
## Goal
Добавить в Business Logic детерминированное правило пересчёта `DeliveryPrice.price` через multiplier из секции `business_logic` и провести это значение через конфигурацию и service orchestration без переноса формулы в Service или Adapter.
## Constraints
- Соблюдать layered architecture из `AGENTS.md`.
- Формула умножения и округления должна жить только в `app/domain/`; Service может только передавать нужный параметр и делегировать вызов.
- Значение multiplier должно читаться из YAML-конфига через секцию `business_logic`.
- После применения multiplier цена должна оставаться `Decimal`, но иметь целочисленное значение.
- Правило округления должно быть явным и детерминированным: до `Decimal("1")` c `ROUND_HALF_UP`.
- Не изменять provider adapters, внешние HTTP-контракты и формат ответа кроме нового значения `price`.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- В `business_logic` configuration section добавлено обязательное поле для multiplier цены провайдера с валидацией положительного значения.
- Domain functions применяют multiplier к каждой валидной цене провайдера до сортировки результата.
- Итоговая цена после умножения округляется до целого значения по правилу `ROUND_HALF_UP`.
- Service не содержит формулу расчёта и по-прежнему делегирует обработку цен в domain logic.
- Поведение одинаково для свежих ответов провайдера и для cache hit: в обоих случаях наружу возвращается уже пересчитанная цена.
## Definition of Done
- [ ] Обновлена конфигурация `business_logic` и YAML fixtures/example configs для multiplier.
- [ ] Реализована pure domain logic для пересчёта и округления цены.
- [ ] Service wiring передаёт multiplier в domain logic без добавления business rules в Service.
- [ ] Unit/service/config tests покрывают стандартные и edge-case сценарии.
## Tests
- Обновить `tests/domain/test_price.py` для проверки multiplier, округления `ROUND_HALF_UP`, пустого input и невалидных цен.
- Обновить `tests/services/test_aggregator.py` для проверки делегирования multiplier и одинакового поведения fresh/cache hit.
- Обновить `tests/config/test_config_sections.py` для проверки чтения multiplier из YAML и валидации невалидных значений.
- При необходимости обновить `tests/smoke/test_app_import.py` под обязательное новое поле конфигурации.
## Commands
- `poetry run pytest tests/domain/test_price.py -q`
- `poetry run pytest tests/services/test_aggregator.py -q`
- `poetry run pytest tests/config/test_config_sections.py -q`
- `poetry run pytest tests/smoke/test_app_import.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -1,44 +0,0 @@
---
id: 014
title: Add minimal structlog JSON logging
status: DONE
created: 2026-03-12
---
## Context
После удаления observability stack приложение осталось без централизованной конфигурации логирования. При этом в runtime-коде есть локальная настройка `logging.basicConfig(...)`, а новое требование состоит в том, чтобы все runtime-логи приложения выводились в JSON без возврата tracing, metrics и alerting.
## Goal
Подключить минимальную централизованную настройку `structlog`, чтобы runtime-логи приложения и Uvicorn выводились как JSON-объекты, а прикладные модули использовали общий logging setup вместо локальной конфигурации.
## Constraints
- Соблюдать layered architecture из `AGENTS.md`.
- Scope задачи: только logging wiring, JSON formatting и интеграция существующих logger calls.
- `structlog` использовать только для JSON logging; не добавлять `request_id`, `trace_id`, OpenTelemetry, metrics, SigNoz, Telegram и иные observability features.
- Конфигурация логирования должна быть централизована на уровне app startup или отдельного runtime-модуля; в Controller, Service, Repository, Adapter и Business Logic запрещено вызывать `logging.basicConfig(...)`.
- Не изменять API contract, business rules, provider protocol, cache behavior и логику обработки ошибок.
- используй context7, чтобы узнать контракт текущей версии пакета structlog
## Acceptance criteria
- При старте приложения выполняется единая инициализация logging на базе `structlog`.
- Логи приложения и `uvicorn.error`/`uvicorn.access` сериализуются в JSON, одна запись на строку.
- Каждая лог-запись содержит как минимум поля `event`, `level`, `timestamp` и `logger`.
- В прикладных модулях отсутствуют локальные вызовы `logging.basicConfig(...)` и текстовые formatter-конфигурации для runtime logging.
- Добавлен тест, который валидирует emitted log line как корректный JSON и проверяет обязательные поля.
## Definition of Done
- [ ] Добавлена централизованная конфигурация JSON logging на базе `structlog`.
- [ ] Удалены локальные настройки logging из прикладных модулей.
- [ ] `uvicorn.error` и `uvicorn.access` подключены к тому же JSON logging setup.
- [ ] Добавлены или обновлены тесты для logging bootstrap и JSON serialization.
- [ ] Пройдены все команды из раздела Commands.
## Tests
- Добавить `tests/logging/test_json_logging.py` для проверки JSON serialization и обязательных полей лог-записи.
- Обновить `tests/smoke/test_app_import.py` для проверки вызова централизованного logging bootstrap при создании app.
- При изменении конфигурации Uvicorn loggers добавить тест на `uvicorn.error` и `uvicorn.access` без запуска реального сервера.
## Commands
- `poetry run pytest tests/logging/test_json_logging.py -q`
- `poetry run pytest tests/smoke/test_app_import.py -q`
- `poetry run pytest tests/adapters/delivery_providers/cdek/test_client.py -q`
@@ -1,50 +0,0 @@
---
id: 015
title: Add minimal OpenTelemetry tracing
status: DONE
created: 2026-03-13
---
## Context
После удаления observability stack в задаче `012` runtime-приложение осталось без трассировки. Появилось новое требование: вернуть только минимальный tracing для входящих HTTP-запросов и исходящих вызовов провайдера/Redis, без возврата metrics, alerting и trace/log correlation.
## Goal
Подключить минимальный OpenTelemetry tracing через централизованный runtime bootstrap: настроить `TracerProvider` и OTLP exporter из YAML-конфига, добавить instrumentation для FastAPI, httpx и Redis и встроить этот bootstrap в startup приложения без переноса telemetry concern в Business Logic.
## Constraints
- Соблюдать layered architecture из `AGENTS.md`.
- Scope задачи: только tracing bootstrap, YAML configuration, instrumentation wiring и тесты на это поведение.
- Не добавлять metrics, request correlation middleware, `request_id`, `trace_id` в логи, SigNoz, Telegram alerting и иные observability features кроме tracing.
- Не добавлять business rules, branching или data access в runtime tracing bootstrap.
- Инициализация OpenTelemetry должна жить в отдельном runtime-модуле или app startup; Controller, Service, Repository, Adapter и Business Logic не должны вручную создавать exporter/provider.
- Конфигурация tracing должна читаться из отдельной секции `observability` в YAML-файлах конфигурации.
- Bootstrap tracing должен быть идемпотентным: повторные вызовы `create_app()` в тестах не должны дублировать global instrumentation и span processors.
- Не изменять API contract, логику расчета тарифов, кэш semantics и provider protocol.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- В конфигурации приложения добавлена секция `observability` с параметрами включения tracing, `service_name`, OTLP endpoint и флагом insecure transport.
- При `observability.enabled = true` приложение на старте создает `TracerProvider` с `Resource(service.name=...)` и OTLP exporter, используя значения из YAML-конфига.
- При `observability.enabled = true` централизованно включается instrumentation для FastAPI, httpx и Redis.
- При `observability.enabled = false` приложение стартует без инициализации exporter/provider и без регистрации instrumentation.
- Повторное создание приложения или повторный вызов tracing bootstrap не приводит к duplicate instrumentation/span processor registration.
- Реализация не восстанавливает metrics, alerts, request/log correlation и не меняет JSON logging contract.
## Definition of Done
- [ ] Добавлена секция `observability` в config schema и runtime YAML-файлы.
- [ ] Реализован централизованный runtime bootstrap для OpenTelemetry tracing.
- [ ] `create_app()` подключает tracing bootstrap без бизнес-логики и без дублирования инициализации.
- [ ] Добавлены tests на конфигурацию, enabled/disabled режимы и идемпотентность tracing setup.
- [ ] Добавлен tracing test, подтверждающий создание spans для FastAPI/httpx/Redis instrumentation или корректный wiring этих instrumentors.
## Tests
- Обновить `tests/config/test_config_sections.py` для проверки секции `observability`, обязательных полей и disabled/enabled конфигурации.
- Обновить `tests/smoke/test_app_import.py` для проверки вызова централизованного tracing bootstrap при создании app.
- Добавить `tests/tracing/test_bootstrap.py` для проверки OTLP exporter/provider setup, `service.name`, enabled/disabled режимов и идемпотентности instrumentation.
- Добавить tracing test с in-memory exporter или эквивалентным deterministic harness для проверки, что FastAPI/httpx/Redis instrumentation корректно подключается без запуска внешнего OTLP collector.
## Commands
- `poetry run pytest tests/config/test_config_sections.py -q`
- `poetry run pytest tests/smoke/test_app_import.py -q`
- `poetry run pytest tests/tracing/test_bootstrap.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -1,43 +0,0 @@
---
id: 016
title: Add CDEK order registration adapter
status: DONE
created: 2026-03-14
---
## Context
Сейчас CDEK adapter поддерживает только расчёт тарифа. Новый сценарий требует регистрацию заказа в CDEK по контракту из `http-client.http` в разделе `Регистрация заказа (тип "доставка", до двери)`.
## Goal
Расширить существующий CDEK adapter регистрацией заказа: добавить в текущий adapter/provider сбор payload по контракту, вызов `POST /v2/orders`, детерминированную обработку provider errors и маппинг успешного ответа во внутреннюю response model.
## Constraints
- Изменения ограничены существующим Adapter layer и schema/mapper моделями, необходимыми для стабильного adapter contract.
- Использовать существующий OAuth2 flow CDEK; не дублировать auth logic.
- Контракт запроса должен соответствовать разделу `Регистрация заказа (тип "доставка", до двери)` из `http-client.http`.
- В scope этой задачи входят только значения `type=2` и `tariff_code=535`; не расширять поддержку на другие типы заказа и тарифы.
- Не добавлять новые controller/service модули, controller routing, service orchestration, cache behavior и business logic.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Существующий CDEK adapter/provider предоставляет стабильный метод регистрации заказа, принимающий валидированную internal model вместо raw dict.
- Adapter отправляет `POST /v2/orders` с bearer token и JSON payload, соответствующим контракту из `http-client.http`.
- Успешный ответ CDEK маппится во внутреннюю response model с `order_uuid`, полученным из `entity.uuid`.
- Provider validation errors класса 4xx маппятся в детерминированную provider request error, а transport/5xx ошибки — в adapter client error.
- Adapter tests покрывают success case, payload mapping, response mapping и error handling для order registration.
## Definition of Done
- [ ] Расширен существующий adapter method set для регистрации заказа CDEK.
- [ ] Реализованы request/response mapper(s) для order registration.
- [ ] Переиспользуется существующий OAuth2 auth client.
- [ ] Добавлены adapter tests для order registration сценария.
## Tests
- Добавить `tests/adapters/delivery_providers/cdek/test_order_client.py` для success/error сценариев регистрации заказа.
- Проверить, что payload содержит поля из контракта `http-client.http`.
- Проверить маппинг `entity.uuid` в внутреннюю response model.
- Для внешних HTTP взаимодействий использовать stubs/mocks.
## Commands
- `poetry run pytest tests/adapters/delivery_providers/cdek/test_order_client.py -q`
- `python3 spec/gen_spec_index.py --check`
-49
View File
@@ -1,49 +0,0 @@
---
id: 017
title: Return all tariffs from CDEK price calculation
status: DONE
created: 2026-03-14
---
## Context
Сейчас `POST /api/v1/delivery/price` для CDEK возвращает только один тариф, хотя продуктовый контракт требует возвращать унифицированный список тарифов. Текущая реализация adapter/service использует однокотировочный provider contract и CDEK mapper берёт только первый элемент `tariff_codes[0]`.
## Goal
Изменить price flow так, чтобы CDEK adapter возвращал все тарифы из успешного ответа CDEK, а service агрегировал и сортировал объединённый список тарифов без изменения архитектурных границ слоёв.
## Constraints
- Scope задачи ограничен расчётом стоимости доставки и CDEK tariff list response; не изменять order creation flow.
- Controller должен сохранить текущий публичный endpoint `POST /api/v1/delivery/price` и по-прежнему вызывать ровно один метод Service.
- Service остаётся orchestration layer: собирает тарифы от провайдеров, работает с cache и делегирует фильтрацию/сортировку в Business Logic.
- Pure business rules не переносить в Service или Adapter.
- Изменение должно затронуть provider contract так, чтобы один provider мог вернуть несколько тарифов в одном запросе.
- Scope задачи не включает добавление новых провайдеров, новых endpoint'ов и расширение response schema beyond текущего `DeliveryPrice`, если это не требуется для возврата всех тарифов.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Интерфейс `DeliveryProvider` поддерживает возврат списка тарифов для одного provider request.
- CDEK mapper преобразует все элементы `tariff_codes` из валидного ответа CDEK в список `DeliveryPrice`, а не только первый тариф.
- CDEK provider adapter возвращает service полный список тарифов, полученный из ответа CDEK.
- `AggregatorService.get_all_prices()` корректно обрабатывает список тарифов от каждого provider, объединяет их в единый список и передаёт его в domain filtering/sorting.
- Cache coordination в service поддерживает сохранение и чтение списка тарифов для provider request.
- `POST /api/v1/delivery/price` возвращает все тарифы CDEK в унифицированном формате, отсортированные по цене по возрастанию.
- Existing graceful degradation сохраняется: failure одного provider исключает только его тарифы и не ломает успешные результаты других providers.
## Definition of Done
- [ ] Обновлён provider contract для возврата списка тарифов.
- [ ] Обновлены CDEK mapper и adapter/client для многотарифного ответа.
- [ ] Обновлён `AggregatorService.get_all_prices()` для объединения и кеширования списков тарифов.
- [ ] Добавлены или обновлены tests для adapter, service и controller/API сценариев многотарифного ответа.
## Tests
- Обновить `tests/adapters/delivery_providers/cdek/test_mapper.py` для проверки mapping всех тарифов из `tariff_codes`.
- Обновить `tests/adapters/delivery_providers/cdek/test_client.py` для проверки, что provider возвращает список тарифов, а не один объект.
- Обновить `tests/services/test_aggregator.py` для flattening provider results, cache hit/miss со списком тарифов и partial failure сценариев.
- Обновить `tests/controllers/v1/test_delivery.py` для проверки, что API возвращает несколько тарифов CDEK в одном ответе.
## Commands
- `poetry run pytest tests/adapters/delivery_providers/cdek/test_mapper.py -q`
- `poetry run pytest tests/adapters/delivery_providers/cdek/test_client.py -q`
- `poetry run pytest tests/services/test_aggregator.py -q`
- `poetry run pytest tests/controllers/v1/test_delivery.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -1,49 +0,0 @@
---
id: 018
title: Add optional parcel type filter to price request
status: DONE
created: 2026-03-16
---
## Context
Сейчас `POST /api/v1/delivery/price` возвращает все доступные тарифы без возможности отфильтровать их по типу отправления. Новый пользовательский сценарий требует опциональный параметр `parcel_type`, который должен фильтровать уже агрегированный список тарифов для всех провайдеров.
## Goal
Расширить контракт `DeliveryRequest` опциональным полем `parcel_type` и добавить детерминированную фильтрацию тарифов в price flow по правилам `doc` и `parcel` без изменения публичного path endpoint.
## Constraints
- `parcel_type` является необязательным полем запроса `POST /api/v1/delivery/price`.
- Допустимые значения ограничены `doc` и `parcel`; другие значения должны отклоняться schema validation.
- Правило фильтрации является pure business rule и должно жить в `app/domain/`; Controller только валидирует DTO, Service только оркестрирует и передаёт параметр в domain logic.
- Фильтрация должна применяться к унифицированному списку тарифов для всех провайдеров и не должна требовать provider-specific branching или изменения provider transport contract.
- Для `doc` включаются только тарифы, у которых `service_name` содержит `документ` или `document` без учёта регистра.
- Для `parcel` возвращаются все остальные тарифы, не попавшие под правило `doc`.
- Если `parcel_type` не передан, поведение endpoint остаётся прежним: возвращаются все тарифы.
- Scope задачи не включает изменение order flow, добавление новых endpoint'ов, расширение набора значений `parcel_type` и иные изменения вне price flow.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- `DeliveryRequest` поддерживает опциональное поле `parcel_type`.
- `POST /api/v1/delivery/price` принимает запросы без `parcel_type` и возвращает полный список тарифов без дополнительной фильтрации.
- При `parcel_type=doc` ответ содержит только тарифы, у которых `service_name` содержит `документ` или `document` без учёта регистра.
- При `parcel_type=parcel` ответ содержит только тарифы, у которых `service_name` не содержит `документ` и `document` без учёта регистра.
- Невалидное значение `parcel_type` приводит к 422 response на уровне schema validation.
- Фильтрация работает одинаково для тарифов, полученных из provider responses и из cache.
## Definition of Done
- [ ] Обновлён контракт price request с опциональным `parcel_type`.
- [ ] Реализована pure domain logic для фильтрации тарифов по `parcel_type`.
- [ ] Service wiring передаёт `parcel_type` в domain logic без добавления business rules в Service.
- [ ] Controller/API tests покрывают сценарии `doc`, `parcel`, отсутствие параметра и невалидное значение.
- [ ] Поведение одинаково для fresh provider results и cache hit.
## Tests
- Обновить `tests/domain/test_price.py` для проверки case-insensitive фильтрации по `документ` и `document`, а также поведения без `parcel_type`.
- Обновить `tests/services/test_aggregator.py` для проверки делегирования `parcel_type` в domain logic и одинакового результата для fresh path и cache hit.
- Обновить `tests/controllers/v1/test_delivery.py` для проверки optional request field, 422 на невалидное значение и response filtering для `doc` и `parcel`.
## Commands
- `poetry run pytest tests/domain/test_price.py -q`
- `poetry run pytest tests/services/test_aggregator.py -q`
- `poetry run pytest tests/controllers/v1/test_delivery.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -1,53 +0,0 @@
---
id: 019
title: Add CDEK order creation endpoint
status: DONE
created: 2026-03-14
---
## Context
Сейчас order flow должен принимать уже готовые точные адреса в полях `from_location.address` и `to_location.address`. Для нового пользовательского сценария нужен отдельный endpoint `/order`, который создаёт заказ в CDEK по контракту из `http-client.http`, не выполняя address suggestion lookup внутри order flow.
## Goal
Добавить `POST /api/v1/delivery/order` в существующий controller и существующий service с request/response schemas и error mapping для регистрации заказа в CDEK через adapter contract из задачи `016`.
## Constraints
- Controller отвечает только за DTO validation, routing и mapping service exceptions в HTTP responses.
- Endpoint должен вызывать ровно один метод Service: `AggregatorService.create_order()`.
- Новый endpoint должен быть добавлен в существующий controller модуль `app/controllers/v1/delivery.py`; не создавать отдельный controller модуль.
- Логика создания заказа должна быть добавлена в существующий service модуль `app/services/aggregator.py`; не создавать отдельный service модуль.
- Service оркестрирует только вызов injected CDEK order adapter и не содержит business logic или provider HTTP-деталей.
- `from_location.address` и `to_location.address` считаются уже выбранными точными строками адреса; в рамках этой задачи запрещено добавлять address suggestion routing, внешние address lookup вызовы и нормализацию адреса.
- Контракт входного запроса должен соответствовать разделу `Регистрация заказа (тип "доставка", до двери)` из `http-client.http`.
- В scope задачи входят только значения `type=2` и `tariff_code=535`; не расширять поддержку на другие типы заказа и тарифы.
- Scope задачи не включает `POST /api/v1/delivery/suggest-address`, конфигурацию address suggestion providers, кеширование, агрегацию тарифов, расчёт стоимости, новые провайдеры и расширение order flow за пределы CDEK.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Существует endpoint `POST /api/v1/delivery/order`, принимающий payload по контракту из `http-client.http`.
- Реализованы request/response schemas `OrderCreateRequest` и `OrderCreateResponse` для создания заказа.
- Endpoint реализован в существующем controller `app/controllers/v1/delivery.py`.
- Controller делегирует обработку только в `AggregatorService.create_order()`.
- Service вызывает injected adapter для регистрации заказа и возвращает `OrderCreateResponse` с `provider` и `order_uuid`.
- Логика orchestration размещена в существующем service `app/services/aggregator.py`.
- Endpoint принимает значения `from_location.address` и `to_location.address` как opaque input strings и не выполняет address suggestion lookup перед вызовом adapter.
- Ошибки валидации входного payload возвращают 422, provider request errors маппятся в 400, недоступность CDEK и transport failures — в 503.
- API и service tests покрывают success case и основные failure scenarios.
## Definition of Done
- [ ] Добавлены order request/response schemas.
- [ ] Реализован метод `AggregatorService.create_order()` для оркестрации регистрации заказа.
- [ ] Реализован endpoint `POST /api/v1/delivery/order` в существующем controller.
- [ ] Добавлены service и controller/API tests для order creation flow.
## Tests
- Добавить `tests/services/test_order.py` для проверки вызова adapter и маппинга ошибок сервиса.
- Добавить `tests/controllers/v1/test_order.py` для success case, schema validation и HTTP mapping ошибок.
- При необходимости обновить `tests/smoke/test_app_import.py` для проверки подключения нового endpoint и service wiring без новых модулей.
- Использовать test doubles для adapter dependency.
## Commands
- `poetry run pytest tests/services/test_order.py -q`
- `poetry run pytest tests/controllers/v1/test_order.py -q`
- `poetry run pytest tests/smoke/test_app_import.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -1,58 +0,0 @@
---
id: 020
title: Rename price request model and use cities_map for CDEK codes
status: DONE
created: 2026-03-21
---
## Context
Текущий price flow принимает `DeliveryRequest` со строковыми `from_city` и `to_city`, а также использует внешний lookup города через `_resolve_city_code()` в CDEK client. Новый контракт API переходит на идентификаторы городов и локальный справочник `cities_map`, который хранит provider-specific данные города. Для этого изменения нужен только минимальный набор новых валидаций, необходимый для корректного price flow.
## Goal
Переименовать request model расчёта доставки в `DeliveryCalculationRequest`, перевести поля `from_city` и `to_city` на тип `int`, убрать `country_code` из price API и использовать `cities_map` как источник provider city codes для CDEK tariff request вместо `_resolve_city_code()`, добавив только минимально необходимые валидации для этого перехода.
## Constraints
- Scope задачи ограничен price calculation flow: request schema, controller/service wiring, domain normalization, cache key composition, provider contract и CDEK adapter/client.
- `POST /api/v1/delivery/order`, `OrderCreateRequest`, `OrderCreateResponse` и `country_code` внутри order payload не изменять.
- Controller не добавляет business rules; Service не реализует provider-specific lookup; pure validation и normalization остаются в Business Logic.
- Для расчёта CDEK использовать только локальный `cities_map`; внешний city lookup через CDEK API и `_resolve_city_code()` должен быть удалён из client.
- Не требовать полноты `cities_map`; отсутствие записи города или provider-specific данных должно обрабатываться детерминированно.
- Учитывать provider-specific структуру `cities_map`, но не реализовывать новых providers в рамках этой задачи.
- Новые проверки ограничить минимально необходимыми для работы контракта и CDEK lookup: тип city identifier во входной schema и наличие валидного `cdek.code` для используемого города.
- Не добавлять предварительную валидацию всего `cities_map`, проверку неиспользуемых provider sections, проверку `city_uuid`, `full_name`, `label`, `country` или иные дополнительные consistency-checks.
- Не добавлять новые range/business validations для city identifier сверх тех, что нужны для поиска города в `cities_map`.
- Не изменять файлы в `spec/`, кроме этой задачи, `spec/overview.md` и сгенерированного `spec/index.md`.
## Acceptance criteria
- Модель запроса расчёта переименована в `DeliveryCalculationRequest`; controller, service и provider contract для price flow используют новое имя.
- Поля `from_city` и `to_city` в price request принимают целочисленные идентификаторы города; поле `country_code` отсутствует в публичном API расчёта доставки.
- Domain normalization и service cache key больше не используют `country_code` и корректно работают с city identifiers.
- Новые валидации, добавленные этой задачей, ограничены:
- schema-level проверкой, что `from_city` и `to_city` передаются как `int`
- runtime-проверкой, что для конкретного city identifier, использованного в CDEK price flow, существует валидный `cdek.code`
- CDEK client строит payload `from_location.code` и `to_location.code` на основе `cities_map` и значений `cdek.code` для переданных city identifiers.
- Если city identifier отсутствует в `cities_map` или для него нет валидного `cdek.code`, CDEK price flow завершается детерминированной provider request error без внешнего city lookup.
- Метод `_resolve_city_code()` и связанный HTTP lookup `location/suggest/cities` удалены из CDEK client; price flow не обращается к CDEK API подсказок городов.
- Existing order creation flow и order schemas остаются без изменения.
## Definition of Done
- [ ] Обновлены request schema, controller/service/provider type hints и связанные импорты для нового имени `DeliveryCalculationRequest`.
- [ ] Удалён `country_code` из price calculation contract, normalization и cache key.
- [ ] Реализован lookup CDEK city codes через `cities_map` без внешнего city suggest API.
- [ ] Удалён `_resolve_city_code()` и обновлены adapter tests под новое поведение.
- [ ] Добавлены или обновлены tests только для минимально необходимой schema validation, service wiring, cache key и CDEK payload mapping/error scenarios.
## Tests
- Обновить `tests/domain/test_price.py` только для проверки, что normalizer корректно принимает и сохраняет city identifiers без `country_code`.
- Обновить `tests/services/test_aggregator.py` для проверки нового request model, отсутствия `country_code` в service flow и обновлённого cache key.
- Обновить `tests/controllers/v1/test_delivery.py` только для проверки API schema с `from_city`/`to_city` типа `int` и 422 на невалидные значения типа.
- Обновить `tests/adapters/delivery_providers/cdek/test_client.py` для проверки построения payload по `cities_map`, ошибок при отсутствии `cdek.code` или city entry и отсутствия city lookup request.
- При необходимости обновить `tests/smoke/test_app_import.py` только для совместимости нового request model с app wiring.
## Commands
- `poetry run pytest tests/domain/test_price.py -q`
- `poetry run pytest tests/services/test_aggregator.py -q`
- `poetry run pytest tests/controllers/v1/test_delivery.py -q`
- `poetry run pytest tests/adapters/delivery_providers/cdek/test_client.py -q`
- `poetry run pytest tests/smoke/test_app_import.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -1,45 +0,0 @@
---
id: 021
title: Add address suggestion adapter and country provider mapping
status: DONE
created: 2026-03-25
---
## Context
Перед созданием заказа клиенту нужно получить точное значение адреса для полей `from_location.address` и `to_location.address`. Для этого нужен отдельный address suggestion flow с внешним provider, а выбор provider должен определяться по `country_code` через YAML-конфиг.
## Goal
Добавить contract для address suggestion adapters, секцию конфигурации address suggestions с маппингом `country_code -> provider_id` и реализовать интеграцию с `dadata.ru` для стран, сопоставленных с provider id `dadata`.
## Constraints
- Изменения ограничены Adapter layer, config schema/loading и моделями, необходимыми для стабильного adapter contract.
- Внешний IO должен оставаться внутри address suggestion adapter modules.
- В scope задачи входит только интеграция с `dadata.ru`; concrete HTTP integration второго европейского provider не реализовывать в рамках этой задачи.
- Конфигурация должна читаться из `.yaml` и содержать отдельную секцию для address suggestion providers и country mapping.
- Adapter не должен содержать business decisions; он только отправляет запрос, маппит ответ и детерминированно обрабатывает provider/transport errors.
- Не изменять файлы в `spec/`.
- Поищи описание api dadata и следуй её описанию при составлении payload запроса
## Acceptance criteria
- Существует интерфейс `AddressSuggestionProvider` со стабильным контрактом `suggest(request: AddressSuggestRequest) -> list[AddressSuggestion]`.
- В YAML-конфиге добавлена секция address suggestions с настройками `dadata` и маппингом `country_code -> provider_id`.
- Реализован adapter `dadata`, который принимает internal request model и возвращает унифицированный список `AddressSuggestion`.
- Provider-specific поля ответа `dadata` не утекают за пределы adapter contract.
- Ошибки `dadata` класса 4xx маппятся в детерминированную provider request error, а transport/5xx ошибки — в adapter client error.
- Тесты покрывают загрузку конфигурации address suggestions, request/response mapping `dadata` и основные error scenarios.
## Definition of Done
- [ ] Добавлен base contract для address suggestion providers.
- [ ] Добавлена YAML-конфигурация address suggestions и country mapping.
- [ ] Реализован adapter `dadata` для address suggestions.
- [ ] Добавлены config и adapter tests для нового flow.
## Tests
- Обновить `tests/config/test_config_sections.py` для проверки секции address suggestions и маппинга стран на provider id.
- Добавить `tests/adapters/address_suggestions/dadata/test_client.py` для success/error сценариев и mapping.
- Проверить, что наружу возвращается только унифицированная model `AddressSuggestion`.
## Commands
- `poetry run pytest tests/config/test_config_sections.py -q`
- `poetry run pytest tests/adapters/address_suggestions/dadata/test_client.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -1,49 +0,0 @@
---
id: 022
title: Add address suggestion endpoint
status: DONE
created: 2026-03-25
---
## Context
Перед реализацией order flow клиенту нужен отдельный endpoint, который возвращает подсказки адреса и позволяет выбрать точное значение для `from_location.address` и `to_location.address`. Выбор provider должен происходить по `country_code` через конфигурационный маппинг стран.
## Goal
Добавить `POST /api/v1/delivery/suggest-address` в существующий controller и существующий service с request/response schemas, routing на address suggestion provider по `country_code` и детерминированным HTTP error mapping.
## Constraints
- Controller отвечает только за DTO validation, routing и mapping service exceptions в HTTP responses.
- Endpoint должен вызывать ровно один метод Service: `AggregatorService.suggest_addresses()`.
- Новый endpoint должен быть добавлен в существующий controller модуль `app/controllers/v1/delivery.py`; не создавать отдельный controller модуль.
- Логика provider selection должна быть добавлена в существующий service модуль `app/services/aggregator.py`; не создавать отдельный service модуль.
- Service выбирает provider по `country_code` через injected config mapping и вызывает ровно один address suggestion adapter; provider HTTP-детали в Service запрещены.
- Scope задачи не включает создание заказа CDEK, изменение `POST /api/v1/delivery/order`, расчёт стоимости доставки, cache behavior и concrete HTTP integration европейского provider.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- Существует endpoint `POST /api/v1/delivery/suggest-address`, принимающий `AddressSuggestRequest` и возвращающий `list[AddressSuggestion]`.
- Реализованы request/response schemas `AddressSuggestRequest` и `AddressSuggestion`.
- Endpoint реализован в существующем controller `app/controllers/v1/delivery.py`.
- Controller делегирует обработку только в `AggregatorService.suggest_addresses()`.
- Service определяет provider по `country_code` через конфигурационный маппинг и вызывает только соответствующий registered adapter.
- Если `country_code` отсутствует в маппинге или сопоставлен с незарегистрированным provider, endpoint возвращает детерминированный 400 response.
- Provider request errors маппятся в 400, недоступность внешнего сервиса и transport failures — в 503.
- Service и controller/API tests покрывают как минимум сценарии: route в `dadata`, route в второй provider через test double, unsupported country и provider failure.
## Definition of Done
- [ ] Добавлены address suggestion request/response schemas.
- [ ] Реализован метод `AggregatorService.suggest_addresses()` для provider routing и orchestration.
- [ ] Реализован endpoint `POST /api/v1/delivery/suggest-address` в существующем controller.
- [ ] Добавлены service и controller/API tests для address suggestion flow.
## Tests
- Добавить `tests/services/test_address_suggestions.py` для проверки routing по `country_code`, unsupported country, незарегистрированного provider и provider failures.
- Добавить `tests/controllers/v1/test_address_suggestions.py` для success case, schema validation и HTTP mapping ошибок.
- При необходимости обновить `tests/smoke/test_app_import.py` для проверки подключения нового endpoint и service wiring без новых controller/service модулей.
- Использовать test doubles для address suggestion adapters.
## Commands
- `poetry run pytest tests/services/test_address_suggestions.py -q`
- `poetry run pytest tests/controllers/v1/test_address_suggestions.py -q`
- `poetry run pytest tests/smoke/test_app_import.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -1,55 +0,0 @@
---
id: 023
title: Add Yandex Geosuggest address suggestion adapter and CIS routing
status: DONE
created: 2026-03-29
---
## Context
Текущий flow подсказок адреса поддерживает только `dadata` и уже использует routing по `country_code` через YAML-конфиг. Появилось новое требование: для части стран СНГ использовать отдельный provider `Yandex Geosuggest`, чтобы разгрузить `dadata` и зафиксировать routing по странам на уровне конфигурации.
## Goal
Добавить новый adapter `yandex_geosuggest` для address suggestions, настроить routing по `country_code` так, чтобы `RU`, `BY` и `KZ` оставались на `dadata`, а `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM` и `UZ` шли через `Yandex Geosuggest`, и покрыть это тестами без изменения публичного API endpoint.
## Constraints
- Scope задачи ограничен flow подсказок адреса: YAML-конфиг, wiring зависимостей, service routing, новый adapter и тесты.
- Controller path и публичный контракт `POST /api/v1/delivery/suggest-address` не изменять.
- Service остаётся orchestration layer: выбирает provider по injected config mapping и вызывает ровно один adapter без provider-specific HTTP-логики.
- Внешний IO должен оставаться только внутри нового adapter `app/adapters/address_suggestions/yandex_geosuggest/`.
- Для `RU`, `BY` и `KZ` existing mapping на `dadata` должен сохраниться без изменения контракта `dadata` adapter.
- Для `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM` и `UZ` конфиг должен выбирать provider id `yandex_geosuggest`.
- Новый adapter должен следовать официальному HTTP-контракту Yandex Geosuggest: `GET https://suggest-maps.yandex.ru/v1/suggest`, обязательные query-параметры `apikey` и `text`; для полного адреса использовать `print_address=1`; если в internal request передан `limit`, он должен маппиться в query-параметр `results`.
- Unified mapping наружу должен по-прежнему возвращать только `AddressSuggestion` без утечки provider-specific payload.
- Для Yandex Geosuggest поле `postal_code` не извлекается из provider response и должно возвращаться как `None`.
- Ошибки Yandex Geosuggest `400` должны маппиться в deterministic provider request error; `403`, `429`, transport errors и `5xx` должны маппиться в adapter client error/unavailable path.
- Scope задачи не включает добавление европейского provider, изменение order flow, price flow, новых endpoint'ов и расширение internal model `AddressSuggestion`.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- В конфиге address suggestion providers добавлен provider id `yandex_geosuggest` с параметрами, необходимыми для вызова Yandex Geosuggest.
- Country mapping в конфиге маршрутизирует `RU`, `BY`, `KZ` на `dadata`, а `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM`, `UZ` на `yandex_geosuggest`.
- Реализован adapter `app/adapters/address_suggestions/yandex_geosuggest/client.py`, который отправляет запрос в Yandex Geosuggest по официальному контракту и возвращает `list[AddressSuggestion]`.
- Adapter использует `request.city` и `request.query` как значение `text`, передаёт `print_address=1`, а `request.limit` при наличии маппит в `results`.
- Поля `address`, `street`, `house` и `flat` детерминированно извлекаются из ответа Yandex Geosuggest без утечки внешнего payload за пределы adapter contract, а `postal_code` возвращается как `None`.
- `AggregatorService.suggest_addresses()` по `country_code` выбирает `yandex_geosuggest` для стран `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM`, `UZ` и сохраняет routing на `dadata` для `RU`, `BY`, `KZ`.
- При ответе Yandex Geosuggest с `400` service flow возвращает deterministic bad-request path, а при `403`, `429`, `5xx` и transport failure — deterministic unavailable path.
## Definition of Done
- [ ] Добавлена конфигурация `yandex_geosuggest` и обновлён mapping стран для address suggestions.
- [ ] Реализован новый Yandex Geosuggest adapter без утечки HTTP-деталей в Service.
- [ ] Обновлён wiring address suggestion providers без изменения публичного endpoint контракта.
- [ ] Добавлены tests для config, adapter mapping/error handling и service routing по странам СНГ, включая `postal_code=None` для Yandex Geosuggest.
- [ ] Пройдены все команды из раздела Commands.
## Tests
- Обновить `tests/config/test_config_sections.py` для проверки секции `yandex_geosuggest` и явного country mapping: `RU`, `BY`, `KZ` -> `dadata`; `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM`, `UZ` -> `yandex_geosuggest`.
- Добавить `tests/adapters/address_suggestions/yandex_geosuggest/test_client.py` для success case, mapping `limit -> results`, извлечения `address`/`street`/`house`/`flat`, возврата `postal_code=None` и error scenarios `400`, `403`, `429`, `5xx`.
- Обновить `tests/services/test_address_suggestions.py` для проверки routing на `yandex_geosuggest` по странам `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM`, `UZ` и сохранения routing на `dadata` для `RU`, `BY`, `KZ`.
- При необходимости обновить `tests/controllers/v1/test_address_suggestions.py` только для совместимости существующего endpoint с новым provider routing без изменения публичного API.
## Commands
- `poetry run pytest tests/config/test_config_sections.py -q`
- `poetry run pytest tests/adapters/address_suggestions/yandex_geosuggest/test_client.py -q`
- `poetry run pytest tests/services/test_address_suggestions.py -q`
- `poetry run pytest tests/controllers/v1/test_address_suggestions.py -q`
- `python3 spec/gen_spec_index.py`
@@ -1,56 +0,0 @@
---
id: 024
title: Add TomTom address suggestion adapter and Europe routing
status: DONE
created: 2026-03-29
---
## Context
Текущий flow подсказок адреса поддерживает `dadata` для `RU`, `BY`, `KZ` и `yandex_geosuggest` для части стран СНГ. Следующий этап требует добавить третий provider `TomTom` для европейских стран, сохранив текущий публичный API и routing через YAML-конфиг.
## Goal
Добавить новый adapter `tomtom` для address suggestions на базе TomTom Search API Fuzzy Search, настроить routing европейских стран на provider id `tomtom` через конфиг и покрыть это config, adapter и service tests без изменения публичного API endpoint.
## Constraints
- Scope задачи ограничен flow подсказок адреса: YAML-конфиг, wiring зависимостей, service routing, новый adapter и тесты.
- Controller path и публичный контракт `POST /api/v1/delivery/suggest-address` не изменять.
- Service остаётся orchestration layer: выбирает provider по injected config mapping и вызывает ровно один adapter без provider-specific HTTP-логики.
- Внешний IO должен оставаться только внутри нового adapter `app/adapters/address_suggestions/tomtom/`.
- Новый adapter должен следовать официальному HTTP-контракту TomTom Search API Fuzzy Search: `GET https://api.tomtom.com/search/2/search/{query}.json`.
- TomTom adapter должен формировать `query` из `request.city` и `request.query`, передавать `countrySet=request.country_code`, `typeahead=true`, а `request.limit` при наличии маппить в query-параметр `limit`.
- Для исключения POI из address suggestion flow adapter должен отправлять address-oriented `idxSet=PAD,Addr,Str,EPP`.
- Routing европейских стран должен определяться только YAML-маппингом `country_code -> provider_id`; запрещено добавлять в Service hardcoded классификацию Европы.
- Unified mapping наружу должен возвращать только `AddressSuggestion` без утечки provider-specific payload.
- Для TomTom Search поля должны маппиться детерминированно: `address.freeformAddress -> address`, `address.streetName -> street`, `address.streetNumber -> house`, `address.postalCode -> postal_code`, `flat=None`.
- Ошибки TomTom `400` должны маппиться в deterministic provider request error; `403`, `429`, transport errors и `5xx` должны маппиться в adapter client error/unavailable path.
- Scope задачи не включает изменение order flow, price flow, новых endpoint'ов, расширение internal model `AddressSuggestion` и изменение контрактов существующих adapters `dadata` и `yandex_geosuggest`.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- В конфиге address suggestion providers добавлен provider id `tomtom` с параметрами, необходимыми для вызова TomTom Search API Fuzzy Search.
- Country mapping в конфиге маршрутизирует на `tomtom` европейские страны, явно перечисленные в YAML, при этом routing для `RU`, `BY`, `KZ` на `dadata` и для `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM`, `UZ` на `yandex_geosuggest` сохраняется без изменений.
- Реализован adapter `app/adapters/address_suggestions/tomtom/client.py`, который отправляет запрос в TomTom Search API Fuzzy Search по официальному контракту и возвращает `list[AddressSuggestion]`.
- Adapter объединяет `request.city` и `request.query` в search query, передаёт `countrySet=request.country_code`, `typeahead=true`, `idxSet=PAD,Addr,Str,EPP` и при наличии `request.limit` маппит его в `limit`.
- Поля `address`, `street`, `house` и `postal_code` детерминированно извлекаются из ответа TomTom (`freeformAddress`, `streetName`, `streetNumber`, `postalCode`), а `flat` возвращается как `None`.
- `AggregatorService.suggest_addresses()` по `country_code` выбирает `tomtom` для европейских стран, явно сопоставленных в YAML-конфиге, и сохраняет существующий routing на `dadata` и `yandex_geosuggest` для уже поддержанных стран.
- При ответе TomTom с `400` service flow возвращает deterministic bad-request path, а при `403`, `429`, `5xx` и transport failure — deterministic unavailable path.
## Definition of Done
- [ ] Добавлена конфигурация `tomtom` и обновлён country mapping для европейских стран.
- [ ] Реализован новый TomTom adapter без утечки HTTP-деталей в Service.
- [ ] Обновлён wiring address suggestion providers без изменения публичного endpoint контракта.
- [ ] Добавлены tests для config, adapter mapping/error handling и service routing на `tomtom` с сохранением существующего routing на `dadata` и `yandex_geosuggest`.
- [ ] Пройдены все команды из раздела Commands.
## Tests
- Обновить `tests/config/test_config_sections.py` для проверки секции `tomtom` и country mapping, в котором европейские страны маршрутизируются на `tomtom`, а существующие маппинги `RU`, `BY`, `KZ` -> `dadata` и `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM`, `UZ` -> `yandex_geosuggest` сохраняются.
- Добавить `tests/adapters/address_suggestions/tomtom/test_client.py` для success case, mapping `limit`, формирования search query из `city` и `query`, передачи `typeahead=true` и `idxSet=PAD,Addr,Str,EPP`, возврата `flat=None`, извлечения `address`/`street`/`house`/`postal_code` и error scenarios `400`, `403`, `429`, `5xx`.
- Обновить `tests/services/test_address_suggestions.py` для проверки routing на `tomtom` по европейским странам из YAML-конфига и сохранения routing на `dadata` и `yandex_geosuggest` для уже поддержанных стран.
- При необходимости обновить `tests/controllers/v1/test_address_suggestions.py` только для совместимости существующего endpoint с новым provider routing без изменения публичного API.
## Commands
- `poetry run pytest tests/config/test_config_sections.py -q`
- `poetry run pytest tests/adapters/address_suggestions/tomtom/test_client.py -q`
- `poetry run pytest tests/services/test_address_suggestions.py -q`
- `poetry run pytest tests/controllers/v1/test_address_suggestions.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -1,51 +0,0 @@
---
id: 025
title: Remove company from Create Delivery Order parties
status: DONE
created: 2026-04-03
---
## Context
Текущий контракт `POST /api/v1/delivery/order` допускает поле `company` в `sender`, потому что order party schema используется и для `sender`, и для `recipient`. Новый сценарий требует упростить Create Delivery Order payload: поля `sender.company` и `recipient.company` больше не должны приниматься и не должны отправляться в CDEK.
## Goal
Удалить `company` из request contract Create Delivery Order на уровне schema validation, controller/API contract и CDEK order registration payload без изменения endpoint path, service orchestration и response model.
## Constraints
- Изменения ограничены существующими модулями order flow: `app/schemas/order.py`, `app/controllers/v1/delivery.py`, `app/services/aggregator.py`, `app/adapters/delivery_providers/cdek/` и связанными тестами; не создавать новые controller/service модули.
- `POST /api/v1/delivery/order` должен оставаться в существующем controller и по-прежнему вызывать ровно один метод Service: `AggregatorService.create_order()`.
- Service не должен получать новую business logic; orchestration order flow должна остаться прежней.
- Поля `sender.company` и `recipient.company` должны отсутствовать в публичном request contract и не должны сериализоваться в payload, отправляемый в CDEK.
- Payload, содержащий `sender.company` или `recipient.company`, должен считаться невалидным и отклоняться schema validation с HTTP 422.
- `from_location.address` и `to_location.address` остаются opaque input strings; не добавлять address suggestion lookup, нормализацию адреса или иные изменения order flow.
- Scope задачи не включает изменение response contract, price flow, address suggestion flow, provider routing, кеширование, новые провайдеры и расширение поддерживаемых `type`/`tariff_code`.
- Обновить пример контракта Create Delivery Order в `http-client.http`, чтобы он не содержал `company`.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- `OrderCreateRequest` больше не содержит поле `company` ни для `sender`, ни для `recipient`.
- `POST /api/v1/delivery/order` успешно принимает прежний payload без `company` и не требует никаких новых полей.
- Если request payload содержит `sender.company` или `recipient.company`, endpoint возвращает 422 на уровне schema validation.
- `AggregatorService.create_order()` продолжает только оркестрировать вызов injected order adapter без новой business logic.
- CDEK adapter отправляет JSON payload для регистрации заказа без поля `company`.
- `http-client.http` содержит актуальный пример регистрации заказа без `company`.
- Service, controller/API и adapter tests покрывают success case после удаления поля и rejection scenario для `company`.
## Definition of Done
- [ ] Обновлён order request contract без поля `company` для `sender` и `recipient`.
- [ ] Schema validation отклоняет `sender.company` и `recipient.company`.
- [ ] CDEK order registration payload больше не содержит `company`.
- [ ] Обновлены order flow tests для service, controller/API и adapter layers.
- [ ] Обновлён пример Create Delivery Order в `http-client.http`.
## Tests
- Обновить `tests/services/test_order.py`, убрав `company` из валидных fixture payloads и сохранив проверку service orchestration.
- Обновить `tests/controllers/v1/test_order.py` для success case без `company` и добавить сценарий 422 при передаче `sender.company` и `recipient.company`.
- Обновить `tests/adapters/delivery_providers/cdek/test_order_client.py`, чтобы проверить отсутствие `company` в отправляемом CDEK payload.
- При необходимости обновить другие order-related tests, завязанные на старый request contract.
## Commands
- `poetry run pytest tests/services/test_order.py -q`
- `poetry run pytest tests/controllers/v1/test_order.py -q`
- `poetry run pytest tests/adapters/delivery_providers/cdek/test_order_client.py -q`
- `python3 spec/gen_spec_index.py --check`
@@ -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`
-54
View File
@@ -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,11 +65,11 @@ 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"}
],
},
},
request=httpx.Request("GET", "https://api.cdek.test/v2/orders/cdek-order-uuid"),
)
http_client = SequenceHTTPClient([response])
@@ -87,6 +88,55 @@ def test_get_order_parses_status_and_waybill_uuid() -> None:
assert http_client.calls[0]["headers"] == {"Authorization": "Bearer test-token"}
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,7 +59,8 @@ def test_map_cdek_order_response_extracts_order_uuid_without_waybill() -> None:
def test_map_cdek_order_response_extracts_waybill_from_related_entities() -> None:
result = map_cdek_order_response(
{
"entity": {"uuid": "cdek-order-uuid"},
"entity": {
"uuid": "cdek-order-uuid",
"related_entities": [
{"type": "delivery", "uuid": "ignored"},
{
@@ -51,6 +69,7 @@ def test_map_cdek_order_response_extracts_waybill_from_related_entities() -> Non
"url": "https://cdek.test/waybill/1.pdf",
},
],
},
}
)
@@ -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"}
],
},
}
)
@@ -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
+6 -3
View File
@@ -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"
+29 -10
View File
@@ -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",
+33 -10
View File
@@ -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
+41
View File
@@ -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()
+27 -2
View File
@@ -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
+74
View File
@@ -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()
+121
View File
@@ -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)]
+1
View File
@@ -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
View File
@@ -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