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 })} /> +