Add CRM order queue: live sync, view modal, receipt tabs, delete

Wires the CRM (exoCRM GetOrders) into the dashboard as a locally
persisted order queue instead of the previous static mockup:

- CrmClient Protocol + ExoCrmClient/StubCrmClient for the CRM's
  signed JSON-RPC API
- Order model + migration, synced from CRM on each queue view;
  soft-deleted orders stay hidden across re-syncs
- GET/DELETE /api/v1/orders with "no receipt"/"receipt issued" tabs
  (the latter is empty until Checkbox fiscalization lands)
- Dashboard: real order list, item-detail modal, tab switcher,
  one-click delete

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-22 21:39:03 +03:00
co-authored by Claude Sonnet 5
parent ef55689295
commit f4072be451
30 changed files with 1326 additions and 82 deletions
+46
View File
@@ -0,0 +1,46 @@
"""Локальная копия заказов, синхронизированная из CRM
Revision ID: 0002
Revises: 0001
Create Date: 2026-09-22
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from sqlalchemy.dialects import postgresql
revision: str = "0002"
down_revision: str | None = "0001"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_table(
"orders",
sa.Column("id", sa.String(length=32), nullable=False),
sa.Column("create_date_time", sa.DateTime(timezone=True), nullable=False),
sa.Column("recipient_name", sa.String(length=255), nullable=True),
sa.Column("recipient_phone", sa.String(length=32), nullable=True),
sa.Column("recipient_email", sa.String(length=320), nullable=True),
sa.Column("waybill_number", sa.String(length=64), nullable=True),
sa.Column("notes", sa.Text(), nullable=True),
sa.Column("total_amount_kopecks", sa.BigInteger(), nullable=False),
sa.Column("goods", postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column("is_deleted", sa.Boolean(), nullable=False),
sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("receipt_created_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_orders")),
)
op.create_index(op.f("ix_orders_is_deleted"), "orders", ["is_deleted"])
op.create_index(op.f("ix_orders_receipt_created_at"), "orders", ["receipt_created_at"])
def downgrade() -> None:
op.drop_table("orders")
+10
View File
@@ -10,9 +10,12 @@ from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.security import TokenError, decode_access_token
from app.db.models.user import User, UserRole
from app.db.session import get_session
from app.services.crm.client import CrmClient
from app.services.crm.exo_client import ExoCrmClient
bearer_scheme = HTTPBearer(auto_error=False)
@@ -85,3 +88,10 @@ require_any = require_roles(UserRole.ADMIN, UserRole.CASHIER, UserRole.VIEWER)
AdminUser = Annotated[User, Depends(require_admin)]
CashierUser = Annotated[User, Depends(require_cashier)]
def get_crm_client() -> CrmClient:
return ExoCrmClient(settings)
CrmClientDep = Annotated[CrmClient, Depends(get_crm_client)]
+43
View File
@@ -0,0 +1,43 @@
"""Локальная очередь заказов (синхронизируется из CRM) — список, вкладки, удаление."""
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Request, status
from app.api.deps import CashierUser, CrmClientDep, SessionDep, require_any
from app.db.models.audit import AuditAction
from app.schemas.orders import OrderRowOut
from app.services import audit
from app.services import orders as orders_service
router = APIRouter(prefix="/orders", tags=["orders"], dependencies=[Depends(require_any)])
@router.get("", response_model=list[OrderRowOut], response_model_by_alias=False)
async def list_orders(
session: SessionDep, crm: CrmClientDep, has_receipt: bool = False
) -> list[OrderRowOut]:
# by_alias=False: без этого FastAPI сериализует вложенные OrderGoodOut через
# CRM-алиасы (SKU/Name/...) вместо snake_case полей, которые ждёт фронтенд.
await orders_service.sync_orders_from_crm(session, crm)
orders = await orders_service.list_orders(session, has_receipt=has_receipt)
return [OrderRowOut.from_order(order) for order in orders]
@router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_order(
order_id: str, request: Request, session: SessionDep, actor: CashierUser
) -> None:
order = await orders_service.delete_order(session, order_id)
if order is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Заказ не найден")
await audit.record(
session,
action=AuditAction.ORDER_DELETED,
user=actor,
entity_type="order",
entity_id=order.id,
request=request,
)
await session.commit()
+2 -1
View File
@@ -2,9 +2,10 @@
from fastapi import APIRouter
from app.api.v1 import auth, health, users
from app.api.v1 import auth, health, orders, users
api_router = APIRouter()
api_router.include_router(health.router)
api_router.include_router(auth.router)
api_router.include_router(users.router)
api_router.include_router(orders.router)
+7
View File
@@ -55,6 +55,13 @@ class Settings(BaseSettings):
first_admin_password: str = ""
first_admin_name: str = "Администратор"
# --- CRM ---
crm_base_url: str = "https://optstore.exocrm.com/api/1.1/"
crm_api_key: str = ""
crm_secret_key: str = ""
crm_shop_key: str = ""
crm_sid: int = 1
@property
def cors_origins(self) -> list[str]:
"""Список разрешённых origin'ов из строки через запятую."""
+2
View File
@@ -6,12 +6,14 @@ Alembic автогенерирует миграции по `Base.metadata`, по
from app.db.base import Base
from app.db.models.audit import AuditAction, AuditLog
from app.db.models.order import Order
from app.db.models.user import RefreshToken, User, UserRole
__all__ = [
"AuditAction",
"AuditLog",
"Base",
"Order",
"RefreshToken",
"User",
"UserRole",
+1
View File
@@ -31,6 +31,7 @@ class AuditAction(str):
USER_CREATED = "user.created"
USER_UPDATED = "user.updated"
USER_DEACTIVATED = "user.deactivated"
ORDER_DELETED = "order.deleted"
class AuditLog(UUIDPrimaryKeyMixin, Base):
+45
View File
@@ -0,0 +1,45 @@
"""Локальная копия заказов, синхронизированная из CRM.
Живёт отдельно от live-проксирования (`app/services/crm/`), чтобы кассир мог
скрыть ненужный заказ (soft-delete) и он не появился снова при следующей
синхронизации, и чтобы отличать заказы без чека от уже пробитых.
"""
from __future__ import annotations
from datetime import datetime
from typing import Any
from sqlalchemy import BigInteger, Boolean, DateTime, String, Text
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column
from app.db.base import Base, TimestampMixin
class Order(TimestampMixin, Base):
__tablename__ = "orders"
# PK — собственный ID заказа в CRM, а не сгенерированный UUID: синхронизация
# делает upsert по нему.
id: Mapped[str] = mapped_column(String(32), primary_key=True)
create_date_time: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
recipient_name: Mapped[str | None] = mapped_column(String(255))
recipient_phone: Mapped[str | None] = mapped_column(String(32))
recipient_email: Mapped[str | None] = mapped_column(String(320))
waybill_number: Mapped[str | None] = mapped_column(String(64))
notes: Mapped[str | None] = mapped_column(Text)
# Деньги — integer копейки (конвенция проекта), а не строка/float.
total_amount_kopecks: Mapped[int] = mapped_column(BigInteger, nullable=False)
# Товарные позиции — снимок из CRM для отображения в модалке. Построчно в
# копейки не переводится: не участвует в денежных расчётах, только в показе.
goods: Mapped[list[dict[str, Any]]] = mapped_column(JSONB, nullable=False, default=list)
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Заполняется будущей интеграцией с Checkbox — сейчас всегда NULL.
receipt_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
+95
View File
@@ -0,0 +1,95 @@
"""Схемы заказов CRM.
Поля валидируются напрямую из "сырых" PascalCase-ключей ответа CRM через
`Field(alias=...)`, а наружу (в JSON фронту) отдаются как чистые snake_case
имена. Денежные значения остаются строками "как есть" от CRM — конвенция
integer-kopecks из CLAUDE.md относится к будущим персистентным моделям
`Order`/`Receipt`, а не к этому read-only проксирующему эндпоинту.
"""
from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING, Any
from pydantic import BaseModel, ConfigDict, Field, field_validator
if TYPE_CHECKING:
from app.db.models.order import Order
class OrderGoodOut(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: str = Field(alias="ID")
sku: str = Field(alias="SKU")
name: str = Field(alias="Name")
price: str = Field(alias="Price")
quantity: str = Field(alias="Quantity")
discount_amount: str | None = Field(default=None, alias="DiscountAmount")
discount_percent: str | None = Field(default=None, alias="DiscountPercent")
amount: str = Field(alias="Amount")
class OrderTotalOut(BaseModel):
model_config = ConfigDict(populate_by_name=True)
cost: str = Field(alias="Cost")
quantity: str = Field(alias="Quantity")
weight: str = Field(alias="Weight")
discount_amount: str = Field(alias="DiscountAmount")
discount_percent: str = Field(alias="DiscountPercent")
amount: str = Field(alias="Amount")
# CRM возвращает Quantity/Weight то числом, то строкой в зависимости от
# заказа — приводим к строке единообразно для фронта.
@field_validator("quantity", "weight", mode="before")
@classmethod
def _stringify(cls, value: Any) -> Any:
return str(value) if value is not None else value
class OrderOut(BaseModel):
model_config = ConfigDict(populate_by_name=True)
id: str = Field(alias="ID")
create_date_time: str = Field(alias="CreateDateTime")
recipient_name: str | None = Field(default=None, alias="RecipientDName")
recipient_phone: str | None = Field(default=None, alias="RecipientPhone")
recipient_email: str | None = Field(default=None, alias="RecipientEmail")
waybill_number: str | None = Field(default=None, alias="Waybill_Number")
notes: str | None = Field(default=None, alias="Notes")
total: OrderTotalOut = Field(alias="Total")
goods: list[OrderGoodOut] = Field(default_factory=list, alias="Goods")
class OrderRowOut(BaseModel):
"""Ответ `GET /orders` — строится из локальной таблицы `orders`, не из CRM напрямую."""
model_config = ConfigDict(from_attributes=True)
id: str
create_date_time: datetime
recipient_name: str | None
recipient_phone: str | None
recipient_email: str | None
waybill_number: str | None
notes: str | None
total_amount: str
goods: list[OrderGoodOut]
has_receipt: bool
@classmethod
def from_order(cls, order: Order) -> OrderRowOut:
return cls(
id=order.id,
create_date_time=order.create_date_time,
recipient_name=order.recipient_name,
recipient_phone=order.recipient_phone,
recipient_email=order.recipient_email,
waybill_number=order.waybill_number,
notes=order.notes,
total_amount=f"{order.total_amount_kopecks / 100:.2f}",
goods=[OrderGoodOut.model_validate(good) for good in order.goods],
has_receipt=order.receipt_created_at is not None,
)
+4
View File
@@ -0,0 +1,4 @@
"""Интеграция с CRM (exoCRM).
См. `client.py` за Protocol и `exo_client.py`/`stub_client.py` за реализациями.
"""
+38
View File
@@ -0,0 +1,38 @@
"""Контрольная сумма запросов к CRM API (см. документацию "My CRM API 1.1")."""
from __future__ import annotations
import hashlib
from typing import Any
def _collect_values_sorted(obj: Any) -> list[Any]:
"""Рекурсивно сортирует ключи на каждом уровне и собирает значения depth-first."""
values: list[Any] = []
if isinstance(obj, dict):
for key in sorted(obj.keys()):
values.extend(_collect_values_sorted(obj[key]))
elif isinstance(obj, list):
for item in obj:
values.extend(_collect_values_sorted(item))
else:
values.append(obj)
return values
def _to_str(value: Any) -> str:
# Булевы значения CRM ожидает в контрольной сумме как "1"/"" (PHP-style
# truthy-приведение) — это нигде не задокументировано и подобрано опытным
# путём: "True"/"False" и "true"/"false" оба дают "Checksum Error".
if isinstance(value, bool):
return "1" if value else ""
return str(value)
def compute_md5sum(payload: dict[str, Any], secret_key: str) -> str:
"""Считает md5sum по алгоритму из документации CRM: отсортировать все ключи
(включая вложенные), конкатенировать все значения, добавить приватный ключ, взять MD5.
"""
values = _collect_values_sorted(payload)
concatenated = "".join(_to_str(v) for v in values)
return hashlib.md5((concatenated + secret_key).encode("utf-8")).hexdigest()
+15
View File
@@ -0,0 +1,15 @@
"""Protocol клиента CRM — позволяет подменять реализацию в тестах (`StubCrmClient`)."""
from __future__ import annotations
from typing import Protocol
from app.schemas.orders import OrderOut
class CrmError(Exception):
"""CRM ответила `status: ERROR` (см. поле `errors` в ответе API)."""
class CrmClient(Protocol):
async def get_orders(self, *, status: str) -> list[OrderOut]: ...
+46
View File
@@ -0,0 +1,46 @@
"""Реальный клиент exoCRM (`GetOrders`)."""
from __future__ import annotations
import httpx
from app.core.config import Settings
from app.schemas.orders import OrderOut
from app.services.crm.checksum import compute_md5sum
from app.services.crm.client import CrmError
class ExoCrmClient:
def __init__(self, settings: Settings) -> None:
self._base_url = settings.crm_base_url
self._api_key = settings.crm_api_key
self._secret_key = settings.crm_secret_key
self._shop_key = settings.crm_shop_key
self._sid = settings.crm_sid
async def get_orders(self, *, status: str) -> list[OrderOut]:
body = {
"apikey": self._api_key,
"object": "Orders",
"method": "GetOrders",
"params": {
"sid": self._sid,
"key": self._shop_key,
"Status": status,
"ReturnGoods": True,
"ReturnTotals": True,
},
}
body["md5sum"] = compute_md5sum(body, self._secret_key)
async with httpx.AsyncClient(timeout=30) as client:
response = await client.post(self._base_url, json=body)
response.raise_for_status()
data = response.json()
if data.get("status") != "OK":
errors = data.get("errors") or {}
message = "; ".join(f"{code}: {text}" for code, text in errors.items())
raise CrmError(f"CRM вернула ошибку: {message or 'неизвестная ошибка'}")
return [OrderOut.model_validate(order) for order in data.get("result", [])]
+49
View File
@@ -0,0 +1,49 @@
"""Фикстурный CRM-клиент для тестов — не ходит в сеть."""
from __future__ import annotations
from app.schemas.orders import OrderOut
_FIXTURE_ORDERS: list[dict] = [
{
"ID": "100001",
"CreateDateTime": "2026-09-20 10:00:00",
"RecipientDName": "Тестовий Покупець",
"RecipientPhone": "+380501112233",
"RecipientEmail": None,
"Waybill_Number": "20450123456789",
"Notes": "Тестовий заказ",
"Total": {
"Cost": "0.00",
"Quantity": "2",
"Weight": "0",
"DiscountAmount": "0.00",
"DiscountPercent": "0.00",
"Amount": "1200.00",
},
"Goods": [
{
"ID": "1",
"SKU": "SKU-1",
"Name": "Товар 1",
"Price": "600.00",
"Quantity": "2.000",
"DiscountAmount": "0.00",
"DiscountPercent": "0.00",
"Amount": "1200.00",
}
],
}
]
class StubCrmClient:
def __init__(self, orders: list[dict] | None = None) -> None:
self._orders = orders if orders is not None else _FIXTURE_ORDERS
async def get_orders(self, *, status: str) -> list[OrderOut]:
return [
OrderOut.model_validate(order)
for order in self._orders
if order.get("Status", status) == status
]
+82
View File
@@ -0,0 +1,82 @@
"""Синхронизация локальной очереди заказов с CRM и работа с ней."""
from __future__ import annotations
from datetime import UTC, datetime
from decimal import Decimal
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models.order import Order
from app.services.crm.client import CrmClient
_CRM_STATUS = "APPROVED"
def _to_kopecks(amount: str) -> int:
return int((Decimal(amount) * 100).to_integral_value())
def _parse_crm_datetime(value: str) -> datetime:
return datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
async def sync_orders_from_crm(session: AsyncSession, crm: CrmClient) -> None:
"""Подтягивает заказы CRM (статус APPROVED) в локальную таблицу.
Уже скрытые (`is_deleted`) заказы не восстанавливаются и не перезаписываются
— иначе кнопка «Удалить» переставала бы работать при следующем открытии
дашборда, т.к. CRM продолжает возвращать эти заказы как есть.
"""
crm_orders = await crm.get_orders(status=_CRM_STATUS)
if not crm_orders:
return
existing = await session.scalars(
select(Order).where(Order.id.in_(order.id for order in crm_orders))
)
existing_by_id = {order.id: order for order in existing}
for crm_order in crm_orders:
local = existing_by_id.get(crm_order.id)
if local is not None:
if local.is_deleted:
continue
else:
local = Order(id=crm_order.id)
session.add(local)
local.create_date_time = _parse_crm_datetime(crm_order.create_date_time)
local.recipient_name = crm_order.recipient_name
local.recipient_phone = crm_order.recipient_phone
local.recipient_email = crm_order.recipient_email
local.waybill_number = crm_order.waybill_number
local.notes = crm_order.notes
local.total_amount_kopecks = _to_kopecks(crm_order.total.amount)
local.goods = [good.model_dump(by_alias=False) for good in crm_order.goods]
await session.commit()
async def list_orders(session: AsyncSession, *, has_receipt: bool) -> list[Order]:
receipt_filter = (
Order.receipt_created_at.is_not(None) if has_receipt else Order.receipt_created_at.is_(None)
)
result = await session.scalars(
select(Order)
.where(Order.is_deleted.is_(False))
.where(receipt_filter)
.order_by(Order.create_date_time.desc())
)
return list(result)
async def delete_order(session: AsyncSession, order_id: str) -> Order | None:
order = await session.get(Order, order_id)
if order is None or order.is_deleted:
return None
order.is_deleted = True
order.deleted_at = datetime.now(UTC)
return order
+28
View File
@@ -0,0 +1,28 @@
"""Тесты контрольной суммы CRM-запросов. Без сети."""
from __future__ import annotations
import hashlib
from app.services.crm.checksum import compute_md5sum
class TestComputeMd5sum:
def test_matches_manual_calculation_for_flat_payload(self) -> None:
payload = {"b": "2", "a": "1"}
expected = hashlib.md5(b"12secret").hexdigest()
assert compute_md5sum(payload, "secret") == expected
def test_sorts_nested_keys_before_concatenating(self) -> None:
payload = {"params": {"z": "1", "a": "2"}, "apikey": "k"}
# sorted top-level: apikey, params -> "k" then sorted nested (a,z) -> "2","1"
expected = hashlib.md5(b"k21secret").hexdigest()
assert compute_md5sum(payload, "secret") == expected
def test_true_becomes_one_and_false_becomes_empty_string(self) -> None:
payload = {"a": True, "b": False}
expected = hashlib.md5(b"1secret").hexdigest()
assert compute_md5sum(payload, "secret") == expected
def test_different_payloads_give_different_sums(self) -> None:
assert compute_md5sum({"a": "1"}, "secret") != compute_md5sum({"a": "2"}, "secret")
+108
View File
@@ -0,0 +1,108 @@
"""Тесты ExoCrmClient. Сеть замокана через respx — реальных запросов не делает."""
from __future__ import annotations
import pytest
import respx
from httpx import Response
from app.core.config import Settings
from app.services.crm.client import CrmError
from app.services.crm.exo_client import ExoCrmClient
BASE_URL = "https://crm.example.test/api/1.1/"
def _settings() -> Settings:
return Settings(
secret_key="test-secret-key",
encryption_key="dGVzdC1lbmNyeXB0aW9uLWtleS0zMi1ieXRlcyEh",
crm_base_url=BASE_URL,
crm_api_key="apikey123",
crm_secret_key="secret123",
crm_shop_key="shopkey123",
crm_sid=1,
) # type: ignore[arg-type]
ORDER_PAYLOAD = {
"ID": "1",
"CreateDateTime": "2026-09-20 10:00:00",
"RecipientDName": "Іван Іванов",
"RecipientPhone": "+380501112233",
"RecipientEmail": None,
"Waybill_Number": "",
"Notes": None,
"Total": {
"Cost": "0.00",
"Quantity": 1,
"Weight": 0,
"DiscountAmount": "0.00",
"DiscountPercent": "0.00",
"Amount": "100.00",
},
"Goods": [
{
"ID": "10",
"SKU": "SKU-1",
"Name": "Товар",
"Price": "100.00",
"Quantity": "1.000",
"DiscountAmount": "0.00",
"DiscountPercent": "0.00",
"Amount": "100.00",
}
],
}
class TestExoCrmClientGetOrders:
@respx.mock
async def test_sends_expected_request_body(self) -> None:
route = respx.post(BASE_URL).mock(
return_value=Response(200, json={"status": "OK", "result": []})
)
client = ExoCrmClient(_settings())
await client.get_orders(status="APPROVED")
sent = route.calls.last.request
body = sent.content
import json
parsed = json.loads(body)
assert parsed["apikey"] == "apikey123"
assert parsed["object"] == "Orders"
assert parsed["method"] == "GetOrders"
assert parsed["params"]["sid"] == 1
assert parsed["params"]["key"] == "shopkey123"
assert parsed["params"]["Status"] == "APPROVED"
assert "md5sum" in parsed
@respx.mock
async def test_parses_successful_response_into_order_out(self) -> None:
respx.post(BASE_URL).mock(
return_value=Response(200, json={"status": "OK", "result": [ORDER_PAYLOAD]})
)
client = ExoCrmClient(_settings())
orders = await client.get_orders(status="APPROVED")
assert len(orders) == 1
order = orders[0]
assert order.id == "1"
assert order.recipient_name == "Іван Іванов"
assert order.total.quantity == "1"
assert order.goods[0].sku == "SKU-1"
@respx.mock
async def test_raises_crm_error_on_error_status(self) -> None:
respx.post(BASE_URL).mock(
return_value=Response(
200, json={"status": "ERROR", "errors": {"1005": "Checksum Error"}}
)
)
client = ExoCrmClient(_settings())
with pytest.raises(CrmError, match="Checksum Error"):
await client.get_orders(status="APPROVED")
+26 -19
View File
@@ -1,8 +1,8 @@
"""Миграция 0001 обязана точно соответствовать моделям.
"""Миграции обязаны точно соответствовать моделям.
Расхождение между `Base.metadata` и миграцией обнаруживается только на живой
Расхождение между `Base.metadata` и миграциями обнаруживается только на живой
БД и обычно уже в проде. Проверка статическая: сравниваем таблицы, колонки,
nullability и индексы, которые создаёт миграция, с тем, что описано моделями.
nullability и индексы, которые создают миграции, с тем, что описано моделями.
"""
from __future__ import annotations
@@ -14,10 +14,12 @@ import pytest
from app.db.models import Base
MIGRATION = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0001_users_and_audit.py"
SOURCE = MIGRATION.read_text(encoding="utf-8")
VERSIONS_DIR = Path(__file__).resolve().parents[1] / "alembic" / "versions"
MIGRATION_FILES = sorted(VERSIONS_DIR.glob("*.py"))
SOURCES = {path.name: path.read_text(encoding="utf-8") for path in MIGRATION_FILES}
# Склеенный текст всех миграций — таблица может быть создана в любой из них.
COMBINED_SOURCE = "\n".join(SOURCES.values())
# Миграция 0001 создаёт схему с нуля, поэтому в ней обязаны быть все таблицы.
MODEL_TABLES = set(Base.metadata.tables)
@@ -26,24 +28,27 @@ def _migration_block(table: str) -> str:
Границей служит следующий op.create_table; для последней таблицы — конец файла.
"""
start = SOURCE.index(f'op.create_table(\n "{table}",')
next_table = SOURCE.find("op.create_table(", start + 1)
end = next_table if next_table != -1 else len(SOURCE)
return SOURCE[start:end]
start = COMBINED_SOURCE.index(f'op.create_table(\n "{table}",')
next_table = COMBINED_SOURCE.find("op.create_table(", start + 1)
end = next_table if next_table != -1 else len(COMBINED_SOURCE)
return COMBINED_SOURCE[start:end]
def test_migration_creates_every_model_table() -> None:
created = set(re.findall(r'op\.create_table\(\n\s+"(\w+)"', SOURCE))
def test_migrations_create_every_model_table() -> None:
created = set(re.findall(r'op\.create_table\(\n\s+"(\w+)"', COMBINED_SOURCE))
assert created == MODEL_TABLES, (
f"Миграция и модели разошлись. Только в моделях: {MODEL_TABLES - created}; "
f"только в миграции: {created - MODEL_TABLES}"
f"Миграции и модели разошлись. Только в моделях: {MODEL_TABLES - created}; "
f"только в миграциях: {created - MODEL_TABLES}"
)
def test_downgrade_drops_every_created_table() -> None:
downgrade = SOURCE[SOURCE.index("def downgrade()") :]
dropped = set(re.findall(r'op\.drop_table\("(\w+)"\)', downgrade))
assert dropped == MODEL_TABLES, f"downgrade не удаляет: {MODEL_TABLES - dropped}"
def test_each_migration_downgrade_drops_its_own_tables() -> None:
"""Каждая миграция откатывает ровно те таблицы, которые сама создаёт."""
for name, source in SOURCES.items():
created = set(re.findall(r'op\.create_table\(\n\s+"(\w+)"', source))
downgrade = source[source.index("def downgrade()") :]
dropped = set(re.findall(r'op\.drop_table\("(\w+)"\)', downgrade))
assert dropped == created, f"{name}: downgrade не удаляет {created - dropped}"
@pytest.mark.parametrize("table_name", sorted(MODEL_TABLES))
@@ -77,7 +82,9 @@ def test_nullability_matches(table_name: str) -> None:
@pytest.mark.parametrize("table_name", sorted(MODEL_TABLES))
def test_indexes_match(table_name: str) -> None:
model_indexes = {index.name for index in Base.metadata.tables[table_name].indexes}
migration_indexes = set(re.findall(r'op\.create_index\(\s*(?:op\.f\()?"(\w+)"', SOURCE))
migration_indexes = set(
re.findall(r'op\.create_index\(\s*(?:op\.f\()?"(\w+)"', COMBINED_SOURCE)
)
missing = model_indexes - migration_indexes
assert not missing, f"Таблица {table_name}: миграция не создаёт индексы {missing}"
+159
View File
@@ -0,0 +1,159 @@
"""Тесты роутера заказов.
Auth подменена через dependency_overrides, сервисный слой (`app.services.orders`)
— через monkeypatch. `SessionDep` создаёт `AsyncSession`, но соединение с БД
открывается лениво только при первом запросе — раз сервисные функции его не
трогают, реальная БД не нужна.
"""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
import pytest
from fastapi.testclient import TestClient
from app.api.deps import get_crm_client, get_current_user
from app.db.models.order import Order
from app.db.models.user import User, UserRole
from app.main import app
from app.services.crm.stub_client import StubCrmClient
pytestmark = pytest.mark.usefixtures("_patch_orders_service")
def _user(role: UserRole) -> User:
return User(
id=uuid.uuid4(),
email="test@example.com",
password_hash="x",
full_name="Тест",
role=role,
is_active=True,
)
def _order(order_id: str = "1") -> Order:
return Order(
id=order_id,
create_date_time=datetime(2026, 9, 20, 10, 0, 0, tzinfo=UTC),
recipient_name="Тест Тестов",
recipient_phone="+380501112233",
recipient_email=None,
waybill_number="20450123456789",
notes=None,
total_amount_kopecks=120000,
goods=[
{
"id": "1",
"sku": "SKU-1",
"name": "Товар 1",
"price": "1200.00",
"quantity": "1.000",
"discount_amount": "0.00",
"discount_percent": "0.00",
"amount": "1200.00",
}
],
is_deleted=False,
deleted_at=None,
receipt_created_at=None,
)
@pytest.fixture
def _patch_orders_service(monkeypatch: pytest.MonkeyPatch) -> None:
import app.api.v1.orders as orders_router
async def fake_sync(session: object, crm: object) -> None:
return None
async def fake_list(session: object, *, has_receipt: bool) -> list[Order]:
return [] if has_receipt else [_order()]
async def fake_delete(session: object, order_id: str) -> Order | None:
return _order(order_id) if order_id == "1" else None
monkeypatch.setattr(orders_router.orders_service, "sync_orders_from_crm", fake_sync)
monkeypatch.setattr(orders_router.orders_service, "list_orders", fake_list)
monkeypatch.setattr(orders_router.orders_service, "delete_order", fake_delete)
async def fake_record(session: object, **kwargs: object) -> None:
return None
monkeypatch.setattr(orders_router.audit, "record", fake_record)
# session.commit() вызывается напрямую роутером после audit.record — сама
# сессия не трогает БД до первого execute/commit, поэтому патчим только
# commit, а не всю SessionDep.
from sqlalchemy.ext.asyncio import AsyncSession
async def fake_commit(self: AsyncSession) -> None:
return None
monkeypatch.setattr(AsyncSession, "commit", fake_commit)
@pytest.fixture
def client() -> TestClient:
app.dependency_overrides[get_crm_client] = lambda: StubCrmClient()
try:
yield TestClient(app)
finally:
app.dependency_overrides.pop(get_crm_client, None)
app.dependency_overrides.pop(get_current_user, None)
class TestListOrders:
@pytest.mark.parametrize("role", [UserRole.ADMIN, UserRole.CASHIER, UserRole.VIEWER])
def test_any_authenticated_role_can_list_no_receipt_orders(
self, client: TestClient, role: UserRole
) -> None:
app.dependency_overrides[get_current_user] = lambda: _user(role)
response = client.get("/api/v1/orders", params={"has_receipt": "false"})
assert response.status_code == 200
body = response.json()
assert len(body) == 1
assert body[0]["id"] == "1"
assert body[0]["total_amount"] == "1200.00"
assert body[0]["has_receipt"] is False
assert body[0]["goods"][0]["sku"] == "SKU-1"
def test_has_receipt_tab_is_empty_for_now(self, client: TestClient) -> None:
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER)
response = client.get("/api/v1/orders", params={"has_receipt": "true"})
assert response.status_code == 200
assert response.json() == []
def test_requires_authentication(self, client: TestClient) -> None:
response = client.get("/api/v1/orders")
assert response.status_code == 401
class TestDeleteOrder:
@pytest.mark.parametrize("role", [UserRole.ADMIN, UserRole.CASHIER])
def test_admin_and_cashier_can_delete(self, client: TestClient, role: UserRole) -> None:
app.dependency_overrides[get_current_user] = lambda: _user(role)
response = client.delete("/api/v1/orders/1")
assert response.status_code == 204
def test_viewer_cannot_delete(self, client: TestClient) -> None:
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.VIEWER)
response = client.delete("/api/v1/orders/1")
assert response.status_code == 403
def test_deleting_unknown_order_is_404(self, client: TestClient) -> None:
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER)
response = client.delete("/api/v1/orders/does-not-exist")
assert response.status_code == 404
+26
View File
@@ -0,0 +1,26 @@
"""Юнит-тесты чистых хелперов сервиса заказов. Без БД и без сети."""
from __future__ import annotations
from datetime import datetime
from app.services.orders import _parse_crm_datetime, _to_kopecks
class TestToKopecks:
def test_converts_decimal_string_to_kopecks(self) -> None:
assert _to_kopecks("599.00") == 59900
def test_handles_fractional_kopecks(self) -> None:
assert _to_kopecks("12.34") == 1234
def test_handles_integer_without_fraction(self) -> None:
assert _to_kopecks("100") == 10000
def test_handles_zero(self) -> None:
assert _to_kopecks("0.00") == 0
class TestParseCrmDatetime:
def test_parses_crm_format(self) -> None:
assert _parse_crm_datetime("2026-09-20 10:00:00") == datetime(2026, 9, 20, 10, 0, 0)