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:
@@ -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