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
+8
View File
@@ -48,3 +48,11 @@ REFRESH_TOKEN_EXPIRE_DAYS=7
FIRST_ADMIN_EMAIL=admin@example.com FIRST_ADMIN_EMAIL=admin@example.com
FIRST_ADMIN_PASSWORD=change-me-admin-password FIRST_ADMIN_PASSWORD=change-me-admin-password
FIRST_ADMIN_NAME=Администратор FIRST_ADMIN_NAME=Администратор
# --- CRM (exoCRM) ---------------------------------------------------------
# Ключи доступа к магазину — запросить у администратора CRM.
CRM_BASE_URL=https://optstore.exocrm.com/api/1.1/
CRM_API_KEY=change-me-crm-apikey
CRM_SECRET_KEY=change-me-crm-secretkey
CRM_SHOP_KEY=change-me-crm-shopkey
CRM_SID=1
+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 fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.core.security import TokenError, decode_access_token from app.core.security import TokenError, decode_access_token
from app.db.models.user import User, UserRole from app.db.models.user import User, UserRole
from app.db.session import get_session 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) 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)] AdminUser = Annotated[User, Depends(require_admin)]
CashierUser = Annotated[User, Depends(require_cashier)] 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 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 = APIRouter()
api_router.include_router(health.router) api_router.include_router(health.router)
api_router.include_router(auth.router) api_router.include_router(auth.router)
api_router.include_router(users.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_password: str = ""
first_admin_name: 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 @property
def cors_origins(self) -> list[str]: def cors_origins(self) -> list[str]:
"""Список разрешённых origin'ов из строки через запятую.""" """Список разрешённых origin'ов из строки через запятую."""
+2
View File
@@ -6,12 +6,14 @@ Alembic автогенерирует миграции по `Base.metadata`, по
from app.db.base import Base from app.db.base import Base
from app.db.models.audit import AuditAction, AuditLog from app.db.models.audit import AuditAction, AuditLog
from app.db.models.order import Order
from app.db.models.user import RefreshToken, User, UserRole from app.db.models.user import RefreshToken, User, UserRole
__all__ = [ __all__ = [
"AuditAction", "AuditAction",
"AuditLog", "AuditLog",
"Base", "Base",
"Order",
"RefreshToken", "RefreshToken",
"User", "User",
"UserRole", "UserRole",
+1
View File
@@ -31,6 +31,7 @@ class AuditAction(str):
USER_CREATED = "user.created" USER_CREATED = "user.created"
USER_UPDATED = "user.updated" USER_UPDATED = "user.updated"
USER_DEACTIVATED = "user.deactivated" USER_DEACTIVATED = "user.deactivated"
ORDER_DELETED = "order.deleted"
class AuditLog(UUIDPrimaryKeyMixin, Base): 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")
+25 -18
View File
@@ -1,8 +1,8 @@
"""Миграция 0001 обязана точно соответствовать моделям. """Миграции обязаны точно соответствовать моделям.
Расхождение между `Base.metadata` и миграцией обнаруживается только на живой Расхождение между `Base.metadata` и миграциями обнаруживается только на живой
БД и обычно уже в проде. Проверка статическая: сравниваем таблицы, колонки, БД и обычно уже в проде. Проверка статическая: сравниваем таблицы, колонки,
nullability и индексы, которые создаёт миграция, с тем, что описано моделями. nullability и индексы, которые создают миграции, с тем, что описано моделями.
""" """
from __future__ import annotations from __future__ import annotations
@@ -14,10 +14,12 @@ import pytest
from app.db.models import Base from app.db.models import Base
MIGRATION = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0001_users_and_audit.py" VERSIONS_DIR = Path(__file__).resolve().parents[1] / "alembic" / "versions"
SOURCE = MIGRATION.read_text(encoding="utf-8") 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) MODEL_TABLES = set(Base.metadata.tables)
@@ -26,24 +28,27 @@ def _migration_block(table: str) -> str:
Границей служит следующий op.create_table; для последней таблицы — конец файла. Границей служит следующий op.create_table; для последней таблицы — конец файла.
""" """
start = SOURCE.index(f'op.create_table(\n "{table}",') start = COMBINED_SOURCE.index(f'op.create_table(\n "{table}",')
next_table = SOURCE.find("op.create_table(", start + 1) next_table = COMBINED_SOURCE.find("op.create_table(", start + 1)
end = next_table if next_table != -1 else len(SOURCE) end = next_table if next_table != -1 else len(COMBINED_SOURCE)
return SOURCE[start:end] return COMBINED_SOURCE[start:end]
def test_migration_creates_every_model_table() -> None: def test_migrations_create_every_model_table() -> None:
created = set(re.findall(r'op\.create_table\(\n\s+"(\w+)"', SOURCE)) created = set(re.findall(r'op\.create_table\(\n\s+"(\w+)"', COMBINED_SOURCE))
assert created == MODEL_TABLES, ( assert created == MODEL_TABLES, (
f"Миграция и модели разошлись. Только в моделях: {MODEL_TABLES - created}; " f"Миграции и модели разошлись. Только в моделях: {MODEL_TABLES - created}; "
f"только в миграции: {created - MODEL_TABLES}" f"только в миграциях: {created - MODEL_TABLES}"
) )
def test_downgrade_drops_every_created_table() -> None: def test_each_migration_downgrade_drops_its_own_tables() -> None:
downgrade = SOURCE[SOURCE.index("def downgrade()") :] """Каждая миграция откатывает ровно те таблицы, которые сама создаёт."""
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)) dropped = set(re.findall(r'op\.drop_table\("(\w+)"\)', downgrade))
assert dropped == MODEL_TABLES, f"downgrade не удаляет: {MODEL_TABLES - dropped}" assert dropped == created, f"{name}: downgrade не удаляет {created - dropped}"
@pytest.mark.parametrize("table_name", sorted(MODEL_TABLES)) @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)) @pytest.mark.parametrize("table_name", sorted(MODEL_TABLES))
def test_indexes_match(table_name: str) -> None: def test_indexes_match(table_name: str) -> None:
model_indexes = {index.name for index in Base.metadata.tables[table_name].indexes} 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 missing = model_indexes - migration_indexes
assert not missing, f"Таблица {table_name}: миграция не создаёт индексы {missing}" 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)
+10
View File
@@ -0,0 +1,10 @@
import { apiFetch } from '@/api/client'
import type { Order } from '@/features/orders/types'
export function getOrders(hasReceipt: boolean): Promise<Order[]> {
return apiFetch<Order[]>(`/orders?has_receipt=${hasReceipt}`)
}
export function deleteOrder(orderId: string): Promise<void> {
return apiFetch<void>(`/orders/${orderId}`, { method: 'DELETE' })
}
+21
View File
@@ -0,0 +1,21 @@
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 100;
}
.modal-content {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
box-shadow: var(--shadow-card);
max-width: 640px;
width: 100%;
max-height: 90vh;
overflow-y: auto;
}
+27
View File
@@ -0,0 +1,27 @@
import { useEffect } from 'react'
import type { ReactNode } from 'react'
import '@/components/Modal.css'
interface ModalProps {
onClose: () => void
children: ReactNode
}
export function Modal({ onClose, children }: ModalProps) {
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKeyDown)
return () => document.removeEventListener('keydown', onKeyDown)
}, [onClose])
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(event) => event.stopPropagation()}>
{children}
</div>
</div>
)
}
@@ -0,0 +1,113 @@
.order-modal {
padding: 20px 24px;
display: flex;
flex-direction: column;
gap: 16px;
}
.order-modal-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.order-modal-header h3 {
margin: 0;
font-size: 17px;
}
.order-modal-close {
border: none;
background: none;
color: var(--color-text-muted);
font-size: 22px;
line-height: 1;
cursor: pointer;
padding: 4px;
}
.order-modal-close:hover {
color: var(--color-text);
}
.order-modal-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 20px;
font-size: 14px;
}
.order-modal-meta div {
display: flex;
flex-direction: column;
gap: 2px;
}
.order-modal-label {
font-size: 11px;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.order-modal-notes p {
margin: 4px 0 0;
font-size: 13px;
white-space: pre-wrap;
color: var(--color-text-muted);
}
.order-modal-goods {
width: 100%;
border-collapse: collapse;
font-size: 13px;
}
.order-modal-goods th,
.order-modal-goods td {
padding: 8px 10px;
text-align: left;
border-bottom: 1px solid var(--color-border);
}
.order-modal-goods th {
color: var(--color-text-muted);
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.order-modal-goods-empty {
text-align: center;
color: var(--color-text-muted);
padding: 16px 0;
}
.order-modal-total {
display: flex;
justify-content: flex-end;
gap: 12px;
font-size: 16px;
font-weight: 700;
}
.order-modal-actions {
display: flex;
justify-content: flex-end;
}
.order-modal-close-btn {
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
border-radius: 8px;
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
}
.order-modal-close-btn:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
@@ -0,0 +1,98 @@
import { Modal } from '@/components/Modal'
import '@/features/orders/OrderDetailModal.css'
import type { Order } from '@/features/orders/types'
interface OrderDetailModalProps {
order: Order
onClose: () => void
}
export function OrderDetailModal({ order, onClose }: OrderDetailModalProps) {
return (
<Modal onClose={onClose}>
<div className="order-modal">
<div className="order-modal-header">
<h3>Просмотр заказа {order.id}</h3>
<button type="button" className="order-modal-close" onClick={onClose} aria-label="Закрыть">
×
</button>
</div>
<div className="order-modal-meta">
<div>
<span className="order-modal-label">Дата</span>
<span>{order.create_date_time}</span>
</div>
<div>
<span className="order-modal-label">Номер ТТН</span>
<span>{order.waybill_number || '—'}</span>
</div>
<div>
<span className="order-modal-label">Клиент</span>
<span>{order.recipient_name || '—'}</span>
</div>
<div>
<span className="order-modal-label">Телефон</span>
<span>{order.recipient_phone || '—'}</span>
</div>
{order.recipient_email && (
<div>
<span className="order-modal-label">Email</span>
<span>{order.recipient_email}</span>
</div>
)}
</div>
{order.notes && (
<div className="order-modal-notes">
<span className="order-modal-label">Заметки</span>
<p>{order.notes}</p>
</div>
)}
<table className="order-modal-goods">
<thead>
<tr>
<th>Наименование</th>
<th>SKU</th>
<th>Цена</th>
<th>Кол-во</th>
<th>Скидка</th>
<th>Сумма</th>
</tr>
</thead>
<tbody>
{order.goods.map((good) => (
<tr key={good.id}>
<td>{good.name}</td>
<td>{good.sku}</td>
<td>{good.price}</td>
<td>{good.quantity}</td>
<td>{good.discount_amount && good.discount_amount !== '0.00' ? good.discount_amount : '—'}</td>
<td>{good.amount}</td>
</tr>
))}
{order.goods.length === 0 && (
<tr>
<td colSpan={6} className="order-modal-goods-empty">
Товары не указаны
</td>
</tr>
)}
</tbody>
</table>
<div className="order-modal-total">
<span>Итого</span>
<span>{order.total_amount} ₴</span>
</div>
<div className="order-modal-actions">
<button type="button" className="order-modal-close-btn" onClick={onClose}>
Закрыть
</button>
</div>
</div>
</Modal>
)
}
+28
View File
@@ -0,0 +1,28 @@
/**
* Типы, зеркалящие backend/app/schemas/orders.py (`OrderRowOut`/`OrderGoodOut`).
* Меняются синхронно с ними вручную — см. пояснение в @/api/types.ts.
*/
export interface OrderGood {
id: string
sku: string
name: string
price: string
quantity: string
discount_amount: string | null
discount_percent: string | null
amount: string
}
export interface Order {
id: string
create_date_time: string
recipient_name: string | null
recipient_phone: string | null
recipient_email: string | null
waybill_number: string | null
notes: string | null
total_amount: string
goods: OrderGood[]
has_receipt: boolean
}
@@ -0,0 +1,7 @@
import { useQuery } from '@tanstack/react-query'
import { getOrders } from '@/api/orders'
export function useOrders(hasReceipt: boolean) {
return useQuery({ queryKey: ['orders', hasReceipt], queryFn: () => getOrders(hasReceipt) })
}
+75
View File
@@ -85,6 +85,32 @@
cursor: not-allowed; cursor: not-allowed;
} }
.orders-tabs {
display: flex;
gap: 8px;
}
.orders-tab {
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text-muted);
border-radius: 999px;
padding: 6px 16px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.orders-tab:hover {
color: var(--color-text);
}
.orders-tab--active {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.orders-filterbar { .orders-filterbar {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -198,6 +224,11 @@
color: #166534; color: #166534;
} }
.orders-status--approved {
background: #dcfce7;
color: #166534;
}
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
.orders-status--processing { .orders-status--processing {
background: rgba(217, 119, 6, 0.18); background: rgba(217, 119, 6, 0.18);
@@ -213,6 +244,50 @@
background: rgba(22, 163, 74, 0.18); background: rgba(22, 163, 74, 0.18);
color: #86efac; color: #86efac;
} }
.orders-status--approved {
background: rgba(22, 163, 74, 0.18);
color: #86efac;
}
}
.orders-actions-cell {
display: flex;
gap: 8px;
}
.orders-view-btn {
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
border-radius: 6px;
padding: 6px 12px;
font-size: 13px;
cursor: pointer;
}
.orders-view-btn:hover {
border-color: var(--color-primary);
color: var(--color-primary);
}
.orders-delete-btn {
border: 1px solid var(--color-border);
background: var(--color-surface);
color: #b91c1c;
border-radius: 6px;
padding: 6px 12px;
font-size: 13px;
cursor: pointer;
}
.orders-delete-btn:hover:not(:disabled) {
border-color: #b91c1c;
}
.orders-delete-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
} }
.orders-receipt-btn { .orders-receipt-btn {
+90 -45
View File
@@ -1,7 +1,12 @@
import { useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { deleteOrder } from '@/api/orders'
import '@/pages/DashboardPage.css' import '@/pages/DashboardPage.css'
import { useAuth } from '@/features/auth/useAuth' import { useAuth } from '@/features/auth/useAuth'
import { OrderDetailModal } from '@/features/orders/OrderDetailModal'
import type { Order } from '@/features/orders/types'
import { useOrders } from '@/features/orders/useOrders'
const ROLE_LABEL: Record<string, string> = { const ROLE_LABEL: Record<string, string> = {
admin: 'Администратор', admin: 'Администратор',
@@ -9,53 +14,36 @@ const ROLE_LABEL: Record<string, string> = {
viewer: 'Наблюдатель', viewer: 'Наблюдатель',
} }
type OrderStatus = 'processing' | 'new' | 'delivered' type Tab = 'no_receipt' | 'has_receipt'
const STATUS_LABEL: Record<OrderStatus, string> = { const TABS: { key: Tab; label: string }[] = [
processing: 'Обработка', { key: 'no_receipt', label: 'Без чека' },
new: 'Новый', { key: 'has_receipt', label: 'Выписаны чеки' },
delivered: 'Доставлен',
}
interface OrderRow {
id: string
ttn: string
client: string
totalLabel: string
prepayment: string
status: OrderStatus
fiscalized: boolean
}
/**
* Вёрстка макета из ТЗ — статичные данные, реальная очередь «доставлено, чек
* не пробит» приедет на этапе 3+ вместе с CrmClient/StubCrmClient и заказами
* из БД. Пока не подключаем это к API, чтобы не гадать форму данных CRM.
*/
const MOCK_ORDERS: OrderRow[] = [
{ id: '#ORD-24810', ttn: '20400123456', client: 'Иван Смирнов', totalLabel: '45 990 ₴', prepayment: '', status: 'processing', fiscalized: false },
{ id: '#ORD-24809', ttn: '20400123457', client: 'Ольга Ткаченко', totalLabel: '12 450 ₴', prepayment: '0%', status: 'new', fiscalized: false },
{ id: '#ORD-24808', ttn: '20400123458', client: 'Иван Смирнов', totalLabel: '45 990 ₴', prepayment: '15%', status: 'new', fiscalized: false },
{ id: '#ORD-24807', ttn: '20400123459', client: 'Марія Бondar', totalLabel: '8 200 ₴', prepayment: '20%', status: 'delivered', fiscalized: false },
{ id: '#ORD-24806', ttn: '20400123460', client: 'Петро Коваль', totalLabel: '31 000 ₴', prepayment: '0%', status: 'delivered', fiscalized: false },
{ id: '#ORD-24805', ttn: '20400123461', client: 'Іван Смирнов', totalLabel: '45 990 ₴', prepayment: '0%', status: 'delivered', fiscalized: false },
] ]
export function DashboardPage() { export function DashboardPage() {
const { user, logout } = useAuth() const { user, logout } = useAuth()
const [tab, setTab] = useState<Tab>('no_receipt')
const { data: orders, isLoading, isError } = useOrders(tab === 'has_receipt')
const queryClient = useQueryClient()
const [selected, setSelected] = useState<Set<string>>(new Set()) const [selected, setSelected] = useState<Set<string>>(new Set())
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [viewingOrder, setViewingOrder] = useState<Order | null>(null)
const [deletingId, setDeletingId] = useState<string | null>(null)
const canDelete = user?.role !== 'viewer'
const filtered = useMemo(() => { const filtered = useMemo(() => {
const query = search.trim().toLowerCase() const query = search.trim().toLowerCase()
if (!query) return MOCK_ORDERS const list = orders ?? []
return MOCK_ORDERS.filter( if (!query) return list
return list.filter(
(order) => (order) =>
order.id.toLowerCase().includes(query) || order.id.toLowerCase().includes(query) ||
order.ttn.includes(query) || (order.waybill_number ?? '').includes(query) ||
order.client.toLowerCase().includes(query), (order.recipient_name ?? '').toLowerCase().includes(query),
) )
}, [search]) }, [orders, search])
const allSelected = filtered.length > 0 && filtered.every((order) => selected.has(order.id)) const allSelected = filtered.length > 0 && filtered.every((order) => selected.has(order.id))
@@ -72,6 +60,21 @@ export function DashboardPage() {
}) })
} }
async function handleDelete(order: Order) {
setDeletingId(order.id)
try {
await deleteOrder(order.id)
setSelected((prev) => {
const next = new Set(prev)
next.delete(order.id)
return next
})
await queryClient.invalidateQueries({ queryKey: ['orders'] })
} finally {
setDeletingId(null)
}
}
return ( return (
<div className="dashboard-shell"> <div className="dashboard-shell">
<header className="dashboard-topbar"> <header className="dashboard-topbar">
@@ -93,6 +96,19 @@ export function DashboardPage() {
</button> </button>
</div> </div>
<div className="orders-tabs">
{TABS.map((t) => (
<button
key={t.key}
type="button"
className={`orders-tab${tab === t.key ? ' orders-tab--active' : ''}`}
onClick={() => setTab(t.key)}
>
{t.label}
</button>
))}
</div>
<div className="orders-filterbar"> <div className="orders-filterbar">
<span className="orders-selected-count">Выбрано: {selected.size} заказов</span> <span className="orders-selected-count">Выбрано: {selected.size} заказов</span>
<input <input
@@ -124,7 +140,23 @@ export function DashboardPage() {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{filtered.map((order) => ( {isLoading && (
<tr>
<td colSpan={8} className="orders-empty">
Загрузка…
</td>
</tr>
)}
{isError && (
<tr>
<td colSpan={8} className="orders-empty">
Не удалось загрузить заказы из CRM
</td>
</tr>
)}
{!isLoading &&
!isError &&
filtered.map((order) => (
<tr key={order.id}> <tr key={order.id}>
<td> <td>
<input <input
@@ -135,25 +167,36 @@ export function DashboardPage() {
/> />
</td> </td>
<td>{order.id}</td> <td>{order.id}</td>
<td>{order.ttn}</td> <td>{order.waybill_number || '—'}</td>
<td>{order.client}</td> <td>{order.recipient_name || '—'}</td>
<td>{order.totalLabel}</td> <td>{order.total_amount} ₴</td>
<td> <td>
<input type="text" className="orders-prepayment-input" defaultValue={order.prepayment} placeholder="0%" /> <input type="text" className="orders-prepayment-input" placeholder="0%" />
</td> </td>
<td> <td>
<span className={`orders-status orders-status--${order.status}`}> <span className="orders-status orders-status--approved">Одобрен</span>
{STATUS_LABEL[order.status]}
</span>
</td> </td>
<td> <td className="orders-actions-cell">
<button type="button" className="orders-receipt-btn" disabled={order.status !== 'delivered'}> <button type="button" className="orders-view-btn" onClick={() => setViewingOrder(order)}>
Просмотр
</button>
<button type="button" className="orders-receipt-btn" disabled>
Чек Чек
</button> </button>
{canDelete && (
<button
type="button"
className="orders-delete-btn"
disabled={deletingId === order.id}
onClick={() => void handleDelete(order)}
>
Удалить
</button>
)}
</td> </td>
</tr> </tr>
))} ))}
{filtered.length === 0 && ( {!isLoading && !isError && filtered.length === 0 && (
<tr> <tr>
<td colSpan={8} className="orders-empty"> <td colSpan={8} className="orders-empty">
Ничего не найдено Ничего не найдено
@@ -164,6 +207,8 @@ export function DashboardPage() {
</table> </table>
</div> </div>
</main> </main>
{viewingOrder && <OrderDetailModal order={viewingOrder} onClose={() => setViewingOrder(null)} />}
</div> </div>
) )
} }