Author SHA1 Message Date
lauadminandClaude Opus 5.5 a7cba7616a Poll ETTN statuses in batches and commit per receipt
The cron poll committed only at the end; with ~100 created receipts and a 1s
pause between Checkbox requests it hit arq's 300s timeout every run, so all
status updates were rolled back and fiscalized receipts stayed "created".
Now each run polls at most 30 least-recently-checked receipts and commits
after each one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:25:12 +03:00
lauadminandClaude Opus 5.5 a6072800b2 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>
2026-09-25 01:23:39 +03:00
12 changed files with 221 additions and 23 deletions
+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. 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 ### Checkbox ETTN receipts
+9 -1
View File
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
from app.api.deps import CashierUser, CrmClientDep, SessionDep, require_any from app.api.deps import CashierUser, CrmClientDep, SessionDep, require_any
from app.db.models.audit import AuditAction 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 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
@@ -29,6 +29,14 @@ async def list_orders(
return [OrderRowOut.from_order(order, receipts.get(order.id)) for order in 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) @router.patch("/{order_id}", response_model=OrderRowOut, response_model_by_alias=False)
async def update_order( async def update_order(
order_id: str, order_id: str,
+7
View File
@@ -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): class OrderGoodIn(BaseModel):
"""Позиция заказа из формы редактирования. `amount` считает сервер.""" """Позиция заказа из формы редактирования. `amount` считает сервер."""
+38 -9
View File
@@ -8,7 +8,7 @@ from datetime import UTC, datetime
from decimal import ROUND_HALF_UP, Decimal from decimal import ROUND_HALF_UP, Decimal
from typing import Any from typing import Any
from sqlalchemy import select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.db.models.order import Order from app.db.models.order import Order
@@ -25,9 +25,15 @@ _NP_BATCH_SIZE = 100
# посылку, заказ уходит во вкладку «Отказы». # посылку, заказ уходит во вкладку «Отказы».
NP_REFUSAL_STATUS_CODES = ("102", "103", "105", "108") NP_REFUSAL_STATUS_CODES = ("102", "103", "105", "108")
# Коды NP "відправлення отримано" и отказы — после них статус ТТН больше не # Коды NP "відправлення отримано" (106 — отримано і створено ЄН зворотньої
# опрашивается. # доставки) — заказ уходит во вкладку «Полученные».
_NP_FINAL_STATUS_CODES = ("9", "10", "11", *NP_REFUSAL_STATUS_CODES) 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). # Поля, которые кассир может править в карточке заказа (кроме goods/total).
_EDITABLE_FIELDS = ( _EDITABLE_FIELDS = (
@@ -43,6 +49,7 @@ class OrderTab(enum.StrEnum):
NO_RECEIPT = "no_receipt" NO_RECEIPT = "no_receipt"
HAS_RECEIPT = "has_receipt" HAS_RECEIPT = "has_receipt"
REFUSED = "refused" REFUSED = "refused"
RECEIVED = "received"
class OrderEditError(Exception): 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]: async def list_orders(session: AsyncSession, *, tab: OrderTab) -> list[Order]:
"""Заказы вкладки. Отказ клиента важнее наличия чека: такой заказ показывается """Заказы вкладки. Статус посылки важнее наличия чека: полученный заказ
только в «Отказах», даже если по нему уже создан ЕТТН-чек.""" показывается только в «Полученных», отказ — только в «Отказах», даже если по
нему уже создан ЕТТН-чек."""
refused = Order.np_status_code.in_(NP_REFUSAL_STATUS_CODES) 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 = { tab_filter = {
OrderTab.REFUSED: refused, OrderTab.REFUSED: refused,
OrderTab.NO_RECEIPT: Order.receipt_created_at.is_(None) & not_refused, OrderTab.RECEIVED: received,
OrderTab.HAS_RECEIPT: Order.receipt_created_at.is_not(None) & not_refused, OrderTab.NO_RECEIPT: Order.receipt_created_at.is_(None) & in_transit,
OrderTab.HAS_RECEIPT: Order.receipt_created_at.is_not(None) & in_transit,
}[tab] }[tab]
result = await session.scalars( result = await session.scalars(
select(Order) select(Order)
@@ -114,6 +126,23 @@ async def list_orders(session: AsyncSession, *, tab: OrderTab) -> list[Order]:
return list(result) 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: async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None:
"""Обновляет статус ТТН, сумму и статус оплаты наложки по заказам в пути. """Обновляет статус ТТН, сумму и статус оплаты наложки по заказам в пути.
+19 -4
View File
@@ -68,6 +68,11 @@ _CHECKBOX_TO_STATUS = {
# или Checkbox был недоступен) и повторить его из cron'а. # или Checkbox был недоступен) и повторить его из cron'а.
_PENDING_RETRY_AFTER = timedelta(minutes=1) _PENDING_RETRY_AFTER = timedelta(minutes=1)
# Сколько `created`-чеков опрашивать за один прогон cron'а (раз в минуту):
# при паузе ~1 с между запросами к Checkbox прогон укладывается в минуту и
# не наслаивается на следующий.
_ETTN_POLL_BATCH = 30
# Статус заказа в CRM, когда Checkbox принял ЕТТН-чек: заказ можно собирать. # Статус заказа в CRM, когда Checkbox принял ЕТТН-чек: заказ можно собирать.
CRM_STATUS_AFTER_RECEIPT = "PACKED" CRM_STATUS_AFTER_RECEIPT = "PACKED"
# Чеки, при которых заказу нужен этот статус в CRM (Checkbox чек принял). # Чеки, при которых заказу нужен этот статус в CRM (Checkbox чек принял).
@@ -495,9 +500,20 @@ async def cancel_receipt(
async def sync_ettn_statuses(session: AsyncSession, client: CheckboxClient) -> None: async def sync_ettn_statuses(session: AsyncSession, client: CheckboxClient) -> None:
"""Опрашивает Checkbox по чекам, ожидающим оплаты посылки (`created`).""" """Опрашивает Checkbox по чекам, ожидающим оплаты посылки (`created`).
За прогон — не больше `_ETTN_POLL_BATCH` давно не проверенных чеков, с
коммитом после каждого: запросы к Checkbox идут с паузой, и опрос всех
чеков разом не укладывался в таймаут cron-задачи, а откат по таймауту
терял весь прогон — статусы не обновлялись вовсе.
"""
receipts = list( receipts = list(
await session.scalars(select(Receipt).where(Receipt.status == ReceiptStatus.CREATED)) await session.scalars(
select(Receipt)
.where(Receipt.status == ReceiptStatus.CREATED)
.order_by(Receipt.last_checked_at.asc().nulls_first())
.limit(_ETTN_POLL_BATCH)
)
) )
registers: dict[uuid.UUID, CheckboxCredentials] = {} registers: dict[uuid.UUID, CheckboxCredentials] = {}
for receipt in receipts: for receipt in receipts:
@@ -521,8 +537,7 @@ async def sync_ettn_statuses(session: AsyncSession, client: CheckboxClient) -> N
_apply_ettn(receipt, ettn) _apply_ettn(receipt, ettn)
if receipt.status in CLOSED_STATUSES: if receipt.status in CLOSED_STATUSES:
await _release_order(session, receipt.order_id) await _release_order(session, receipt.order_id)
await session.commit()
await session.commit()
async def sync_crm_statuses(session: AsyncSession, crm: CrmClient) -> None: async def sync_crm_statuses(session: AsyncSession, crm: CrmClient) -> None:
+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, "list_orders", fake_list)
monkeypatch.setattr(orders_router.orders_service, "delete_order", fake_delete) 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( async def fake_update(
session: object, order_id: str, data: object session: object, order_id: str, data: object
) -> tuple[Order, list[str]] | None: ) -> tuple[Order, list[str]] | None:
@@ -238,3 +243,17 @@ class TestUpdateOrder:
response = client.patch("/api/v1/orders/1", json={**_UPDATE_BODY, "goods": []}) response = client.patch("/api/v1/orders/1", json={**_UPDATE_BODY, "goods": []})
assert response.status_code == 422 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, OrderTab,
_parse_crm_datetime, _parse_crm_datetime,
_to_kopecks, _to_kopecks,
cod_in_transit,
list_orders, list_orders,
update_order, update_order,
) )
@@ -185,8 +186,48 @@ class TestListOrders:
assert "orders.np_status_code IN ('102', '103', '105', '108')" in sql assert "orders.np_status_code IN ('102', '103', '105', '108')" in sql
assert "receipt_created_at" not 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]) @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) 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 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
+5 -1
View File
@@ -1,10 +1,14 @@
import { apiFetch } from '@/api/client' 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<Order[]> { export function getOrders(tab: OrderTab): Promise<Order[]> {
return apiFetch<Order[]>(`/orders?tab=${tab}`) return apiFetch<Order[]>(`/orders?tab=${tab}`)
} }
export function getOrdersSummary(): Promise<OrdersSummary> {
return apiFetch<OrdersSummary>('/orders/summary')
}
export function deleteOrder(orderId: string): Promise<void> { export function deleteOrder(orderId: string): Promise<void> {
return apiFetch<void>(`/orders/${orderId}`, { method: 'DELETE' }) return apiFetch<void>(`/orders/${orderId}`, { method: 'DELETE' })
} }
+7 -1
View File
@@ -6,7 +6,7 @@
import type { ReceiptStatus } from '@/features/receipts/types' import type { ReceiptStatus } from '@/features/receipts/types'
/** Вкладка дашборда (`OrderTab` в backend/app/services/orders.py). */ /** Вкладка дашборда (`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 { export interface OrderGood {
id: string id: string
@@ -62,3 +62,9 @@ export interface OrderUpdate {
total_amount: string total_amount: string
goods: OrderGoodUpdate[] goods: OrderGoodUpdate[]
} }
/** Ответ `GET /orders/summary` (`OrdersSummaryOut`). */
export interface OrdersSummary {
cod_in_transit_count: number
cod_in_transit_amount: string
}
+12 -1
View File
@@ -1,11 +1,13 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { getOrders } from '@/api/orders' import { getOrders, getOrdersSummary } from '@/api/orders'
import type { OrderTab } from '@/features/orders/types' 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
// Статусы NP обновляет worker раз в минуту — чаще сводку опрашивать незачем.
const SUMMARY_POLL_MS = 60_000
export function useOrders(tab: OrderTab) { export function useOrders(tab: OrderTab) {
return useQuery({ 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,
})
}
+33
View File
@@ -65,6 +65,39 @@
justify-content: space-between; 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 { .orders-create-btn {
border: none; border: none;
background: var(--color-primary); background: var(--color-primary);
+28 -3
View File
@@ -8,7 +8,7 @@ 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, OrderTab } from '@/features/orders/types' 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 { defaultPrepayment, prepaymentMatches, toKopecks } from '@/features/receipts/money'
import { CANCELLABLE, RECEIPT_STATUS } from '@/features/receipts/types' import { CANCELLABLE, RECEIPT_STATUS } from '@/features/receipts/types'
import type { ReceiptRequestItem } from '@/features/receipts/types' import type { ReceiptRequestItem } from '@/features/receipts/types'
@@ -22,6 +22,7 @@ const ROLE_LABEL: Record<string, string> = {
const TABS: { key: OrderTab; label: string }[] = [ const TABS: { key: OrderTab; label: string }[] = [
{ key: 'no_receipt', label: 'Без чека' }, { key: 'no_receipt', label: 'Без чека' },
{ key: 'has_receipt', label: 'Выписаны чеки' }, { key: 'has_receipt', label: 'Выписаны чеки' },
{ key: 'received', label: 'Полученные' },
{ key: 'refused', label: 'Отказы' }, { key: 'refused', label: 'Отказы' },
] ]
@@ -44,10 +45,17 @@ function paymentBadge(order: Order): { label: string; tone: 'delivered' | 'proce
return null 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() { export function DashboardPage() {
const { user, logout } = useAuth() const { user, logout } = useAuth()
const [tab, setTab] = useState<OrderTab>('no_receipt') const [tab, setTab] = useState<OrderTab>('no_receipt')
const { data: orders, isLoading, isError } = useOrders(tab) const { data: orders, isLoading, isError } = useOrders(tab)
const { data: summary } = useOrdersSummary()
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('')
@@ -102,7 +110,12 @@ export function DashboardPage() {
async function handleCancel(order: Order) { async function handleCancel(order: Order) {
if (!order.receipt_id) return 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 if (!window.confirm(`Отменить ЕТТН-чек по заказу ${order.id}? ${outcome}`)) return
setCancellingId(order.id) setCancellingId(order.id)
try { try {
@@ -193,6 +206,18 @@ export function DashboardPage() {
</div> </div>
</div> </div>
<div className="orders-summary">
<div className="orders-summary-card" title="Посылки с наложкой, которые ещё не забрали и по которым нет отказа">
<span className="orders-summary-label">Наложка в пути</span>
<span className="orders-summary-value">
{summary ? `${formatMoney(summary.cod_in_transit_amount)} ₴` : '—'}
</span>
<span className="orders-summary-hint">
{summary ? `${summary.cod_in_transit_count} посылок` : ' '}
</span>
</div>
</div>
{notice && ( {notice && (
<div className={`orders-notice orders-notice--${notice.tone}`}> <div className={`orders-notice orders-notice--${notice.tone}`}>
<ul> <ul>
@@ -354,7 +379,7 @@ export function DashboardPage() {
Отменить Отменить
</button> </button>
)} )}
{canDelete && (tab === 'no_receipt' || (tab === 'refused' && !order.has_receipt)) && ( {canDelete && (tab === 'no_receipt' || ((tab === 'refused' || tab === 'received') && !order.has_receipt)) && (
<button <button
type="button" type="button"
className="orders-delete-btn" className="orders-delete-btn"