Allow editing orders in the order card

Cashiers can edit recipient, TTN, notes, goods and total in the order
modal and save via PATCH /orders/{id}. Edited orders get edited_at and
are no longer overwritten by CRM sync. Editing is blocked once a receipt
exists; changing the TTN resets Nova Poshta tracking fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 17:23:41 +03:00
co-authored by Claude Opus 5.5
parent cbf9832e5b
commit 56ac0fc370
16 changed files with 946 additions and 58 deletions
+127 -3
View File
@@ -1,10 +1,16 @@
"""Юнит-тесты чистых хелперов сервиса заказов. Без БД и без сети."""
"""Юнит-тесты сервиса заказов. Без БД и без сети — сессия подменяется заглушкой."""
from __future__ import annotations
from datetime import datetime
import asyncio
from datetime import UTC, datetime
from typing import Any
from app.services.orders import _parse_crm_datetime, _to_kopecks
import pytest
from app.db.models.order import Order
from app.schemas.orders import OrderUpdateIn
from app.services.orders import OrderEditError, _parse_crm_datetime, _to_kopecks, update_order
class TestToKopecks:
@@ -24,3 +30,121 @@ class TestToKopecks:
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]