Add CRM order queue: live sync, view modal, receipt tabs, delete
Wires the CRM (exoCRM GetOrders) into the dashboard as a locally persisted order queue instead of the previous static mockup: - CrmClient Protocol + ExoCrmClient/StubCrmClient for the CRM's signed JSON-RPC API - Order model + migration, synced from CRM on each queue view; soft-deleted orders stay hidden across re-syncs - GET/DELETE /api/v1/orders with "no receipt"/"receipt issued" tabs (the latter is empty until Checkbox fiscalization lands) - Dashboard: real order list, item-detail modal, tab switcher, one-click delete Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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()") :]
|
||||
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}"
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user