Files
lux_fiscal/backend/tests/test_receipts_service.py
T
lauadminandClaude Opus 5.5 b8f2fe6b6e Translate UI and user-facing messages to Ukrainian (#5)
- All frontend pages, labels, notices and errors; html lang=uk, uk-UA money format
- Brand "Assistant System" in the top bar and page title
- Backend error details returned to the UI (auth, orders, receipts,
  cash registers, Checkbox/CRM/NP errors) and CLI output
- Tests updated for the new messages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:27:39 +03:00

458 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Тесты сервиса ЕТТН-чеков: суммы, валидация, машина состояний.
БД заменена минимальной фейковой сессией (`get` по словарю + счётчик commit),
Checkbox — `StubCheckboxClient`.
"""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
import pytest
from app.core import crypto
from app.db.models.cash_register import CashRegister
from app.db.models.order import Order
from app.db.models.receipt import Receipt, ReceiptStatus
from app.db.models.user import User
from app.schemas.checkbox import EttnStatus
from app.services import receipts as svc
from app.services.checkbox.client import CheckboxRateLimitedError, CheckboxUnavailableError
from app.services.checkbox.stub_client import StubCheckboxClient
def _good(**overrides: Any) -> dict[str, Any]:
good = {
"id": "1",
"sku": "SKU-1",
"name": "Товар 1",
"price": "600.00",
"quantity": "2.000",
"discount_amount": "0.00",
"discount_percent": "0.00",
"amount": "1200.00",
}
return {**good, **overrides}
def _order(**overrides: Any) -> Order:
fields: dict[str, Any] = {
"id": "100",
"create_date_time": datetime(2026, 9, 20, tzinfo=UTC),
"recipient_name": "Тест",
"recipient_phone": "0501112233",
"recipient_email": "a@b.ua",
"waybill_number": "20450123456789",
"notes": None,
"total_amount_kopecks": 120000,
"goods": [_good()],
"is_deleted": False,
"receipt_created_at": None,
"np_status": "В дорозі",
"np_status_code": "5",
"np_cod_amount_kopecks": 120000,
}
fields.update(overrides)
return Order(**fields)
def _register(tax_codes: list[Any] | None = None) -> CashRegister:
return CashRegister(
id=uuid.uuid4(),
name="Каса",
license_key_enc=crypto.encrypt("lic"),
cashier_pin_enc=crypto.encrypt("1111"),
tax_codes=tax_codes or [],
is_active=True,
is_default=True,
)
class TestResolveAmounts:
def test_default_prepayment_is_total_minus_cod(self) -> None:
amounts = svc.resolve_amounts(_order(np_cod_amount_kopecks=100000), None)
assert amounts.prepayment_kopecks == 20000
assert amounts.cod_kopecks == 100000
def test_explicit_prepayment_must_match_cod(self) -> None:
with pytest.raises(svc.ReceiptValidationError, match="≠ післяплата"):
svc.resolve_amounts(_order(np_cod_amount_kopecks=100000), 10000)
@pytest.mark.parametrize(
("overrides", "message"),
[
({"waybill_number": None}, "немає ТТН"),
({"np_cod_amount_kopecks": None}, "контролю оплати"),
({"np_status_code": "9"}, "не в дорозі"),
({"np_cod_amount_kopecks": 130000}, "більша за суму замовлення"),
({"is_deleted": True}, "видалено"),
],
)
def test_rejects(self, overrides: dict[str, Any], message: str) -> None:
with pytest.raises(svc.ReceiptValidationError, match=message):
svc.resolve_amounts(_order(**overrides), None)
class TestBuildBody:
def test_goods_payments_delivery(self) -> None:
order = _order()
body = svc.build_ettn_body(order, _register(), svc.resolve_amounts(order, None))
assert body["provider"] == "novapost"
rb = body["receipt_body"]
assert rb["goods"] == [
{
"good": {"code": "SKU-1", "name": "Товар 1", "price": 60000},
"quantity": 2000,
"is_return": False,
}
]
assert rb["payments"] == [
{
"type": "ETTN",
"label": "Експрес-накладна",
"value": 120000,
"ettn": "20450123456789",
}
]
assert "discounts" not in rb
assert rb["delivery"] == {"phone": "380501112233", "emails": ["a@b.ua"]}
def test_prepayment_line_and_order_discounts_and_tax(self) -> None:
order = _order(
goods=[_good(amount="1100.00")], # скидка по строке 100 ₴
total_amount_kopecks=105000, # ещё 50 ₴ скидки на заказ
np_cod_amount_kopecks=85000, # 200 ₴ предоплаты
)
body = svc.build_ettn_body(order, _register([8]), svc.resolve_amounts(order, 20000))
rb = body["receipt_body"]
assert rb["goods"][0]["good"]["tax"] == [8]
assert rb["goods"][0]["discounts"] == [
{"type": "DISCOUNT", "mode": "VALUE", "value": 10000}
]
# Скидка заказа 50 ₴ + предоплата 200 ₴ — одной «Знижкой», без PRE_PAYMENT.
assert [(d["type"], d["value"]) for d in rb["discounts"]] == [("DISCOUNT", 25000)]
assert rb["payments"][0]["value"] == 85000
def test_all_goods_with_prices_quantities_and_sums(self) -> None:
order = _order(
goods=[
_good(id="1", sku="A", name="Сукня", price="600.00", amount="1200.00"),
_good(id="2", sku="", name="Пояс", price="150.50", quantity="1.000",
amount="150.50"),
_good(id="3", sku="C", name="Тканина", price="99.99", quantity="1.500",
amount="149.99"),
],
total_amount_kopecks=150049,
np_cod_amount_kopecks=130049, # 200 ₴ предоплаты
)
body = svc.build_ettn_body(order, _register(), svc.resolve_amounts(order, 20000))
rb = body["receipt_body"]
assert [
(g["good"]["code"], g["good"]["name"], g["good"]["price"], g["quantity"])
for g in rb["goods"]
] == [
("A", "Сукня", 60000, 2000),
("2", "Пояс", 15050, 1000), # пустой SKU → ID товара
("C", "Тканина", 9999, 1500),
]
# 99.99 × 1.5 = 149.985 → CRM округлила до 149.99, скидки по строке нет.
assert all("discounts" not in g for g in rb["goods"])
goods_total = sum(svc._line_sum(g["good"]["price"], g["quantity"]) for g in rb["goods"])
assert goods_total - 20000 == rb["payments"][0]["value"] == 130049
assert rb["discounts"] == [
{"type": "DISCOUNT", "mode": "VALUE", "value": 20000, "name": "Знижка"}
]
def test_fractional_quantity(self) -> None:
order = _order(
goods=[_good(price="100.00", quantity="2.250", amount="225.00")],
total_amount_kopecks=22500,
np_cod_amount_kopecks=22500,
)
body = svc.build_ettn_body(order, _register(), svc.resolve_amounts(order, None))
assert body["receipt_body"]["goods"][0]["quantity"] == 2250
def test_bad_phone_is_skipped(self) -> None:
order = _order(recipient_phone="12345", recipient_email=None)
body = svc.build_ettn_body(order, _register(), svc.resolve_amounts(order, None))
assert "delivery" not in body["receipt_body"]
class TestRequestReceipts:
"""Чек создаётся от кассы, к которой опрос НП привязал заказ."""
class Session:
def __init__(self, registers: list[CashRegister], orders: list[Order]) -> None:
self.registers, self.orders, self.added = registers, orders, []
async def scalars(self, statement: Any) -> list[Any]:
entity = statement.column_descriptions[0]["entity"]
if entity is CashRegister:
return self.registers
if entity is Order:
return self.orders
return [] # активные чеки заказов
def add(self, obj: Any) -> None:
self.added.append(obj)
@staticmethod
def _user() -> User:
return User(id=uuid.uuid4(), email="c@x.ua")
async def test_uses_order_register(self) -> None:
default, other = _register(), _register()
order = _order(cash_register_id=other.id)
session = self.Session([default, other], [order])
result = await svc.request_receipts(
session, [(order.id, None)], user=self._user() # type: ignore[arg-type]
)
assert result.errors == {}
assert result.created[0].cash_register_id == other.id
async def test_unbound_order_is_rejected(self) -> None:
order = _order(cash_register_id=None)
session = self.Session([_register()], [order])
result = await svc.request_receipts(
session, [(order.id, None)], user=self._user() # type: ignore[arg-type]
)
assert result.created == []
assert "Касу не визначено" in result.errors[order.id]
class FakeSession:
def __init__(self, *objects: Any) -> None:
self.objects = {(type(o), o.id): o for o in objects}
self.commits = 0
async def get(self, model: type, key: Any, **_: Any) -> Any:
return self.objects.get((model, key))
async def commit(self) -> None:
self.commits += 1
def _pending(order: Order, register: CashRegister) -> Receipt:
amounts = svc.resolve_amounts(order, None)
return Receipt(
id=uuid.uuid4(),
order_id=order.id,
cash_register_id=register.id,
waybill_number=order.waybill_number,
total_kopecks=amounts.total_kopecks,
prepayment_kopecks=amounts.prepayment_kopecks,
cod_kopecks=amounts.cod_kopecks,
status=ReceiptStatus.PENDING,
request_body=svc.build_ettn_body(order, register, amounts),
)
class TestCreateEttn:
async def test_pending_becomes_created(self) -> None:
order, register = _order(receipt_created_at=datetime.now(UTC)), _register()
receipt = _pending(order, register)
client = StubCheckboxClient()
await svc.create_ettn_for_receipt(FakeSession(order, register, receipt), client, receipt.id)
assert receipt.status == ReceiptStatus.CREATED
assert receipt.checkbox_ettn_id in client.orders
assert order.receipt_created_at is not None
async def test_second_call_is_noop(self) -> None:
order, register = _order(), _register()
receipt = _pending(order, register)
client = StubCheckboxClient()
session = FakeSession(order, register, receipt)
await svc.create_ettn_for_receipt(session, client, receipt.id)
await svc.create_ettn_for_receipt(session, client, receipt.id)
assert len(client.orders) == 1
async def test_rejection_fails_and_releases_order(self) -> None:
order, register = _order(receipt_created_at=datetime.now(UTC)), _register()
receipt = _pending(order, register)
client = StubCheckboxClient()
# Та же ТТН уже привязана — стаб отвечает ошибкой, как Checkbox.
await client.create_ettn(svc.credentials(register), receipt.request_body)
await svc.create_ettn_for_receipt(FakeSession(order, register, receipt), client, receipt.id)
assert receipt.status == ReceiptStatus.FAILED
assert "вже прив'язана" in (receipt.error or "")
assert order.receipt_created_at is None
async def test_unavailable_keeps_pending_then_reconciles(self) -> None:
order, register = _order(), _register()
receipt = _pending(order, register)
client = StubCheckboxClient()
session = FakeSession(order, register, receipt)
real_create = client.create_ettn
async def create_then_timeout(creds: Any, body: dict[str, Any]) -> Any:
await real_create(creds, body) # запрос дошёл до Checkbox…
raise CheckboxUnavailableError("timeout") # …но ответа мы не получили
client.create_ettn = create_then_timeout # type: ignore[method-assign]
await svc.create_ettn_for_receipt(session, client, receipt.id)
assert receipt.status == ReceiptStatus.PENDING and receipt.error
client.create_ettn = real_create # type: ignore[method-assign]
await svc.create_ettn_for_receipt(session, client, receipt.id)
assert receipt.status == ReceiptStatus.CREATED
assert len(client.orders) == 1 # второго чека на ту же ТТН нет
async def test_rate_limit_keeps_pending_without_unknown_outcome(self) -> None:
order, register = _order(receipt_created_at=datetime.now(UTC)), _register()
receipt = _pending(order, register)
client = StubCheckboxClient()
session = FakeSession(order, register, receipt)
async def rate_limited(creds: Any, body: dict[str, Any]) -> Any:
raise CheckboxRateLimitedError("To many requests", retry_after=1)
client.create_ettn = rate_limited # type: ignore[method-assign]
with pytest.raises(CheckboxRateLimitedError):
await svc.create_ettn_for_receipt(session, client, receipt.id)
assert receipt.status == ReceiptStatus.PENDING
assert receipt.error is None # повтор не пойдёт через find_ettn
assert order.receipt_created_at is not None # заказ не вернулся в очередь
assert session.commits == 1 # блокировка строки снята
class TestApplyEttn:
@pytest.mark.parametrize(
("checkbox_status", "expected"),
[
(EttnStatus.CREATED, ReceiptStatus.CREATED),
(EttnStatus.DONE, ReceiptStatus.DONE),
(EttnStatus.DONE_WITHOUT_SMS, ReceiptStatus.DONE),
(EttnStatus.RETURNED, ReceiptStatus.RETURNED),
(EttnStatus.RECEIPT_ERROR, ReceiptStatus.RECEIPT_ERROR),
(EttnStatus.CANCELLED, ReceiptStatus.CANCELLED),
],
)
async def test_status_mapping(self, checkbox_status: str, expected: ReceiptStatus) -> None:
order, register = _order(), _register()
receipt = _pending(order, register)
client = StubCheckboxClient()
await svc.create_ettn_for_receipt(FakeSession(order, register, receipt), client, receipt.id)
client.set_status(receipt.checkbox_ettn_id or "", checkbox_status, raw_error="boom")
ettn = await client.get_ettn(svc.credentials(register), receipt.checkbox_ettn_id or "")
svc._apply_ettn(receipt, ettn)
assert receipt.status == expected
if expected == ReceiptStatus.DONE:
assert receipt.checkbox_receipt_id
if expected == ReceiptStatus.RECEIPT_ERROR:
assert receipt.error == "boom"
class TestCancel:
async def test_cancel_created(self, monkeypatch: pytest.MonkeyPatch) -> None:
order, register = _order(receipt_created_at=datetime.now(UTC)), _register()
receipt = _pending(order, register)
client = StubCheckboxClient()
session = FakeSession(order, register, receipt)
session.add = lambda obj: None # type: ignore[attr-defined]
await svc.create_ettn_for_receipt(session, client, receipt.id)
order.receipt_created_at = datetime.now(UTC)
await svc.cancel_receipt(session, client, receipt.id, user=None) # type: ignore[arg-type]
assert receipt.status == ReceiptStatus.CANCELLED
assert client.orders[receipt.checkbox_ettn_id or ""].status == EttnStatus.CANCELLED
assert order.receipt_created_at is None
async def test_cannot_cancel_done(self) -> None:
order, register = _order(), _register()
receipt = _pending(order, register)
receipt.status = ReceiptStatus.DONE
with pytest.raises(svc.ReceiptStateError):
await svc.cancel_receipt(
FakeSession(order, register, receipt), # type: ignore[arg-type]
StubCheckboxClient(),
receipt.id,
user=None, # type: ignore[arg-type]
)
class CrmSession:
"""Фейковая сессия для sync_crm_statuses: scalars → id, scalar → строка с «блокировкой»."""
def __init__(self, *receipts: Receipt) -> None:
self.receipts = {r.id: r for r in receipts}
self.added: list[Any] = []
self.commits = 0
self.rollbacks = 0
async def scalars(self, _: Any) -> list[uuid.UUID]:
return [r.id for r in self.receipts.values() if r.crm_status_set_at is None]
async def scalar(self, query: Any) -> Receipt | None:
receipt_id = query.whereclause.clauses[0].right.value
receipt = self.receipts[receipt_id]
return receipt if receipt.crm_status_set_at is None else None
def add(self, obj: Any) -> None:
self.added.append(obj)
async def commit(self) -> None:
self.commits += 1
async def rollback(self) -> None:
self.rollbacks += 1
class TestSyncCrmStatuses:
async def test_sets_packed_once(self) -> None:
from app.services.crm.stub_client import StubCrmClient
order, register = _order(), _register()
receipt = _pending(order, register)
receipt.status = ReceiptStatus.CREATED
session = CrmSession(receipt)
crm = StubCrmClient()
await svc.sync_crm_statuses(session, crm) # type: ignore[arg-type]
await svc.sync_crm_statuses(session, crm) # type: ignore[arg-type]
assert crm.statuses == {"100": "PACKED"}
assert receipt.crm_status_set_at is not None
assert session.commits == 1 # второй проход ничего не делает
async def test_crm_failure_is_retried_later(self) -> None:
from app.services.crm.client import CrmError
from app.services.crm.stub_client import StubCrmClient
order, register = _order(), _register()
receipt = _pending(order, register)
receipt.status = ReceiptStatus.CREATED
session = CrmSession(receipt)
crm = StubCrmClient()
async def broken(**_: Any) -> None:
raise CrmError("CRM вернула ошибку")
real_set_status = crm.set_status
crm.set_status = broken # type: ignore[method-assign]
await svc.sync_crm_statuses(session, crm) # type: ignore[arg-type]
assert receipt.crm_status_set_at is None and session.rollbacks == 1
crm.set_status = real_set_status # type: ignore[method-assign]
await svc.sync_crm_statuses(session, crm) # type: ignore[arg-type]
assert crm.statuses == {"100": "PACKED"}