Stage #1

Merged
yusupal1ev merged 4 commits from stage into master 2026-06-19 08:32:00 +03:00
52 changed files with 1209 additions and 4810 deletions
+59 -12
View File
@@ -4,6 +4,7 @@ on:
push:
branches:
- master
- stage
jobs:
deploy:
@@ -13,17 +14,51 @@ 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 "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 "COMPOSE_PROJECT=g2s-aggregator"
echo "DEPLOY_DIR=/home/deploy/g2s-aggregator"
} >> "$GITHUB_ENV"
fi
- name: Build and push image
run: |
docker login gitea.p4r4dls.ru \
-u ${{ secrets.REGISTRY_USER }} \
-p ${{ secrets.REGISTRY_TOKEN }}
docker buildx create --use --name multibuilder
docker buildx build \
--platform linux/amd64 \
--push \
-t gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.1.0 \
.
IMG=gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:${IMAGE_TAG}
if docker manifest inspect "$IMG" >/dev/null 2>&1; then
echo "Образ $IMG уже есть в реестре — сборка пропущена"
else
docker buildx rm multibuilder 2>/dev/null || true
docker buildx create --use --name multibuilder --driver-opt network=host
docker buildx build \
--platform linux/amd64 \
--load \
-t "$IMG" \
.
docker push "$IMG"
fi
- name: Setup SSH
run: |
@@ -47,11 +82,23 @@ 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
@@ -63,17 +110,17 @@ jobs:
render docker-compose.template.yml docker-compose.rendered.yml
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
- 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
"
+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
+2 -2
View File
@@ -35,8 +35,8 @@ adapter:
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}"
+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
+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`
Generated
+1086
View File
File diff suppressed because it is too large Load Diff