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
+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