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:
@@ -24,14 +24,18 @@ MODEL_TABLES = set(Base.metadata.tables)
|
||||
|
||||
|
||||
def _migration_block(table: str) -> str:
|
||||
"""Тело вызова op.create_table для указанной таблицы.
|
||||
"""Тело вызова op.create_table для указанной таблицы плюс её поздние op.add_column.
|
||||
|
||||
Границей служит следующий op.create_table; для последней таблицы — конец файла.
|
||||
Границей create_table служит следующий op.create_table или конец файла миграции.
|
||||
"""
|
||||
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]
|
||||
marker = f'op.create_table(\n "{table}",'
|
||||
source = next(text for text in SOURCES.values() if marker in text)
|
||||
start = source.index(marker)
|
||||
next_table = source.find("op.create_table(", start + 1)
|
||||
end = next_table if next_table != -1 else len(source)
|
||||
# Колонки, добавленные позже через op.add_column, — в любой миграции.
|
||||
added = re.findall(rf'op\.add_column\(\s*"{table}",\s*(sa\.Column\(.*?\n)', COMBINED_SOURCE)
|
||||
return source[start:end] + "\n" + "\n".join(added)
|
||||
|
||||
|
||||
def test_migrations_create_every_model_table() -> None:
|
||||
|
||||
@@ -79,6 +79,19 @@ def _patch_orders_service(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(orders_router.orders_service, "list_orders", fake_list)
|
||||
monkeypatch.setattr(orders_router.orders_service, "delete_order", fake_delete)
|
||||
|
||||
async def fake_update(
|
||||
session: object, order_id: str, data: object
|
||||
) -> tuple[Order, list[str]] | None:
|
||||
if order_id == "locked":
|
||||
raise orders_router.orders_service.OrderEditError("По заказу уже создан чек")
|
||||
if order_id != "1":
|
||||
return None
|
||||
order = _order(order_id)
|
||||
order.edited_at = datetime(2026, 9, 24, tzinfo=UTC)
|
||||
return order, ["notes"]
|
||||
|
||||
monkeypatch.setattr(orders_router.orders_service, "update_order", fake_update)
|
||||
|
||||
async def fake_latest(session: object, order_ids: list[str]) -> dict:
|
||||
return {}
|
||||
|
||||
@@ -164,3 +177,56 @@ class TestDeleteOrder:
|
||||
response = client.delete("/api/v1/orders/does-not-exist")
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
_UPDATE_BODY = {
|
||||
"recipient_name": "Тест Тестов",
|
||||
"recipient_phone": "+380501112233",
|
||||
"waybill_number": "20450123456789",
|
||||
"notes": "правка",
|
||||
"total_amount": "1200.00",
|
||||
"goods": [
|
||||
{"id": "1", "sku": "SKU-1", "name": "Товар 1", "price": "1200.00", "quantity": "1"}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class TestUpdateOrder:
|
||||
@pytest.mark.parametrize("role", [UserRole.ADMIN, UserRole.CASHIER])
|
||||
def test_admin_and_cashier_can_update(self, client: TestClient, role: UserRole) -> None:
|
||||
app.dependency_overrides[get_current_user] = lambda: _user(role)
|
||||
|
||||
response = client.patch("/api/v1/orders/1", json=_UPDATE_BODY)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["edited"] is True
|
||||
assert body["goods"][0]["sku"] == "SKU-1"
|
||||
|
||||
def test_viewer_cannot_update(self, client: TestClient) -> None:
|
||||
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.VIEWER)
|
||||
|
||||
response = client.patch("/api/v1/orders/1", json=_UPDATE_BODY)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_unknown_order_is_404(self, client: TestClient) -> None:
|
||||
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER)
|
||||
|
||||
response = client.patch("/api/v1/orders/nope", json=_UPDATE_BODY)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
def test_order_with_receipt_is_409(self, client: TestClient) -> None:
|
||||
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER)
|
||||
|
||||
response = client.patch("/api/v1/orders/locked", json=_UPDATE_BODY)
|
||||
|
||||
assert response.status_code == 409
|
||||
|
||||
def test_empty_goods_is_rejected(self, client: TestClient) -> None:
|
||||
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER)
|
||||
|
||||
response = client.patch("/api/v1/orders/1", json={**_UPDATE_BODY, "goods": []})
|
||||
|
||||
assert response.status_code == 422
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user