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:
@@ -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)]
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -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'ов из строки через запятую."""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Интеграция с CRM (exoCRM).
|
||||
|
||||
См. `client.py` за Protocol и `exo_client.py`/`stub_client.py` за реализациями.
|
||||
"""
|
||||
@@ -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()
|
||||
@@ -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]: ...
|
||||
@@ -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", [])]
|
||||
@@ -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
|
||||
]
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user