Add COD-in-transit summary and received tab to the dashboard

- GET /orders/summary: count and sum of cash-on-delivery for parcels not
  yet picked up (excludes received, refused, deleted/unknown TTNs, paid COD);
  shown as a card above the tabs.
- New «Полученные» tab: orders with NP received codes (9, 10, 11, 106) move
  there automatically, regardless of receipt; 106 is now a final status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-25 01:23:39 +03:00
co-authored by Claude Opus 5.5
parent 528a869657
commit a6072800b2
11 changed files with 202 additions and 19 deletions
+19
View File
@@ -79,6 +79,11 @@ 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_cod_in_transit(session: object) -> tuple[int, int]:
return 3, 245050
monkeypatch.setattr(orders_router.orders_service, "cod_in_transit", fake_cod_in_transit)
async def fake_update(
session: object, order_id: str, data: object
) -> tuple[Order, list[str]] | None:
@@ -238,3 +243,17 @@ class TestUpdateOrder:
response = client.patch("/api/v1/orders/1", json={**_UPDATE_BODY, "goods": []})
assert response.status_code == 422
class TestSummary:
@pytest.mark.parametrize("role", [UserRole.ADMIN, UserRole.CASHIER, UserRole.VIEWER])
def test_returns_cod_in_transit(self, client: TestClient, role: UserRole) -> None:
app.dependency_overrides[get_current_user] = lambda: _user(role)
response = client.get("/api/v1/orders/summary")
assert response.status_code == 200
assert response.json() == {
"cod_in_transit_count": 3,
"cod_in_transit_amount": "2450.50",
}
+43 -2
View File
@@ -16,6 +16,7 @@ from app.services.orders import (
OrderTab,
_parse_crm_datetime,
_to_kopecks,
cod_in_transit,
list_orders,
update_order,
)
@@ -185,8 +186,48 @@ class TestListOrders:
assert "orders.np_status_code IN ('102', '103', '105', '108')" in sql
assert "receipt_created_at" not in sql
def test_received_tab_filters_by_received_codes_only(self) -> None:
sql = _list_where(OrderTab.RECEIVED)
assert "orders.np_status_code IN ('9', '10', '11', '106')" 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:
def test_other_tabs_exclude_received_and_refusals(self, tab: OrderTab) -> None:
sql = _list_where(tab)
assert "NOT IN ('102', '103', '105', '108')" in sql
assert "NOT IN ('9', '10', '11', '106', '102', '103', '105', '108')" in sql
assert "receipt_created_at" in sql
class _CapturingExecuteSession:
def __init__(self) -> None:
self.statement: Any = None
async def execute(self, statement: Any) -> Any:
self.statement = statement
class _Result:
def one(self) -> tuple[int, int]:
return 2, 150000
return _Result()
class TestCodInTransit:
def test_excludes_received_refused_and_paid(self) -> None:
session = _CapturingExecuteSession()
result = asyncio.run(cod_in_transit(session)) # type: ignore[arg-type]
sql = str(
session.statement.compile(
dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True}
)
)
assert result == (2, 150000)
assert "sum(orders.np_cod_amount_kopecks)" in sql
assert "orders.is_deleted IS false" in sql
assert "orders.np_cod_amount_kopecks IS NOT NULL" in sql
assert (
"orders.np_status_code NOT IN "
"('9', '10', '11', '106', '102', '103', '105', '108', '2', '3')" in sql
)
assert "orders.np_payment_status IS DISTINCT FROM 'Payed'" in sql