Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b3c2b6d63 | ||
|
|
2b92d82693 | ||
|
|
a7cba7616a | ||
|
|
ed635cab94 | ||
|
|
a6072800b2 | ||
|
|
528a869657 | ||
|
|
f88b5cccc0 |
+4
-1
@@ -58,7 +58,8 @@ CRM_SHOP_KEY=change-me-crm-shopkey
|
||||
CRM_SID=1
|
||||
|
||||
# --- Nova Poshta ----------------------------------------------------------
|
||||
# Ключ API кабинета Nova Poshta (my.novaposhta.ua → Настройки → API).
|
||||
# Ключи API Nova Poshta задаются у касс (страница «Кассы»). Эта переменная нужна
|
||||
# только миграции 0008: переносит старый общий ключ в кассу по умолчанию.
|
||||
NOVA_POSHTA_API_KEY=change-me-novaposhta-apikey
|
||||
|
||||
# --- Checkbox -------------------------------------------------------------
|
||||
@@ -67,6 +68,8 @@ NOVA_POSHTA_API_KEY=change-me-novaposhta-apikey
|
||||
CHECKBOX_BASE_URL=https://api.checkbox.ua
|
||||
CHECKBOX_CLIENT_NAME=lux_fiscal
|
||||
CHECKBOX_CLIENT_VERSION=0.1.0
|
||||
# Пауза между запросами к Checkbox (мс): Новая Почта за Checkbox ограничивает частоту.
|
||||
CHECKBOX_MIN_REQUEST_INTERVAL_MS=1000
|
||||
# ЕТТН-чеки на тестовой кассе Checkbox не работают: локально весь цикл
|
||||
# прогоняется через стаб (чек «фискализируется» на втором опросе). В production запрещено.
|
||||
CHECKBOX_USE_STUB=false
|
||||
|
||||
@@ -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 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
|
||||
|
||||
@@ -126,4 +126,6 @@ Stages 1–4 (scaffolding, auth/audit, CRM order queue, Nova Poshta tracking) ar
|
||||
- Two-phase create: `POST /receipts` writes `Receipt(pending)` + audit and commits, then enqueues `create_ettn_receipt`; the worker calls Checkbox. A timeout leaves the row `pending` with `error` set — the retry first looks the TTN up via `find_ettn` instead of blindly re-posting (would create a second receipt). Keep this.
|
||||
- State machine and the "one live receipt per order" partial unique index live in `app/db/models/receipt.py`. `orders.receipt_created_at` is set on request and reset to NULL when a receipt ends `failed`/`cancelled` (order goes back to the queue).
|
||||
- Once Checkbox accepts the receipt, the order is moved to `PACKED` in the CRM (`services/receipts.sync_crm_statuses`, marked by `receipts.crm_status_set_at`; runs right after creation and is retried by cron). The live exoCRM `SetStatus` differs from its docs: params must be `{"Orders": [id], "Status": ...}` (the documented `{"ID": ...}` returns "Undefined order list."), and the reply has no `status: OK` — success is `{"<id>": {"Status": "Success"}}`.
|
||||
- Nova Poshta's rate limit comes back through Checkbox as a 4xx with `code=third_party.generic` and «To many requests» / `20000401501`, not as a 429. `http_client._transient_error` maps it to `CheckboxRateLimitedError`: the receipt stays `pending` and the worker retries with `arq.Retry`. The client also sends requests one at a time with a `CHECKBOX_MIN_REQUEST_INTERVAL_MS` pause, so don't parallelize Checkbox calls in the worker.
|
||||
- Each cash register has its own Nova Poshta API key (`cash_registers.np_api_key_enc`, Fernet). `sync_np_statuses` polls TTNs with register keys and binds the order to the register whose key sees the TTN as its own (`orders.cash_register_id`; ownership = response contains `PhoneSender` — a foreign key gets a truncated reply without sender/`AfterpaymentOnGoodsCost`). Receipts are created from the order's register, not the default one; an unbound order is rejected. `NOVA_POSHTA_API_KEY` env is only read by migration 0008.
|
||||
- ETTN does **not** work on a Checkbox test cash register. Locally use `CHECKBOX_USE_STUB=true`; client selection is only in `services/checkbox/client.get_checkbox_client()`.
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Ключ API Новой Почты у кассы и привязка заказа к кассе
|
||||
|
||||
Revision ID: 0008
|
||||
Revises: 0007
|
||||
Create Date: 2026-09-25
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from app.core import crypto
|
||||
from app.core.config import settings
|
||||
|
||||
revision: str = "0008"
|
||||
down_revision: str | None = "0007"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"cash_registers", sa.Column("np_api_key_enc", sa.String(length=512), nullable=True)
|
||||
)
|
||||
op.add_column("orders", sa.Column("cash_register_id", sa.Uuid(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
op.f("fk_orders_cash_register_id_cash_registers"),
|
||||
"orders",
|
||||
"cash_registers",
|
||||
["cash_register_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
op.create_index(op.f("ix_orders_cash_register_id"), "orders", ["cash_register_id"])
|
||||
|
||||
# Раньше ключ НП был один на всё приложение (NOVA_POSHTA_API_KEY) — переносим
|
||||
# его в кассу по умолчанию, чтобы опрос статусов не остановился после обновления.
|
||||
if settings.nova_poshta_api_key:
|
||||
op.get_bind().execute(
|
||||
sa.text(
|
||||
"UPDATE cash_registers SET np_api_key_enc = :key "
|
||||
"WHERE id = (SELECT id FROM cash_registers "
|
||||
"ORDER BY is_default DESC, created_at LIMIT 1)"
|
||||
),
|
||||
{"key": crypto.encrypt(settings.nova_poshta_api_key)},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_orders_cash_register_id"), table_name="orders")
|
||||
op.drop_constraint(
|
||||
op.f("fk_orders_cash_register_id_cash_registers"), "orders", type_="foreignkey"
|
||||
)
|
||||
op.drop_column("orders", "cash_register_id")
|
||||
op.drop_column("cash_registers", "np_api_key_enc")
|
||||
@@ -27,6 +27,27 @@ async def _clear_default(session: SessionDep, keep_id: uuid.UUID) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def _ensure_np_key_unique(
|
||||
session: SessionDep, api_key: str, exclude_id: uuid.UUID | None = None
|
||||
) -> None:
|
||||
"""Один кабинет НП — одна касса, иначе неясно, от какой кассы пробивать его посылки."""
|
||||
registers = await session.scalars(
|
||||
select(CashRegister).where(CashRegister.np_api_key_enc.is_not(None))
|
||||
)
|
||||
for register in registers:
|
||||
if register.id == exclude_id:
|
||||
continue
|
||||
try:
|
||||
same = crypto.decrypt(register.np_api_key_enc) == api_key
|
||||
except crypto.DecryptionError:
|
||||
continue
|
||||
if same:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Этот ключ Новой Почты уже привязан к кассе «{register.name}»",
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[CashRegisterOut])
|
||||
async def list_cash_registers(session: SessionDep) -> list[CashRegisterOut]:
|
||||
registers = await session.scalars(select(CashRegister).order_by(CashRegister.created_at))
|
||||
@@ -37,12 +58,15 @@ async def list_cash_registers(session: SessionDep) -> list[CashRegisterOut]:
|
||||
async def create_cash_register(
|
||||
payload: CashRegisterCreate, request: Request, session: SessionDep, actor: AdminUser
|
||||
) -> CashRegisterOut:
|
||||
if payload.np_api_key:
|
||||
await _ensure_np_key_unique(session, payload.np_api_key)
|
||||
register = CashRegister(
|
||||
id=uuid.uuid4(),
|
||||
name=payload.name,
|
||||
fiscal_number=payload.fiscal_number,
|
||||
license_key_enc=crypto.encrypt(payload.license_key),
|
||||
cashier_pin_enc=crypto.encrypt(payload.pin_code),
|
||||
np_api_key_enc=crypto.encrypt(payload.np_api_key) if payload.np_api_key else None,
|
||||
tax_codes=payload.tax_codes,
|
||||
is_active=True,
|
||||
is_default=payload.is_default,
|
||||
@@ -56,7 +80,11 @@ async def create_cash_register(
|
||||
user=actor,
|
||||
entity_type="cash_register",
|
||||
entity_id=register.id,
|
||||
payload={"name": register.name, "fiscal_number": register.fiscal_number},
|
||||
payload={
|
||||
"name": register.name,
|
||||
"fiscal_number": register.fiscal_number,
|
||||
"np_api_key_set": register.np_api_key_enc is not None,
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
await session.commit()
|
||||
@@ -80,6 +108,9 @@ async def update_cash_register(
|
||||
register.license_key_enc = crypto.encrypt(changes["license_key"])
|
||||
if "pin_code" in changes:
|
||||
register.cashier_pin_enc = crypto.encrypt(changes["pin_code"])
|
||||
if changes.get("np_api_key"):
|
||||
await _ensure_np_key_unique(session, changes["np_api_key"], exclude_id=register.id)
|
||||
register.np_api_key_enc = crypto.encrypt(changes["np_api_key"])
|
||||
if "fiscal_number" in changes:
|
||||
register.fiscal_number = changes["fiscal_number"]
|
||||
for field in ("name", "tax_codes", "is_active", "is_default"):
|
||||
@@ -94,7 +125,7 @@ async def update_cash_register(
|
||||
user=actor,
|
||||
entity_type="cash_register",
|
||||
entity_id=register.id,
|
||||
payload=changes, # license_key / pin_code редактируются в audit._sanitize
|
||||
payload=changes, # license_key / pin_code / np_api_key редактируются в audit._sanitize
|
||||
request=request,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
@@ -6,28 +6,37 @@ 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
|
||||
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]
|
||||
)
|
||||
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,
|
||||
|
||||
@@ -63,12 +63,17 @@ class Settings(BaseSettings):
|
||||
crm_sid: int = 1
|
||||
|
||||
# --- Nova Poshta ---
|
||||
# Ключи НП хранятся у касс (`cash_registers.np_api_key_enc`). Эта переменная
|
||||
# читается только миграцией 0008: переносит старый общий ключ в кассу по умолчанию.
|
||||
nova_poshta_api_key: str = ""
|
||||
|
||||
# --- Checkbox ---
|
||||
checkbox_base_url: str = "https://api.checkbox.ua"
|
||||
checkbox_client_name: str = "lux_fiscal"
|
||||
checkbox_client_version: str = "0.1.0"
|
||||
# Пауза между запросами к Checkbox из одного процесса: при создании ЕТТН
|
||||
# Checkbox ходит в API Новой Почты, а та отвечает «To many requests» на частые вызовы.
|
||||
checkbox_min_request_interval_ms: int = 1000
|
||||
# Стаб вместо реального Checkbox: ЕТТН работает только на боевой кассе,
|
||||
# поэтому локально весь цикл прогоняется через стаб. В проде запрещено.
|
||||
checkbox_use_stub: bool = False
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Кассы (ПРРО) Checkbox.
|
||||
|
||||
Ключ лицензии и PIN кассира хранятся только в зашифрованном виде
|
||||
(`app/core/crypto.py`): утечка дампа БД не должна давать доступ к кассе.
|
||||
Ключ лицензии, PIN кассира и ключ API Новой Почты хранятся только в
|
||||
зашифрованном виде (`app/core/crypto.py`): утечка дампа БД не должна давать
|
||||
доступ к кассе и кабинету отправителя.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,6 +23,10 @@ class CashRegister(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
fiscal_number: Mapped[str | None] = mapped_column(String(64))
|
||||
license_key_enc: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
cashier_pin_enc: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
# Ключ API кабинета отправителя НП. Посылки, которые этот ключ видит как
|
||||
# свои, привязываются к кассе (`orders.cash_register_id`), и ЕТТН-чеки по
|
||||
# ним создаются от неё. Без ключа касса статусы ТТН не опрашивает.
|
||||
np_api_key_enc: Mapped[str | None] = mapped_column(String(512))
|
||||
# Коды налоговых ставок Checkbox для всех товаров чека; пусто — поле `tax`
|
||||
# не передаётся (неплательщик ПДВ).
|
||||
tax_codes: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list)
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, String, Text
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -54,3 +55,9 @@ class Order(TimestampMixin, Base):
|
||||
np_status_code: Mapped[str | None] = mapped_column(String(16))
|
||||
np_cod_amount_kopecks: Mapped[int | None] = mapped_column(BigInteger)
|
||||
np_payment_status: Mapped[str | None] = mapped_column(String(32))
|
||||
|
||||
# Касса, чей ключ НП видит ТТН как свою (определяется при опросе статусов,
|
||||
# см. `services/orders.sync_np_statuses`). От неё создаётся ЕТТН-чек.
|
||||
cash_register_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("cash_registers.id", ondelete="SET NULL"), index=True
|
||||
)
|
||||
|
||||
@@ -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` считает сервер."""
|
||||
|
||||
|
||||
@@ -70,26 +70,34 @@ class ReceiptCreateResponse(BaseModel):
|
||||
# --- Кассы -------------------------------------------------------------------
|
||||
|
||||
|
||||
def _masked(encrypted: str) -> str:
|
||||
try:
|
||||
return crypto.mask(crypto.decrypt(encrypted))
|
||||
except crypto.DecryptionError:
|
||||
return "не расшифровывается — введите заново"
|
||||
|
||||
|
||||
class CashRegisterOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
fiscal_number: str | None
|
||||
license_key_masked: str
|
||||
# None — ключ НП не задан: касса не опрашивает посылки и не получает заказы.
|
||||
np_api_key_masked: str | None
|
||||
tax_codes: list[Any]
|
||||
is_active: bool
|
||||
is_default: bool
|
||||
|
||||
@classmethod
|
||||
def from_register(cls, register: CashRegister) -> CashRegisterOut:
|
||||
try:
|
||||
masked = crypto.mask(crypto.decrypt(register.license_key_enc))
|
||||
except crypto.DecryptionError:
|
||||
masked = "не расшифровывается — введите заново"
|
||||
return cls(
|
||||
id=register.id,
|
||||
name=register.name,
|
||||
fiscal_number=register.fiscal_number,
|
||||
license_key_masked=masked,
|
||||
license_key_masked=_masked(register.license_key_enc),
|
||||
np_api_key_masked=(
|
||||
_masked(register.np_api_key_enc) if register.np_api_key_enc else None
|
||||
),
|
||||
tax_codes=register.tax_codes,
|
||||
is_active=register.is_active,
|
||||
is_default=register.is_default,
|
||||
@@ -101,6 +109,7 @@ class CashRegisterCreate(BaseModel):
|
||||
fiscal_number: str | None = Field(default=None, max_length=64)
|
||||
license_key: str = Field(min_length=1, max_length=255)
|
||||
pin_code: str = Field(min_length=1, max_length=32)
|
||||
np_api_key: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
tax_codes: list[int | str] = Field(default_factory=list, max_length=2)
|
||||
is_default: bool = True
|
||||
|
||||
@@ -112,6 +121,7 @@ class CashRegisterUpdate(BaseModel):
|
||||
fiscal_number: str | None = Field(default=None, max_length=64)
|
||||
license_key: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
pin_code: str | None = Field(default=None, min_length=1, max_length=32)
|
||||
np_api_key: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
tax_codes: list[int | str] | None = Field(default=None, max_length=2)
|
||||
is_active: bool | None = None
|
||||
is_default: bool | None = None
|
||||
|
||||
@@ -32,9 +32,19 @@ class TrackingStatusOut(BaseModel):
|
||||
# какого-то типа накладной не возвращает `AfterpaymentOnGoodsCost`.
|
||||
cod_amount: str | None = Field(default=None)
|
||||
|
||||
# Телефон отправителя NP отдаёт только кабинету отправителя (или получателя):
|
||||
# по чужому ключу без телефона ответ урезан — нет ни отправителя, ни
|
||||
# `AfterpaymentOnGoodsCost`. По этому полю определяется, чья это посылка.
|
||||
phone_sender: str | None = Field(default=None, alias="PhoneSender")
|
||||
|
||||
# Статус оплаты аналогично раздвоен на `PaymentStatus`/`ExpressWaybillPaymentStatus`.
|
||||
payment_status: str | None = Field(default=None)
|
||||
|
||||
@property
|
||||
def is_own(self) -> bool:
|
||||
"""ТТН видна ключу как своя — отправлена из кабинета этого ключа."""
|
||||
return bool(self.phone_sender)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _fold_express_fields(cls, data: Any) -> Any:
|
||||
|
||||
@@ -22,6 +22,7 @@ _REDACTED_KEYS = {
|
||||
"secret",
|
||||
"license_key",
|
||||
"api_key",
|
||||
"np_api_key",
|
||||
"pin",
|
||||
"pin_code",
|
||||
"cashier_pin",
|
||||
|
||||
@@ -23,6 +23,18 @@ class CheckboxUnavailableError(CheckboxError):
|
||||
"""Сеть, таймаут или 5xx — исход запроса неизвестен, можно повторить."""
|
||||
|
||||
|
||||
class CheckboxRateLimitedError(CheckboxUnavailableError):
|
||||
"""Лимит частоты запросов (Checkbox 429 или «To many requests» от Новой Почты).
|
||||
|
||||
В отличие от прочих `CheckboxUnavailableError` исход известен — запрос отклонён,
|
||||
повторять можно не раньше чем через `retry_after` секунд.
|
||||
"""
|
||||
|
||||
def __init__(self, message: str, retry_after: float) -> None:
|
||||
super().__init__(message)
|
||||
self.retry_after = retry_after
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CheckboxCredentials:
|
||||
"""Расшифрованные доступы одной кассы. Живут только в памяти."""
|
||||
|
||||
@@ -2,10 +2,17 @@
|
||||
|
||||
Авторизация — токен кассира по PIN-коду (`/api/v1/cashier/signinPinCode`).
|
||||
Токен кэшируется в памяти процесса по кассе; на 401 — один повторный вход.
|
||||
|
||||
Запросы из одного процесса идут строго по одному с паузой
|
||||
`CHECKBOX_MIN_REQUEST_INTERVAL_MS`: при создании ЕТТН Checkbox синхронно ходит
|
||||
в API Новой Почты, а та на частые вызовы отвечает «To many requests».
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import time
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
@@ -17,6 +24,7 @@ from app.schemas.checkbox import EttnOut, EttnStatus
|
||||
from app.services.checkbox.client import (
|
||||
CheckboxCredentials,
|
||||
CheckboxError,
|
||||
CheckboxRateLimitedError,
|
||||
CheckboxUnavailableError,
|
||||
)
|
||||
|
||||
@@ -27,6 +35,15 @@ _TIMEOUT = httpx.Timeout(30, connect=10)
|
||||
_FIND_PAGES = 3
|
||||
_FIND_PAGE_SIZE = 50
|
||||
|
||||
# Ошибки Новой Почты, которые Checkbox отдаёт 4xx с `code=third_party.*`:
|
||||
# {"message": "To many requests", "info": ["Try again after 1 seconds"],
|
||||
# "errorCodes": ["20000401501"]} — лимит частоты НП;
|
||||
# "cURL error 28: SSL connection timeout" — Checkbox не дождался НП.
|
||||
_NP_RATE_LIMIT_RE = re.compile(r"20000401501|too? many requests", re.IGNORECASE)
|
||||
_NP_RETRY_AFTER_RE = re.compile(r"try again after (\d+) second", re.IGNORECASE)
|
||||
_NP_TIMEOUT_RE = re.compile(r"curl error|timed? ?out", re.IGNORECASE)
|
||||
_MIN_RETRY_AFTER = 1.0
|
||||
|
||||
|
||||
def _error_message(response: httpx.Response) -> str:
|
||||
try:
|
||||
@@ -55,6 +72,29 @@ def _error_message(response: httpx.Response) -> str:
|
||||
return f"HTTP {response.status_code}"
|
||||
|
||||
|
||||
def _retry_after(response: httpx.Response) -> float:
|
||||
seconds: float = 0
|
||||
if match := _NP_RETRY_AFTER_RE.search(response.text):
|
||||
seconds = float(match.group(1))
|
||||
elif (header := response.headers.get("Retry-After", "")).isdigit():
|
||||
seconds = float(header)
|
||||
return max(seconds, _MIN_RETRY_AFTER)
|
||||
|
||||
|
||||
def _transient_error(response: httpx.Response) -> CheckboxUnavailableError | None:
|
||||
"""Ответ, после которого запрос можно повторить, или None, если ошибка окончательная."""
|
||||
if response.status_code == 429:
|
||||
return CheckboxRateLimitedError(_error_message(response), _retry_after(response))
|
||||
if response.status_code >= 500:
|
||||
return CheckboxUnavailableError(_error_message(response))
|
||||
if response.status_code >= 400 and '"third_party.' in response.text:
|
||||
if _NP_RATE_LIMIT_RE.search(response.text):
|
||||
return CheckboxRateLimitedError(_error_message(response), _retry_after(response))
|
||||
if _NP_TIMEOUT_RE.search(response.text):
|
||||
return CheckboxUnavailableError(_error_message(response))
|
||||
return None
|
||||
|
||||
|
||||
class HttpCheckboxClient:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._base_url = settings.checkbox_base_url.rstrip("/")
|
||||
@@ -63,6 +103,10 @@ class HttpCheckboxClient:
|
||||
"X-Client-Version": settings.checkbox_client_version,
|
||||
}
|
||||
self._tokens: dict[uuid.UUID, str] = {}
|
||||
self._min_interval = settings.checkbox_min_request_interval_ms / 1000
|
||||
self._send_lock = asyncio.Lock()
|
||||
self._sign_in_lock = asyncio.Lock()
|
||||
self._last_sent_at = float("-inf")
|
||||
|
||||
async def _send(
|
||||
self,
|
||||
@@ -73,18 +117,36 @@ class HttpCheckboxClient:
|
||||
json: Any = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> httpx.Response:
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=self._base_url, timeout=_TIMEOUT) as client:
|
||||
response = await client.request(
|
||||
method, path, headers=headers, json=json, params=params
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise CheckboxUnavailableError(f"Checkbox недоступен: {exc!r}") from exc
|
||||
# 429 — «Занадто часто виконуються запити»: исход не ошибка данных, повторяемо.
|
||||
if response.status_code >= 500 or response.status_code == 429:
|
||||
raise CheckboxUnavailableError(_error_message(response))
|
||||
# Строго по одному запросу с паузой — параллельные задачи worker'а
|
||||
# иначе пачкой упираются в лимит Новой Почты.
|
||||
async with self._send_lock:
|
||||
delay = self._last_sent_at + self._min_interval - time.monotonic()
|
||||
if delay > 0:
|
||||
await asyncio.sleep(delay)
|
||||
self._last_sent_at = time.monotonic()
|
||||
try:
|
||||
async with httpx.AsyncClient(base_url=self._base_url, timeout=_TIMEOUT) as client:
|
||||
response = await client.request(
|
||||
method, path, headers=headers, json=json, params=params
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise CheckboxUnavailableError(f"Checkbox недоступен: {exc!r}") from exc
|
||||
if error := _transient_error(response):
|
||||
raise error
|
||||
return response
|
||||
|
||||
async def _token(self, creds: CheckboxCredentials, *, stale: str | None = None) -> str:
|
||||
"""Токен кассира; `stale` — отвергнутый Checkbox'ом (401), его не переиспользуем.
|
||||
|
||||
Под блокировкой: параллельные задачи без токена входят один раз,
|
||||
остальные берут токен, полученный первой.
|
||||
"""
|
||||
async with self._sign_in_lock:
|
||||
token = self._tokens.get(creds.cash_register_id)
|
||||
if token is not None and token != stale:
|
||||
return token
|
||||
return await self._sign_in(creds)
|
||||
|
||||
async def _sign_in(self, creds: CheckboxCredentials) -> str:
|
||||
response = await self._send(
|
||||
"POST",
|
||||
@@ -107,7 +169,7 @@ class HttpCheckboxClient:
|
||||
json: Any = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> httpx.Response:
|
||||
token = self._tokens.get(creds.cash_register_id) or await self._sign_in(creds)
|
||||
token = await self._token(creds)
|
||||
for attempt in range(2):
|
||||
response = await self._send(
|
||||
method,
|
||||
@@ -121,7 +183,7 @@ class HttpCheckboxClient:
|
||||
params=params,
|
||||
)
|
||||
if response.status_code == 401 and attempt == 0:
|
||||
token = await self._sign_in(creds)
|
||||
token = await self._token(creds, stale=token)
|
||||
continue
|
||||
break
|
||||
if response.status_code >= 400:
|
||||
|
||||
@@ -15,4 +15,8 @@ class NovaPoshtaError(Exception):
|
||||
|
||||
|
||||
class NovaPoshtaClient(Protocol):
|
||||
async def get_statuses(self, *, waybill_numbers: list[str]) -> list[TrackingStatusOut]: ...
|
||||
"""Ключ API передаётся в каждый вызов: у каждой кассы свой кабинет отправителя."""
|
||||
|
||||
async def get_statuses(
|
||||
self, *, api_key: str, waybill_numbers: list[str]
|
||||
) -> list[TrackingStatusOut]: ...
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.schemas.tracking import TrackingStatusOut
|
||||
from app.services.nova_poshta.client import NovaPoshtaError
|
||||
|
||||
@@ -15,10 +14,9 @@ _MAX_DOCUMENTS_PER_REQUEST = 100
|
||||
|
||||
|
||||
class NpTrackingClient:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._api_key = settings.nova_poshta_api_key
|
||||
|
||||
async def get_statuses(self, *, waybill_numbers: list[str]) -> list[TrackingStatusOut]:
|
||||
async def get_statuses(
|
||||
self, *, api_key: str, waybill_numbers: list[str]
|
||||
) -> list[TrackingStatusOut]:
|
||||
if not waybill_numbers:
|
||||
return []
|
||||
if len(waybill_numbers) > _MAX_DOCUMENTS_PER_REQUEST:
|
||||
@@ -28,7 +26,7 @@ class NpTrackingClient:
|
||||
)
|
||||
|
||||
body = {
|
||||
"apiKey": self._api_key,
|
||||
"apiKey": api_key,
|
||||
"modelName": "TrackingDocument",
|
||||
"calledMethod": "getStatusDocuments",
|
||||
"methodProperties": {
|
||||
|
||||
@@ -15,17 +15,32 @@ _FIXTURE_STATUSES: dict[str, dict] = {
|
||||
"AmountToPay": "1200.00",
|
||||
"AfterpaymentOnGoodsCost": 1200,
|
||||
"PaymentStatus": "Paid",
|
||||
"PhoneSender": "380501112233",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class StubNovaPoshtaClient:
|
||||
def __init__(self, statuses: dict[str, dict] | None = None) -> None:
|
||||
self._statuses = statuses if statuses is not None else _FIXTURE_STATUSES
|
||||
"""`statuses` — ответы NP по ТТН; `by_key` — отдельные ответы для конкретных
|
||||
ключей API (кабинет отправителя видит свои ТТН полностью, чужие — урезанно)."""
|
||||
|
||||
async def get_statuses(self, *, waybill_numbers: list[str]) -> list[TrackingStatusOut]:
|
||||
def __init__(
|
||||
self,
|
||||
statuses: dict[str, dict] | None = None,
|
||||
*,
|
||||
by_key: dict[str, dict[str, dict]] | None = None,
|
||||
) -> None:
|
||||
self._statuses = statuses if statuses is not None else _FIXTURE_STATUSES
|
||||
self._by_key = by_key or {}
|
||||
self.calls: list[tuple[str, list[str]]] = []
|
||||
|
||||
async def get_statuses(
|
||||
self, *, api_key: str, waybill_numbers: list[str]
|
||||
) -> list[TrackingStatusOut]:
|
||||
self.calls.append((api_key, list(waybill_numbers)))
|
||||
statuses = self._by_key.get(api_key, self._statuses)
|
||||
return [
|
||||
TrackingStatusOut.model_validate(self._statuses[number])
|
||||
TrackingStatusOut.model_validate(statuses[number])
|
||||
for number in waybill_numbers
|
||||
if number in self._statuses
|
||||
if number in statuses
|
||||
]
|
||||
|
||||
+189
-21
@@ -2,24 +2,47 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
import httpx
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core import crypto
|
||||
from app.core.logging import get_logger
|
||||
from app.db.models.cash_register import CashRegister
|
||||
from app.db.models.order import Order
|
||||
from app.schemas.orders import OrderUpdateIn
|
||||
from app.schemas.tracking import TrackingStatusOut
|
||||
from app.services.crm.client import CrmClient
|
||||
from app.services.nova_poshta.client import NovaPoshtaClient
|
||||
from app.services.nova_poshta.client import NovaPoshtaClient, NovaPoshtaError
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
_CRM_STATUS = "APPROVED"
|
||||
|
||||
# NP отклоняет запросы с более чем 100 накладными за раз (см. np_client.py).
|
||||
_NP_BATCH_SIZE = 100
|
||||
|
||||
# Коды NP "відмова одержувача" / "припинено зберігання" — клиент не забрал
|
||||
# посылку, заказ уходит во вкладку «Отказы».
|
||||
NP_REFUSAL_STATUS_CODES = ("102", "103", "105", "108")
|
||||
|
||||
# Коды 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 = (
|
||||
"recipient_name",
|
||||
@@ -30,6 +53,13 @@ _EDITABLE_FIELDS = (
|
||||
)
|
||||
|
||||
|
||||
class OrderTab(enum.StrEnum):
|
||||
NO_RECEIPT = "no_receipt"
|
||||
HAS_RECEIPT = "has_receipt"
|
||||
REFUSED = "refused"
|
||||
RECEIVED = "received"
|
||||
|
||||
|
||||
class OrderEditError(Exception):
|
||||
"""Заказ нельзя сохранить — сообщение показывается кассиру."""
|
||||
|
||||
@@ -80,50 +110,187 @@ 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)
|
||||
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.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)
|
||||
.where(Order.is_deleted.is_(False))
|
||||
.where(receipt_filter)
|
||||
.where(tab_filter)
|
||||
.order_by(Order.create_date_time.desc())
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _NpAccount:
|
||||
"""Кабинет отправителя НП, привязанный к кассе."""
|
||||
|
||||
register_id: uuid.UUID
|
||||
api_key: str
|
||||
|
||||
|
||||
async def _np_accounts(session: AsyncSession) -> list[_NpAccount]:
|
||||
"""Активные кассы с ключом НП; касса по умолчанию — первой."""
|
||||
registers = await session.scalars(
|
||||
select(CashRegister)
|
||||
.where(CashRegister.is_active.is_(True))
|
||||
.where(CashRegister.np_api_key_enc.is_not(None))
|
||||
.order_by(CashRegister.is_default.desc(), CashRegister.created_at)
|
||||
)
|
||||
accounts = []
|
||||
for register in registers:
|
||||
try:
|
||||
accounts.append(_NpAccount(register.id, crypto.decrypt(register.np_api_key_enc)))
|
||||
except crypto.DecryptionError:
|
||||
log.warning("np_api_key_undecryptable", cash_register_id=str(register.id))
|
||||
return accounts
|
||||
|
||||
|
||||
async def _fetch_statuses(
|
||||
np: NovaPoshtaClient, api_key: str, waybill_numbers: list[str]
|
||||
) -> dict[str, TrackingStatusOut]:
|
||||
statuses: dict[str, TrackingStatusOut] = {}
|
||||
for i in range(0, len(waybill_numbers), _NP_BATCH_SIZE):
|
||||
batch = waybill_numbers[i : i + _NP_BATCH_SIZE]
|
||||
for tracking_status in await np.get_statuses(api_key=api_key, waybill_numbers=batch):
|
||||
statuses[tracking_status.number] = tracking_status
|
||||
return statuses
|
||||
|
||||
|
||||
def _apply_status(order: Order, tracking_status: TrackingStatusOut) -> None:
|
||||
order.np_status = tracking_status.status
|
||||
order.np_status_code = tracking_status.status_code
|
||||
order.np_cod_amount_kopecks = (
|
||||
_to_kopecks(tracking_status.cod_amount) if tracking_status.cod_amount else None
|
||||
)
|
||||
order.np_payment_status = tracking_status.payment_status
|
||||
|
||||
|
||||
async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None:
|
||||
"""Обновляет статус ТТН и сумму наложенного платежа для заказов без чека.
|
||||
"""Обновляет статус ТТН, сумму и статус оплаты наложки по заказам в пути
|
||||
и привязывает каждый заказ к кассе, чей ключ НП видит ТТН как свою.
|
||||
|
||||
Заказ, уже привязанный к кассе, опрашивается её ключом. Непривязанный (или
|
||||
переставший быть «своим» для своей кассы) — ключами всех касс по очереди:
|
||||
первая, для которой ТТН своя (`TrackingStatusOut.is_own`), его забирает.
|
||||
Если ТТН не своя ни для одной кассы, статус доставки всё равно сохраняется,
|
||||
но без наложки и без кассы — чек по такой посылке не создать.
|
||||
|
||||
Опрашиваются и заказы с уже созданным ЕТТН-чеком: именно после создания
|
||||
чека посылка доезжает и оплачивается, и этот переход кассир должен видеть.
|
||||
Опрос прекращается, когда NP вернул финальный статус "отримано" или отказ.
|
||||
|
||||
Вызывается ARQ worker'ом раз в минуту (см. `app/worker.py`), а не из
|
||||
HTTP-запроса: опрос статусов не должен зависеть от того, открыт ли сейчас
|
||||
дашборд.
|
||||
"""
|
||||
accounts = await _np_accounts(session)
|
||||
if not accounts:
|
||||
log.warning("np_no_api_keys", hint="Укажите ключ API Новой Почты у кассы")
|
||||
return
|
||||
|
||||
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:
|
||||
return
|
||||
|
||||
waybill_numbers = list(orders_by_waybill)
|
||||
for i in range(0, len(waybill_numbers), _NP_BATCH_SIZE):
|
||||
batch = waybill_numbers[i : i + _NP_BATCH_SIZE]
|
||||
statuses = await np.get_statuses(waybill_numbers=batch)
|
||||
for tracking_status in statuses:
|
||||
order = orders_by_waybill.get(tracking_status.number)
|
||||
account_ids = {account.register_id for account in accounts}
|
||||
unresolved = {
|
||||
waybill: order
|
||||
for waybill, order in orders_by_waybill.items()
|
||||
if order.cash_register_id not in account_ids
|
||||
}
|
||||
# Урезанный ответ по чужому ключу — на случай, если ТТН не своя ни для кого.
|
||||
foreign: dict[str, TrackingStatusOut] = {}
|
||||
complete = True
|
||||
|
||||
for account in accounts:
|
||||
bound = {
|
||||
waybill: order
|
||||
for waybill, order in orders_by_waybill.items()
|
||||
if order.cash_register_id == account.register_id
|
||||
}
|
||||
if not bound:
|
||||
continue
|
||||
try:
|
||||
statuses = await _fetch_statuses(np, account.api_key, list(bound))
|
||||
except (NovaPoshtaError, httpx.HTTPError) as exc:
|
||||
# Заказы кассы остаются как есть до следующего опроса.
|
||||
log.warning("np_poll_failed", cash_register_id=str(account.register_id), error=str(exc))
|
||||
continue
|
||||
for waybill, order in bound.items():
|
||||
tracking_status = statuses.get(waybill)
|
||||
if tracking_status is not None and tracking_status.is_own:
|
||||
_apply_status(order, tracking_status)
|
||||
else:
|
||||
unresolved[waybill] = order
|
||||
if tracking_status is not None:
|
||||
foreign[waybill] = tracking_status
|
||||
|
||||
for account in accounts:
|
||||
if not unresolved:
|
||||
break
|
||||
try:
|
||||
statuses = await _fetch_statuses(np, account.api_key, list(unresolved))
|
||||
except (NovaPoshtaError, httpx.HTTPError) as exc:
|
||||
log.warning("np_poll_failed", cash_register_id=str(account.register_id), error=str(exc))
|
||||
complete = False
|
||||
continue
|
||||
for waybill, tracking_status in statuses.items():
|
||||
order = unresolved.get(waybill)
|
||||
if order is None:
|
||||
continue
|
||||
order.np_status = tracking_status.status
|
||||
order.np_status_code = tracking_status.status_code
|
||||
order.np_cod_amount_kopecks = (
|
||||
_to_kopecks(tracking_status.cod_amount) if tracking_status.cod_amount else None
|
||||
)
|
||||
order.np_payment_status = tracking_status.payment_status
|
||||
if tracking_status.is_own:
|
||||
order.cash_register_id = account.register_id
|
||||
_apply_status(order, tracking_status)
|
||||
del unresolved[waybill]
|
||||
else:
|
||||
foreign.setdefault(waybill, tracking_status)
|
||||
|
||||
# Если какой-то ключ не ответил, не отвязываем: ТТН могла быть его.
|
||||
if complete:
|
||||
for waybill, order in unresolved.items():
|
||||
order.cash_register_id = None
|
||||
if waybill in foreign:
|
||||
_apply_status(order, foreign[waybill])
|
||||
|
||||
await session.commit()
|
||||
|
||||
@@ -211,7 +378,8 @@ async def update_order(
|
||||
return order, changed
|
||||
|
||||
if "waybill_number" in changed:
|
||||
# Статус старой ТТН к новой не относится — worker опросит новую за минуту.
|
||||
# Статус и касса старой ТТН к новой не относятся — worker опросит новую за минуту.
|
||||
order.cash_register_id = None
|
||||
order.np_status = None
|
||||
order.np_status_code = None
|
||||
order.np_cod_amount_kopecks = None
|
||||
|
||||
@@ -38,6 +38,7 @@ from app.services.checkbox.client import (
|
||||
CheckboxClient,
|
||||
CheckboxCredentials,
|
||||
CheckboxError,
|
||||
CheckboxRateLimitedError,
|
||||
CheckboxUnavailableError,
|
||||
)
|
||||
from app.services.crm.client import CrmClient, CrmError
|
||||
@@ -67,6 +68,11 @@ _CHECKBOX_TO_STATUS = {
|
||||
# или Checkbox был недоступен) и повторить его из cron'а.
|
||||
_PENDING_RETRY_AFTER = timedelta(minutes=1)
|
||||
|
||||
# Сколько `created`-чеков опрашивать за один прогон cron'а (раз в минуту):
|
||||
# при паузе ~1 с между запросами к Checkbox прогон укладывается в минуту и
|
||||
# не наслаивается на следующий.
|
||||
_ETTN_POLL_BATCH = 30
|
||||
|
||||
# Статус заказа в CRM, когда Checkbox принял ЕТТН-чек: заказ можно собирать.
|
||||
CRM_STATUS_AFTER_RECEIPT = "PACKED"
|
||||
# Чеки, при которых заказу нужен этот статус в CRM (Checkbox чек принял).
|
||||
@@ -255,13 +261,11 @@ def credentials(register: CashRegister) -> CheckboxCredentials:
|
||||
)
|
||||
|
||||
|
||||
async def get_default_register(session: AsyncSession) -> CashRegister | None:
|
||||
return await session.scalar(
|
||||
select(CashRegister)
|
||||
.where(CashRegister.is_active.is_(True))
|
||||
.order_by(CashRegister.is_default.desc(), CashRegister.created_at)
|
||||
.limit(1)
|
||||
async def _active_registers(session: AsyncSession) -> dict[uuid.UUID, CashRegister]:
|
||||
registers = await session.scalars(
|
||||
select(CashRegister).where(CashRegister.is_active.is_(True))
|
||||
)
|
||||
return {register.id: register for register in registers}
|
||||
|
||||
|
||||
# --- Создание ----------------------------------------------------------------
|
||||
@@ -294,10 +298,13 @@ async def request_receipts(
|
||||
Без commit и без обращения к Checkbox: вызывающий код коммитит и ставит
|
||||
задачи worker'у (`create_ettn_for_receipt`) по `result.created`.
|
||||
Ошибки по отдельным заказам не мешают остальным — массовое действие.
|
||||
|
||||
Чек создаётся от кассы, чей ключ НП видит ТТН заказа как свою
|
||||
(`orders.cash_register_id`, проставляет `orders.sync_np_statuses`).
|
||||
"""
|
||||
result = RequestResult(created=[], errors={})
|
||||
register = await get_default_register(session)
|
||||
if register is None:
|
||||
registers = await _active_registers(session)
|
||||
if not registers:
|
||||
for order_id, _ in items:
|
||||
result.errors[order_id] = "Не настроена касса Checkbox"
|
||||
return result
|
||||
@@ -317,6 +324,13 @@ async def request_receipts(
|
||||
if order_id in busy:
|
||||
result.errors[order_id] = "По заказу уже есть чек"
|
||||
continue
|
||||
register = registers.get(order.cash_register_id) if order.cash_register_id else None
|
||||
if register is None:
|
||||
result.errors[order_id] = (
|
||||
"Касса не определена: ТТН не найдена ни одним ключом Новой Почты "
|
||||
"активных касс"
|
||||
)
|
||||
continue
|
||||
try:
|
||||
amounts = resolve_amounts(order, prepayment)
|
||||
body = build_ettn_body(order, register, amounts)
|
||||
@@ -353,6 +367,7 @@ async def request_receipts(
|
||||
payload={
|
||||
"order_id": order.id,
|
||||
"waybill_number": order.waybill_number,
|
||||
"cash_register_id": str(register.id),
|
||||
"cod_kopecks": amounts.cod_kopecks,
|
||||
"prepayment_kopecks": amounts.prepayment_kopecks,
|
||||
},
|
||||
@@ -387,7 +402,10 @@ def _apply_ettn(receipt: Receipt, ettn: EttnOut) -> None:
|
||||
async def create_ettn_for_receipt(
|
||||
session: AsyncSession, client: CheckboxClient, receipt_id: uuid.UUID
|
||||
) -> Receipt | None:
|
||||
"""Отправляет `pending`-чек в Checkbox. Идемпотентна — безопасно вызывать повторно."""
|
||||
"""Отправляет `pending`-чек в Checkbox. Идемпотентна — безопасно вызывать повторно.
|
||||
|
||||
На лимит частоты пробрасывает `CheckboxRateLimitedError`, чек остаётся `pending`.
|
||||
"""
|
||||
receipt = await session.get(Receipt, receipt_id, with_for_update=True)
|
||||
if receipt is None or receipt.status != ReceiptStatus.PENDING:
|
||||
return receipt
|
||||
@@ -403,6 +421,12 @@ async def create_ettn_for_receipt(
|
||||
await client.find_ettn(creds, receipt.waybill_number) if receipt.error else None
|
||||
)
|
||||
ettn = existing or await client.create_ettn(creds, receipt.request_body)
|
||||
except CheckboxRateLimitedError as exc:
|
||||
# Запрос отклонён, чек точно не создан: остаётся `pending` без пометки
|
||||
# «исход неизвестен». Повтор с задержкой — забота вызывающего (worker).
|
||||
await session.commit() # снять блокировку строки
|
||||
log.info("ettn_rate_limited", receipt_id=str(receipt_id), retry_after=exc.retry_after)
|
||||
raise
|
||||
except CheckboxUnavailableError as exc:
|
||||
receipt.error = str(exc)
|
||||
await session.commit()
|
||||
@@ -435,7 +459,10 @@ async def retry_pending_receipts(session: AsyncSession, client: CheckboxClient)
|
||||
)
|
||||
)
|
||||
for receipt_id in receipt_ids:
|
||||
await create_ettn_for_receipt(session, client, receipt_id)
|
||||
try:
|
||||
await create_ettn_for_receipt(session, client, receipt_id)
|
||||
except CheckboxRateLimitedError:
|
||||
break # остальные — в следующем проходе cron'а
|
||||
|
||||
|
||||
# --- Отмена и опрос ----------------------------------------------------------
|
||||
@@ -482,9 +509,20 @@ async def cancel_receipt(
|
||||
|
||||
|
||||
async def sync_ettn_statuses(session: AsyncSession, client: CheckboxClient) -> None:
|
||||
"""Опрашивает Checkbox по чекам, ожидающим оплаты посылки (`created`)."""
|
||||
"""Опрашивает Checkbox по чекам, ожидающим оплаты посылки (`created`).
|
||||
|
||||
За прогон — не больше `_ETTN_POLL_BATCH` давно не проверенных чеков, с
|
||||
коммитом после каждого: запросы к Checkbox идут с паузой, и опрос всех
|
||||
чеков разом не укладывался в таймаут cron-задачи, а откат по таймауту
|
||||
терял весь прогон — статусы не обновлялись вовсе.
|
||||
"""
|
||||
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] = {}
|
||||
for receipt in receipts:
|
||||
@@ -508,8 +546,7 @@ async def sync_ettn_statuses(session: AsyncSession, client: CheckboxClient) -> N
|
||||
_apply_ettn(receipt, ettn)
|
||||
if receipt.status in CLOSED_STATUSES:
|
||||
await _release_order(session, receipt.order_id)
|
||||
|
||||
await session.commit()
|
||||
await session.commit()
|
||||
|
||||
|
||||
async def sync_crm_statuses(session: AsyncSession, crm: CrmClient) -> None:
|
||||
|
||||
+17
-8
@@ -2,7 +2,7 @@
|
||||
|
||||
Запускается отдельным процессом: `arq app.worker.WorkerSettings`.
|
||||
- `create_ettn_receipt` — задача, которую ставит API после запроса кассира;
|
||||
- `poll_np_statuses` — раз в минуту статусы ТТН по заказам без чека;
|
||||
- `poll_np_statuses` — раз в минуту статусы ТТН по ключам НП касс и привязка заказов к кассам;
|
||||
- `poll_receipts` — раз в минуту повтор зависших `pending`, статусы `created`-чеков
|
||||
и повтор смены статуса заказа в CRM (PACKED), если CRM была недоступна.
|
||||
"""
|
||||
@@ -12,24 +12,28 @@ from __future__ import annotations
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from arq import cron
|
||||
from arq import Retry, cron, func
|
||||
from arq.connections import RedisSettings
|
||||
|
||||
from app.core.config import settings
|
||||
from app.core.logging import configure_logging, get_logger
|
||||
from app.db.session import SessionFactory
|
||||
from app.services import receipts as receipts_service
|
||||
from app.services.checkbox.client import get_checkbox_client
|
||||
from app.services.checkbox.client import CheckboxRateLimitedError, get_checkbox_client
|
||||
from app.services.crm.exo_client import ExoCrmClient
|
||||
from app.services.nova_poshta.np_client import NpTrackingClient
|
||||
from app.services.orders import sync_np_statuses
|
||||
|
||||
log = get_logger(__name__)
|
||||
|
||||
# Повторы `create_ettn_receipt` при лимите частоты: пауза retry_after + 5 с × номер попытки.
|
||||
_RATE_LIMIT_BACKOFF = 5
|
||||
_CREATE_ETTN_MAX_TRIES = 6
|
||||
|
||||
|
||||
async def startup(ctx: dict[str, Any]) -> None:
|
||||
configure_logging()
|
||||
ctx["np_client"] = NpTrackingClient(settings)
|
||||
ctx["np_client"] = NpTrackingClient()
|
||||
ctx["checkbox_client"] = get_checkbox_client()
|
||||
ctx["crm_client"] = ExoCrmClient(settings)
|
||||
log.info("worker_starting", environment=settings.environment)
|
||||
@@ -43,9 +47,14 @@ async def poll_np_statuses(ctx: dict[str, Any]) -> None:
|
||||
|
||||
async def create_ettn_receipt(ctx: dict[str, Any], receipt_id: str) -> None:
|
||||
async with SessionFactory() as session:
|
||||
await receipts_service.create_ettn_for_receipt(
|
||||
session, ctx["checkbox_client"], uuid.UUID(receipt_id)
|
||||
)
|
||||
try:
|
||||
await receipts_service.create_ettn_for_receipt(
|
||||
session, ctx["checkbox_client"], uuid.UUID(receipt_id)
|
||||
)
|
||||
except CheckboxRateLimitedError as exc:
|
||||
# Лимит частоты Новой Почты: повтор с нарастающей паузой. Когда попытки
|
||||
# кончатся, `pending`-чек подберёт cron `poll_receipts`.
|
||||
raise Retry(defer=exc.retry_after + _RATE_LIMIT_BACKOFF * ctx["job_try"]) from exc
|
||||
# Чек принят Checkbox — сразу переводим заказ в CRM в PACKED.
|
||||
await receipts_service.sync_crm_statuses(session, ctx["crm_client"])
|
||||
|
||||
@@ -61,7 +70,7 @@ async def poll_receipts(ctx: dict[str, Any]) -> None:
|
||||
class WorkerSettings:
|
||||
redis_settings = RedisSettings.from_dsn(settings.redis_url)
|
||||
on_startup = startup
|
||||
functions = [create_ettn_receipt]
|
||||
functions = [func(create_ettn_receipt, max_tries=_CREATE_ETTN_MAX_TRIES)]
|
||||
cron_jobs = [
|
||||
cron(poll_np_statuses, minute=set(range(60)), run_at_startup=True),
|
||||
cron(poll_receipts, minute=set(range(60)), second=30),
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import httpx
|
||||
@@ -14,6 +16,7 @@ from app.core.config import Settings
|
||||
from app.services.checkbox.client import (
|
||||
CheckboxCredentials,
|
||||
CheckboxError,
|
||||
CheckboxRateLimitedError,
|
||||
CheckboxUnavailableError,
|
||||
)
|
||||
from app.services.checkbox.http_client import HttpCheckboxClient
|
||||
@@ -35,11 +38,12 @@ ETTN = {
|
||||
}
|
||||
|
||||
|
||||
def _client() -> HttpCheckboxClient:
|
||||
def _client(interval_ms: int = 0) -> HttpCheckboxClient:
|
||||
settings = Settings(
|
||||
secret_key="test-secret-key",
|
||||
encryption_key="dGVzdC1lbmNyeXB0aW9uLWtleS0zMi1ieXRlcyEh",
|
||||
checkbox_base_url=BASE,
|
||||
checkbox_min_request_interval_ms=interval_ms,
|
||||
) # type: ignore[arg-type]
|
||||
return HttpCheckboxClient(settings)
|
||||
|
||||
@@ -178,5 +182,78 @@ async def test_rate_limit_is_retryable() -> None:
|
||||
respx.post(f"{BASE}/api/v1/ettn").mock(
|
||||
return_value=Response(429, json={"message": "Занадто часто виконуються запити"})
|
||||
)
|
||||
with pytest.raises(CheckboxUnavailableError):
|
||||
with pytest.raises(CheckboxRateLimitedError):
|
||||
await _client().create_ettn(CREDS, {})
|
||||
|
||||
|
||||
def _np_error(message: str, info: list[str], codes: list[str]) -> Response:
|
||||
"""Ошибка Новой Почты в том виде, в каком её отдаёт боевой Checkbox."""
|
||||
inner = {
|
||||
"type": "ettn",
|
||||
"message": message,
|
||||
"code": 0,
|
||||
"context": {"success": False, "errors": [message], "info": info, "errorCodes": codes},
|
||||
}
|
||||
return Response(400, json={"code": "third_party.generic", "message": json.dumps(inner)})
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_np_too_many_requests_is_rate_limited() -> None:
|
||||
_signin()
|
||||
respx.post(f"{BASE}/api/v1/ettn").mock(
|
||||
return_value=_np_error("To many requests", ["Try again after 3 seconds"], ["20000401501"])
|
||||
)
|
||||
with pytest.raises(CheckboxRateLimitedError) as exc_info:
|
||||
await _client().create_ettn(CREDS, {})
|
||||
assert exc_info.value.retry_after == 3
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_np_zero_retry_after_waits_at_least_a_second() -> None:
|
||||
_signin()
|
||||
respx.post(f"{BASE}/api/v1/ettn").mock(
|
||||
return_value=_np_error("To many requests", ["Try again after 0 seconds"], ["20000401501"])
|
||||
)
|
||||
with pytest.raises(CheckboxRateLimitedError) as exc_info:
|
||||
await _client().create_ettn(CREDS, {})
|
||||
assert exc_info.value.retry_after == 1
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_np_timeout_is_unavailable_not_rate_limited() -> None:
|
||||
_signin()
|
||||
respx.post(f"{BASE}/api/v1/ettn").mock(
|
||||
return_value=_np_error("cURL error 28: SSL connection timeout", [], [])
|
||||
)
|
||||
with pytest.raises(CheckboxUnavailableError) as exc_info:
|
||||
await _client().create_ettn(CREDS, {})
|
||||
assert not isinstance(exc_info.value, CheckboxRateLimitedError)
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_concurrent_calls_sign_in_once() -> None:
|
||||
signin = _signin()
|
||||
respx.get(f"{BASE}/api/v1/ettn/e-1").mock(return_value=Response(200, json=ETTN))
|
||||
client = _client()
|
||||
|
||||
await asyncio.gather(*(client.get_ettn(CREDS, "e-1") for _ in range(5)))
|
||||
|
||||
assert signin.call_count == 1
|
||||
|
||||
|
||||
@respx.mock
|
||||
async def test_requests_are_spaced_by_min_interval() -> None:
|
||||
_signin()
|
||||
sent_at: list[float] = []
|
||||
|
||||
def record(_: httpx.Request) -> Response:
|
||||
sent_at.append(time.monotonic())
|
||||
return Response(200, json=ETTN)
|
||||
|
||||
respx.get(f"{BASE}/api/v1/ettn/e-1").mock(side_effect=record)
|
||||
client = _client(interval_ms=50)
|
||||
|
||||
await asyncio.gather(*(client.get_ettn(CREDS, "e-1") for _ in range(3)))
|
||||
|
||||
gaps = [b - a for a, b in zip(sent_at, sent_at[1:], strict=False)]
|
||||
assert len(gaps) == 2 and all(gap >= 0.045 for gap in gaps)
|
||||
|
||||
@@ -8,7 +8,7 @@ import pytest
|
||||
import respx
|
||||
from httpx import Response
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.schemas.tracking import TrackingStatusOut
|
||||
from app.services.nova_poshta.client import NovaPoshtaError
|
||||
from app.services.nova_poshta.np_client import _API_URL, NpTrackingClient
|
||||
|
||||
@@ -27,12 +27,7 @@ STATUS_PAYLOAD = {
|
||||
}
|
||||
|
||||
|
||||
def _settings() -> Settings:
|
||||
return Settings(
|
||||
secret_key="test-secret-key",
|
||||
encryption_key="dGVzdC1lbmNyeXB0aW9uLWtleS0zMi1ieXRlcyEh",
|
||||
nova_poshta_api_key="np-apikey-123",
|
||||
) # type: ignore[arg-type]
|
||||
API_KEY = "np-apikey-123"
|
||||
|
||||
|
||||
class TestNpTrackingClientGetStatuses:
|
||||
@@ -41,13 +36,13 @@ class TestNpTrackingClientGetStatuses:
|
||||
route = respx.post(_API_URL).mock(
|
||||
return_value=Response(200, json={"success": True, "data": [], "errors": []})
|
||||
)
|
||||
client = NpTrackingClient(_settings())
|
||||
client = NpTrackingClient()
|
||||
|
||||
await client.get_statuses(waybill_numbers=["20451540916703"])
|
||||
await client.get_statuses(api_key=API_KEY, waybill_numbers=["20451540916703"])
|
||||
|
||||
sent = route.calls.last.request
|
||||
body = json.loads(sent.content)
|
||||
assert body["apiKey"] == "np-apikey-123"
|
||||
assert body["apiKey"] == API_KEY
|
||||
assert body["modelName"] == "TrackingDocument"
|
||||
assert body["calledMethod"] == "getStatusDocuments"
|
||||
assert body["methodProperties"]["Documents"] == [
|
||||
@@ -63,9 +58,9 @@ class TestNpTrackingClientGetStatuses:
|
||||
200, json={"success": True, "data": [STATUS_PAYLOAD], "errors": []}
|
||||
)
|
||||
)
|
||||
client = NpTrackingClient(_settings())
|
||||
client = NpTrackingClient()
|
||||
|
||||
statuses = await client.get_statuses(waybill_numbers=["20451540916703"])
|
||||
statuses = await client.get_statuses(api_key=API_KEY, waybill_numbers=["20451540916703"])
|
||||
|
||||
assert len(statuses) == 1
|
||||
status = statuses[0]
|
||||
@@ -75,6 +70,13 @@ class TestNpTrackingClientGetStatuses:
|
||||
# не "сколько заплатить сейчас" (`ExpressWaybillAmountToPay` = 827.48).
|
||||
assert status.cod_amount == "699"
|
||||
assert status.payment_status == "NeedPayment"
|
||||
assert not status.is_own # в STATUS_PAYLOAD нет PhoneSender
|
||||
|
||||
def test_is_own_by_sender_phone(self) -> None:
|
||||
own = TrackingStatusOut.model_validate({**STATUS_PAYLOAD, "PhoneSender": "380961112233"})
|
||||
foreign = TrackingStatusOut.model_validate({**STATUS_PAYLOAD, "PhoneSender": ""})
|
||||
assert own.is_own
|
||||
assert not foreign.is_own
|
||||
|
||||
@respx.mock
|
||||
async def test_falls_back_to_amount_to_pay_when_afterpayment_missing(self) -> None:
|
||||
@@ -82,9 +84,9 @@ class TestNpTrackingClientGetStatuses:
|
||||
respx.post(_API_URL).mock(
|
||||
return_value=Response(200, json={"success": True, "data": [payload], "errors": []})
|
||||
)
|
||||
client = NpTrackingClient(_settings())
|
||||
client = NpTrackingClient()
|
||||
|
||||
statuses = await client.get_statuses(waybill_numbers=["20451540916703"])
|
||||
statuses = await client.get_statuses(api_key=API_KEY, waybill_numbers=["20451540916703"])
|
||||
|
||||
assert statuses[0].cod_amount == "827.48"
|
||||
|
||||
@@ -95,18 +97,20 @@ class TestNpTrackingClientGetStatuses:
|
||||
200, json={"success": False, "data": [], "errors": ["Invalid apiKey"]}
|
||||
)
|
||||
)
|
||||
client = NpTrackingClient(_settings())
|
||||
client = NpTrackingClient()
|
||||
|
||||
with pytest.raises(NovaPoshtaError, match="Invalid apiKey"):
|
||||
await client.get_statuses(waybill_numbers=["20451540916703"])
|
||||
await client.get_statuses(api_key=API_KEY, waybill_numbers=["20451540916703"])
|
||||
|
||||
async def test_returns_empty_list_for_no_documents(self) -> None:
|
||||
client = NpTrackingClient(_settings())
|
||||
client = NpTrackingClient()
|
||||
|
||||
assert await client.get_statuses(waybill_numbers=[]) == []
|
||||
assert await client.get_statuses(api_key=API_KEY, waybill_numbers=[]) == []
|
||||
|
||||
async def test_rejects_too_many_documents(self) -> None:
|
||||
client = NpTrackingClient(_settings())
|
||||
client = NpTrackingClient()
|
||||
|
||||
with pytest.raises(NovaPoshtaError, match="Слишком много"):
|
||||
await client.get_statuses(waybill_numbers=[str(i) for i in range(101)])
|
||||
await client.get_statuses(
|
||||
api_key=API_KEY, waybill_numbers=[str(i) for i in range(101)]
|
||||
)
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Опрос статусов НП ключами касс и привязка заказов к кассам (`sync_np_statuses`)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from app.core import crypto
|
||||
from app.db.models.cash_register import CashRegister
|
||||
from app.db.models.order import Order
|
||||
from app.services.nova_poshta.stub_client import StubNovaPoshtaClient
|
||||
from app.services.orders import sync_np_statuses
|
||||
|
||||
|
||||
def _register(name: str, np_key: str | None, *, is_default: bool = False) -> CashRegister:
|
||||
return CashRegister(
|
||||
id=uuid.uuid4(),
|
||||
name=name,
|
||||
license_key_enc=crypto.encrypt("lic"),
|
||||
cashier_pin_enc=crypto.encrypt("1111"),
|
||||
np_api_key_enc=crypto.encrypt(np_key) if np_key else None,
|
||||
tax_codes=[],
|
||||
is_active=True,
|
||||
is_default=is_default,
|
||||
created_at=datetime(2026, 9, 1, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
def _order(waybill: str, register_id: uuid.UUID | None = None) -> Order:
|
||||
return Order(
|
||||
id=waybill[-4:],
|
||||
create_date_time=datetime(2026, 9, 20, tzinfo=UTC),
|
||||
waybill_number=waybill,
|
||||
total_amount_kopecks=120000,
|
||||
goods=[],
|
||||
is_deleted=False,
|
||||
cash_register_id=register_id,
|
||||
)
|
||||
|
||||
|
||||
def _status(waybill: str, *, own: bool) -> dict[str, Any]:
|
||||
status = {"Number": waybill, "Status": "В дорозі", "StatusCode": "5"}
|
||||
if own:
|
||||
status |= {"PhoneSender": "380961112233", "AfterpaymentOnGoodsCost": 1200}
|
||||
return status
|
||||
|
||||
|
||||
class FakeSession:
|
||||
"""`scalars` отдаёт кассы (с ключом НП, активные) или заказы — по сущности запроса."""
|
||||
|
||||
def __init__(self, registers: list[CashRegister], orders: list[Order]) -> None:
|
||||
self.registers = registers
|
||||
self.orders = orders
|
||||
self.commits = 0
|
||||
|
||||
async def scalars(self, statement: Any) -> list[Any]:
|
||||
entity = statement.column_descriptions[0]["entity"]
|
||||
if entity is CashRegister:
|
||||
return [r for r in self.registers if r.np_api_key_enc and r.is_active]
|
||||
return self.orders
|
||||
|
||||
async def commit(self) -> None:
|
||||
self.commits += 1
|
||||
|
||||
|
||||
async def test_unbound_order_is_claimed_by_register_that_sees_it_as_own() -> None:
|
||||
first, second = _register("A", "key-a", is_default=True), _register("B", "key-b")
|
||||
order = _order("20450000000001")
|
||||
np = StubNovaPoshtaClient(
|
||||
by_key={
|
||||
"key-a": {order.waybill_number: _status(order.waybill_number, own=False)},
|
||||
"key-b": {order.waybill_number: _status(order.waybill_number, own=True)},
|
||||
}
|
||||
)
|
||||
session = FakeSession([first, second], [order])
|
||||
|
||||
await sync_np_statuses(session, np) # type: ignore[arg-type]
|
||||
|
||||
assert order.cash_register_id == second.id
|
||||
assert order.np_cod_amount_kopecks == 120000
|
||||
assert session.commits == 1
|
||||
|
||||
|
||||
async def test_bound_order_is_polled_only_with_its_register_key() -> None:
|
||||
first, second = _register("A", "key-a", is_default=True), _register("B", "key-b")
|
||||
order = _order("20450000000002", register_id=second.id)
|
||||
np = StubNovaPoshtaClient(
|
||||
by_key={"key-b": {order.waybill_number: _status(order.waybill_number, own=True)}}
|
||||
)
|
||||
|
||||
await sync_np_statuses(FakeSession([first, second], [order]), np) # type: ignore[arg-type]
|
||||
|
||||
assert [key for key, _ in np.calls] == ["key-b"]
|
||||
assert order.cash_register_id == second.id
|
||||
|
||||
|
||||
async def test_foreign_waybill_keeps_status_but_no_register_or_cod() -> None:
|
||||
register = _register("A", "key-a", is_default=True)
|
||||
order = _order("20450000000003", register_id=register.id)
|
||||
np = StubNovaPoshtaClient(
|
||||
by_key={"key-a": {order.waybill_number: _status(order.waybill_number, own=False)}}
|
||||
)
|
||||
|
||||
await sync_np_statuses(FakeSession([register], [order]), np) # type: ignore[arg-type]
|
||||
|
||||
assert order.cash_register_id is None
|
||||
assert order.np_status_code == "5"
|
||||
assert order.np_cod_amount_kopecks is None
|
||||
|
||||
|
||||
async def test_no_registers_with_np_key_does_nothing() -> None:
|
||||
order = _order("20450000000004")
|
||||
np = StubNovaPoshtaClient()
|
||||
session = FakeSession([_register("A", None)], [order])
|
||||
|
||||
await sync_np_statuses(session, np) # type: ignore[arg-type]
|
||||
|
||||
assert np.calls == []
|
||||
assert session.commits == 0
|
||||
@@ -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
|
||||
@@ -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:
|
||||
@@ -132,7 +137,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 +147,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
|
||||
@@ -230,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",
|
||||
}
|
||||
|
||||
@@ -7,10 +7,19 @@ 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,
|
||||
cod_in_transit,
|
||||
list_orders,
|
||||
update_order,
|
||||
)
|
||||
|
||||
|
||||
class TestToKopecks:
|
||||
@@ -148,3 +157,77 @@ 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
|
||||
|
||||
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_received_and_refusals(self, tab: OrderTab) -> None:
|
||||
sql = _list_where(tab)
|
||||
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
|
||||
|
||||
@@ -16,9 +16,10 @@ from app.core import crypto
|
||||
from app.db.models.cash_register import CashRegister
|
||||
from app.db.models.order import Order
|
||||
from app.db.models.receipt import Receipt, ReceiptStatus
|
||||
from app.db.models.user import User
|
||||
from app.schemas.checkbox import EttnStatus
|
||||
from app.services import receipts as svc
|
||||
from app.services.checkbox.client import CheckboxUnavailableError
|
||||
from app.services.checkbox.client import CheckboxRateLimitedError, CheckboxUnavailableError
|
||||
from app.services.checkbox.stub_client import StubCheckboxClient
|
||||
|
||||
|
||||
@@ -182,6 +183,52 @@ class TestBuildBody:
|
||||
assert "delivery" not in body["receipt_body"]
|
||||
|
||||
|
||||
class TestRequestReceipts:
|
||||
"""Чек создаётся от кассы, к которой опрос НП привязал заказ."""
|
||||
|
||||
class Session:
|
||||
def __init__(self, registers: list[CashRegister], orders: list[Order]) -> None:
|
||||
self.registers, self.orders, self.added = registers, orders, []
|
||||
|
||||
async def scalars(self, statement: Any) -> list[Any]:
|
||||
entity = statement.column_descriptions[0]["entity"]
|
||||
if entity is CashRegister:
|
||||
return self.registers
|
||||
if entity is Order:
|
||||
return self.orders
|
||||
return [] # активные чеки заказов
|
||||
|
||||
def add(self, obj: Any) -> None:
|
||||
self.added.append(obj)
|
||||
|
||||
@staticmethod
|
||||
def _user() -> User:
|
||||
return User(id=uuid.uuid4(), email="c@x.ua")
|
||||
|
||||
async def test_uses_order_register(self) -> None:
|
||||
default, other = _register(), _register()
|
||||
order = _order(cash_register_id=other.id)
|
||||
session = self.Session([default, other], [order])
|
||||
|
||||
result = await svc.request_receipts(
|
||||
session, [(order.id, None)], user=self._user() # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
assert result.errors == {}
|
||||
assert result.created[0].cash_register_id == other.id
|
||||
|
||||
async def test_unbound_order_is_rejected(self) -> None:
|
||||
order = _order(cash_register_id=None)
|
||||
session = self.Session([_register()], [order])
|
||||
|
||||
result = await svc.request_receipts(
|
||||
session, [(order.id, None)], user=self._user() # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
assert result.created == []
|
||||
assert "Касса не определена" in result.errors[order.id]
|
||||
|
||||
|
||||
class FakeSession:
|
||||
def __init__(self, *objects: Any) -> None:
|
||||
self.objects = {(type(o), o.id): o for o in objects}
|
||||
@@ -266,6 +313,24 @@ class TestCreateEttn:
|
||||
assert receipt.status == ReceiptStatus.CREATED
|
||||
assert len(client.orders) == 1 # второго чека на ту же ТТН нет
|
||||
|
||||
async def test_rate_limit_keeps_pending_without_unknown_outcome(self) -> None:
|
||||
order, register = _order(receipt_created_at=datetime.now(UTC)), _register()
|
||||
receipt = _pending(order, register)
|
||||
client = StubCheckboxClient()
|
||||
session = FakeSession(order, register, receipt)
|
||||
|
||||
async def rate_limited(creds: Any, body: dict[str, Any]) -> Any:
|
||||
raise CheckboxRateLimitedError("To many requests", retry_after=1)
|
||||
|
||||
client.create_ettn = rate_limited # type: ignore[method-assign]
|
||||
with pytest.raises(CheckboxRateLimitedError):
|
||||
await svc.create_ettn_for_receipt(session, client, receipt.id)
|
||||
|
||||
assert receipt.status == ReceiptStatus.PENDING
|
||||
assert receipt.error is None # повтор не пойдёт через find_ettn
|
||||
assert order.receipt_created_at is not None # заказ не вернулся в очередь
|
||||
assert session.commits == 1 # блокировка строки снята
|
||||
|
||||
|
||||
class TestApplyEttn:
|
||||
@pytest.mark.parametrize(
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import { apiFetch } from '@/api/client'
|
||||
import type { Order, OrderUpdate } from '@/features/orders/types'
|
||||
import type { Order, OrdersSummary, OrderTab, OrderUpdate } from '@/features/orders/types'
|
||||
|
||||
export function getOrders(hasReceipt: boolean): Promise<Order[]> {
|
||||
return apiFetch<Order[]>(`/orders?has_receipt=${hasReceipt}`)
|
||||
export function getOrders(tab: OrderTab): Promise<Order[]> {
|
||||
return apiFetch<Order[]>(`/orders?tab=${tab}`)
|
||||
}
|
||||
|
||||
export function getOrdersSummary(): Promise<OrdersSummary> {
|
||||
return apiFetch<OrdersSummary>('/orders/summary')
|
||||
}
|
||||
|
||||
export function deleteOrder(orderId: string): Promise<void> {
|
||||
|
||||
@@ -5,6 +5,8 @@ export interface CashRegister {
|
||||
name: string
|
||||
fiscal_number: string | null
|
||||
license_key_masked: string
|
||||
/** null — ключ Новой Почты не задан: касса не получает посылки. */
|
||||
np_api_key_masked: string | null
|
||||
tax_codes: (number | string)[]
|
||||
is_active: boolean
|
||||
is_default: boolean
|
||||
@@ -15,6 +17,7 @@ export interface CashRegisterCreate {
|
||||
fiscal_number: string | null
|
||||
license_key: string
|
||||
pin_code: string
|
||||
np_api_key: string | null
|
||||
tax_codes: (number | string)[]
|
||||
is_default: boolean
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
|
||||
import type { ReceiptStatus } from '@/features/receipts/types'
|
||||
|
||||
/** Вкладка дашборда (`OrderTab` в backend/app/services/orders.py). */
|
||||
export type OrderTab = 'no_receipt' | 'has_receipt' | 'received' | 'refused'
|
||||
|
||||
export interface OrderGood {
|
||||
id: string
|
||||
sku: string
|
||||
@@ -59,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
|
||||
}
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
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(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
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export function useOrdersSummary() {
|
||||
return useQuery({
|
||||
// Под ключом ['orders', ...] — чтобы invalidateQueries(['orders']) обновлял и сводку.
|
||||
queryKey: ['orders', 'summary'],
|
||||
queryFn: getOrdersSummary,
|
||||
refetchInterval: SUMMARY_POLL_MS,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -44,3 +44,7 @@
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cr-missing {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ interface FormState {
|
||||
fiscal_number: string
|
||||
license_key: string
|
||||
pin_code: string
|
||||
np_api_key: string
|
||||
tax_codes: string
|
||||
is_default: boolean
|
||||
}
|
||||
@@ -28,6 +29,7 @@ const EMPTY_FORM: FormState = {
|
||||
fiscal_number: '',
|
||||
license_key: '',
|
||||
pin_code: '',
|
||||
np_api_key: '',
|
||||
tax_codes: '',
|
||||
is_default: true,
|
||||
}
|
||||
@@ -75,6 +77,7 @@ export function CashRegistersPage() {
|
||||
fiscal_number: register.fiscal_number ?? '',
|
||||
license_key: '',
|
||||
pin_code: '',
|
||||
np_api_key: '',
|
||||
tax_codes: register.tax_codes.join(', '),
|
||||
is_default: register.is_default,
|
||||
})
|
||||
@@ -88,7 +91,7 @@ export function CashRegistersPage() {
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
if (editingId) {
|
||||
// Пустые ключ/PIN при редактировании — «не менять».
|
||||
// Пустые ключи/PIN при редактировании — «не менять».
|
||||
const payload: CashRegisterUpdate = {
|
||||
name: form.name,
|
||||
fiscal_number: form.fiscal_number || null,
|
||||
@@ -97,6 +100,7 @@ export function CashRegistersPage() {
|
||||
}
|
||||
if (form.license_key) payload.license_key = form.license_key
|
||||
if (form.pin_code) payload.pin_code = form.pin_code
|
||||
if (form.np_api_key) payload.np_api_key = form.np_api_key
|
||||
await run(() => updateCashRegister(editingId, payload), 'Касса обновлена')
|
||||
} else {
|
||||
await run(
|
||||
@@ -106,6 +110,7 @@ export function CashRegistersPage() {
|
||||
fiscal_number: form.fiscal_number || null,
|
||||
license_key: form.license_key,
|
||||
pin_code: form.pin_code,
|
||||
np_api_key: form.np_api_key,
|
||||
tax_codes: parseTaxCodes(form.tax_codes),
|
||||
is_default: form.is_default,
|
||||
}),
|
||||
@@ -145,6 +150,7 @@ export function CashRegistersPage() {
|
||||
<th>Название</th>
|
||||
<th>Фиск. номер</th>
|
||||
<th>Ключ лицензии</th>
|
||||
<th>Ключ Новой Почты</th>
|
||||
<th>Налоги</th>
|
||||
<th>Статус</th>
|
||||
<th>Действия</th>
|
||||
@@ -153,21 +159,21 @@ export function CashRegistersPage() {
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td colSpan={6} className="orders-empty">
|
||||
<td colSpan={7} className="orders-empty">
|
||||
Загрузка…
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{isError && (
|
||||
<tr>
|
||||
<td colSpan={6} className="orders-empty">
|
||||
<td colSpan={7} className="orders-empty">
|
||||
Не удалось загрузить кассы
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{registers?.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="orders-empty">
|
||||
<td colSpan={7} className="orders-empty">
|
||||
Кассы не настроены — чеки создавать нельзя
|
||||
</td>
|
||||
</tr>
|
||||
@@ -180,6 +186,7 @@ export function CashRegistersPage() {
|
||||
</td>
|
||||
<td>{register.fiscal_number || '—'}</td>
|
||||
<td>{register.license_key_masked}</td>
|
||||
<td>{register.np_api_key_masked ?? <span className="cr-missing">не задан</span>}</td>
|
||||
<td>{register.tax_codes.length ? register.tax_codes.join(', ') : 'без налога'}</td>
|
||||
<td>
|
||||
<span className={`orders-status orders-status--${register.is_active ? 'delivered' : 'new'}`}>
|
||||
@@ -249,6 +256,16 @@ export function CashRegistersPage() {
|
||||
onChange={(e) => setForm({ ...form, pin_code: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Ключ API Новой Почты (посылки этого кабинета пробиваются через эту кассу)
|
||||
<input
|
||||
required={!editingId}
|
||||
autoComplete="off"
|
||||
placeholder={editingId ? 'не менять' : ''}
|
||||
value={form.np_api_key}
|
||||
onChange={(e) => setForm({ ...form, np_api_key: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Коды налогов (через запятую, пусто — без налога)
|
||||
<input value={form.tax_codes} onChange={(e) => setForm({ ...form, tax_codes: e.target.value })} />
|
||||
@@ -259,7 +276,7 @@ export function CashRegistersPage() {
|
||||
checked={form.is_default}
|
||||
onChange={(e) => setForm({ ...form, is_default: e.target.checked })}
|
||||
/>
|
||||
Основная касса (по ней создаются чеки)
|
||||
Основная касса (первой проверяет новые посылки)
|
||||
</label>
|
||||
<div className="cr-form-actions">
|
||||
<button type="submit" className="orders-create-btn" disabled={busy}>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -7,8 +7,8 @@ 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 { useOrders } from '@/features/orders/useOrders'
|
||||
import type { Order, OrderTab } from '@/features/orders/types'
|
||||
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'
|
||||
@@ -19,11 +19,11 @@ const ROLE_LABEL: Record<string, string> = {
|
||||
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: 'received', label: 'Полученные' },
|
||||
{ key: 'refused', label: 'Отказы' },
|
||||
]
|
||||
|
||||
function npStatusTone(order: Order): 'delivered' | 'processing' | 'danger' | 'new' {
|
||||
@@ -45,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<Tab>('no_receipt')
|
||||
const { data: orders, isLoading, isError } = useOrders(tab === 'has_receipt')
|
||||
const [tab, setTab] = useState<OrderTab>('no_receipt')
|
||||
const { data: orders, isLoading, isError } = useOrders(tab)
|
||||
const { data: summary } = useOrdersSummary()
|
||||
const queryClient = useQueryClient()
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [search, setSearch] = useState('')
|
||||
@@ -103,7 +110,13 @@ export function DashboardPage() {
|
||||
|
||||
async function handleCancel(order: Order) {
|
||||
if (!order.receipt_id) return
|
||||
if (!window.confirm(`Отменить ЕТТН-чек по заказу ${order.id}? Заказ вернётся в очередь.`)) return
|
||||
const outcome = {
|
||||
no_receipt: 'Заказ вернётся в очередь.',
|
||||
has_receipt: 'Заказ вернётся в очередь.',
|
||||
received: 'Заказ останется в полученных.',
|
||||
refused: 'Заказ останется в отказах.',
|
||||
}[tab]
|
||||
if (!window.confirm(`Отменить ЕТТН-чек по заказу ${order.id}? ${outcome}`)) return
|
||||
setCancellingId(order.id)
|
||||
try {
|
||||
await cancelReceipt(order.receipt_id)
|
||||
@@ -193,6 +206,18 @@ export function DashboardPage() {
|
||||
</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 && (
|
||||
<div className={`orders-notice orders-notice--${notice.tone}`}>
|
||||
<ul>
|
||||
@@ -336,7 +361,7 @@ export function DashboardPage() {
|
||||
Чек
|
||||
</button>
|
||||
)}
|
||||
{order.receipt_status && (tab === 'has_receipt' || !CANCELLABLE.has(order.receipt_status)) && (
|
||||
{order.receipt_status && (tab !== 'no_receipt' || !CANCELLABLE.has(order.receipt_status)) && (
|
||||
<span
|
||||
className={`orders-status orders-status--${RECEIPT_STATUS[order.receipt_status].tone}`}
|
||||
title={order.receipt_error ?? undefined}
|
||||
@@ -344,7 +369,7 @@ export function DashboardPage() {
|
||||
{RECEIPT_STATUS[order.receipt_status].label}
|
||||
</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
|
||||
type="button"
|
||||
className="orders-delete-btn"
|
||||
@@ -354,7 +379,7 @@ export function DashboardPage() {
|
||||
Отменить
|
||||
</button>
|
||||
)}
|
||||
{canDelete && tab === 'no_receipt' && (
|
||||
{canDelete && (tab === 'no_receipt' || ((tab === 'refused' || tab === 'received') && !order.has_receipt)) && (
|
||||
<button
|
||||
type="button"
|
||||
className="orders-delete-btn"
|
||||
|
||||
Reference in New Issue
Block a user