Author SHA1 Message Date
lauadminandClaude Opus 5.5 8b3c2b6d63 Bind Nova Poshta API key to cash register (#3)
Each cash register stores its own encrypted NP API key. Status polling uses
register keys and binds an order to the register whose key sees the TTN as
its own (PhoneSender present); ETTN receipts are created from that register.
Migration 0008 moves the old NOVA_POSHTA_API_KEY into the default register.

Closes #3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:02:40 +03:00
lauadmin 2b92d82693 Merge pull request 'Poll ETTN statuses in batches and commit per receipt' (#2) from feature/checkbox-ettn into main 2026-09-25 09:27:06 +00:00
lauadminandClaude Opus 5.5 a7cba7616a Poll ETTN statuses in batches and commit per receipt
The cron poll committed only at the end; with ~100 created receipts and a 1s
pause between Checkbox requests it hit arq's 300s timeout every run, so all
status updates were rolled back and fiscalized receipts stayed "created".
Now each run polls at most 30 least-recently-checked receipts and commits
after each one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:25:12 +03:00
lauadmin ed635cab94 Merge pull request 'Checkbox ETTN receipts, order editing, dashboard tabs and COD summary' (#1) from feature/checkbox-ettn into main 2026-09-24 22:36:51 +00:00
lauadminandClaude Opus 5.5 a6072800b2 Add COD-in-transit summary and received tab to the dashboard
- GET /orders/summary: count and sum of cash-on-delivery for parcels not
  yet picked up (excludes received, refused, deleted/unknown TTNs, paid COD);
  shown as a card above the tabs.
- New «Полученные» tab: orders with NP received codes (9, 10, 11, 106) move
  there automatically, regardless of receipt; 106 is now a final status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 01:23:39 +03:00
31 changed files with 752 additions and 96 deletions
+2 -1
View File
@@ -58,7 +58,8 @@ CRM_SHOP_KEY=change-me-crm-shopkey
CRM_SID=1 CRM_SID=1
# --- Nova Poshta ---------------------------------------------------------- # --- Nova Poshta ----------------------------------------------------------
# Ключ API кабинета Nova Poshta (my.novaposhta.ua → Настройки → API). # Ключи API Nova Poshta задаются у касс (страница «Кассы»). Эта переменная нужна
# только миграции 0008: переносит старый общий ключ в кассу по умолчанию.
NOVA_POSHTA_API_KEY=change-me-novaposhta-apikey NOVA_POSHTA_API_KEY=change-me-novaposhta-apikey
# --- Checkbox ------------------------------------------------------------- # --- Checkbox -------------------------------------------------------------
+2 -1
View File
@@ -118,7 +118,7 @@ Not yet enforced by types anywhere in the current code, but is a hard project co
## Project status (see the plan for the full roadmap) ## Project status (see the plan for the full roadmap)
Stages 1–4 (scaffolding, auth/audit, CRM order queue, Nova Poshta tracking) are done. The dashboard has three tabs (`GET /orders?tab=no_receipt|has_receipt|refused`, `services/orders.OrderTab`); an order whose NP status code is a refusal (`NP_REFUSAL_STATUS_CODES`) shows only under «Отказы», whether or not it has a receipt. Checkbox ETTN receipts are implemented per `.plans/checkbox-ettn-receipts.md` but not yet verified on a real cash register. Stages 1–4 (scaffolding, auth/audit, CRM order queue, Nova Poshta tracking) are done. The dashboard has four tabs (`GET /orders?tab=no_receipt|has_receipt|received|refused`, `services/orders.OrderTab`); NP status beats receipt presence: an order whose NP status code is «received» (`NP_RECEIVED_STATUS_CODES`) shows only under «Полученные», a refusal (`NP_REFUSAL_STATUS_CODES`) only under «Отказы», whether or not it has a receipt. Checkbox ETTN receipts are implemented per `.plans/checkbox-ettn-receipts.md` but not yet verified on a real cash register.
### Checkbox ETTN receipts ### Checkbox ETTN receipts
@@ -127,4 +127,5 @@ Stages 1–4 (scaffolding, auth/audit, CRM order queue, Nova Poshta tracking) ar
- 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). - 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"}}`. - 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. - 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()`. - 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")
+33 -2
View File
@@ -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]) @router.get("", response_model=list[CashRegisterOut])
async def list_cash_registers(session: SessionDep) -> list[CashRegisterOut]: async def list_cash_registers(session: SessionDep) -> list[CashRegisterOut]:
registers = await session.scalars(select(CashRegister).order_by(CashRegister.created_at)) 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( async def create_cash_register(
payload: CashRegisterCreate, request: Request, session: SessionDep, actor: AdminUser payload: CashRegisterCreate, request: Request, session: SessionDep, actor: AdminUser
) -> CashRegisterOut: ) -> CashRegisterOut:
if payload.np_api_key:
await _ensure_np_key_unique(session, payload.np_api_key)
register = CashRegister( register = CashRegister(
id=uuid.uuid4(), id=uuid.uuid4(),
name=payload.name, name=payload.name,
fiscal_number=payload.fiscal_number, fiscal_number=payload.fiscal_number,
license_key_enc=crypto.encrypt(payload.license_key), license_key_enc=crypto.encrypt(payload.license_key),
cashier_pin_enc=crypto.encrypt(payload.pin_code), 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, tax_codes=payload.tax_codes,
is_active=True, is_active=True,
is_default=payload.is_default, is_default=payload.is_default,
@@ -56,7 +80,11 @@ async def create_cash_register(
user=actor, user=actor,
entity_type="cash_register", entity_type="cash_register",
entity_id=register.id, 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, request=request,
) )
await session.commit() await session.commit()
@@ -80,6 +108,9 @@ async def update_cash_register(
register.license_key_enc = crypto.encrypt(changes["license_key"]) register.license_key_enc = crypto.encrypt(changes["license_key"])
if "pin_code" in changes: if "pin_code" in changes:
register.cashier_pin_enc = crypto.encrypt(changes["pin_code"]) 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: if "fiscal_number" in changes:
register.fiscal_number = changes["fiscal_number"] register.fiscal_number = changes["fiscal_number"]
for field in ("name", "tax_codes", "is_active", "is_default"): for field in ("name", "tax_codes", "is_active", "is_default"):
@@ -94,7 +125,7 @@ async def update_cash_register(
user=actor, user=actor,
entity_type="cash_register", entity_type="cash_register",
entity_id=register.id, 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, request=request,
) )
await session.commit() await session.commit()
+9 -1
View File
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
from app.api.deps import CashierUser, CrmClientDep, SessionDep, require_any from app.api.deps import CashierUser, CrmClientDep, SessionDep, require_any
from app.db.models.audit import AuditAction from app.db.models.audit import AuditAction
from app.schemas.orders import OrderRowOut, OrderUpdateIn from app.schemas.orders import OrderRowOut, OrdersSummaryOut, OrderUpdateIn
from app.services import audit from app.services import audit
from app.services import orders as orders_service from app.services import orders as orders_service
from app.services import receipts as receipts_service from app.services import receipts as receipts_service
@@ -29,6 +29,14 @@ async def list_orders(
return [OrderRowOut.from_order(order, receipts.get(order.id)) for order in orders] return [OrderRowOut.from_order(order, receipts.get(order.id)) for order in orders]
@router.get("/summary", response_model=OrdersSummaryOut)
async def orders_summary(session: SessionDep) -> OrdersSummaryOut:
count, kopecks = await orders_service.cod_in_transit(session)
return OrdersSummaryOut(
cod_in_transit_count=count, cod_in_transit_amount=f"{kopecks / 100:.2f}"
)
@router.patch("/{order_id}", response_model=OrderRowOut, response_model_by_alias=False) @router.patch("/{order_id}", response_model=OrderRowOut, response_model_by_alias=False)
async def update_order( async def update_order(
order_id: str, order_id: str,
+2
View File
@@ -63,6 +63,8 @@ class Settings(BaseSettings):
crm_sid: int = 1 crm_sid: int = 1
# --- Nova Poshta --- # --- Nova Poshta ---
# Ключи НП хранятся у касс (`cash_registers.np_api_key_enc`). Эта переменная
# читается только миграцией 0008: переносит старый общий ключ в кассу по умолчанию.
nova_poshta_api_key: str = "" nova_poshta_api_key: str = ""
# --- Checkbox --- # --- Checkbox ---
+7 -2
View File
@@ -1,7 +1,8 @@
"""Кассы (ПРРО) Checkbox. """Кассы (ПРРО) Checkbox.
Ключ лицензии и PIN кассира хранятся только в зашифрованном виде Ключ лицензии, PIN кассира и ключ API Новой Почты хранятся только в
(`app/core/crypto.py`): утечка дампа БД не должна давать доступ к кассе. зашифрованном виде (`app/core/crypto.py`): утечка дампа БД не должна давать
доступ к кассе и кабинету отправителя.
""" """
from __future__ import annotations from __future__ import annotations
@@ -22,6 +23,10 @@ class CashRegister(UUIDPrimaryKeyMixin, TimestampMixin, Base):
fiscal_number: Mapped[str | None] = mapped_column(String(64)) fiscal_number: Mapped[str | None] = mapped_column(String(64))
license_key_enc: Mapped[str] = mapped_column(String(512), nullable=False) license_key_enc: Mapped[str] = mapped_column(String(512), nullable=False)
cashier_pin_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` # Коды налоговых ставок Checkbox для всех товаров чека; пусто — поле `tax`
# не передаётся (неплательщик ПДВ). # не передаётся (неплательщик ПДВ).
tax_codes: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list) tax_codes: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list)
+8 -1
View File
@@ -7,10 +7,11 @@
from __future__ import annotations from __future__ import annotations
import uuid
from datetime import datetime from datetime import datetime
from typing import Any 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.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column 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_status_code: Mapped[str | None] = mapped_column(String(16))
np_cod_amount_kopecks: Mapped[int | None] = mapped_column(BigInteger) np_cod_amount_kopecks: Mapped[int | None] = mapped_column(BigInteger)
np_payment_status: Mapped[str | None] = mapped_column(String(32)) 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
)
+7
View File
@@ -122,6 +122,13 @@ class OrderRowOut(BaseModel):
) )
class OrdersSummaryOut(BaseModel):
"""`GET /orders/summary` — сводка над таблицей заказов."""
cod_in_transit_count: int
cod_in_transit_amount: str
class OrderGoodIn(BaseModel): class OrderGoodIn(BaseModel):
"""Позиция заказа из формы редактирования. `amount` считает сервер.""" """Позиция заказа из формы редактирования. `amount` считает сервер."""
+15 -5
View File
@@ -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): class CashRegisterOut(BaseModel):
id: uuid.UUID id: uuid.UUID
name: str name: str
fiscal_number: str | None fiscal_number: str | None
license_key_masked: str license_key_masked: str
# None — ключ НП не задан: касса не опрашивает посылки и не получает заказы.
np_api_key_masked: str | None
tax_codes: list[Any] tax_codes: list[Any]
is_active: bool is_active: bool
is_default: bool is_default: bool
@classmethod @classmethod
def from_register(cls, register: CashRegister) -> CashRegisterOut: def from_register(cls, register: CashRegister) -> CashRegisterOut:
try:
masked = crypto.mask(crypto.decrypt(register.license_key_enc))
except crypto.DecryptionError:
masked = "не расшифровывается — введите заново"
return cls( return cls(
id=register.id, id=register.id,
name=register.name, name=register.name,
fiscal_number=register.fiscal_number, 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, tax_codes=register.tax_codes,
is_active=register.is_active, is_active=register.is_active,
is_default=register.is_default, is_default=register.is_default,
@@ -101,6 +109,7 @@ class CashRegisterCreate(BaseModel):
fiscal_number: str | None = Field(default=None, max_length=64) fiscal_number: str | None = Field(default=None, max_length=64)
license_key: str = Field(min_length=1, max_length=255) license_key: str = Field(min_length=1, max_length=255)
pin_code: str = Field(min_length=1, max_length=32) 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) tax_codes: list[int | str] = Field(default_factory=list, max_length=2)
is_default: bool = True is_default: bool = True
@@ -112,6 +121,7 @@ class CashRegisterUpdate(BaseModel):
fiscal_number: str | None = Field(default=None, max_length=64) fiscal_number: str | None = Field(default=None, max_length=64)
license_key: str | None = Field(default=None, min_length=1, max_length=255) 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) 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) tax_codes: list[int | str] | None = Field(default=None, max_length=2)
is_active: bool | None = None is_active: bool | None = None
is_default: bool | None = None is_default: bool | None = None
+10
View File
@@ -32,9 +32,19 @@ class TrackingStatusOut(BaseModel):
# какого-то типа накладной не возвращает `AfterpaymentOnGoodsCost`. # какого-то типа накладной не возвращает `AfterpaymentOnGoodsCost`.
cod_amount: str | None = Field(default=None) cod_amount: str | None = Field(default=None)
# Телефон отправителя NP отдаёт только кабинету отправителя (или получателя):
# по чужому ключу без телефона ответ урезан — нет ни отправителя, ни
# `AfterpaymentOnGoodsCost`. По этому полю определяется, чья это посылка.
phone_sender: str | None = Field(default=None, alias="PhoneSender")
# Статус оплаты аналогично раздвоен на `PaymentStatus`/`ExpressWaybillPaymentStatus`. # Статус оплаты аналогично раздвоен на `PaymentStatus`/`ExpressWaybillPaymentStatus`.
payment_status: str | None = Field(default=None) payment_status: str | None = Field(default=None)
@property
def is_own(self) -> bool:
"""ТТН видна ключу как своя — отправлена из кабинета этого ключа."""
return bool(self.phone_sender)
@model_validator(mode="before") @model_validator(mode="before")
@classmethod @classmethod
def _fold_express_fields(cls, data: Any) -> Any: def _fold_express_fields(cls, data: Any) -> Any:
+1
View File
@@ -22,6 +22,7 @@ _REDACTED_KEYS = {
"secret", "secret",
"license_key", "license_key",
"api_key", "api_key",
"np_api_key",
"pin", "pin",
"pin_code", "pin_code",
"cashier_pin", "cashier_pin",
+5 -1
View File
@@ -15,4 +15,8 @@ class NovaPoshtaError(Exception):
class NovaPoshtaClient(Protocol): 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 import httpx
from app.core.config import Settings
from app.schemas.tracking import TrackingStatusOut from app.schemas.tracking import TrackingStatusOut
from app.services.nova_poshta.client import NovaPoshtaError from app.services.nova_poshta.client import NovaPoshtaError
@@ -15,10 +14,9 @@ _MAX_DOCUMENTS_PER_REQUEST = 100
class NpTrackingClient: class NpTrackingClient:
def __init__(self, settings: Settings) -> None: async def get_statuses(
self._api_key = settings.nova_poshta_api_key self, *, api_key: str, waybill_numbers: list[str]
) -> list[TrackingStatusOut]:
async def get_statuses(self, *, waybill_numbers: list[str]) -> list[TrackingStatusOut]:
if not waybill_numbers: if not waybill_numbers:
return [] return []
if len(waybill_numbers) > _MAX_DOCUMENTS_PER_REQUEST: if len(waybill_numbers) > _MAX_DOCUMENTS_PER_REQUEST:
@@ -28,7 +26,7 @@ class NpTrackingClient:
) )
body = { body = {
"apiKey": self._api_key, "apiKey": api_key,
"modelName": "TrackingDocument", "modelName": "TrackingDocument",
"calledMethod": "getStatusDocuments", "calledMethod": "getStatusDocuments",
"methodProperties": { "methodProperties": {
@@ -15,17 +15,32 @@ _FIXTURE_STATUSES: dict[str, dict] = {
"AmountToPay": "1200.00", "AmountToPay": "1200.00",
"AfterpaymentOnGoodsCost": 1200, "AfterpaymentOnGoodsCost": 1200,
"PaymentStatus": "Paid", "PaymentStatus": "Paid",
"PhoneSender": "380501112233",
} }
} }
class StubNovaPoshtaClient: class StubNovaPoshtaClient:
def __init__(self, statuses: dict[str, dict] | None = None) -> None: """`statuses` — ответы NP по ТТН; `by_key` — отдельные ответы для конкретных
self._statuses = statuses if statuses is not None else _FIXTURE_STATUSES ключей 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 [ return [
TrackingStatusOut.model_validate(self._statuses[number]) TrackingStatusOut.model_validate(statuses[number])
for number in waybill_numbers for number in waybill_numbers
if number in self._statuses if number in statuses
] ]
+164 -24
View File
@@ -4,17 +4,25 @@ from __future__ import annotations
import enum import enum
import uuid import uuid
from dataclasses import dataclass
from datetime import UTC, datetime 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 import httpx
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession 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.db.models.order import Order
from app.schemas.orders import OrderUpdateIn from app.schemas.orders import OrderUpdateIn
from app.schemas.tracking import TrackingStatusOut
from app.services.crm.client import CrmClient 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" _CRM_STATUS = "APPROVED"
@@ -25,9 +33,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 +57,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 +111,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,8 +134,77 @@ 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
@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: async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None:
"""Обновляет статус ТТН, сумму и статус оплаты наложки по заказам в пути. """Обновляет статус ТТН, сумму и статус оплаты наложки по заказам в пути
и привязывает каждый заказ к кассе, чей ключ НП видит ТТН как свою.
Заказ, уже привязанный к кассе, опрашивается её ключом. Непривязанный (или
переставший быть «своим» для своей кассы) — ключами всех касс по очереди:
первая, для которой ТТН своя (`TrackingStatusOut.is_own`), его забирает.
Если ТТН не своя ни для одной кассы, статус доставки всё равно сохраняется,
но без наложки и без кассы — чек по такой посылке не создать.
Опрашиваются и заказы с уже созданным ЕТТН-чеком: именно после создания Опрашиваются и заказы с уже созданным ЕТТН-чеком: именно после создания
чека посылка доезжает и оплачивается, и этот переход кассир должен видеть. чека посылка доезжает и оплачивается, и этот переход кассир должен видеть.
@@ -125,6 +214,11 @@ async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None:
HTTP-запроса: опрос статусов не должен зависеть от того, открыт ли сейчас HTTP-запроса: опрос статусов не должен зависеть от того, открыт ли сейчас
дашборд. дашборд.
""" """
accounts = await _np_accounts(session)
if not accounts:
log.warning("np_no_api_keys", hint="Укажите ключ API Новой Почты у кассы")
return
orders = await session.scalars( orders = await session.scalars(
select(Order) select(Order)
.where(Order.is_deleted.is_(False)) .where(Order.is_deleted.is_(False))
@@ -138,20 +232,65 @@ async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None:
if not orders_by_waybill: if not orders_by_waybill:
return return
waybill_numbers = list(orders_by_waybill) account_ids = {account.register_id for account in accounts}
for i in range(0, len(waybill_numbers), _NP_BATCH_SIZE): unresolved = {
batch = waybill_numbers[i : i + _NP_BATCH_SIZE] waybill: order
statuses = await np.get_statuses(waybill_numbers=batch) for waybill, order in orders_by_waybill.items()
for tracking_status in statuses: if order.cash_register_id not in account_ids
order = orders_by_waybill.get(tracking_status.number) }
# Урезанный ответ по чужому ключу — на случай, если ТТН не своя ни для кого.
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: if order is None:
continue continue
order.np_status = tracking_status.status if tracking_status.is_own:
order.np_status_code = tracking_status.status_code order.cash_register_id = account.register_id
order.np_cod_amount_kopecks = ( _apply_status(order, tracking_status)
_to_kopecks(tracking_status.cod_amount) if tracking_status.cod_amount else None del unresolved[waybill]
) else:
order.np_payment_status = tracking_status.payment_status 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() await session.commit()
@@ -239,7 +378,8 @@ async def update_order(
return order, changed return order, changed
if "waybill_number" in changed: if "waybill_number" in changed:
# Статус старой ТТН к новой не относится — worker опросит новую за минуту. # Статус и касса старой ТТН к новой не относятся — worker опросит новую за минуту.
order.cash_register_id = None
order.np_status = None order.np_status = None
order.np_status_code = None order.np_status_code = None
order.np_cod_amount_kopecks = None order.np_cod_amount_kopecks = None
+35 -11
View File
@@ -68,6 +68,11 @@ _CHECKBOX_TO_STATUS = {
# или Checkbox был недоступен) и повторить его из cron'а. # или Checkbox был недоступен) и повторить его из cron'а.
_PENDING_RETRY_AFTER = timedelta(minutes=1) _PENDING_RETRY_AFTER = timedelta(minutes=1)
# Сколько `created`-чеков опрашивать за один прогон cron'а (раз в минуту):
# при паузе ~1 с между запросами к Checkbox прогон укладывается в минуту и
# не наслаивается на следующий.
_ETTN_POLL_BATCH = 30
# Статус заказа в CRM, когда Checkbox принял ЕТТН-чек: заказ можно собирать. # Статус заказа в CRM, когда Checkbox принял ЕТТН-чек: заказ можно собирать.
CRM_STATUS_AFTER_RECEIPT = "PACKED" CRM_STATUS_AFTER_RECEIPT = "PACKED"
# Чеки, при которых заказу нужен этот статус в CRM (Checkbox чек принял). # Чеки, при которых заказу нужен этот статус в CRM (Checkbox чек принял).
@@ -256,13 +261,11 @@ def credentials(register: CashRegister) -> CheckboxCredentials:
) )
async def get_default_register(session: AsyncSession) -> CashRegister | None: async def _active_registers(session: AsyncSession) -> dict[uuid.UUID, CashRegister]:
return await session.scalar( registers = await session.scalars(
select(CashRegister) select(CashRegister).where(CashRegister.is_active.is_(True))
.where(CashRegister.is_active.is_(True))
.order_by(CashRegister.is_default.desc(), CashRegister.created_at)
.limit(1)
) )
return {register.id: register for register in registers}
# --- Создание ---------------------------------------------------------------- # --- Создание ----------------------------------------------------------------
@@ -295,10 +298,13 @@ async def request_receipts(
Без commit и без обращения к Checkbox: вызывающий код коммитит и ставит Без commit и без обращения к Checkbox: вызывающий код коммитит и ставит
задачи worker'у (`create_ettn_for_receipt`) по `result.created`. задачи worker'у (`create_ettn_for_receipt`) по `result.created`.
Ошибки по отдельным заказам не мешают остальным — массовое действие. Ошибки по отдельным заказам не мешают остальным — массовое действие.
Чек создаётся от кассы, чей ключ НП видит ТТН заказа как свою
(`orders.cash_register_id`, проставляет `orders.sync_np_statuses`).
""" """
result = RequestResult(created=[], errors={}) result = RequestResult(created=[], errors={})
register = await get_default_register(session) registers = await _active_registers(session)
if register is None: if not registers:
for order_id, _ in items: for order_id, _ in items:
result.errors[order_id] = "Не настроена касса Checkbox" result.errors[order_id] = "Не настроена касса Checkbox"
return result return result
@@ -318,6 +324,13 @@ async def request_receipts(
if order_id in busy: if order_id in busy:
result.errors[order_id] = "По заказу уже есть чек" result.errors[order_id] = "По заказу уже есть чек"
continue 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: try:
amounts = resolve_amounts(order, prepayment) amounts = resolve_amounts(order, prepayment)
body = build_ettn_body(order, register, amounts) body = build_ettn_body(order, register, amounts)
@@ -354,6 +367,7 @@ async def request_receipts(
payload={ payload={
"order_id": order.id, "order_id": order.id,
"waybill_number": order.waybill_number, "waybill_number": order.waybill_number,
"cash_register_id": str(register.id),
"cod_kopecks": amounts.cod_kopecks, "cod_kopecks": amounts.cod_kopecks,
"prepayment_kopecks": amounts.prepayment_kopecks, "prepayment_kopecks": amounts.prepayment_kopecks,
}, },
@@ -495,9 +509,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,7 +546,6 @@ 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()
+2 -2
View File
@@ -2,7 +2,7 @@
Запускается отдельным процессом: `arq app.worker.WorkerSettings`. Запускается отдельным процессом: `arq app.worker.WorkerSettings`.
- `create_ettn_receipt` — задача, которую ставит API после запроса кассира; - `create_ettn_receipt` — задача, которую ставит API после запроса кассира;
- `poll_np_statuses` — раз в минуту статусы ТТН по заказам без чека; - `poll_np_statuses` — раз в минуту статусы ТТН по ключам НП касс и привязка заказов к кассам;
- `poll_receipts` — раз в минуту повтор зависших `pending`, статусы `created`-чеков - `poll_receipts` — раз в минуту повтор зависших `pending`, статусы `created`-чеков
и повтор смены статуса заказа в CRM (PACKED), если CRM была недоступна. и повтор смены статуса заказа в CRM (PACKED), если CRM была недоступна.
""" """
@@ -33,7 +33,7 @@ _CREATE_ETTN_MAX_TRIES = 6
async def startup(ctx: dict[str, Any]) -> None: async def startup(ctx: dict[str, Any]) -> None:
configure_logging() configure_logging()
ctx["np_client"] = NpTrackingClient(settings) ctx["np_client"] = NpTrackingClient()
ctx["checkbox_client"] = get_checkbox_client() ctx["checkbox_client"] = get_checkbox_client()
ctx["crm_client"] = ExoCrmClient(settings) ctx["crm_client"] = ExoCrmClient(settings)
log.info("worker_starting", environment=settings.environment) log.info("worker_starting", environment=settings.environment)
+24 -20
View File
@@ -8,7 +8,7 @@ import pytest
import respx import respx
from httpx import Response 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.client import NovaPoshtaError
from app.services.nova_poshta.np_client import _API_URL, NpTrackingClient from app.services.nova_poshta.np_client import _API_URL, NpTrackingClient
@@ -27,12 +27,7 @@ STATUS_PAYLOAD = {
} }
def _settings() -> Settings: API_KEY = "np-apikey-123"
return Settings(
secret_key="test-secret-key",
encryption_key="dGVzdC1lbmNyeXB0aW9uLWtleS0zMi1ieXRlcyEh",
nova_poshta_api_key="np-apikey-123",
) # type: ignore[arg-type]
class TestNpTrackingClientGetStatuses: class TestNpTrackingClientGetStatuses:
@@ -41,13 +36,13 @@ class TestNpTrackingClientGetStatuses:
route = respx.post(_API_URL).mock( route = respx.post(_API_URL).mock(
return_value=Response(200, json={"success": True, "data": [], "errors": []}) 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 sent = route.calls.last.request
body = json.loads(sent.content) body = json.loads(sent.content)
assert body["apiKey"] == "np-apikey-123" assert body["apiKey"] == API_KEY
assert body["modelName"] == "TrackingDocument" assert body["modelName"] == "TrackingDocument"
assert body["calledMethod"] == "getStatusDocuments" assert body["calledMethod"] == "getStatusDocuments"
assert body["methodProperties"]["Documents"] == [ assert body["methodProperties"]["Documents"] == [
@@ -63,9 +58,9 @@ class TestNpTrackingClientGetStatuses:
200, json={"success": True, "data": [STATUS_PAYLOAD], "errors": []} 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 assert len(statuses) == 1
status = statuses[0] status = statuses[0]
@@ -75,6 +70,13 @@ class TestNpTrackingClientGetStatuses:
# не "сколько заплатить сейчас" (`ExpressWaybillAmountToPay` = 827.48). # не "сколько заплатить сейчас" (`ExpressWaybillAmountToPay` = 827.48).
assert status.cod_amount == "699" assert status.cod_amount == "699"
assert status.payment_status == "NeedPayment" 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 @respx.mock
async def test_falls_back_to_amount_to_pay_when_afterpayment_missing(self) -> None: 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( respx.post(_API_URL).mock(
return_value=Response(200, json={"success": True, "data": [payload], "errors": []}) 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" assert statuses[0].cod_amount == "827.48"
@@ -95,18 +97,20 @@ class TestNpTrackingClientGetStatuses:
200, json={"success": False, "data": [], "errors": ["Invalid apiKey"]} 200, json={"success": False, "data": [], "errors": ["Invalid apiKey"]}
) )
) )
client = NpTrackingClient(_settings()) client = NpTrackingClient()
with pytest.raises(NovaPoshtaError, match="Invalid apiKey"): 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: 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: async def test_rejects_too_many_documents(self) -> None:
client = NpTrackingClient(_settings()) client = NpTrackingClient()
with pytest.raises(NovaPoshtaError, match="Слишком много"): 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)]
)
+120
View File
@@ -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
+19
View File
@@ -79,6 +79,11 @@ def _patch_orders_service(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(orders_router.orders_service, "list_orders", fake_list) monkeypatch.setattr(orders_router.orders_service, "list_orders", fake_list)
monkeypatch.setattr(orders_router.orders_service, "delete_order", fake_delete) monkeypatch.setattr(orders_router.orders_service, "delete_order", fake_delete)
async def fake_cod_in_transit(session: object) -> tuple[int, int]:
return 3, 245050
monkeypatch.setattr(orders_router.orders_service, "cod_in_transit", fake_cod_in_transit)
async def fake_update( async def fake_update(
session: object, order_id: str, data: object session: object, order_id: str, data: object
) -> tuple[Order, list[str]] | None: ) -> tuple[Order, list[str]] | None:
@@ -238,3 +243,17 @@ class TestUpdateOrder:
response = client.patch("/api/v1/orders/1", json={**_UPDATE_BODY, "goods": []}) response = client.patch("/api/v1/orders/1", json={**_UPDATE_BODY, "goods": []})
assert response.status_code == 422 assert response.status_code == 422
class TestSummary:
@pytest.mark.parametrize("role", [UserRole.ADMIN, UserRole.CASHIER, UserRole.VIEWER])
def test_returns_cod_in_transit(self, client: TestClient, role: UserRole) -> None:
app.dependency_overrides[get_current_user] = lambda: _user(role)
response = client.get("/api/v1/orders/summary")
assert response.status_code == 200
assert response.json() == {
"cod_in_transit_count": 3,
"cod_in_transit_amount": "2450.50",
}
+43 -2
View File
@@ -16,6 +16,7 @@ from app.services.orders import (
OrderTab, OrderTab,
_parse_crm_datetime, _parse_crm_datetime,
_to_kopecks, _to_kopecks,
cod_in_transit,
list_orders, list_orders,
update_order, update_order,
) )
@@ -185,8 +186,48 @@ class TestListOrders:
assert "orders.np_status_code IN ('102', '103', '105', '108')" in sql assert "orders.np_status_code IN ('102', '103', '105', '108')" in sql
assert "receipt_created_at" not in sql assert "receipt_created_at" not in sql
def test_received_tab_filters_by_received_codes_only(self) -> None:
sql = _list_where(OrderTab.RECEIVED)
assert "orders.np_status_code IN ('9', '10', '11', '106')" in sql
assert "receipt_created_at" not in sql
@pytest.mark.parametrize("tab", [OrderTab.NO_RECEIPT, OrderTab.HAS_RECEIPT]) @pytest.mark.parametrize("tab", [OrderTab.NO_RECEIPT, OrderTab.HAS_RECEIPT])
def test_other_tabs_exclude_refusals(self, tab: OrderTab) -> None: def test_other_tabs_exclude_received_and_refusals(self, tab: OrderTab) -> None:
sql = _list_where(tab) sql = _list_where(tab)
assert "NOT IN ('102', '103', '105', '108')" in sql assert "NOT IN ('9', '10', '11', '106', '102', '103', '105', '108')" in sql
assert "receipt_created_at" in sql assert "receipt_created_at" in sql
class _CapturingExecuteSession:
def __init__(self) -> None:
self.statement: Any = None
async def execute(self, statement: Any) -> Any:
self.statement = statement
class _Result:
def one(self) -> tuple[int, int]:
return 2, 150000
return _Result()
class TestCodInTransit:
def test_excludes_received_refused_and_paid(self) -> None:
session = _CapturingExecuteSession()
result = asyncio.run(cod_in_transit(session)) # type: ignore[arg-type]
sql = str(
session.statement.compile(
dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True}
)
)
assert result == (2, 150000)
assert "sum(orders.np_cod_amount_kopecks)" in sql
assert "orders.is_deleted IS false" in sql
assert "orders.np_cod_amount_kopecks IS NOT NULL" in sql
assert (
"orders.np_status_code NOT IN "
"('9', '10', '11', '106', '102', '103', '105', '108', '2', '3')" in sql
)
assert "orders.np_payment_status IS DISTINCT FROM 'Payed'" in sql
+47
View File
@@ -16,6 +16,7 @@ from app.core import crypto
from app.db.models.cash_register import CashRegister from app.db.models.cash_register import CashRegister
from app.db.models.order import Order from app.db.models.order import Order
from app.db.models.receipt import Receipt, ReceiptStatus from app.db.models.receipt import Receipt, ReceiptStatus
from app.db.models.user import User
from app.schemas.checkbox import EttnStatus from app.schemas.checkbox import EttnStatus
from app.services import receipts as svc from app.services import receipts as svc
from app.services.checkbox.client import CheckboxRateLimitedError, CheckboxUnavailableError from app.services.checkbox.client import CheckboxRateLimitedError, CheckboxUnavailableError
@@ -182,6 +183,52 @@ class TestBuildBody:
assert "delivery" not in body["receipt_body"] 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: class FakeSession:
def __init__(self, *objects: Any) -> None: def __init__(self, *objects: Any) -> None:
self.objects = {(type(o), o.id): o for o in objects} self.objects = {(type(o), o.id): o for o in objects}
+5 -1
View File
@@ -1,10 +1,14 @@
import { apiFetch } from '@/api/client' import { apiFetch } from '@/api/client'
import type { Order, OrderTab, OrderUpdate } from '@/features/orders/types' import type { Order, OrdersSummary, OrderTab, OrderUpdate } from '@/features/orders/types'
export function getOrders(tab: OrderTab): Promise<Order[]> { export function getOrders(tab: OrderTab): Promise<Order[]> {
return apiFetch<Order[]>(`/orders?tab=${tab}`) return apiFetch<Order[]>(`/orders?tab=${tab}`)
} }
export function getOrdersSummary(): Promise<OrdersSummary> {
return apiFetch<OrdersSummary>('/orders/summary')
}
export function deleteOrder(orderId: string): Promise<void> { export function deleteOrder(orderId: string): Promise<void> {
return apiFetch<void>(`/orders/${orderId}`, { method: 'DELETE' }) return apiFetch<void>(`/orders/${orderId}`, { method: 'DELETE' })
} }
@@ -5,6 +5,8 @@ export interface CashRegister {
name: string name: string
fiscal_number: string | null fiscal_number: string | null
license_key_masked: string license_key_masked: string
/** null — ключ Новой Почты не задан: касса не получает посылки. */
np_api_key_masked: string | null
tax_codes: (number | string)[] tax_codes: (number | string)[]
is_active: boolean is_active: boolean
is_default: boolean is_default: boolean
@@ -15,6 +17,7 @@ export interface CashRegisterCreate {
fiscal_number: string | null fiscal_number: string | null
license_key: string license_key: string
pin_code: string pin_code: string
np_api_key: string | null
tax_codes: (number | string)[] tax_codes: (number | string)[]
is_default: boolean is_default: boolean
} }
+7 -1
View File
@@ -6,7 +6,7 @@
import type { ReceiptStatus } from '@/features/receipts/types' import type { ReceiptStatus } from '@/features/receipts/types'
/** Вкладка дашборда (`OrderTab` в backend/app/services/orders.py). */ /** Вкладка дашборда (`OrderTab` в backend/app/services/orders.py). */
export type OrderTab = 'no_receipt' | 'has_receipt' | 'refused' export type OrderTab = 'no_receipt' | 'has_receipt' | 'received' | 'refused'
export interface OrderGood { export interface OrderGood {
id: string id: string
@@ -62,3 +62,9 @@ export interface OrderUpdate {
total_amount: string total_amount: string
goods: OrderGoodUpdate[] goods: OrderGoodUpdate[]
} }
/** Ответ `GET /orders/summary` (`OrdersSummaryOut`). */
export interface OrdersSummary {
cod_in_transit_count: number
cod_in_transit_amount: string
}
+12 -1
View File
@@ -1,11 +1,13 @@
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { getOrders } from '@/api/orders' import { getOrders, getOrdersSummary } from '@/api/orders'
import type { OrderTab } from '@/features/orders/types' import type { OrderTab } from '@/features/orders/types'
/** Пока чек отправляется в Checkbox — опрашиваем часто, иначе статусы чеков обновляет worker раз в минуту. */ /** Пока чек отправляется в Checkbox — опрашиваем часто, иначе статусы чеков обновляет worker раз в минуту. */
const PENDING_POLL_MS = 3_000 const PENDING_POLL_MS = 3_000
const RECEIPTS_POLL_MS = 30_000 const RECEIPTS_POLL_MS = 30_000
// Статусы NP обновляет worker раз в минуту — чаще сводку опрашивать незачем.
const SUMMARY_POLL_MS = 60_000
export function useOrders(tab: OrderTab) { export function useOrders(tab: OrderTab) {
return useQuery({ return useQuery({
@@ -17,3 +19,12 @@ export function useOrders(tab: OrderTab) {
}, },
}) })
} }
export function useOrdersSummary() {
return useQuery({
// Под ключом ['orders', ...] — чтобы invalidateQueries(['orders']) обновлял и сводку.
queryKey: ['orders', 'summary'],
queryFn: getOrdersSummary,
refetchInterval: SUMMARY_POLL_MS,
})
}
+4
View File
@@ -44,3 +44,7 @@
display: flex; display: flex;
gap: 8px; gap: 8px;
} }
.cr-missing {
color: var(--color-danger);
}
+22 -5
View File
@@ -19,6 +19,7 @@ interface FormState {
fiscal_number: string fiscal_number: string
license_key: string license_key: string
pin_code: string pin_code: string
np_api_key: string
tax_codes: string tax_codes: string
is_default: boolean is_default: boolean
} }
@@ -28,6 +29,7 @@ const EMPTY_FORM: FormState = {
fiscal_number: '', fiscal_number: '',
license_key: '', license_key: '',
pin_code: '', pin_code: '',
np_api_key: '',
tax_codes: '', tax_codes: '',
is_default: true, is_default: true,
} }
@@ -75,6 +77,7 @@ export function CashRegistersPage() {
fiscal_number: register.fiscal_number ?? '', fiscal_number: register.fiscal_number ?? '',
license_key: '', license_key: '',
pin_code: '', pin_code: '',
np_api_key: '',
tax_codes: register.tax_codes.join(', '), tax_codes: register.tax_codes.join(', '),
is_default: register.is_default, is_default: register.is_default,
}) })
@@ -88,7 +91,7 @@ export function CashRegistersPage() {
async function handleSubmit(event: FormEvent) { async function handleSubmit(event: FormEvent) {
event.preventDefault() event.preventDefault()
if (editingId) { if (editingId) {
// Пустые ключ/PIN при редактировании — «не менять». // Пустые ключи/PIN при редактировании — «не менять».
const payload: CashRegisterUpdate = { const payload: CashRegisterUpdate = {
name: form.name, name: form.name,
fiscal_number: form.fiscal_number || null, 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.license_key) payload.license_key = form.license_key
if (form.pin_code) payload.pin_code = form.pin_code 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), 'Касса обновлена') await run(() => updateCashRegister(editingId, payload), 'Касса обновлена')
} else { } else {
await run( await run(
@@ -106,6 +110,7 @@ export function CashRegistersPage() {
fiscal_number: form.fiscal_number || null, fiscal_number: form.fiscal_number || null,
license_key: form.license_key, license_key: form.license_key,
pin_code: form.pin_code, pin_code: form.pin_code,
np_api_key: form.np_api_key,
tax_codes: parseTaxCodes(form.tax_codes), tax_codes: parseTaxCodes(form.tax_codes),
is_default: form.is_default, 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>
<th>Налоги</th> <th>Налоги</th>
<th>Статус</th> <th>Статус</th>
<th>Действия</th> <th>Действия</th>
@@ -153,21 +159,21 @@ export function CashRegistersPage() {
<tbody> <tbody>
{isLoading && ( {isLoading && (
<tr> <tr>
<td colSpan={6} className="orders-empty"> <td colSpan={7} className="orders-empty">
Загрузка… Загрузка…
</td> </td>
</tr> </tr>
)} )}
{isError && ( {isError && (
<tr> <tr>
<td colSpan={6} className="orders-empty"> <td colSpan={7} className="orders-empty">
Не удалось загрузить кассы Не удалось загрузить кассы
</td> </td>
</tr> </tr>
)} )}
{registers?.length === 0 && ( {registers?.length === 0 && (
<tr> <tr>
<td colSpan={6} className="orders-empty"> <td colSpan={7} className="orders-empty">
Кассы не настроены — чеки создавать нельзя Кассы не настроены — чеки создавать нельзя
</td> </td>
</tr> </tr>
@@ -180,6 +186,7 @@ export function CashRegistersPage() {
</td> </td>
<td>{register.fiscal_number || '—'}</td> <td>{register.fiscal_number || '—'}</td>
<td>{register.license_key_masked}</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>{register.tax_codes.length ? register.tax_codes.join(', ') : 'без налога'}</td>
<td> <td>
<span className={`orders-status orders-status--${register.is_active ? 'delivered' : 'new'}`}> <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 })} onChange={(e) => setForm({ ...form, pin_code: e.target.value })}
/> />
</label> </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> <label>
Коды налогов (через запятую, пусто — без налога) Коды налогов (через запятую, пусто — без налога)
<input value={form.tax_codes} onChange={(e) => setForm({ ...form, tax_codes: e.target.value })} /> <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} checked={form.is_default}
onChange={(e) => setForm({ ...form, is_default: e.target.checked })} onChange={(e) => setForm({ ...form, is_default: e.target.checked })}
/> />
Основная касса (по ней создаются чеки) Основная касса (первой проверяет новые посылки)
</label> </label>
<div className="cr-form-actions"> <div className="cr-form-actions">
<button type="submit" className="orders-create-btn" disabled={busy}> <button type="submit" className="orders-create-btn" disabled={busy}>
+33
View File
@@ -65,6 +65,39 @@
justify-content: space-between; justify-content: space-between;
} }
.orders-summary {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.orders-summary-card {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 220px;
padding: 14px 18px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 10px;
}
.orders-summary-label {
font-size: 13px;
color: var(--color-text-muted);
}
.orders-summary-value {
font-size: 22px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.orders-summary-hint {
font-size: 12px;
color: var(--color-text-muted);
}
.orders-create-btn { .orders-create-btn {
border: none; border: none;
background: var(--color-primary); background: var(--color-primary);
+28 -3
View File
@@ -8,7 +8,7 @@ import '@/pages/DashboardPage.css'
import { useAuth } from '@/features/auth/useAuth' import { useAuth } from '@/features/auth/useAuth'
import { OrderDetailModal } from '@/features/orders/OrderDetailModal' import { OrderDetailModal } from '@/features/orders/OrderDetailModal'
import type { Order, OrderTab } from '@/features/orders/types' import type { Order, OrderTab } from '@/features/orders/types'
import { useOrders } from '@/features/orders/useOrders' import { useOrders, useOrdersSummary } from '@/features/orders/useOrders'
import { defaultPrepayment, prepaymentMatches, toKopecks } from '@/features/receipts/money' import { defaultPrepayment, prepaymentMatches, toKopecks } from '@/features/receipts/money'
import { CANCELLABLE, RECEIPT_STATUS } from '@/features/receipts/types' import { CANCELLABLE, RECEIPT_STATUS } from '@/features/receipts/types'
import type { ReceiptRequestItem } from '@/features/receipts/types' import type { ReceiptRequestItem } from '@/features/receipts/types'
@@ -22,6 +22,7 @@ const ROLE_LABEL: Record<string, string> = {
const TABS: { key: OrderTab; label: string }[] = [ const TABS: { key: OrderTab; label: string }[] = [
{ key: 'no_receipt', label: 'Без чека' }, { key: 'no_receipt', label: 'Без чека' },
{ key: 'has_receipt', label: 'Выписаны чеки' }, { key: 'has_receipt', label: 'Выписаны чеки' },
{ key: 'received', label: 'Полученные' },
{ key: 'refused', label: 'Отказы' }, { key: 'refused', label: 'Отказы' },
] ]
@@ -44,10 +45,17 @@ function paymentBadge(order: Order): { label: string; tone: 'delivered' | 'proce
return null return null
} }
const MONEY_FORMAT = new Intl.NumberFormat('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
function formatMoney(amount: string): string {
return MONEY_FORMAT.format(Number(amount))
}
export function DashboardPage() { export function DashboardPage() {
const { user, logout } = useAuth() const { user, logout } = useAuth()
const [tab, setTab] = useState<OrderTab>('no_receipt') const [tab, setTab] = useState<OrderTab>('no_receipt')
const { data: orders, isLoading, isError } = useOrders(tab) const { data: orders, isLoading, isError } = useOrders(tab)
const { data: summary } = useOrdersSummary()
const queryClient = useQueryClient() const queryClient = useQueryClient()
const [selected, setSelected] = useState<Set<string>>(new Set()) const [selected, setSelected] = useState<Set<string>>(new Set())
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
@@ -102,7 +110,12 @@ export function DashboardPage() {
async function handleCancel(order: Order) { async function handleCancel(order: Order) {
if (!order.receipt_id) return if (!order.receipt_id) return
const outcome = tab === 'refused' ? 'Заказ останется в отказах.' : 'Заказ вернётся в очередь.' const outcome = {
no_receipt: 'Заказ вернётся в очередь.',
has_receipt: 'Заказ вернётся в очередь.',
received: 'Заказ останется в полученных.',
refused: 'Заказ останется в отказах.',
}[tab]
if (!window.confirm(`Отменить ЕТТН-чек по заказу ${order.id}? ${outcome}`)) return if (!window.confirm(`Отменить ЕТТН-чек по заказу ${order.id}? ${outcome}`)) return
setCancellingId(order.id) setCancellingId(order.id)
try { try {
@@ -193,6 +206,18 @@ export function DashboardPage() {
</div> </div>
</div> </div>
<div className="orders-summary">
<div className="orders-summary-card" title="Посылки с наложкой, которые ещё не забрали и по которым нет отказа">
<span className="orders-summary-label">Наложка в пути</span>
<span className="orders-summary-value">
{summary ? `${formatMoney(summary.cod_in_transit_amount)} ₴` : '—'}
</span>
<span className="orders-summary-hint">
{summary ? `${summary.cod_in_transit_count} посылок` : ' '}
</span>
</div>
</div>
{notice && ( {notice && (
<div className={`orders-notice orders-notice--${notice.tone}`}> <div className={`orders-notice orders-notice--${notice.tone}`}>
<ul> <ul>
@@ -354,7 +379,7 @@ export function DashboardPage() {
Отменить Отменить
</button> </button>
)} )}
{canDelete && (tab === 'no_receipt' || (tab === 'refused' && !order.has_receipt)) && ( {canDelete && (tab === 'no_receipt' || ((tab === 'refused' || tab === 'received') && !order.has_receipt)) && (
<button <button
type="button" type="button"
className="orders-delete-btn" className="orders-delete-btn"