diff --git a/.env.example b/.env.example index e43a803..0cfdf8d 100644 --- a/.env.example +++ b/.env.example @@ -48,3 +48,11 @@ REFRESH_TOKEN_EXPIRE_DAYS=7 FIRST_ADMIN_EMAIL=admin@example.com FIRST_ADMIN_PASSWORD=change-me-admin-password 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 diff --git a/backend/alembic/versions/0002_orders.py b/backend/alembic/versions/0002_orders.py new file mode 100644 index 0000000..402cc90 --- /dev/null +++ b/backend/alembic/versions/0002_orders.py @@ -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") diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index dd84e88..c669e8b 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -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)] diff --git a/backend/app/api/v1/orders.py b/backend/app/api/v1/orders.py new file mode 100644 index 0000000..f2355fd --- /dev/null +++ b/backend/app/api/v1/orders.py @@ -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() diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index a6ddb96..91ca6b6 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -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) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 890c573..258239f 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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'ов из строки через запятую.""" diff --git a/backend/app/db/models/__init__.py b/backend/app/db/models/__init__.py index 6b8b6fa..5f1d3a2 100644 --- a/backend/app/db/models/__init__.py +++ b/backend/app/db/models/__init__.py @@ -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", diff --git a/backend/app/db/models/audit.py b/backend/app/db/models/audit.py index 7c03fd0..f348f0d 100644 --- a/backend/app/db/models/audit.py +++ b/backend/app/db/models/audit.py @@ -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): diff --git a/backend/app/db/models/order.py b/backend/app/db/models/order.py new file mode 100644 index 0000000..4cee3a6 --- /dev/null +++ b/backend/app/db/models/order.py @@ -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) diff --git a/backend/app/schemas/orders.py b/backend/app/schemas/orders.py new file mode 100644 index 0000000..0f4d112 --- /dev/null +++ b/backend/app/schemas/orders.py @@ -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, + ) diff --git a/backend/app/services/crm/__init__.py b/backend/app/services/crm/__init__.py new file mode 100644 index 0000000..513b90e --- /dev/null +++ b/backend/app/services/crm/__init__.py @@ -0,0 +1,4 @@ +"""Интеграция с CRM (exoCRM). + +См. `client.py` за Protocol и `exo_client.py`/`stub_client.py` за реализациями. +""" diff --git a/backend/app/services/crm/checksum.py b/backend/app/services/crm/checksum.py new file mode 100644 index 0000000..8ba69ea --- /dev/null +++ b/backend/app/services/crm/checksum.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() diff --git a/backend/app/services/crm/client.py b/backend/app/services/crm/client.py new file mode 100644 index 0000000..91d1b26 --- /dev/null +++ b/backend/app/services/crm/client.py @@ -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]: ... diff --git a/backend/app/services/crm/exo_client.py b/backend/app/services/crm/exo_client.py new file mode 100644 index 0000000..074f49d --- /dev/null +++ b/backend/app/services/crm/exo_client.py @@ -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", [])] diff --git a/backend/app/services/crm/stub_client.py b/backend/app/services/crm/stub_client.py new file mode 100644 index 0000000..75baf70 --- /dev/null +++ b/backend/app/services/crm/stub_client.py @@ -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 + ] diff --git a/backend/app/services/orders.py b/backend/app/services/orders.py new file mode 100644 index 0000000..363ad3e --- /dev/null +++ b/backend/app/services/orders.py @@ -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 diff --git a/backend/tests/test_crm_checksum.py b/backend/tests/test_crm_checksum.py new file mode 100644 index 0000000..3ce092a --- /dev/null +++ b/backend/tests/test_crm_checksum.py @@ -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") diff --git a/backend/tests/test_crm_client.py b/backend/tests/test_crm_client.py new file mode 100644 index 0000000..08b2974 --- /dev/null +++ b/backend/tests/test_crm_client.py @@ -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") diff --git a/backend/tests/test_migration_matches_models.py b/backend/tests/test_migration_matches_models.py index 2ca6f50..f8b396c 100644 --- a/backend/tests/test_migration_matches_models.py +++ b/backend/tests/test_migration_matches_models.py @@ -1,8 +1,8 @@ -"""Миграция 0001 обязана точно соответствовать моделям. +"""Миграции обязаны точно соответствовать моделям. -Расхождение между `Base.metadata` и миграцией обнаруживается только на живой +Расхождение между `Base.metadata` и миграциями обнаруживается только на живой БД и обычно уже в проде. Проверка статическая: сравниваем таблицы, колонки, -nullability и индексы, которые создаёт миграция, с тем, что описано моделями. +nullability и индексы, которые создают миграции, с тем, что описано моделями. """ from __future__ import annotations @@ -14,10 +14,12 @@ import pytest from app.db.models import Base -MIGRATION = Path(__file__).resolve().parents[1] / "alembic" / "versions" / "0001_users_and_audit.py" -SOURCE = MIGRATION.read_text(encoding="utf-8") +VERSIONS_DIR = Path(__file__).resolve().parents[1] / "alembic" / "versions" +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) @@ -26,24 +28,27 @@ def _migration_block(table: str) -> str: Границей служит следующий op.create_table; для последней таблицы — конец файла. """ - start = SOURCE.index(f'op.create_table(\n "{table}",') - next_table = SOURCE.find("op.create_table(", start + 1) - end = next_table if next_table != -1 else len(SOURCE) - return SOURCE[start:end] + start = COMBINED_SOURCE.index(f'op.create_table(\n "{table}",') + next_table = COMBINED_SOURCE.find("op.create_table(", start + 1) + end = next_table if next_table != -1 else len(COMBINED_SOURCE) + return COMBINED_SOURCE[start:end] -def test_migration_creates_every_model_table() -> None: - created = set(re.findall(r'op\.create_table\(\n\s+"(\w+)"', SOURCE)) +def test_migrations_create_every_model_table() -> None: + created = set(re.findall(r'op\.create_table\(\n\s+"(\w+)"', COMBINED_SOURCE)) assert created == MODEL_TABLES, ( - f"Миграция и модели разошлись. Только в моделях: {MODEL_TABLES - created}; " - f"только в миграции: {created - MODEL_TABLES}" + f"Миграции и модели разошлись. Только в моделях: {MODEL_TABLES - created}; " + f"только в миграциях: {created - MODEL_TABLES}" ) -def test_downgrade_drops_every_created_table() -> None: - downgrade = SOURCE[SOURCE.index("def downgrade()") :] - dropped = set(re.findall(r'op\.drop_table\("(\w+)"\)', downgrade)) - assert dropped == MODEL_TABLES, f"downgrade не удаляет: {MODEL_TABLES - dropped}" +def test_each_migration_downgrade_drops_its_own_tables() -> None: + """Каждая миграция откатывает ровно те таблицы, которые сама создаёт.""" + 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)) + assert dropped == created, f"{name}: downgrade не удаляет {created - dropped}" @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)) def test_indexes_match(table_name: str) -> None: 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 assert not missing, f"Таблица {table_name}: миграция не создаёт индексы {missing}" diff --git a/backend/tests/test_orders_router.py b/backend/tests/test_orders_router.py new file mode 100644 index 0000000..025e170 --- /dev/null +++ b/backend/tests/test_orders_router.py @@ -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 diff --git a/backend/tests/test_orders_service.py b/backend/tests/test_orders_service.py new file mode 100644 index 0000000..6856e43 --- /dev/null +++ b/backend/tests/test_orders_service.py @@ -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) diff --git a/frontend/src/api/orders.ts b/frontend/src/api/orders.ts new file mode 100644 index 0000000..39fa290 --- /dev/null +++ b/frontend/src/api/orders.ts @@ -0,0 +1,10 @@ +import { apiFetch } from '@/api/client' +import type { Order } from '@/features/orders/types' + +export function getOrders(hasReceipt: boolean): Promise { + return apiFetch(`/orders?has_receipt=${hasReceipt}`) +} + +export function deleteOrder(orderId: string): Promise { + return apiFetch(`/orders/${orderId}`, { method: 'DELETE' }) +} diff --git a/frontend/src/components/Modal.css b/frontend/src/components/Modal.css new file mode 100644 index 0000000..3223351 --- /dev/null +++ b/frontend/src/components/Modal.css @@ -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; +} diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx new file mode 100644 index 0000000..725ca67 --- /dev/null +++ b/frontend/src/components/Modal.tsx @@ -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 ( +
+
event.stopPropagation()}> + {children} +
+
+ ) +} diff --git a/frontend/src/features/orders/OrderDetailModal.css b/frontend/src/features/orders/OrderDetailModal.css new file mode 100644 index 0000000..c74a55c --- /dev/null +++ b/frontend/src/features/orders/OrderDetailModal.css @@ -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); +} diff --git a/frontend/src/features/orders/OrderDetailModal.tsx b/frontend/src/features/orders/OrderDetailModal.tsx new file mode 100644 index 0000000..fbd78a0 --- /dev/null +++ b/frontend/src/features/orders/OrderDetailModal.tsx @@ -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 ( + +
+
+

Просмотр заказа {order.id}

+ +
+ +
+
+ Дата + {order.create_date_time} +
+
+ Номер ТТН + {order.waybill_number || '—'} +
+
+ Клиент + {order.recipient_name || '—'} +
+
+ Телефон + {order.recipient_phone || '—'} +
+ {order.recipient_email && ( +
+ Email + {order.recipient_email} +
+ )} +
+ + {order.notes && ( +
+ Заметки +

{order.notes}

+
+ )} + + + + + + + + + + + + + + {order.goods.map((good) => ( + + + + + + + + + ))} + {order.goods.length === 0 && ( + + + + )} + +
НаименованиеSKUЦенаКол-воСкидкаСумма
{good.name}{good.sku}{good.price}{good.quantity}{good.discount_amount && good.discount_amount !== '0.00' ? good.discount_amount : '—'}{good.amount}
+ Товары не указаны +
+ +
+ Итого + {order.total_amount} ₴ +
+ +
+ +
+
+
+ ) +} diff --git a/frontend/src/features/orders/types.ts b/frontend/src/features/orders/types.ts new file mode 100644 index 0000000..1334dc9 --- /dev/null +++ b/frontend/src/features/orders/types.ts @@ -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 +} diff --git a/frontend/src/features/orders/useOrders.ts b/frontend/src/features/orders/useOrders.ts new file mode 100644 index 0000000..8818d31 --- /dev/null +++ b/frontend/src/features/orders/useOrders.ts @@ -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) }) +} diff --git a/frontend/src/pages/DashboardPage.css b/frontend/src/pages/DashboardPage.css index 6c62f96..c17555d 100644 --- a/frontend/src/pages/DashboardPage.css +++ b/frontend/src/pages/DashboardPage.css @@ -85,6 +85,32 @@ 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 { display: flex; align-items: center; @@ -198,6 +224,11 @@ color: #166534; } +.orders-status--approved { + background: #dcfce7; + color: #166534; +} + @media (prefers-color-scheme: dark) { .orders-status--processing { background: rgba(217, 119, 6, 0.18); @@ -213,6 +244,50 @@ background: rgba(22, 163, 74, 0.18); 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 { diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 73f320e..e57d907 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,7 +1,12 @@ +import { useQueryClient } from '@tanstack/react-query' import { useMemo, useState } from 'react' +import { deleteOrder } from '@/api/orders' import '@/pages/DashboardPage.css' 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 = { admin: 'Администратор', @@ -9,53 +14,36 @@ const ROLE_LABEL: Record = { viewer: 'Наблюдатель', } -type OrderStatus = 'processing' | 'new' | 'delivered' +type Tab = 'no_receipt' | 'has_receipt' -const STATUS_LABEL: Record = { - processing: 'Обработка', - new: 'Новый', - 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 }, +const TABS: { key: Tab; label: string }[] = [ + { key: 'no_receipt', label: 'Без чека' }, + { key: 'has_receipt', label: 'Выписаны чеки' }, ] export function DashboardPage() { const { user, logout } = useAuth() + const [tab, setTab] = useState('no_receipt') + const { data: orders, isLoading, isError } = useOrders(tab === 'has_receipt') + const queryClient = useQueryClient() const [selected, setSelected] = useState>(new Set()) const [search, setSearch] = useState('') + const [viewingOrder, setViewingOrder] = useState(null) + const [deletingId, setDeletingId] = useState(null) + + const canDelete = user?.role !== 'viewer' const filtered = useMemo(() => { const query = search.trim().toLowerCase() - if (!query) return MOCK_ORDERS - return MOCK_ORDERS.filter( + const list = orders ?? [] + if (!query) return list + return list.filter( (order) => order.id.toLowerCase().includes(query) || - order.ttn.includes(query) || - order.client.toLowerCase().includes(query), + (order.waybill_number ?? '').includes(query) || + (order.recipient_name ?? '').toLowerCase().includes(query), ) - }, [search]) + }, [orders, search]) 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 (
@@ -93,6 +96,19 @@ export function DashboardPage() {
+
+ {TABS.map((t) => ( + + ))} +
+
Выбрано: {selected.size} заказов - {filtered.map((order) => ( - - - toggleOne(order.id)} - aria-label={`Выбрать заказ ${order.id}`} - /> - - {order.id} - {order.ttn} - {order.client} - {order.totalLabel} - - - - - - {STATUS_LABEL[order.status]} - - - - + {isLoading && ( + + + Загрузка… - ))} - {filtered.length === 0 && ( + )} + {isError && ( + + + Не удалось загрузить заказы из CRM + + + )} + {!isLoading && + !isError && + filtered.map((order) => ( + + + toggleOne(order.id)} + aria-label={`Выбрать заказ ${order.id}`} + /> + + {order.id} + {order.waybill_number || '—'} + {order.recipient_name || '—'} + {order.total_amount} ₴ + + + + + Одобрен + + + + + {canDelete && ( + + )} + + + ))} + {!isLoading && !isError && filtered.length === 0 && ( Ничего не найдено @@ -164,6 +207,8 @@ export function DashboardPage() {
+ + {viewingOrder && setViewingOrder(null)} />} ) }