Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d13e7ce2b3 | ||
|
|
f4072be451 |
@@ -48,3 +48,15 @@ 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
|
||||
|
||||
# --- Nova Poshta ----------------------------------------------------------
|
||||
# Ключ API кабинета Nova Poshta (my.novaposhta.ua → Настройки → API).
|
||||
NOVA_POSHTA_API_KEY=change-me-novaposhta-apikey
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
|---|---|---|
|
||||
| 1 | Каркас: FastAPI, Postgres, Alembic, Docker, логи, health-check | ✅ готово |
|
||||
| 2 | Аутентификация, роли, журнал аудита | ✅ готово |
|
||||
| 3 | Заказы + заглушка CRM | ⏳ |
|
||||
| 4 | Нова Пошта: статусы и сумма послеоплаты | ⏳ |
|
||||
| 3 | Заказы + заглушка CRM | ✅ готово |
|
||||
| 4 | Нова Пошта: статусы и сумма послеоплаты | ✅ готово |
|
||||
| 5 | Checkbox: смены, чеки, PDF | ⏳ |
|
||||
| 6 | Очередь и редактор чека (фронтенд) | ⏳ |
|
||||
| 7 | Админка | ⏳ |
|
||||
|
||||
@@ -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")
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Статус ТТН Nova Poshta на заказе
|
||||
|
||||
Revision ID: 0003
|
||||
Revises: 0002
|
||||
Create Date: 2026-09-23
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0003"
|
||||
down_revision: str | None = "0002"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("orders", sa.Column("np_status", sa.String(length=255), nullable=True))
|
||||
op.add_column("orders", sa.Column("np_status_code", sa.String(length=16), nullable=True))
|
||||
op.add_column("orders", sa.Column("np_cod_amount_kopecks", sa.BigInteger(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("orders", "np_cod_amount_kopecks")
|
||||
op.drop_column("orders", "np_status_code")
|
||||
op.drop_column("orders", "np_status")
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Статус оплаты ТТН Nova Poshta на заказе
|
||||
|
||||
Revision ID: 0004
|
||||
Revises: 0003
|
||||
Create Date: 2026-09-23
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "0004"
|
||||
down_revision: str | None = "0003"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("orders", sa.Column("np_payment_status", sa.String(length=32), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("orders", "np_payment_status")
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,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)
|
||||
|
||||
@@ -55,6 +55,16 @@ 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
|
||||
|
||||
# --- Nova Poshta ---
|
||||
nova_poshta_api_key: str = ""
|
||||
|
||||
@property
|
||||
def cors_origins(self) -> list[str]:
|
||||
"""Список разрешённых origin'ов из строки через запятую."""
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Локальная копия заказов, синхронизированная из 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)
|
||||
|
||||
# Статус ТТН Nova Poshta — опрашивается ARQ worker'ом раз в минуту для
|
||||
# заказов без чека, у которых есть номер ТТН (см. app/worker.py).
|
||||
np_status: Mapped[str | None] = mapped_column(String(255))
|
||||
np_status_code: Mapped[str | None] = mapped_column(String(16))
|
||||
np_cod_amount_kopecks: Mapped[int | None] = mapped_column(BigInteger)
|
||||
np_payment_status: Mapped[str | None] = mapped_column(String(32))
|
||||
@@ -0,0 +1,107 @@
|
||||
"""Схемы заказов 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
|
||||
np_status: str | None
|
||||
np_status_code: str | None
|
||||
np_cod_amount: str | None
|
||||
np_payment_status: str | None
|
||||
|
||||
@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,
|
||||
np_status=order.np_status,
|
||||
np_status_code=order.np_status_code,
|
||||
np_cod_amount=(
|
||||
f"{order.np_cod_amount_kopecks / 100:.2f}"
|
||||
if order.np_cod_amount_kopecks is not None
|
||||
else None
|
||||
),
|
||||
np_payment_status=order.np_payment_status,
|
||||
)
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Схема статуса ТТН Nova Poshta (`TrackingDocument.getStatusDocuments`).
|
||||
|
||||
Поля валидируются из "сырых" PascalCase-ключей ответа NP через `Field(alias=...)`,
|
||||
как и `OrderOut` для CRM. Суммы остаются строками "как есть" от NP — в
|
||||
integer-копейки переводится только на границе с персистентной моделью.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class TrackingStatusOut(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
number: str = Field(alias="Number")
|
||||
status: str = Field(alias="Status")
|
||||
status_code: str = Field(alias="StatusCode")
|
||||
payment_method: str | None = Field(default=None, alias="PaymentMethod")
|
||||
scheduled_delivery_date: str | None = Field(default=None, alias="ScheduledDeliveryDate")
|
||||
actual_delivery_date: str | None = Field(default=None, alias="ActualDeliveryDate")
|
||||
|
||||
# Чистая сумма к перечислению продавцу за товар — поле `AfterpaymentOnGoodsCost`
|
||||
# ("Контроль оплати" в кабинете NP). В отличие от `AmountToPay`/
|
||||
# `ExpressWaybillAmountToPay` (сколько получатель должен заплатить НП прямо
|
||||
# сейчас — включает стоимость доставки и комиссию НП и обнуляется после
|
||||
# оплаты), это поле — зафиксированная при создании ТТН сумма за товар без
|
||||
# доставки и комиссии, и не меняется по ходу доставки. `AmountToPay`/
|
||||
# `ExpressWaybillAmountToPay` — запасной вариант на случай, если NP для
|
||||
# какого-то типа накладной не возвращает `AfterpaymentOnGoodsCost`.
|
||||
cod_amount: str | None = Field(default=None)
|
||||
|
||||
# Статус оплаты аналогично раздвоен на `PaymentStatus`/`ExpressWaybillPaymentStatus`.
|
||||
payment_status: str | None = Field(default=None)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _fold_express_fields(cls, data: Any) -> Any:
|
||||
if not isinstance(data, dict):
|
||||
return data
|
||||
data = dict(data)
|
||||
afterpayment = data.get("AfterpaymentOnGoodsCost")
|
||||
fallback = data.get("AmountToPay") or data.get("ExpressWaybillAmountToPay") or None
|
||||
data.setdefault("cod_amount", str(afterpayment) if afterpayment else fallback)
|
||||
data.setdefault(
|
||||
"payment_status",
|
||||
data.get("PaymentStatus") or data.get("ExpressWaybillPaymentStatus") or None,
|
||||
)
|
||||
return data
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Интеграция с CRM (exoCRM).
|
||||
|
||||
См. `client.py` за Protocol и `exo_client.py`/`stub_client.py` за реализациями.
|
||||
"""
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Контрольная сумма запросов к CRM API (см. документацию "My CRM API 1.1")."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _collect_values_sorted(obj: Any) -> list[Any]:
|
||||
"""Рекурсивно сортирует ключи на каждом уровне и собирает значения depth-first."""
|
||||
values: list[Any] = []
|
||||
if isinstance(obj, dict):
|
||||
for key in sorted(obj.keys()):
|
||||
values.extend(_collect_values_sorted(obj[key]))
|
||||
elif isinstance(obj, list):
|
||||
for item in obj:
|
||||
values.extend(_collect_values_sorted(item))
|
||||
else:
|
||||
values.append(obj)
|
||||
return values
|
||||
|
||||
|
||||
def _to_str(value: Any) -> str:
|
||||
# Булевы значения CRM ожидает в контрольной сумме как "1"/"" (PHP-style
|
||||
# truthy-приведение) — это нигде не задокументировано и подобрано опытным
|
||||
# путём: "True"/"False" и "true"/"false" оба дают "Checksum Error".
|
||||
if isinstance(value, bool):
|
||||
return "1" if value else ""
|
||||
return str(value)
|
||||
|
||||
|
||||
def compute_md5sum(payload: dict[str, Any], secret_key: str) -> str:
|
||||
"""Считает md5sum по алгоритму из документации CRM: отсортировать все ключи
|
||||
(включая вложенные), конкатенировать все значения, добавить приватный ключ, взять MD5.
|
||||
"""
|
||||
values = _collect_values_sorted(payload)
|
||||
concatenated = "".join(_to_str(v) for v in values)
|
||||
return hashlib.md5((concatenated + secret_key).encode("utf-8")).hexdigest()
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Protocol клиента CRM — позволяет подменять реализацию в тестах (`StubCrmClient`)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from app.schemas.orders import OrderOut
|
||||
|
||||
|
||||
class CrmError(Exception):
|
||||
"""CRM ответила `status: ERROR` (см. поле `errors` в ответе API)."""
|
||||
|
||||
|
||||
class CrmClient(Protocol):
|
||||
async def get_orders(self, *, status: str) -> list[OrderOut]: ...
|
||||
@@ -0,0 +1,46 @@
|
||||
"""Реальный клиент exoCRM (`GetOrders`)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.schemas.orders import OrderOut
|
||||
from app.services.crm.checksum import compute_md5sum
|
||||
from app.services.crm.client import CrmError
|
||||
|
||||
|
||||
class ExoCrmClient:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._base_url = settings.crm_base_url
|
||||
self._api_key = settings.crm_api_key
|
||||
self._secret_key = settings.crm_secret_key
|
||||
self._shop_key = settings.crm_shop_key
|
||||
self._sid = settings.crm_sid
|
||||
|
||||
async def get_orders(self, *, status: str) -> list[OrderOut]:
|
||||
body = {
|
||||
"apikey": self._api_key,
|
||||
"object": "Orders",
|
||||
"method": "GetOrders",
|
||||
"params": {
|
||||
"sid": self._sid,
|
||||
"key": self._shop_key,
|
||||
"Status": status,
|
||||
"ReturnGoods": True,
|
||||
"ReturnTotals": True,
|
||||
},
|
||||
}
|
||||
body["md5sum"] = compute_md5sum(body, self._secret_key)
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(self._base_url, json=body)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if data.get("status") != "OK":
|
||||
errors = data.get("errors") or {}
|
||||
message = "; ".join(f"{code}: {text}" for code, text in errors.items())
|
||||
raise CrmError(f"CRM вернула ошибку: {message or 'неизвестная ошибка'}")
|
||||
|
||||
return [OrderOut.model_validate(order) for order in data.get("result", [])]
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Фикстурный CRM-клиент для тестов — не ходит в сеть."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.schemas.orders import OrderOut
|
||||
|
||||
_FIXTURE_ORDERS: list[dict] = [
|
||||
{
|
||||
"ID": "100001",
|
||||
"CreateDateTime": "2026-09-20 10:00:00",
|
||||
"RecipientDName": "Тестовий Покупець",
|
||||
"RecipientPhone": "+380501112233",
|
||||
"RecipientEmail": None,
|
||||
"Waybill_Number": "20450123456789",
|
||||
"Notes": "Тестовий заказ",
|
||||
"Total": {
|
||||
"Cost": "0.00",
|
||||
"Quantity": "2",
|
||||
"Weight": "0",
|
||||
"DiscountAmount": "0.00",
|
||||
"DiscountPercent": "0.00",
|
||||
"Amount": "1200.00",
|
||||
},
|
||||
"Goods": [
|
||||
{
|
||||
"ID": "1",
|
||||
"SKU": "SKU-1",
|
||||
"Name": "Товар 1",
|
||||
"Price": "600.00",
|
||||
"Quantity": "2.000",
|
||||
"DiscountAmount": "0.00",
|
||||
"DiscountPercent": "0.00",
|
||||
"Amount": "1200.00",
|
||||
}
|
||||
],
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class StubCrmClient:
|
||||
def __init__(self, orders: list[dict] | None = None) -> None:
|
||||
self._orders = orders if orders is not None else _FIXTURE_ORDERS
|
||||
|
||||
async def get_orders(self, *, status: str) -> list[OrderOut]:
|
||||
return [
|
||||
OrderOut.model_validate(order)
|
||||
for order in self._orders
|
||||
if order.get("Status", status) == status
|
||||
]
|
||||
@@ -0,0 +1,4 @@
|
||||
"""Интеграция с Nova Poshta (статусы ТТН, сумма наложенного платежа).
|
||||
|
||||
См. `client.py` за Protocol и `np_client.py`/`stub_client.py` за реализациями.
|
||||
"""
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Protocol клиента Nova Poshta — позволяет подменять реализацию в тестах.
|
||||
|
||||
См. `StubNovaPoshtaClient`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from app.schemas.tracking import TrackingStatusOut
|
||||
|
||||
|
||||
class NovaPoshtaError(Exception):
|
||||
"""NP API ответил `success: false` (см. поле `errors` в ответе)."""
|
||||
|
||||
|
||||
class NovaPoshtaClient(Protocol):
|
||||
async def get_statuses(self, *, waybill_numbers: list[str]) -> list[TrackingStatusOut]: ...
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Реальный клиент Nova Poshta (`TrackingDocument.getStatusDocuments`)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.schemas.tracking import TrackingStatusOut
|
||||
from app.services.nova_poshta.client import NovaPoshtaError
|
||||
|
||||
_API_URL = "https://api.novaposhta.ua/v2.0/json/"
|
||||
|
||||
# NP отклоняет запросы с более чем 100 накладными за раз.
|
||||
_MAX_DOCUMENTS_PER_REQUEST = 100
|
||||
|
||||
|
||||
class NpTrackingClient:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._api_key = settings.nova_poshta_api_key
|
||||
|
||||
async def get_statuses(self, *, waybill_numbers: list[str]) -> list[TrackingStatusOut]:
|
||||
if not waybill_numbers:
|
||||
return []
|
||||
if len(waybill_numbers) > _MAX_DOCUMENTS_PER_REQUEST:
|
||||
raise NovaPoshtaError(
|
||||
f"Слишком много ТТН за один запрос: {len(waybill_numbers)} "
|
||||
f"(максимум {_MAX_DOCUMENTS_PER_REQUEST})"
|
||||
)
|
||||
|
||||
body = {
|
||||
"apiKey": self._api_key,
|
||||
"modelName": "TrackingDocument",
|
||||
"calledMethod": "getStatusDocuments",
|
||||
"methodProperties": {
|
||||
"Documents": [{"DocumentNumber": number, "Phone": ""} for number in waybill_numbers]
|
||||
},
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30) as client:
|
||||
response = await client.post(_API_URL, json=body)
|
||||
response.raise_for_status()
|
||||
data = response.json()
|
||||
|
||||
if not data.get("success"):
|
||||
errors = data.get("errors") or []
|
||||
message = "; ".join(str(error) for error in errors)
|
||||
raise NovaPoshtaError(f"NP вернул ошибку: {message or 'неизвестная ошибка'}")
|
||||
|
||||
return [TrackingStatusOut.model_validate(item) for item in data.get("data", [])]
|
||||
@@ -0,0 +1,31 @@
|
||||
"""Фикстурный клиент Nova Poshta для тестов — не ходит в сеть."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from app.schemas.tracking import TrackingStatusOut
|
||||
|
||||
_FIXTURE_STATUSES: dict[str, dict] = {
|
||||
"20450123456789": {
|
||||
"Number": "20450123456789",
|
||||
"Status": "Відправлення отримано",
|
||||
"StatusCode": "9",
|
||||
"PaymentMethod": "Cash",
|
||||
"ScheduledDeliveryDate": "22-09-2026 18:00:00",
|
||||
"ActualDeliveryDate": "22-09-2026 15:30:00",
|
||||
"AmountToPay": "1200.00",
|
||||
"AfterpaymentOnGoodsCost": 1200,
|
||||
"PaymentStatus": "Paid",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class StubNovaPoshtaClient:
|
||||
def __init__(self, statuses: dict[str, dict] | None = None) -> None:
|
||||
self._statuses = statuses if statuses is not None else _FIXTURE_STATUSES
|
||||
|
||||
async def get_statuses(self, *, waybill_numbers: list[str]) -> list[TrackingStatusOut]:
|
||||
return [
|
||||
TrackingStatusOut.model_validate(self._statuses[number])
|
||||
for number in waybill_numbers
|
||||
if number in self._statuses
|
||||
]
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Синхронизация локальной очереди заказов с 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
|
||||
from app.services.nova_poshta.client import NovaPoshtaClient
|
||||
|
||||
_CRM_STATUS = "APPROVED"
|
||||
|
||||
# NP отклоняет запросы с более чем 100 накладными за раз (см. np_client.py).
|
||||
_NP_BATCH_SIZE = 100
|
||||
|
||||
|
||||
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 sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None:
|
||||
"""Обновляет статус ТТН и сумму наложенного платежа для заказов без чека.
|
||||
|
||||
Вызывается ARQ worker'ом раз в минуту (см. `app/worker.py`), а не из
|
||||
HTTP-запроса: опрос статусов не должен зависеть от того, открыт ли сейчас
|
||||
дашборд.
|
||||
"""
|
||||
orders = await session.scalars(
|
||||
select(Order)
|
||||
.where(Order.is_deleted.is_(False))
|
||||
.where(Order.receipt_created_at.is_(None))
|
||||
.where(Order.waybill_number.is_not(None))
|
||||
)
|
||||
orders_by_waybill: dict[str, Order] = {order.waybill_number: order for order in orders}
|
||||
if not orders_by_waybill:
|
||||
return
|
||||
|
||||
waybill_numbers = list(orders_by_waybill)
|
||||
for i in range(0, len(waybill_numbers), _NP_BATCH_SIZE):
|
||||
batch = waybill_numbers[i : i + _NP_BATCH_SIZE]
|
||||
statuses = await np.get_statuses(waybill_numbers=batch)
|
||||
for tracking_status in statuses:
|
||||
order = orders_by_waybill.get(tracking_status.number)
|
||||
if order is None:
|
||||
continue
|
||||
order.np_status = tracking_status.status
|
||||
order.np_status_code = tracking_status.status_code
|
||||
order.np_cod_amount_kopecks = (
|
||||
_to_kopecks(tracking_status.cod_amount) if tracking_status.cod_amount else None
|
||||
)
|
||||
order.np_payment_status = tracking_status.payment_status
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,37 @@
|
||||
"""ARQ worker: раз в минуту опрашивает Nova Poshta по заказам без чека.
|
||||
|
||||
Запускается отдельным процессом: `arq app.worker.WorkerSettings`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from arq import cron
|
||||
from arq.connections import RedisSettings
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.logging import configure_logging, get_logger
|
||||
from app.db.session import SessionFactory
|
||||
from app.services.nova_poshta.np_client import NpTrackingClient
|
||||
from app.services.orders import sync_np_statuses
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
|
||||
async def startup(ctx: dict[str, Any]) -> None:
|
||||
configure_logging()
|
||||
ctx["np_client"] = NpTrackingClient(settings)
|
||||
log.info("worker_starting", environment=settings.environment)
|
||||
|
||||
|
||||
async def poll_np_statuses(ctx: dict[str, Any]) -> None:
|
||||
async with SessionFactory() as session:
|
||||
await sync_np_statuses(session, ctx["np_client"])
|
||||
log.info("np_statuses_polled")
|
||||
|
||||
|
||||
class WorkerSettings:
|
||||
redis_settings = RedisSettings.from_dsn(settings.redis_url)
|
||||
on_startup = startup
|
||||
cron_jobs = [cron(poll_np_statuses, minute=set(range(60)), run_at_startup=True)]
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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()") :]
|
||||
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 == MODEL_TABLES, f"downgrade не удаляет: {MODEL_TABLES - dropped}"
|
||||
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}"
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Тесты NpTrackingClient. Сеть замокана через respx — реальных запросов не делает."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
import respx
|
||||
from httpx import Response
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.services.nova_poshta.client import NovaPoshtaError
|
||||
from app.services.nova_poshta.np_client import _API_URL, NpTrackingClient
|
||||
|
||||
STATUS_PAYLOAD = {
|
||||
"Number": "20451540916703",
|
||||
"Status": "Відправник самостійно вказав цю накладну, але ще не надав до відправки",
|
||||
"StatusCode": "1",
|
||||
"PaymentMethod": "Cash",
|
||||
"ScheduledDeliveryDate": "22-09-2026 18:00:00",
|
||||
"ActualDeliveryDate": "",
|
||||
"AmountToPay": "",
|
||||
"ExpressWaybillAmountToPay": "827.48",
|
||||
"AfterpaymentOnGoodsCost": 699,
|
||||
"PaymentStatus": "",
|
||||
"ExpressWaybillPaymentStatus": "NeedPayment",
|
||||
}
|
||||
|
||||
|
||||
def _settings() -> Settings:
|
||||
return Settings(
|
||||
secret_key="test-secret-key",
|
||||
encryption_key="dGVzdC1lbmNyeXB0aW9uLWtleS0zMi1ieXRlcyEh",
|
||||
nova_poshta_api_key="np-apikey-123",
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
|
||||
class TestNpTrackingClientGetStatuses:
|
||||
@respx.mock
|
||||
async def test_sends_expected_request_body(self) -> None:
|
||||
route = respx.post(_API_URL).mock(
|
||||
return_value=Response(200, json={"success": True, "data": [], "errors": []})
|
||||
)
|
||||
client = NpTrackingClient(_settings())
|
||||
|
||||
await client.get_statuses(waybill_numbers=["20451540916703"])
|
||||
|
||||
sent = route.calls.last.request
|
||||
body = json.loads(sent.content)
|
||||
assert body["apiKey"] == "np-apikey-123"
|
||||
assert body["modelName"] == "TrackingDocument"
|
||||
assert body["calledMethod"] == "getStatusDocuments"
|
||||
assert body["methodProperties"]["Documents"] == [
|
||||
{"DocumentNumber": "20451540916703", "Phone": ""}
|
||||
]
|
||||
|
||||
@respx.mock
|
||||
async def test_parses_successful_response_and_prefers_afterpayment_on_goods_cost(
|
||||
self,
|
||||
) -> None:
|
||||
respx.post(_API_URL).mock(
|
||||
return_value=Response(
|
||||
200, json={"success": True, "data": [STATUS_PAYLOAD], "errors": []}
|
||||
)
|
||||
)
|
||||
client = NpTrackingClient(_settings())
|
||||
|
||||
statuses = await client.get_statuses(waybill_numbers=["20451540916703"])
|
||||
|
||||
assert len(statuses) == 1
|
||||
status = statuses[0]
|
||||
assert status.number == "20451540916703"
|
||||
assert status.status_code == "1"
|
||||
# "Контроль оплати" — сумма за товар без стоимости доставки и комиссии НП,
|
||||
# не "сколько заплатить сейчас" (`ExpressWaybillAmountToPay` = 827.48).
|
||||
assert status.cod_amount == "699"
|
||||
assert status.payment_status == "NeedPayment"
|
||||
|
||||
@respx.mock
|
||||
async def test_falls_back_to_amount_to_pay_when_afterpayment_missing(self) -> None:
|
||||
payload = {**STATUS_PAYLOAD, "AfterpaymentOnGoodsCost": 0}
|
||||
respx.post(_API_URL).mock(
|
||||
return_value=Response(200, json={"success": True, "data": [payload], "errors": []})
|
||||
)
|
||||
client = NpTrackingClient(_settings())
|
||||
|
||||
statuses = await client.get_statuses(waybill_numbers=["20451540916703"])
|
||||
|
||||
assert statuses[0].cod_amount == "827.48"
|
||||
|
||||
@respx.mock
|
||||
async def test_raises_nova_poshta_error_on_failure(self) -> None:
|
||||
respx.post(_API_URL).mock(
|
||||
return_value=Response(
|
||||
200, json={"success": False, "data": [], "errors": ["Invalid apiKey"]}
|
||||
)
|
||||
)
|
||||
client = NpTrackingClient(_settings())
|
||||
|
||||
with pytest.raises(NovaPoshtaError, match="Invalid apiKey"):
|
||||
await client.get_statuses(waybill_numbers=["20451540916703"])
|
||||
|
||||
async def test_returns_empty_list_for_no_documents(self) -> None:
|
||||
client = NpTrackingClient(_settings())
|
||||
|
||||
assert await client.get_statuses(waybill_numbers=[]) == []
|
||||
|
||||
async def test_rejects_too_many_documents(self) -> None:
|
||||
client = NpTrackingClient(_settings())
|
||||
|
||||
with pytest.raises(NovaPoshtaError, match="Слишком много"):
|
||||
await client.get_statuses(waybill_numbers=[str(i) for i in range(101)])
|
||||
@@ -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
|
||||
@@ -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)
|
||||
+3
-5
@@ -75,11 +75,9 @@ services:
|
||||
environment:
|
||||
POSTGRES_HOST: postgres
|
||||
REDIS_HOST: redis
|
||||
# Подключается на этапе 4 (Нова Пошта) — сейчас заглушка, чтобы структура
|
||||
# compose не менялась задним числом.
|
||||
command: ["python", "-c", "print('worker placeholder: см. этап 4 плана'); import time; time.sleep(3600)"]
|
||||
# Заглушка ничего не слушает на 8000, а HEALTHCHECK бэкенда унаследован
|
||||
# из общего образа — без отключения контейнер вечно висел бы "unhealthy".
|
||||
command: ["arq", "app.worker.WorkerSettings"]
|
||||
# ARQ ничего не слушает на 8000, а HEALTHCHECK бэкенда унаследован из
|
||||
# общего образа — без отключения контейнер вечно висел бы "unhealthy".
|
||||
healthcheck:
|
||||
disable: true
|
||||
depends_on:
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,116 @@
|
||||
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.np_status || 'Нет данных'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="order-modal-label">Наложенный платёж</span>
|
||||
<span>{order.np_cod_amount ? `${order.np_cod_amount} ₴` : '—'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="order-modal-label">Оплачено</span>
|
||||
<span>
|
||||
{order.np_payment_status === 'Payed'
|
||||
? 'Да'
|
||||
: order.np_payment_status === 'NeedPayment'
|
||||
? 'Нет'
|
||||
: '—'}
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Типы, зеркалящие 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
|
||||
np_status: string | null
|
||||
np_status_code: string | null
|
||||
np_cod_amount: string | null
|
||||
np_payment_status: string | null
|
||||
}
|
||||
@@ -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) })
|
||||
}
|
||||
@@ -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,16 @@
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.orders-status--approved {
|
||||
background: #dcfce7;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.orders-status--danger {
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.orders-status--processing {
|
||||
background: rgba(217, 119, 6, 0.18);
|
||||
@@ -213,6 +249,55 @@
|
||||
background: rgba(22, 163, 74, 0.18);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.orders-status--approved {
|
||||
background: rgba(22, 163, 74, 0.18);
|
||||
color: #86efac;
|
||||
}
|
||||
|
||||
.orders-status--danger {
|
||||
background: rgba(220, 38, 38, 0.18);
|
||||
color: #fca5a5;
|
||||
}
|
||||
}
|
||||
|
||||
.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 {
|
||||
|
||||
@@ -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<string, string> = {
|
||||
admin: 'Администратор',
|
||||
@@ -9,53 +14,50 @@ const ROLE_LABEL: Record<string, string> = {
|
||||
viewer: 'Наблюдатель',
|
||||
}
|
||||
|
||||
type OrderStatus = 'processing' | 'new' | 'delivered'
|
||||
type Tab = 'no_receipt' | 'has_receipt'
|
||||
|
||||
const STATUS_LABEL: Record<OrderStatus, string> = {
|
||||
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: 'Выписаны чеки' },
|
||||
]
|
||||
|
||||
function npStatusTone(order: Order): 'delivered' | 'processing' | 'danger' | 'new' {
|
||||
const text = (order.np_status ?? '').toLowerCase()
|
||||
if (text.includes('видано') || text.includes('отримано')) return 'delivered'
|
||||
if (text.includes('відмова') || text.includes('не забра') || text.includes('поверн')) return 'danger'
|
||||
if (text) return 'processing'
|
||||
return 'new'
|
||||
}
|
||||
|
||||
function paymentBadge(order: Order): { label: string; tone: 'delivered' | 'processing' } | null {
|
||||
if (order.np_payment_status === 'Payed') return { label: 'Оплачено', tone: 'delivered' }
|
||||
if (order.np_payment_status === 'NeedPayment') return { label: 'Не оплачено', tone: 'processing' }
|
||||
return null
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
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 [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 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 +74,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 (
|
||||
<div className="dashboard-shell">
|
||||
<header className="dashboard-topbar">
|
||||
@@ -93,6 +110,19 @@ export function DashboardPage() {
|
||||
</button>
|
||||
</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">
|
||||
<span className="orders-selected-count">Выбрано: {selected.size} заказов</span>
|
||||
<input
|
||||
@@ -118,13 +148,33 @@ export function DashboardPage() {
|
||||
<th>Номер ТТН</th>
|
||||
<th>Клиент</th>
|
||||
<th>Сумма</th>
|
||||
<th>Наложка</th>
|
||||
<th>Оплачено</th>
|
||||
<th>Предоплата</th>
|
||||
<th>Статус</th>
|
||||
<th>Статус ТТН</th>
|
||||
<th>Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((order) => (
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td colSpan={10} className="orders-empty">
|
||||
Загрузка…
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{isError && (
|
||||
<tr>
|
||||
<td colSpan={10} className="orders-empty">
|
||||
Не удалось загрузить заказы из CRM
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!isLoading &&
|
||||
!isError &&
|
||||
filtered.map((order) => {
|
||||
const badge = paymentBadge(order)
|
||||
return (
|
||||
<tr key={order.id}>
|
||||
<td>
|
||||
<input
|
||||
@@ -135,27 +185,49 @@ export function DashboardPage() {
|
||||
/>
|
||||
</td>
|
||||
<td>{order.id}</td>
|
||||
<td>{order.ttn}</td>
|
||||
<td>{order.client}</td>
|
||||
<td>{order.totalLabel}</td>
|
||||
<td>{order.waybill_number || '—'}</td>
|
||||
<td>{order.recipient_name || '—'}</td>
|
||||
<td>{order.total_amount} ₴</td>
|
||||
<td>{order.np_cod_amount ? `${order.np_cod_amount} ₴` : '—'}</td>
|
||||
<td>
|
||||
<input type="text" className="orders-prepayment-input" defaultValue={order.prepayment} placeholder="0%" />
|
||||
{badge ? (
|
||||
<span className={`orders-status orders-status--${badge.tone}`}>{badge.label}</span>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span className={`orders-status orders-status--${order.status}`}>
|
||||
{STATUS_LABEL[order.status]}
|
||||
<input type="text" className="orders-prepayment-input" placeholder="0%" />
|
||||
</td>
|
||||
<td>
|
||||
<span className={`orders-status orders-status--${npStatusTone(order)}`}>
|
||||
{order.np_status || 'Нет данных'}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" className="orders-receipt-btn" disabled={order.status !== 'delivered'}>
|
||||
<td className="orders-actions-cell">
|
||||
<button type="button" className="orders-view-btn" onClick={() => setViewingOrder(order)}>
|
||||
Просмотр
|
||||
</button>
|
||||
<button type="button" className="orders-receipt-btn" disabled>
|
||||
Чек
|
||||
</button>
|
||||
{canDelete && (
|
||||
<button
|
||||
type="button"
|
||||
className="orders-delete-btn"
|
||||
disabled={deletingId === order.id}
|
||||
onClick={() => void handleDelete(order)}
|
||||
>
|
||||
Удалить
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
)
|
||||
})}
|
||||
{!isLoading && !isError && filtered.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={8} className="orders-empty">
|
||||
<td colSpan={10} className="orders-empty">
|
||||
Ничего не найдено
|
||||
</td>
|
||||
</tr>
|
||||
@@ -164,6 +236,8 @@ export function DashboardPage() {
|
||||
</table>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{viewingOrder && <OrderDetailModal order={viewingOrder} onClose={() => setViewingOrder(null)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user