Checkbox ETTN receipts, order editing, dashboard tabs and COD summary #1

Merged
lauadmin merged 10 commits from feature/checkbox-ettn into main 2026-09-24 22:36:52 +00:00
9 changed files with 116 additions and 33 deletions
Showing only changes of commit f88b5cccc0 - Show all commits
+1 -1
View File
@@ -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) ## 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 ### Checkbox ETTN receipts
+3 -2
View File
@@ -10,18 +10,19 @@ from app.schemas.orders import OrderRowOut, OrderUpdateIn
from app.services import audit from app.services import audit
from app.services import orders as orders_service from app.services import orders as orders_service
from app.services import receipts as receipts_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 = APIRouter(prefix="/orders", tags=["orders"], dependencies=[Depends(require_any)])
@router.get("", response_model=list[OrderRowOut], response_model_by_alias=False) @router.get("", response_model=list[OrderRowOut], response_model_by_alias=False)
async def list_orders( async def list_orders(
session: SessionDep, crm: CrmClientDep, has_receipt: bool = False session: SessionDep, crm: CrmClientDep, tab: OrderTab = OrderTab.NO_RECEIPT
) -> list[OrderRowOut]: ) -> list[OrderRowOut]:
# by_alias=False: без этого FastAPI сериализует вложенные OrderGoodOut через # by_alias=False: без этого FastAPI сериализует вложенные OrderGoodOut через
# CRM-алиасы (SKU/Name/...) вместо snake_case полей, которые ждёт фронтенд. # CRM-алиасы (SKU/Name/...) вместо snake_case полей, которые ждёт фронтенд.
await orders_service.sync_orders_from_crm(session, crm) 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( receipts = await receipts_service.latest_receipts_by_order(
session, [order.id for order in orders] session, [order.id for order in orders]
) )
+35 -7
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import enum
import uuid import uuid
from datetime import UTC, datetime from datetime import UTC, datetime
from decimal import ROUND_HALF_UP, Decimal from decimal import ROUND_HALF_UP, Decimal
@@ -20,6 +21,14 @@ _CRM_STATUS = "APPROVED"
# NP отклоняет запросы с более чем 100 накладными за раз (см. np_client.py). # NP отклоняет запросы с более чем 100 накладными за раз (см. np_client.py).
_NP_BATCH_SIZE = 100 _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). # Поля, которые кассир может править в карточке заказа (кроме goods/total).
_EDITABLE_FIELDS = ( _EDITABLE_FIELDS = (
"recipient_name", "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): class OrderEditError(Exception):
"""Заказ нельзя сохранить — сообщение показывается кассиру.""" """Заказ нельзя сохранить — сообщение показывается кассиру."""
@@ -80,21 +95,31 @@ async def sync_orders_from_crm(session: AsyncSession, crm: CrmClient) -> None:
await session.commit() await session.commit()
async def list_orders(session: AsyncSession, *, has_receipt: bool) -> list[Order]: async def list_orders(session: AsyncSession, *, tab: OrderTab) -> list[Order]:
receipt_filter = ( """Заказы вкладки. Отказ клиента важнее наличия чека: такой заказ показывается
Order.receipt_created_at.is_not(None) if has_receipt else Order.receipt_created_at.is_(None) только в «Отказах», даже если по нему уже создан ЕТТН-чек."""
) 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( result = await session.scalars(
select(Order) select(Order)
.where(Order.is_deleted.is_(False)) .where(Order.is_deleted.is_(False))
.where(receipt_filter) .where(tab_filter)
.order_by(Order.create_date_time.desc()) .order_by(Order.create_date_time.desc())
) )
return list(result) return list(result)
async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None: async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None:
"""Обновляет статус ТТН и сумму наложенного платежа для заказов без чека. """Обновляет статус ТТН, сумму и статус оплаты наложки по заказам в пути.
Опрашиваются и заказы с уже созданным ЕТТН-чеком: именно после создания
чека посылка доезжает и оплачивается, и этот переход кассир должен видеть.
Опрос прекращается, когда NP вернул финальный статус "отримано" или отказ.
Вызывается ARQ worker'ом раз в минуту (см. `app/worker.py`), а не из Вызывается ARQ worker'ом раз в минуту (см. `app/worker.py`), а не из
HTTP-запроса: опрос статусов не должен зависеть от того, открыт ли сейчас HTTP-запроса: опрос статусов не должен зависеть от того, открыт ли сейчас
@@ -103,8 +128,11 @@ async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None:
orders = await session.scalars( orders = await session.scalars(
select(Order) select(Order)
.where(Order.is_deleted.is_(False)) .where(Order.is_deleted.is_(False))
.where(Order.receipt_created_at.is_(None))
.where(Order.waybill_number.is_not(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} orders_by_waybill: dict[str, Order] = {order.waybill_number: order for order in orders}
if not orders_by_waybill: if not orders_by_waybill:
+13 -5
View File
@@ -69,8 +69,8 @@ def _patch_orders_service(monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_sync(session: object, crm: object) -> None: async def fake_sync(session: object, crm: object) -> None:
return None return None
async def fake_list(session: object, *, has_receipt: bool) -> list[Order]: async def fake_list(session: object, *, tab: str) -> list[Order]:
return [] if has_receipt else [_order()] return [_order()] if tab == "no_receipt" else []
async def fake_delete(session: object, order_id: str) -> Order | None: async def fake_delete(session: object, order_id: str) -> Order | None:
return _order(order_id) if order_id == "1" else None return _order(order_id) if order_id == "1" else None
@@ -132,7 +132,7 @@ class TestListOrders:
) -> None: ) -> None:
app.dependency_overrides[get_current_user] = lambda: _user(role) 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 assert response.status_code == 200
body = response.json() body = response.json()
@@ -142,14 +142,22 @@ class TestListOrders:
assert body[0]["has_receipt"] is False assert body[0]["has_receipt"] is False
assert body[0]["goods"][0]["sku"] == "SKU-1" 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) 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.status_code == 200
assert response.json() == [] 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: def test_requires_authentication(self, client: TestClient) -> None:
response = client.get("/api/v1/orders") response = client.get("/api/v1/orders")
assert response.status_code == 401 assert response.status_code == 401
+43 -1
View File
@@ -7,10 +7,18 @@ from datetime import UTC, datetime
from typing import Any from typing import Any
import pytest import pytest
from sqlalchemy.dialects import postgresql
from app.db.models.order import Order from app.db.models.order import Order
from app.schemas.orders import OrderUpdateIn 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: class TestToKopecks:
@@ -148,3 +156,37 @@ class TestUpdateOrder:
assert asyncio.run(update_order(_FakeSession(None), "1", payload)) is None # type: ignore[arg-type] assert asyncio.run(update_order(_FakeSession(None), "1", payload)) is None # type: ignore[arg-type]
deleted = _FakeSession(_order(is_deleted=True)) deleted = _FakeSession(_order(is_deleted=True))
assert asyncio.run(update_order(deleted, "1", payload)) is None # type: ignore[arg-type] 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
+3 -3
View File
@@ -1,8 +1,8 @@
import { apiFetch } from '@/api/client' 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<Order[]> { export function getOrders(tab: OrderTab): Promise<Order[]> {
return apiFetch<Order[]>(`/orders?has_receipt=${hasReceipt}`) return apiFetch<Order[]>(`/orders?tab=${tab}`)
} }
export function deleteOrder(orderId: string): Promise<void> { export function deleteOrder(orderId: string): Promise<void> {
+3
View File
@@ -5,6 +5,9 @@
import type { ReceiptStatus } from '@/features/receipts/types' import type { ReceiptStatus } from '@/features/receipts/types'
/** Вкладка дашборда (`OrderTab` в backend/app/services/orders.py). */
export type OrderTab = 'no_receipt' | 'has_receipt' | 'refused'
export interface OrderGood { export interface OrderGood {
id: string id: string
sku: string sku: string
+5 -4
View File
@@ -1,18 +1,19 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { getOrders } from '@/api/orders' import { getOrders } from '@/api/orders'
import type { OrderTab } from '@/features/orders/types'
/** Пока чек отправляется в Checkbox — опрашиваем часто, иначе статусы чеков обновляет worker раз в минуту. */ /** Пока чек отправляется в Checkbox — опрашиваем часто, иначе статусы чеков обновляет worker раз в минуту. */
const PENDING_POLL_MS = 3_000 const PENDING_POLL_MS = 3_000
const RECEIPTS_POLL_MS = 30_000 const RECEIPTS_POLL_MS = 30_000
export function useOrders(hasReceipt: boolean) { export function useOrders(tab: OrderTab) {
return useQuery({ return useQuery({
queryKey: ['orders', hasReceipt], queryKey: ['orders', tab],
queryFn: () => getOrders(hasReceipt), queryFn: () => getOrders(tab),
refetchInterval: (query) => { refetchInterval: (query) => {
if (query.state.data?.some((order) => order.receipt_status === 'pending')) return PENDING_POLL_MS 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
}, },
}) })
} }
+10 -10
View File
@@ -7,7 +7,7 @@ import { cancelReceipt, createReceipts } from '@/api/receipts'
import '@/pages/DashboardPage.css' import '@/pages/DashboardPage.css'
import { useAuth } from '@/features/auth/useAuth' import { useAuth } from '@/features/auth/useAuth'
import { OrderDetailModal } from '@/features/orders/OrderDetailModal' 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 { useOrders } from '@/features/orders/useOrders'
import { defaultPrepayment, prepaymentMatches, toKopecks } from '@/features/receipts/money' import { defaultPrepayment, prepaymentMatches, toKopecks } from '@/features/receipts/money'
import { CANCELLABLE, RECEIPT_STATUS } from '@/features/receipts/types' import { CANCELLABLE, RECEIPT_STATUS } from '@/features/receipts/types'
@@ -19,11 +19,10 @@ const ROLE_LABEL: Record<string, string> = {
viewer: 'Наблюдатель', viewer: 'Наблюдатель',
} }
type Tab = 'no_receipt' | 'has_receipt' const TABS: { key: OrderTab; label: string }[] = [
const TABS: { key: Tab; label: string }[] = [
{ key: 'no_receipt', label: 'Без чека' }, { key: 'no_receipt', label: 'Без чека' },
{ key: 'has_receipt', label: 'Выписаны чеки' }, { key: 'has_receipt', label: 'Выписаны чеки' },
{ key: 'refused', label: 'Отказы' },
] ]
function npStatusTone(order: Order): 'delivered' | 'processing' | 'danger' | 'new' { function npStatusTone(order: Order): 'delivered' | 'processing' | 'danger' | 'new' {
@@ -47,8 +46,8 @@ function paymentBadge(order: Order): { label: string; tone: 'delivered' | 'proce
export function DashboardPage() { export function DashboardPage() {
const { user, logout } = useAuth() const { user, logout } = useAuth()
const [tab, setTab] = useState<Tab>('no_receipt') const [tab, setTab] = useState<OrderTab>('no_receipt')
const { data: orders, isLoading, isError } = useOrders(tab === 'has_receipt') const { data: orders, isLoading, isError } = useOrders(tab)
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [selected, setSelected] = useState<Set<string>>(new Set()) const [selected, setSelected] = useState<Set<string>>(new Set())
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
@@ -103,7 +102,8 @@ export function DashboardPage() {
async function handleCancel(order: Order) { async function handleCancel(order: Order) {
if (!order.receipt_id) return 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) setCancellingId(order.id)
try { try {
await cancelReceipt(order.receipt_id) await cancelReceipt(order.receipt_id)
@@ -336,7 +336,7 @@ export function DashboardPage() {
Чек Чек
</button> </button>
)} )}
{order.receipt_status && (tab === 'has_receipt' || !CANCELLABLE.has(order.receipt_status)) && ( {order.receipt_status && (tab !== 'no_receipt' || !CANCELLABLE.has(order.receipt_status)) && (
<span <span
className={`orders-status orders-status--${RECEIPT_STATUS[order.receipt_status].tone}`} className={`orders-status orders-status--${RECEIPT_STATUS[order.receipt_status].tone}`}
title={order.receipt_error ?? undefined} title={order.receipt_error ?? undefined}
@@ -344,7 +344,7 @@ export function DashboardPage() {
{RECEIPT_STATUS[order.receipt_status].label} {RECEIPT_STATUS[order.receipt_status].label}
</span> </span>
)} )}
{tab === 'has_receipt' && canFiscalize && order.receipt_status && CANCELLABLE.has(order.receipt_status) && ( {tab !== 'no_receipt' && canFiscalize && order.receipt_status && CANCELLABLE.has(order.receipt_status) && (
<button <button
type="button" type="button"
className="orders-delete-btn" className="orders-delete-btn"
@@ -354,7 +354,7 @@ export function DashboardPage() {
Отменить Отменить
</button> </button>
)} )}
{canDelete && tab === 'no_receipt' && ( {canDelete && (tab === 'no_receipt' || (tab === 'refused' && !order.has_receipt)) && (
<button <button
type="button" type="button"
className="orders-delete-btn" className="orders-delete-btn"