From a6072800b2521e91db582342cfce2051b973c986 Mon Sep 17 00:00:00 2001 From: Liutenko Oleksandr Date: Fri, 25 Sep 2026 01:23:39 +0300 Subject: [PATCH] Add COD-in-transit summary and received tab to the dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CLAUDE.md | 2 +- backend/app/api/v1/orders.py | 10 ++++- backend/app/schemas/orders.py | 7 ++++ backend/app/services/orders.py | 47 ++++++++++++++++++----- backend/tests/test_orders_router.py | 19 +++++++++ backend/tests/test_orders_service.py | 45 +++++++++++++++++++++- frontend/src/api/orders.ts | 6 ++- frontend/src/features/orders/types.ts | 8 +++- frontend/src/features/orders/useOrders.ts | 13 ++++++- frontend/src/pages/DashboardPage.css | 33 ++++++++++++++++ frontend/src/pages/DashboardPage.tsx | 31 +++++++++++++-- 11 files changed, 202 insertions(+), 19 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index b679a03..2266f55 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -118,7 +118,7 @@ Not yet enforced by types anywhere in the current code, but is a hard project co ## Project status (see the plan for the full roadmap) -Stages 1–4 (scaffolding, auth/audit, CRM order queue, Nova Poshta tracking) are done. The dashboard has three tabs (`GET /orders?tab=no_receipt|has_receipt|refused`, `services/orders.OrderTab`); an order whose NP status code is a refusal (`NP_REFUSAL_STATUS_CODES`) shows only under «Отказы», whether or not it has a receipt. Checkbox ETTN receipts are implemented per `.plans/checkbox-ettn-receipts.md` but not yet verified on a real cash register. +Stages 1–4 (scaffolding, auth/audit, CRM order queue, Nova Poshta tracking) are done. The dashboard has four tabs (`GET /orders?tab=no_receipt|has_receipt|received|refused`, `services/orders.OrderTab`); NP status beats receipt presence: an order whose NP status code is «received» (`NP_RECEIVED_STATUS_CODES`) shows only under «Полученные», a refusal (`NP_REFUSAL_STATUS_CODES`) only under «Отказы», whether or not it has a receipt. Checkbox ETTN receipts are implemented per `.plans/checkbox-ettn-receipts.md` but not yet verified on a real cash register. ### Checkbox ETTN receipts diff --git a/backend/app/api/v1/orders.py b/backend/app/api/v1/orders.py index 0ece734..caa97c8 100644 --- a/backend/app/api/v1/orders.py +++ b/backend/app/api/v1/orders.py @@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status from app.api.deps import CashierUser, CrmClientDep, SessionDep, require_any from app.db.models.audit import AuditAction -from app.schemas.orders import OrderRowOut, OrderUpdateIn +from app.schemas.orders import OrderRowOut, OrdersSummaryOut, OrderUpdateIn from app.services import audit from app.services import orders as orders_service from app.services import receipts as receipts_service @@ -29,6 +29,14 @@ async def list_orders( return [OrderRowOut.from_order(order, receipts.get(order.id)) for order in orders] +@router.get("/summary", response_model=OrdersSummaryOut) +async def orders_summary(session: SessionDep) -> OrdersSummaryOut: + count, kopecks = await orders_service.cod_in_transit(session) + return OrdersSummaryOut( + cod_in_transit_count=count, cod_in_transit_amount=f"{kopecks / 100:.2f}" + ) + + @router.patch("/{order_id}", response_model=OrderRowOut, response_model_by_alias=False) async def update_order( order_id: str, diff --git a/backend/app/schemas/orders.py b/backend/app/schemas/orders.py index 0083537..5007930 100644 --- a/backend/app/schemas/orders.py +++ b/backend/app/schemas/orders.py @@ -122,6 +122,13 @@ class OrderRowOut(BaseModel): ) +class OrdersSummaryOut(BaseModel): + """`GET /orders/summary` — сводка над таблицей заказов.""" + + cod_in_transit_count: int + cod_in_transit_amount: str + + class OrderGoodIn(BaseModel): """Позиция заказа из формы редактирования. `amount` считает сервер.""" diff --git a/backend/app/services/orders.py b/backend/app/services/orders.py index 67761fd..c3c98a4 100644 --- a/backend/app/services/orders.py +++ b/backend/app/services/orders.py @@ -8,7 +8,7 @@ from datetime import UTC, datetime from decimal import ROUND_HALF_UP, Decimal from typing import Any -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.db.models.order import Order @@ -25,9 +25,15 @@ _NP_BATCH_SIZE = 100 # посылку, заказ уходит во вкладку «Отказы». NP_REFUSAL_STATUS_CODES = ("102", "103", "105", "108") -# Коды NP "відправлення отримано" и отказы — после них статус ТТН больше не -# опрашивается. -_NP_FINAL_STATUS_CODES = ("9", "10", "11", *NP_REFUSAL_STATUS_CODES) +# Коды NP "відправлення отримано" (106 — отримано і створено ЄН зворотньої +# доставки) — заказ уходит во вкладку «Полученные». +NP_RECEIVED_STATUS_CODES = ("9", "10", "11", "106") + +# Получено или отказ — после этого статус ТТН больше не опрашивается. +_NP_FINAL_STATUS_CODES = (*NP_RECEIVED_STATUS_CODES, *NP_REFUSAL_STATUS_CODES) + +# "Видалено" / "Номер не знайдено" — денег по такой ТТН не будет. +_NP_DEAD_STATUS_CODES = ("2", "3") # Поля, которые кассир может править в карточке заказа (кроме goods/total). _EDITABLE_FIELDS = ( @@ -43,6 +49,7 @@ class OrderTab(enum.StrEnum): NO_RECEIPT = "no_receipt" HAS_RECEIPT = "has_receipt" REFUSED = "refused" + RECEIVED = "received" class OrderEditError(Exception): @@ -96,14 +103,19 @@ async def sync_orders_from_crm(session: AsyncSession, crm: CrmClient) -> None: async def list_orders(session: AsyncSession, *, tab: OrderTab) -> list[Order]: - """Заказы вкладки. Отказ клиента важнее наличия чека: такой заказ показывается - только в «Отказах», даже если по нему уже создан ЕТТН-чек.""" + """Заказы вкладки. Статус посылки важнее наличия чека: полученный заказ + показывается только в «Полученных», отказ — только в «Отказах», даже если по + нему уже создан ЕТТН-чек.""" refused = Order.np_status_code.in_(NP_REFUSAL_STATUS_CODES) - not_refused = Order.np_status_code.is_(None) | ~refused + received = Order.np_status_code.in_(NP_RECEIVED_STATUS_CODES) + in_transit = Order.np_status_code.is_(None) | Order.np_status_code.not_in( + _NP_FINAL_STATUS_CODES + ) tab_filter = { OrderTab.REFUSED: refused, - OrderTab.NO_RECEIPT: Order.receipt_created_at.is_(None) & not_refused, - OrderTab.HAS_RECEIPT: Order.receipt_created_at.is_not(None) & not_refused, + OrderTab.RECEIVED: received, + OrderTab.NO_RECEIPT: Order.receipt_created_at.is_(None) & in_transit, + OrderTab.HAS_RECEIPT: Order.receipt_created_at.is_not(None) & in_transit, }[tab] result = await session.scalars( select(Order) @@ -114,6 +126,23 @@ async def list_orders(session: AsyncSession, *, tab: OrderTab) -> list[Order]: return list(result) +async def cod_in_transit(session: AsyncSession) -> tuple[int, int]: + """Наложка в пути: (кол-во посылок, сумма в копейках). + + Учитываются только посылки, которые ещё не забрали и по которым нет отказа: + финальный статус NP ("отримано"/отказ) или оплата наложки их исключают. + """ + result = await session.execute( + select(func.count(), func.coalesce(func.sum(Order.np_cod_amount_kopecks), 0)) + .where(Order.is_deleted.is_(False)) + .where(Order.np_cod_amount_kopecks.is_not(None)) + .where(Order.np_status_code.not_in((*_NP_FINAL_STATUS_CODES, *_NP_DEAD_STATUS_CODES))) + .where(Order.np_payment_status.is_distinct_from("Payed")) + ) + count, total = result.one() + return count, total + + async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None: """Обновляет статус ТТН, сумму и статус оплаты наложки по заказам в пути. diff --git a/backend/tests/test_orders_router.py b/backend/tests/test_orders_router.py index aedc2b5..b2b14cb 100644 --- a/backend/tests/test_orders_router.py +++ b/backend/tests/test_orders_router.py @@ -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", + } diff --git a/backend/tests/test_orders_service.py b/backend/tests/test_orders_service.py index b9b4541..391de36 100644 --- a/backend/tests/test_orders_service.py +++ b/backend/tests/test_orders_service.py @@ -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 diff --git a/frontend/src/api/orders.ts b/frontend/src/api/orders.ts index bd6f110..ee5058f 100644 --- a/frontend/src/api/orders.ts +++ b/frontend/src/api/orders.ts @@ -1,10 +1,14 @@ import { apiFetch } from '@/api/client' -import type { Order, OrderTab, OrderUpdate } from '@/features/orders/types' +import type { Order, OrdersSummary, OrderTab, OrderUpdate } from '@/features/orders/types' export function getOrders(tab: OrderTab): Promise { return apiFetch(`/orders?tab=${tab}`) } +export function getOrdersSummary(): Promise { + return apiFetch('/orders/summary') +} + export function deleteOrder(orderId: string): Promise { return apiFetch(`/orders/${orderId}`, { method: 'DELETE' }) } diff --git a/frontend/src/features/orders/types.ts b/frontend/src/features/orders/types.ts index 6843a52..60cc2fb 100644 --- a/frontend/src/features/orders/types.ts +++ b/frontend/src/features/orders/types.ts @@ -6,7 +6,7 @@ import type { ReceiptStatus } from '@/features/receipts/types' /** Вкладка дашборда (`OrderTab` в backend/app/services/orders.py). */ -export type OrderTab = 'no_receipt' | 'has_receipt' | 'refused' +export type OrderTab = 'no_receipt' | 'has_receipt' | 'received' | 'refused' export interface OrderGood { id: string @@ -62,3 +62,9 @@ export interface OrderUpdate { total_amount: string goods: OrderGoodUpdate[] } + +/** Ответ `GET /orders/summary` (`OrdersSummaryOut`). */ +export interface OrdersSummary { + cod_in_transit_count: number + cod_in_transit_amount: string +} diff --git a/frontend/src/features/orders/useOrders.ts b/frontend/src/features/orders/useOrders.ts index 8b9957d..8639682 100644 --- a/frontend/src/features/orders/useOrders.ts +++ b/frontend/src/features/orders/useOrders.ts @@ -1,11 +1,13 @@ import { useQuery } from '@tanstack/react-query' -import { getOrders } from '@/api/orders' +import { getOrders, getOrdersSummary } from '@/api/orders' import type { OrderTab } from '@/features/orders/types' /** Пока чек отправляется в Checkbox — опрашиваем часто, иначе статусы чеков обновляет worker раз в минуту. */ const PENDING_POLL_MS = 3_000 const RECEIPTS_POLL_MS = 30_000 +// Статусы NP обновляет worker раз в минуту — чаще сводку опрашивать незачем. +const SUMMARY_POLL_MS = 60_000 export function useOrders(tab: OrderTab) { return useQuery({ @@ -17,3 +19,12 @@ export function useOrders(tab: OrderTab) { }, }) } + +export function useOrdersSummary() { + return useQuery({ + // Под ключом ['orders', ...] — чтобы invalidateQueries(['orders']) обновлял и сводку. + queryKey: ['orders', 'summary'], + queryFn: getOrdersSummary, + refetchInterval: SUMMARY_POLL_MS, + }) +} diff --git a/frontend/src/pages/DashboardPage.css b/frontend/src/pages/DashboardPage.css index 63e57d5..a19ec83 100644 --- a/frontend/src/pages/DashboardPage.css +++ b/frontend/src/pages/DashboardPage.css @@ -65,6 +65,39 @@ justify-content: space-between; } +.orders-summary { + display: flex; + gap: 16px; + flex-wrap: wrap; +} + +.orders-summary-card { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 220px; + padding: 14px 18px; + background: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: 10px; +} + +.orders-summary-label { + font-size: 13px; + color: var(--color-text-muted); +} + +.orders-summary-value { + font-size: 22px; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.orders-summary-hint { + font-size: 12px; + color: var(--color-text-muted); +} + .orders-create-btn { border: none; background: var(--color-primary); diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index eca6a20..d60b86a 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -8,7 +8,7 @@ import '@/pages/DashboardPage.css' import { useAuth } from '@/features/auth/useAuth' import { OrderDetailModal } from '@/features/orders/OrderDetailModal' import type { Order, OrderTab } from '@/features/orders/types' -import { useOrders } from '@/features/orders/useOrders' +import { useOrders, useOrdersSummary } from '@/features/orders/useOrders' import { defaultPrepayment, prepaymentMatches, toKopecks } from '@/features/receipts/money' import { CANCELLABLE, RECEIPT_STATUS } from '@/features/receipts/types' import type { ReceiptRequestItem } from '@/features/receipts/types' @@ -22,6 +22,7 @@ const ROLE_LABEL: Record = { const TABS: { key: OrderTab; label: string }[] = [ { key: 'no_receipt', label: 'Без чека' }, { key: 'has_receipt', label: 'Выписаны чеки' }, + { key: 'received', label: 'Полученные' }, { key: 'refused', label: 'Отказы' }, ] @@ -44,10 +45,17 @@ function paymentBadge(order: Order): { label: string; tone: 'delivered' | 'proce return null } +const MONEY_FORMAT = new Intl.NumberFormat('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + +function formatMoney(amount: string): string { + return MONEY_FORMAT.format(Number(amount)) +} + export function DashboardPage() { const { user, logout } = useAuth() const [tab, setTab] = useState('no_receipt') const { data: orders, isLoading, isError } = useOrders(tab) + const { data: summary } = useOrdersSummary() const queryClient = useQueryClient() const [selected, setSelected] = useState>(new Set()) const [search, setSearch] = useState('') @@ -102,7 +110,12 @@ export function DashboardPage() { async function handleCancel(order: Order) { if (!order.receipt_id) return - const outcome = tab === 'refused' ? 'Заказ останется в отказах.' : 'Заказ вернётся в очередь.' + const outcome = { + no_receipt: 'Заказ вернётся в очередь.', + has_receipt: 'Заказ вернётся в очередь.', + received: 'Заказ останется в полученных.', + refused: 'Заказ останется в отказах.', + }[tab] if (!window.confirm(`Отменить ЕТТН-чек по заказу ${order.id}? ${outcome}`)) return setCancellingId(order.id) try { @@ -193,6 +206,18 @@ export function DashboardPage() { +
+
+ Наложка в пути + + {summary ? `${formatMoney(summary.cod_in_transit_amount)} ₴` : '—'} + + + {summary ? `${summary.cod_in_transit_count} посылок` : ' '} + +
+
+ {notice && (
    @@ -354,7 +379,7 @@ export function DashboardPage() { Отменить )} - {canDelete && (tab === 'no_receipt' || (tab === 'refused' && !order.has_receipt)) && ( + {canDelete && (tab === 'no_receipt' || ((tab === 'refused' || tab === 'received') && !order.has_receipt)) && (