Orders whose Nova Poshta status is a refusal (102/103/105/108) now show only under «Отказы», whether or not they have a receipt. GET /orders takes tab=no_receipt|has_receipt|refused instead of has_receipt. NP statuses are also polled for orders that already have a receipt, until NP reports a final status. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
193 lines
6.6 KiB
Python
193 lines
6.6 KiB
Python
"""Юнит-тесты сервиса заказов. Без БД и без сети — сессия подменяется заглушкой."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
from datetime import UTC, datetime
|
||
from typing import Any
|
||
|
||
import pytest
|
||
from sqlalchemy.dialects import postgresql
|
||
|
||
from app.db.models.order import Order
|
||
from app.schemas.orders import OrderUpdateIn
|
||
from app.services.orders import (
|
||
OrderEditError,
|
||
OrderTab,
|
||
_parse_crm_datetime,
|
||
_to_kopecks,
|
||
list_orders,
|
||
update_order,
|
||
)
|
||
|
||
|
||
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)
|
||
|
||
|
||
class _FakeSession:
|
||
def __init__(self, order: Order | None) -> None:
|
||
self.order = order
|
||
|
||
async def scalar(self, query: Any) -> Order | None:
|
||
return self.order
|
||
|
||
|
||
def _order(**overrides: Any) -> Order:
|
||
fields: dict[str, Any] = {
|
||
"id": "1",
|
||
"recipient_name": "Тест",
|
||
"recipient_phone": "0501112233",
|
||
"recipient_email": None,
|
||
"waybill_number": "20450000000001",
|
||
"notes": None,
|
||
"total_amount_kopecks": 120000,
|
||
"goods": [],
|
||
"is_deleted": False,
|
||
"receipt_created_at": None,
|
||
"edited_at": None,
|
||
"np_status": "Прибув",
|
||
"np_status_code": "7",
|
||
"np_cod_amount_kopecks": 120000,
|
||
"np_payment_status": "NeedPayment",
|
||
}
|
||
fields.update(overrides)
|
||
return Order(**fields)
|
||
|
||
|
||
_GOOD = {
|
||
"id": "1",
|
||
"sku": "A",
|
||
"name": "Товар",
|
||
"price": "600.00",
|
||
"quantity": "2",
|
||
"discount_amount": "50.00",
|
||
}
|
||
|
||
|
||
def _payload(**overrides: Any) -> OrderUpdateIn:
|
||
data: dict[str, Any] = {
|
||
"recipient_name": "Тест",
|
||
"recipient_phone": "0501112233",
|
||
"recipient_email": "",
|
||
"waybill_number": "20450000000001",
|
||
"notes": "",
|
||
"total_amount": "1100.00",
|
||
"goods": [_GOOD],
|
||
}
|
||
data.update(overrides)
|
||
return OrderUpdateIn.model_validate(data)
|
||
|
||
|
||
def _update(order: Order | None, payload: OrderUpdateIn) -> tuple[Order, list[str]]:
|
||
result = asyncio.run(update_order(_FakeSession(order), "1", payload)) # type: ignore[arg-type]
|
||
assert result is not None
|
||
return result
|
||
|
||
|
||
class TestUpdateOrder:
|
||
def test_recomputes_line_amount_and_marks_edited(self) -> None:
|
||
order, changed = _update(_order(), _payload())
|
||
|
||
assert order.goods[0]["amount"] == "1150.00"
|
||
assert order.goods[0]["quantity"] == "2.000"
|
||
assert order.total_amount_kopecks == 110000
|
||
assert order.edited_at is not None
|
||
assert set(changed) == {"goods", "total_amount"}
|
||
|
||
def test_blank_strings_become_null(self) -> None:
|
||
order, _ = _update(_order(), _payload(notes=" "))
|
||
|
||
assert order.notes is None
|
||
assert order.recipient_email is None
|
||
|
||
def test_new_line_gets_generated_id(self) -> None:
|
||
good = {"sku": "", "name": "Новый", "price": "10.00", "quantity": "1"}
|
||
order, _ = _update(_order(), _payload(goods=[good], total_amount="10.00"))
|
||
|
||
assert order.goods[0]["id"].startswith("new-")
|
||
|
||
def test_waybill_change_resets_np_tracking(self) -> None:
|
||
order, changed = _update(_order(), _payload(waybill_number="20450000000002"))
|
||
|
||
assert "waybill_number" in changed
|
||
assert order.np_status is None
|
||
assert order.np_cod_amount_kopecks is None
|
||
|
||
def test_no_changes_does_not_mark_edited(self) -> None:
|
||
# Снимок в формате CRM ("2", а не "2.000") — формат строк не считается правкой.
|
||
stored = {**_GOOD, "discount_percent": "4.17", "amount": "1150.00"}
|
||
order, changed = _update(_order(goods=[stored], total_amount_kopecks=110000), _payload())
|
||
|
||
assert changed == []
|
||
assert order.edited_at is None
|
||
assert order.goods == [stored]
|
||
|
||
def test_total_above_goods_sum_is_rejected(self) -> None:
|
||
with pytest.raises(OrderEditError, match="больше суммы товаров"):
|
||
_update(_order(), _payload(total_amount="1200.00"))
|
||
|
||
def test_discount_above_line_sum_is_rejected(self) -> None:
|
||
good = {"name": "Товар", "price": "10.00", "quantity": "1", "discount_amount": "11"}
|
||
with pytest.raises(OrderEditError, match="скидка больше"):
|
||
_update(_order(), _payload(goods=[good], total_amount="0"))
|
||
|
||
def test_order_with_receipt_is_locked(self) -> None:
|
||
with pytest.raises(OrderEditError, match="уже создан чек"):
|
||
_update(_order(receipt_created_at=datetime.now(UTC)), _payload())
|
||
|
||
def test_missing_or_deleted_order_returns_none(self) -> None:
|
||
payload = _payload()
|
||
assert asyncio.run(update_order(_FakeSession(None), "1", payload)) is None # type: ignore[arg-type]
|
||
deleted = _FakeSession(_order(is_deleted=True))
|
||
assert asyncio.run(update_order(deleted, "1", payload)) is None # type: ignore[arg-type]
|
||
|
||
|
||
class _CapturingSession:
|
||
def __init__(self) -> None:
|
||
self.statement: Any = None
|
||
|
||
async def scalars(self, statement: Any) -> list[Order]:
|
||
self.statement = statement
|
||
return []
|
||
|
||
|
||
def _list_where(tab: OrderTab) -> str:
|
||
"""WHERE-часть запроса вкладки (SELECT перечисляет все колонки, в т.ч. receipt_created_at)."""
|
||
session = _CapturingSession()
|
||
asyncio.run(list_orders(session, tab=tab)) # type: ignore[arg-type]
|
||
sql = str(
|
||
session.statement.compile(
|
||
dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True}
|
||
)
|
||
)
|
||
return sql.split("WHERE", 1)[1]
|
||
|
||
|
||
class TestListOrders:
|
||
def test_refused_tab_filters_by_refusal_codes_only(self) -> None:
|
||
sql = _list_where(OrderTab.REFUSED)
|
||
assert "orders.np_status_code IN ('102', '103', '105', '108')" in sql
|
||
assert "receipt_created_at" not in sql
|
||
|
||
@pytest.mark.parametrize("tab", [OrderTab.NO_RECEIPT, OrderTab.HAS_RECEIPT])
|
||
def test_other_tabs_exclude_refusals(self, tab: OrderTab) -> None:
|
||
sql = _list_where(tab)
|
||
assert "NOT IN ('102', '103', '105', '108')" in sql
|
||
assert "receipt_created_at" in sql
|