Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7cba7616a | ||
|
|
a6072800b2 |
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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` считает сервер."""
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
"""Обновляет статус ТТН, сумму и статус оплаты наложки по заказам в пути.
|
"""Обновляет статус ТТН, сумму и статус оплаты наложки по заказам в пути.
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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",
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user