From 8b3c2b6d63e76a574c5e746536e09872f04efe40 Mon Sep 17 00:00:00 2001 From: Liutenko Oleksandr Date: Fri, 25 Sep 2026 13:02:40 +0300 Subject: [PATCH] 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 --- .env.example | 3 +- CLAUDE.md | 1 + .../versions/0008_np_key_per_register.py | 58 +++++++ backend/app/api/v1/cash_registers.py | 35 ++++- backend/app/core/config.py | 2 + backend/app/db/models/cash_register.py | 9 +- backend/app/db/models/order.py | 9 +- backend/app/schemas/receipts.py | 20 ++- backend/app/schemas/tracking.py | 10 ++ backend/app/services/audit.py | 1 + backend/app/services/nova_poshta/client.py | 6 +- backend/app/services/nova_poshta/np_client.py | 10 +- .../app/services/nova_poshta/stub_client.py | 25 +++- backend/app/services/orders.py | 141 ++++++++++++++++-- backend/app/services/receipts.py | 25 +++- backend/app/worker.py | 4 +- backend/tests/test_nova_poshta_client.py | 44 +++--- backend/tests/test_np_sync.py | 120 +++++++++++++++ backend/tests/test_receipts_service.py | 47 ++++++ frontend/src/features/cashRegisters/types.ts | 3 + frontend/src/pages/CashRegistersPage.css | 4 + frontend/src/pages/CashRegistersPage.tsx | 27 +++- 22 files changed, 531 insertions(+), 73 deletions(-) create mode 100644 backend/alembic/versions/0008_np_key_per_register.py create mode 100644 backend/tests/test_np_sync.py diff --git a/.env.example b/.env.example index 88896ec..baf2612 100644 --- a/.env.example +++ b/.env.example @@ -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 ------------------------------------------------------------- diff --git a/CLAUDE.md b/CLAUDE.md index 2266f55..ab27a8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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). - 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 `{"": {"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()`. diff --git a/backend/alembic/versions/0008_np_key_per_register.py b/backend/alembic/versions/0008_np_key_per_register.py new file mode 100644 index 0000000..c67c1b3 --- /dev/null +++ b/backend/alembic/versions/0008_np_key_per_register.py @@ -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") diff --git a/backend/app/api/v1/cash_registers.py b/backend/app/api/v1/cash_registers.py index 74638e4..3b5b180 100644 --- a/backend/app/api/v1/cash_registers.py +++ b/backend/app/api/v1/cash_registers.py @@ -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() diff --git a/backend/app/core/config.py b/backend/app/core/config.py index eb2fbc5..a5bf2d9 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -63,6 +63,8 @@ class Settings(BaseSettings): crm_sid: int = 1 # --- Nova Poshta --- + # Ключи НП хранятся у касс (`cash_registers.np_api_key_enc`). Эта переменная + # читается только миграцией 0008: переносит старый общий ключ в кассу по умолчанию. nova_poshta_api_key: str = "" # --- Checkbox --- diff --git a/backend/app/db/models/cash_register.py b/backend/app/db/models/cash_register.py index 156f68e..08dff94 100644 --- a/backend/app/db/models/cash_register.py +++ b/backend/app/db/models/cash_register.py @@ -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) diff --git a/backend/app/db/models/order.py b/backend/app/db/models/order.py index b394017..901103c 100644 --- a/backend/app/db/models/order.py +++ b/backend/app/db/models/order.py @@ -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 + ) diff --git a/backend/app/schemas/receipts.py b/backend/app/schemas/receipts.py index 3a9f279..074e2ff 100644 --- a/backend/app/schemas/receipts.py +++ b/backend/app/schemas/receipts.py @@ -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 diff --git a/backend/app/schemas/tracking.py b/backend/app/schemas/tracking.py index ed8d06c..2b99d7d 100644 --- a/backend/app/schemas/tracking.py +++ b/backend/app/schemas/tracking.py @@ -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: diff --git a/backend/app/services/audit.py b/backend/app/services/audit.py index 8eea58f..4cc12ee 100644 --- a/backend/app/services/audit.py +++ b/backend/app/services/audit.py @@ -22,6 +22,7 @@ _REDACTED_KEYS = { "secret", "license_key", "api_key", + "np_api_key", "pin", "pin_code", "cashier_pin", diff --git a/backend/app/services/nova_poshta/client.py b/backend/app/services/nova_poshta/client.py index 7403e84..8ccc672 100644 --- a/backend/app/services/nova_poshta/client.py +++ b/backend/app/services/nova_poshta/client.py @@ -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]: ... diff --git a/backend/app/services/nova_poshta/np_client.py b/backend/app/services/nova_poshta/np_client.py index 3134df8..d6e57d7 100644 --- a/backend/app/services/nova_poshta/np_client.py +++ b/backend/app/services/nova_poshta/np_client.py @@ -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": { diff --git a/backend/app/services/nova_poshta/stub_client.py b/backend/app/services/nova_poshta/stub_client.py index aeca0c6..584291f 100644 --- a/backend/app/services/nova_poshta/stub_client.py +++ b/backend/app/services/nova_poshta/stub_client.py @@ -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 ] diff --git a/backend/app/services/orders.py b/backend/app/services/orders.py index c3c98a4..3f6c636 100644 --- a/backend/app/services/orders.py +++ b/backend/app/services/orders.py @@ -4,17 +4,25 @@ 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 +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" @@ -143,8 +151,60 @@ async def cod_in_transit(session: AsyncSession) -> tuple[int, int]: 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`), его забирает. + Если ТТН не своя ни для одной кассы, статус доставки всё равно сохраняется, + но без наложки и без кассы — чек по такой посылке не создать. Опрашиваются и заказы с уже созданным ЕТТН-чеком: именно после создания чека посылка доезжает и оплачивается, и этот переход кассир должен видеть. @@ -154,6 +214,11 @@ async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None: 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)) @@ -167,20 +232,65 @@ async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None: 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() @@ -268,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 diff --git a/backend/app/services/receipts.py b/backend/app/services/receipts.py index 3b96a2f..c968212 100644 --- a/backend/app/services/receipts.py +++ b/backend/app/services/receipts.py @@ -261,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} # --- Создание ---------------------------------------------------------------- @@ -300,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 @@ -323,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) @@ -359,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, }, diff --git a/backend/app/worker.py b/backend/app/worker.py index 519a43f..53f2334 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -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 была недоступна. """ @@ -33,7 +33,7 @@ _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) diff --git a/backend/tests/test_nova_poshta_client.py b/backend/tests/test_nova_poshta_client.py index 622f188..c132bc3 100644 --- a/backend/tests/test_nova_poshta_client.py +++ b/backend/tests/test_nova_poshta_client.py @@ -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)] + ) diff --git a/backend/tests/test_np_sync.py b/backend/tests/test_np_sync.py new file mode 100644 index 0000000..256412b --- /dev/null +++ b/backend/tests/test_np_sync.py @@ -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 diff --git a/backend/tests/test_receipts_service.py b/backend/tests/test_receipts_service.py index d6e3d92..1746696 100644 --- a/backend/tests/test_receipts_service.py +++ b/backend/tests/test_receipts_service.py @@ -16,6 +16,7 @@ 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 CheckboxRateLimitedError, CheckboxUnavailableError @@ -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} diff --git a/frontend/src/features/cashRegisters/types.ts b/frontend/src/features/cashRegisters/types.ts index 72dbbdd..5a7207a 100644 --- a/frontend/src/features/cashRegisters/types.ts +++ b/frontend/src/features/cashRegisters/types.ts @@ -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 } diff --git a/frontend/src/pages/CashRegistersPage.css b/frontend/src/pages/CashRegistersPage.css index 6de7f4c..bfec53d 100644 --- a/frontend/src/pages/CashRegistersPage.css +++ b/frontend/src/pages/CashRegistersPage.css @@ -44,3 +44,7 @@ display: flex; gap: 8px; } + +.cr-missing { + color: var(--color-danger); +} diff --git a/frontend/src/pages/CashRegistersPage.tsx b/frontend/src/pages/CashRegistersPage.tsx index a2d7ab8..8d7af16 100644 --- a/frontend/src/pages/CashRegistersPage.tsx +++ b/frontend/src/pages/CashRegistersPage.tsx @@ -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() { Название Фиск. номер Ключ лицензии + Ключ Новой Почты Налоги Статус Действия @@ -153,21 +159,21 @@ export function CashRegistersPage() { {isLoading && ( - + Загрузка… )} {isError && ( - + Не удалось загрузить кассы )} {registers?.length === 0 && ( - + Кассы не настроены — чеки создавать нельзя @@ -180,6 +186,7 @@ export function CashRegistersPage() { {register.fiscal_number || '—'} {register.license_key_masked} + {register.np_api_key_masked ?? не задан} {register.tax_codes.length ? register.tax_codes.join(', ') : 'без налога'} @@ -249,6 +256,16 @@ export function CashRegistersPage() { onChange={(e) => setForm({ ...form, pin_code: e.target.value })} /> +