diff --git a/CLAUDE.md b/CLAUDE.md index d1ea60d..3a0840c 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. 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 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. ### Checkbox ETTN receipts diff --git a/backend/app/api/v1/orders.py b/backend/app/api/v1/orders.py index 4ea3057..0ece734 100644 --- a/backend/app/api/v1/orders.py +++ b/backend/app/api/v1/orders.py @@ -10,18 +10,19 @@ from app.schemas.orders import OrderRowOut, OrderUpdateIn from app.services import audit from app.services import orders as orders_service from app.services import receipts as receipts_service +from app.services.orders import OrderTab router = APIRouter(prefix="/orders", tags=["orders"], dependencies=[Depends(require_any)]) @router.get("", response_model=list[OrderRowOut], response_model_by_alias=False) async def list_orders( - session: SessionDep, crm: CrmClientDep, has_receipt: bool = False + session: SessionDep, crm: CrmClientDep, tab: OrderTab = OrderTab.NO_RECEIPT ) -> list[OrderRowOut]: # by_alias=False: без этого FastAPI сериализует вложенные OrderGoodOut через # CRM-алиасы (SKU/Name/...) вместо snake_case полей, которые ждёт фронтенд. await orders_service.sync_orders_from_crm(session, crm) - orders = await orders_service.list_orders(session, has_receipt=has_receipt) + orders = await orders_service.list_orders(session, tab=tab) receipts = await receipts_service.latest_receipts_by_order( session, [order.id for order in orders] ) diff --git a/backend/app/services/orders.py b/backend/app/services/orders.py index b86dee4..67761fd 100644 --- a/backend/app/services/orders.py +++ b/backend/app/services/orders.py @@ -2,6 +2,7 @@ from __future__ import annotations +import enum import uuid from datetime import UTC, datetime from decimal import ROUND_HALF_UP, Decimal @@ -20,6 +21,14 @@ _CRM_STATUS = "APPROVED" # NP отклоняет запросы с более чем 100 накладными за раз (см. np_client.py). _NP_BATCH_SIZE = 100 +# Коды NP "відмова одержувача" / "припинено зберігання" — клиент не забрал +# посылку, заказ уходит во вкладку «Отказы». +NP_REFUSAL_STATUS_CODES = ("102", "103", "105", "108") + +# Коды NP "відправлення отримано" и отказы — после них статус ТТН больше не +# опрашивается. +_NP_FINAL_STATUS_CODES = ("9", "10", "11", *NP_REFUSAL_STATUS_CODES) + # Поля, которые кассир может править в карточке заказа (кроме goods/total). _EDITABLE_FIELDS = ( "recipient_name", @@ -30,6 +39,12 @@ _EDITABLE_FIELDS = ( ) +class OrderTab(enum.StrEnum): + NO_RECEIPT = "no_receipt" + HAS_RECEIPT = "has_receipt" + REFUSED = "refused" + + class OrderEditError(Exception): """Заказ нельзя сохранить — сообщение показывается кассиру.""" @@ -80,21 +95,31 @@ async def sync_orders_from_crm(session: AsyncSession, crm: CrmClient) -> None: await session.commit() -async def list_orders(session: AsyncSession, *, has_receipt: bool) -> list[Order]: - receipt_filter = ( - Order.receipt_created_at.is_not(None) if has_receipt else Order.receipt_created_at.is_(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 + 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, + }[tab] result = await session.scalars( select(Order) .where(Order.is_deleted.is_(False)) - .where(receipt_filter) + .where(tab_filter) .order_by(Order.create_date_time.desc()) ) return list(result) async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None: - """Обновляет статус ТТН и сумму наложенного платежа для заказов без чека. + """Обновляет статус ТТН, сумму и статус оплаты наложки по заказам в пути. + + Опрашиваются и заказы с уже созданным ЕТТН-чеком: именно после создания + чека посылка доезжает и оплачивается, и этот переход кассир должен видеть. + Опрос прекращается, когда NP вернул финальный статус "отримано" или отказ. Вызывается ARQ worker'ом раз в минуту (см. `app/worker.py`), а не из HTTP-запроса: опрос статусов не должен зависеть от того, открыт ли сейчас @@ -103,8 +128,11 @@ async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None: orders = await session.scalars( select(Order) .where(Order.is_deleted.is_(False)) - .where(Order.receipt_created_at.is_(None)) .where(Order.waybill_number.is_not(None)) + .where( + Order.np_status_code.is_(None) + | Order.np_status_code.not_in(_NP_FINAL_STATUS_CODES) + ) ) orders_by_waybill: dict[str, Order] = {order.waybill_number: order for order in orders} if not orders_by_waybill: diff --git a/backend/tests/test_orders_router.py b/backend/tests/test_orders_router.py index 3d3a5d8..aedc2b5 100644 --- a/backend/tests/test_orders_router.py +++ b/backend/tests/test_orders_router.py @@ -69,8 +69,8 @@ def _patch_orders_service(monkeypatch: pytest.MonkeyPatch) -> None: async def fake_sync(session: object, crm: object) -> None: return None - async def fake_list(session: object, *, has_receipt: bool) -> list[Order]: - return [] if has_receipt else [_order()] + async def fake_list(session: object, *, tab: str) -> list[Order]: + return [_order()] if tab == "no_receipt" else [] async def fake_delete(session: object, order_id: str) -> Order | None: return _order(order_id) if order_id == "1" else None @@ -132,7 +132,7 @@ class TestListOrders: ) -> None: app.dependency_overrides[get_current_user] = lambda: _user(role) - response = client.get("/api/v1/orders", params={"has_receipt": "false"}) + response = client.get("/api/v1/orders", params={"tab": "no_receipt"}) assert response.status_code == 200 body = response.json() @@ -142,14 +142,22 @@ class TestListOrders: assert body[0]["has_receipt"] is False assert body[0]["goods"][0]["sku"] == "SKU-1" - def test_has_receipt_tab_is_empty_for_now(self, client: TestClient) -> None: + @pytest.mark.parametrize("tab", ["has_receipt", "refused"]) + def test_other_tabs_are_passed_to_service(self, client: TestClient, tab: str) -> None: app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER) - response = client.get("/api/v1/orders", params={"has_receipt": "true"}) + response = client.get("/api/v1/orders", params={"tab": tab}) assert response.status_code == 200 assert response.json() == [] + def test_unknown_tab_is_rejected(self, client: TestClient) -> None: + app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER) + + response = client.get("/api/v1/orders", params={"tab": "bogus"}) + + assert response.status_code == 422 + def test_requires_authentication(self, client: TestClient) -> None: response = client.get("/api/v1/orders") assert response.status_code == 401 diff --git a/backend/tests/test_orders_service.py b/backend/tests/test_orders_service.py index b7285aa..b9b4541 100644 --- a/backend/tests/test_orders_service.py +++ b/backend/tests/test_orders_service.py @@ -7,10 +7,18 @@ from datetime import UTC, datetime from typing import Any import pytest +from sqlalchemy.dialects import postgresql 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 +from app.services.orders import ( + OrderEditError, + OrderTab, + _parse_crm_datetime, + _to_kopecks, + list_orders, + update_order, +) class TestToKopecks: @@ -148,3 +156,37 @@ class TestUpdateOrder: 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] + + +class _CapturingSession: + def __init__(self) -> None: + self.statement: Any = None + + async def scalars(self, statement: Any) -> list[Order]: + self.statement = statement + return [] + + +def _list_where(tab: OrderTab) -> str: + """WHERE-часть запроса вкладки (SELECT перечисляет все колонки, в т.ч. receipt_created_at).""" + session = _CapturingSession() + asyncio.run(list_orders(session, tab=tab)) # type: ignore[arg-type] + sql = str( + session.statement.compile( + dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True} + ) + ) + return sql.split("WHERE", 1)[1] + + +class TestListOrders: + def test_refused_tab_filters_by_refusal_codes_only(self) -> None: + sql = _list_where(OrderTab.REFUSED) + assert "orders.np_status_code IN ('102', '103', '105', '108')" 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: + sql = _list_where(tab) + assert "NOT IN ('102', '103', '105', '108')" in sql + assert "receipt_created_at" in sql diff --git a/frontend/src/api/orders.ts b/frontend/src/api/orders.ts index 3e19e3c..bd6f110 100644 --- a/frontend/src/api/orders.ts +++ b/frontend/src/api/orders.ts @@ -1,8 +1,8 @@ import { apiFetch } from '@/api/client' -import type { Order, OrderUpdate } from '@/features/orders/types' +import type { Order, OrderTab, OrderUpdate } from '@/features/orders/types' -export function getOrders(hasReceipt: boolean): Promise { - return apiFetch(`/orders?has_receipt=${hasReceipt}`) +export function getOrders(tab: OrderTab): Promise { + return apiFetch(`/orders?tab=${tab}`) } export function deleteOrder(orderId: string): Promise { diff --git a/frontend/src/features/orders/types.ts b/frontend/src/features/orders/types.ts index be7cfb1..6843a52 100644 --- a/frontend/src/features/orders/types.ts +++ b/frontend/src/features/orders/types.ts @@ -5,6 +5,9 @@ import type { ReceiptStatus } from '@/features/receipts/types' +/** Вкладка дашборда (`OrderTab` в backend/app/services/orders.py). */ +export type OrderTab = 'no_receipt' | 'has_receipt' | 'refused' + export interface OrderGood { id: string sku: string diff --git a/frontend/src/features/orders/useOrders.ts b/frontend/src/features/orders/useOrders.ts index 8fdaed0..8b9957d 100644 --- a/frontend/src/features/orders/useOrders.ts +++ b/frontend/src/features/orders/useOrders.ts @@ -1,18 +1,19 @@ import { useQuery } from '@tanstack/react-query' import { getOrders } from '@/api/orders' +import type { OrderTab } from '@/features/orders/types' /** Пока чек отправляется в Checkbox — опрашиваем часто, иначе статусы чеков обновляет worker раз в минуту. */ const PENDING_POLL_MS = 3_000 const RECEIPTS_POLL_MS = 30_000 -export function useOrders(hasReceipt: boolean) { +export function useOrders(tab: OrderTab) { return useQuery({ - queryKey: ['orders', hasReceipt], - queryFn: () => getOrders(hasReceipt), + queryKey: ['orders', tab], + queryFn: () => getOrders(tab), refetchInterval: (query) => { if (query.state.data?.some((order) => order.receipt_status === 'pending')) return PENDING_POLL_MS - return hasReceipt ? RECEIPTS_POLL_MS : false + return tab === 'has_receipt' ? RECEIPTS_POLL_MS : false }, }) } diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index e1c6185..eca6a20 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -7,7 +7,7 @@ import { cancelReceipt, createReceipts } from '@/api/receipts' import '@/pages/DashboardPage.css' import { useAuth } from '@/features/auth/useAuth' import { OrderDetailModal } from '@/features/orders/OrderDetailModal' -import type { Order } from '@/features/orders/types' +import type { Order, OrderTab } from '@/features/orders/types' import { useOrders } from '@/features/orders/useOrders' import { defaultPrepayment, prepaymentMatches, toKopecks } from '@/features/receipts/money' import { CANCELLABLE, RECEIPT_STATUS } from '@/features/receipts/types' @@ -19,11 +19,10 @@ const ROLE_LABEL: Record = { viewer: 'Наблюдатель', } -type Tab = 'no_receipt' | 'has_receipt' - -const TABS: { key: Tab; label: string }[] = [ +const TABS: { key: OrderTab; label: string }[] = [ { key: 'no_receipt', label: 'Без чека' }, { key: 'has_receipt', label: 'Выписаны чеки' }, + { key: 'refused', label: 'Отказы' }, ] function npStatusTone(order: Order): 'delivered' | 'processing' | 'danger' | 'new' { @@ -47,8 +46,8 @@ function paymentBadge(order: Order): { label: string; tone: 'delivered' | 'proce export function DashboardPage() { const { user, logout } = useAuth() - const [tab, setTab] = useState('no_receipt') - const { data: orders, isLoading, isError } = useOrders(tab === 'has_receipt') + const [tab, setTab] = useState('no_receipt') + const { data: orders, isLoading, isError } = useOrders(tab) const queryClient = useQueryClient() const [selected, setSelected] = useState>(new Set()) const [search, setSearch] = useState('') @@ -103,7 +102,8 @@ export function DashboardPage() { async function handleCancel(order: Order) { if (!order.receipt_id) return - if (!window.confirm(`Отменить ЕТТН-чек по заказу ${order.id}? Заказ вернётся в очередь.`)) return + const outcome = tab === 'refused' ? 'Заказ останется в отказах.' : 'Заказ вернётся в очередь.' + if (!window.confirm(`Отменить ЕТТН-чек по заказу ${order.id}? ${outcome}`)) return setCancellingId(order.id) try { await cancelReceipt(order.receipt_id) @@ -336,7 +336,7 @@ export function DashboardPage() { Чек )} - {order.receipt_status && (tab === 'has_receipt' || !CANCELLABLE.has(order.receipt_status)) && ( + {order.receipt_status && (tab !== 'no_receipt' || !CANCELLABLE.has(order.receipt_status)) && ( )} - {tab === 'has_receipt' && canFiscalize && order.receipt_status && CANCELLABLE.has(order.receipt_status) && ( + {tab !== 'no_receipt' && canFiscalize && order.receipt_status && CANCELLABLE.has(order.receipt_status) && (