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