Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8b3c2b6d63 | ||
|
|
2b92d82693 | ||
|
|
a7cba7616a |
+2
-1
@@ -58,7 +58,8 @@ CRM_SHOP_KEY=change-me-crm-shopkey
|
||||
CRM_SID=1
|
||||
|
||||
# --- Nova Poshta ----------------------------------------------------------
|
||||
# Ключ API кабинета Nova Poshta (my.novaposhta.ua → Настройки → API).
|
||||
# Ключи API Nova Poshta задаются у касс (страница «Кассы»). Эта переменная нужна
|
||||
# только миграции 0008: переносит старый общий ключ в кассу по умолчанию.
|
||||
NOVA_POSHTA_API_KEY=change-me-novaposhta-apikey
|
||||
|
||||
# --- Checkbox -------------------------------------------------------------
|
||||
|
||||
@@ -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 `{"<id>": {"Status": "Success"}}`.
|
||||
- Nova Poshta's rate limit comes back through Checkbox as a 4xx with `code=third_party.generic` and «To many requests» / `20000401501`, not as a 429. `http_client._transient_error` maps it to `CheckboxRateLimitedError`: the receipt stays `pending` and the worker retries with `arq.Retry`. The client also sends requests one at a time with a `CHECKBOX_MIN_REQUEST_INTERVAL_MS` pause, so don't parallelize Checkbox calls in the worker.
|
||||
- Each cash register has its own Nova Poshta API key (`cash_registers.np_api_key_enc`, Fernet). `sync_np_statuses` polls TTNs with register keys and binds the order to the register whose key sees the TTN as its own (`orders.cash_register_id`; ownership = response contains `PhoneSender` — a foreign key gets a truncated reply without sender/`AfterpaymentOnGoodsCost`). Receipts are created from the order's register, not the default one; an unbound order is rejected. `NOVA_POSHTA_API_KEY` env is only read by migration 0008.
|
||||
- ETTN does **not** work on a Checkbox test cash register. Locally use `CHECKBOX_USE_STUB=true`; client selection is only in `services/checkbox/client.get_checkbox_client()`.
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Ключ API Новой Почты у кассы и привязка заказа к кассе
|
||||
|
||||
Revision ID: 0008
|
||||
Revises: 0007
|
||||
Create Date: 2026-09-25
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
from app.core import crypto
|
||||
from app.core.config import settings
|
||||
|
||||
revision: str = "0008"
|
||||
down_revision: str | None = "0007"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"cash_registers", sa.Column("np_api_key_enc", sa.String(length=512), nullable=True)
|
||||
)
|
||||
op.add_column("orders", sa.Column("cash_register_id", sa.Uuid(), nullable=True))
|
||||
op.create_foreign_key(
|
||||
op.f("fk_orders_cash_register_id_cash_registers"),
|
||||
"orders",
|
||||
"cash_registers",
|
||||
["cash_register_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
op.create_index(op.f("ix_orders_cash_register_id"), "orders", ["cash_register_id"])
|
||||
|
||||
# Раньше ключ НП был один на всё приложение (NOVA_POSHTA_API_KEY) — переносим
|
||||
# его в кассу по умолчанию, чтобы опрос статусов не остановился после обновления.
|
||||
if settings.nova_poshta_api_key:
|
||||
op.get_bind().execute(
|
||||
sa.text(
|
||||
"UPDATE cash_registers SET np_api_key_enc = :key "
|
||||
"WHERE id = (SELECT id FROM cash_registers "
|
||||
"ORDER BY is_default DESC, created_at LIMIT 1)"
|
||||
),
|
||||
{"key": crypto.encrypt(settings.nova_poshta_api_key)},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index(op.f("ix_orders_cash_register_id"), table_name="orders")
|
||||
op.drop_constraint(
|
||||
op.f("fk_orders_cash_register_id_cash_registers"), "orders", type_="foreignkey"
|
||||
)
|
||||
op.drop_column("orders", "cash_register_id")
|
||||
op.drop_column("cash_registers", "np_api_key_enc")
|
||||
@@ -27,6 +27,27 @@ async def _clear_default(session: SessionDep, keep_id: uuid.UUID) -> None:
|
||||
)
|
||||
|
||||
|
||||
async def _ensure_np_key_unique(
|
||||
session: SessionDep, api_key: str, exclude_id: uuid.UUID | None = None
|
||||
) -> None:
|
||||
"""Один кабинет НП — одна касса, иначе неясно, от какой кассы пробивать его посылки."""
|
||||
registers = await session.scalars(
|
||||
select(CashRegister).where(CashRegister.np_api_key_enc.is_not(None))
|
||||
)
|
||||
for register in registers:
|
||||
if register.id == exclude_id:
|
||||
continue
|
||||
try:
|
||||
same = crypto.decrypt(register.np_api_key_enc) == api_key
|
||||
except crypto.DecryptionError:
|
||||
continue
|
||||
if same:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_409_CONFLICT,
|
||||
detail=f"Этот ключ Новой Почты уже привязан к кассе «{register.name}»",
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[CashRegisterOut])
|
||||
async def list_cash_registers(session: SessionDep) -> list[CashRegisterOut]:
|
||||
registers = await session.scalars(select(CashRegister).order_by(CashRegister.created_at))
|
||||
@@ -37,12 +58,15 @@ async def list_cash_registers(session: SessionDep) -> list[CashRegisterOut]:
|
||||
async def create_cash_register(
|
||||
payload: CashRegisterCreate, request: Request, session: SessionDep, actor: AdminUser
|
||||
) -> CashRegisterOut:
|
||||
if payload.np_api_key:
|
||||
await _ensure_np_key_unique(session, payload.np_api_key)
|
||||
register = CashRegister(
|
||||
id=uuid.uuid4(),
|
||||
name=payload.name,
|
||||
fiscal_number=payload.fiscal_number,
|
||||
license_key_enc=crypto.encrypt(payload.license_key),
|
||||
cashier_pin_enc=crypto.encrypt(payload.pin_code),
|
||||
np_api_key_enc=crypto.encrypt(payload.np_api_key) if payload.np_api_key else None,
|
||||
tax_codes=payload.tax_codes,
|
||||
is_active=True,
|
||||
is_default=payload.is_default,
|
||||
@@ -56,7 +80,11 @@ async def create_cash_register(
|
||||
user=actor,
|
||||
entity_type="cash_register",
|
||||
entity_id=register.id,
|
||||
payload={"name": register.name, "fiscal_number": register.fiscal_number},
|
||||
payload={
|
||||
"name": register.name,
|
||||
"fiscal_number": register.fiscal_number,
|
||||
"np_api_key_set": register.np_api_key_enc is not None,
|
||||
},
|
||||
request=request,
|
||||
)
|
||||
await session.commit()
|
||||
@@ -80,6 +108,9 @@ async def update_cash_register(
|
||||
register.license_key_enc = crypto.encrypt(changes["license_key"])
|
||||
if "pin_code" in changes:
|
||||
register.cashier_pin_enc = crypto.encrypt(changes["pin_code"])
|
||||
if changes.get("np_api_key"):
|
||||
await _ensure_np_key_unique(session, changes["np_api_key"], exclude_id=register.id)
|
||||
register.np_api_key_enc = crypto.encrypt(changes["np_api_key"])
|
||||
if "fiscal_number" in changes:
|
||||
register.fiscal_number = changes["fiscal_number"]
|
||||
for field in ("name", "tax_codes", "is_active", "is_default"):
|
||||
@@ -94,7 +125,7 @@ async def update_cash_register(
|
||||
user=actor,
|
||||
entity_type="cash_register",
|
||||
entity_id=register.id,
|
||||
payload=changes, # license_key / pin_code редактируются в audit._sanitize
|
||||
payload=changes, # license_key / pin_code / np_api_key редактируются в audit._sanitize
|
||||
request=request,
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
@@ -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 ---
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"""Кассы (ПРРО) Checkbox.
|
||||
|
||||
Ключ лицензии и PIN кассира хранятся только в зашифрованном виде
|
||||
(`app/core/crypto.py`): утечка дампа БД не должна давать доступ к кассе.
|
||||
Ключ лицензии, PIN кассира и ключ API Новой Почты хранятся только в
|
||||
зашифрованном виде (`app/core/crypto.py`): утечка дампа БД не должна давать
|
||||
доступ к кассе и кабинету отправителя.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -22,6 +23,10 @@ class CashRegister(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
fiscal_number: Mapped[str | None] = mapped_column(String(64))
|
||||
license_key_enc: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
cashier_pin_enc: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||
# Ключ API кабинета отправителя НП. Посылки, которые этот ключ видит как
|
||||
# свои, привязываются к кассе (`orders.cash_register_id`), и ЕТТН-чеки по
|
||||
# ним создаются от неё. Без ключа касса статусы ТТН не опрашивает.
|
||||
np_api_key_enc: Mapped[str | None] = mapped_column(String(512))
|
||||
# Коды налоговых ставок Checkbox для всех товаров чека; пусто — поле `tax`
|
||||
# не передаётся (неплательщик ПДВ).
|
||||
tax_codes: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list)
|
||||
|
||||
@@ -7,10 +7,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, String, Text
|
||||
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, String, Text
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
@@ -54,3 +55,9 @@ class Order(TimestampMixin, Base):
|
||||
np_status_code: Mapped[str | None] = mapped_column(String(16))
|
||||
np_cod_amount_kopecks: Mapped[int | None] = mapped_column(BigInteger)
|
||||
np_payment_status: Mapped[str | None] = mapped_column(String(32))
|
||||
|
||||
# Касса, чей ключ НП видит ТТН как свою (определяется при опросе статусов,
|
||||
# см. `services/orders.sync_np_statuses`). От неё создаётся ЕТТН-чек.
|
||||
cash_register_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("cash_registers.id", ondelete="SET NULL"), index=True
|
||||
)
|
||||
|
||||
@@ -70,26 +70,34 @@ class ReceiptCreateResponse(BaseModel):
|
||||
# --- Кассы -------------------------------------------------------------------
|
||||
|
||||
|
||||
def _masked(encrypted: str) -> str:
|
||||
try:
|
||||
return crypto.mask(crypto.decrypt(encrypted))
|
||||
except crypto.DecryptionError:
|
||||
return "не расшифровывается — введите заново"
|
||||
|
||||
|
||||
class CashRegisterOut(BaseModel):
|
||||
id: uuid.UUID
|
||||
name: str
|
||||
fiscal_number: str | None
|
||||
license_key_masked: str
|
||||
# None — ключ НП не задан: касса не опрашивает посылки и не получает заказы.
|
||||
np_api_key_masked: str | None
|
||||
tax_codes: list[Any]
|
||||
is_active: bool
|
||||
is_default: bool
|
||||
|
||||
@classmethod
|
||||
def from_register(cls, register: CashRegister) -> CashRegisterOut:
|
||||
try:
|
||||
masked = crypto.mask(crypto.decrypt(register.license_key_enc))
|
||||
except crypto.DecryptionError:
|
||||
masked = "не расшифровывается — введите заново"
|
||||
return cls(
|
||||
id=register.id,
|
||||
name=register.name,
|
||||
fiscal_number=register.fiscal_number,
|
||||
license_key_masked=masked,
|
||||
license_key_masked=_masked(register.license_key_enc),
|
||||
np_api_key_masked=(
|
||||
_masked(register.np_api_key_enc) if register.np_api_key_enc else None
|
||||
),
|
||||
tax_codes=register.tax_codes,
|
||||
is_active=register.is_active,
|
||||
is_default=register.is_default,
|
||||
@@ -101,6 +109,7 @@ class CashRegisterCreate(BaseModel):
|
||||
fiscal_number: str | None = Field(default=None, max_length=64)
|
||||
license_key: str = Field(min_length=1, max_length=255)
|
||||
pin_code: str = Field(min_length=1, max_length=32)
|
||||
np_api_key: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
tax_codes: list[int | str] = Field(default_factory=list, max_length=2)
|
||||
is_default: bool = True
|
||||
|
||||
@@ -112,6 +121,7 @@ class CashRegisterUpdate(BaseModel):
|
||||
fiscal_number: str | None = Field(default=None, max_length=64)
|
||||
license_key: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
pin_code: str | None = Field(default=None, min_length=1, max_length=32)
|
||||
np_api_key: str | None = Field(default=None, min_length=1, max_length=255)
|
||||
tax_codes: list[int | str] | None = Field(default=None, max_length=2)
|
||||
is_active: bool | None = None
|
||||
is_default: bool | None = None
|
||||
|
||||
@@ -32,9 +32,19 @@ class TrackingStatusOut(BaseModel):
|
||||
# какого-то типа накладной не возвращает `AfterpaymentOnGoodsCost`.
|
||||
cod_amount: str | None = Field(default=None)
|
||||
|
||||
# Телефон отправителя NP отдаёт только кабинету отправителя (или получателя):
|
||||
# по чужому ключу без телефона ответ урезан — нет ни отправителя, ни
|
||||
# `AfterpaymentOnGoodsCost`. По этому полю определяется, чья это посылка.
|
||||
phone_sender: str | None = Field(default=None, alias="PhoneSender")
|
||||
|
||||
# Статус оплаты аналогично раздвоен на `PaymentStatus`/`ExpressWaybillPaymentStatus`.
|
||||
payment_status: str | None = Field(default=None)
|
||||
|
||||
@property
|
||||
def is_own(self) -> bool:
|
||||
"""ТТН видна ключу как своя — отправлена из кабинета этого ключа."""
|
||||
return bool(self.phone_sender)
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _fold_express_fields(cls, data: Any) -> Any:
|
||||
|
||||
@@ -22,6 +22,7 @@ _REDACTED_KEYS = {
|
||||
"secret",
|
||||
"license_key",
|
||||
"api_key",
|
||||
"np_api_key",
|
||||
"pin",
|
||||
"pin_code",
|
||||
"cashier_pin",
|
||||
|
||||
@@ -15,4 +15,8 @@ class NovaPoshtaError(Exception):
|
||||
|
||||
|
||||
class NovaPoshtaClient(Protocol):
|
||||
async def get_statuses(self, *, waybill_numbers: list[str]) -> list[TrackingStatusOut]: ...
|
||||
"""Ключ API передаётся в каждый вызов: у каждой кассы свой кабинет отправителя."""
|
||||
|
||||
async def get_statuses(
|
||||
self, *, api_key: str, waybill_numbers: list[str]
|
||||
) -> list[TrackingStatusOut]: ...
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.schemas.tracking import TrackingStatusOut
|
||||
from app.services.nova_poshta.client import NovaPoshtaError
|
||||
|
||||
@@ -15,10 +14,9 @@ _MAX_DOCUMENTS_PER_REQUEST = 100
|
||||
|
||||
|
||||
class NpTrackingClient:
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
self._api_key = settings.nova_poshta_api_key
|
||||
|
||||
async def get_statuses(self, *, waybill_numbers: list[str]) -> list[TrackingStatusOut]:
|
||||
async def get_statuses(
|
||||
self, *, api_key: str, waybill_numbers: list[str]
|
||||
) -> list[TrackingStatusOut]:
|
||||
if not waybill_numbers:
|
||||
return []
|
||||
if len(waybill_numbers) > _MAX_DOCUMENTS_PER_REQUEST:
|
||||
@@ -28,7 +26,7 @@ class NpTrackingClient:
|
||||
)
|
||||
|
||||
body = {
|
||||
"apiKey": self._api_key,
|
||||
"apiKey": api_key,
|
||||
"modelName": "TrackingDocument",
|
||||
"calledMethod": "getStatusDocuments",
|
||||
"methodProperties": {
|
||||
|
||||
@@ -15,17 +15,32 @@ _FIXTURE_STATUSES: dict[str, dict] = {
|
||||
"AmountToPay": "1200.00",
|
||||
"AfterpaymentOnGoodsCost": 1200,
|
||||
"PaymentStatus": "Paid",
|
||||
"PhoneSender": "380501112233",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class StubNovaPoshtaClient:
|
||||
def __init__(self, statuses: dict[str, dict] | None = None) -> None:
|
||||
self._statuses = statuses if statuses is not None else _FIXTURE_STATUSES
|
||||
"""`statuses` — ответы NP по ТТН; `by_key` — отдельные ответы для конкретных
|
||||
ключей API (кабинет отправителя видит свои ТТН полностью, чужие — урезанно)."""
|
||||
|
||||
async def get_statuses(self, *, waybill_numbers: list[str]) -> list[TrackingStatusOut]:
|
||||
def __init__(
|
||||
self,
|
||||
statuses: dict[str, dict] | None = None,
|
||||
*,
|
||||
by_key: dict[str, dict[str, dict]] | None = None,
|
||||
) -> None:
|
||||
self._statuses = statuses if statuses is not None else _FIXTURE_STATUSES
|
||||
self._by_key = by_key or {}
|
||||
self.calls: list[tuple[str, list[str]]] = []
|
||||
|
||||
async def get_statuses(
|
||||
self, *, api_key: str, waybill_numbers: list[str]
|
||||
) -> list[TrackingStatusOut]:
|
||||
self.calls.append((api_key, list(waybill_numbers)))
|
||||
statuses = self._by_key.get(api_key, self._statuses)
|
||||
return [
|
||||
TrackingStatusOut.model_validate(self._statuses[number])
|
||||
TrackingStatusOut.model_validate(statuses[number])
|
||||
for number in waybill_numbers
|
||||
if number in self._statuses
|
||||
if number in statuses
|
||||
]
|
||||
|
||||
+126
-15
@@ -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
|
||||
|
||||
@@ -68,6 +68,11 @@ _CHECKBOX_TO_STATUS = {
|
||||
# или Checkbox был недоступен) и повторить его из cron'а.
|
||||
_PENDING_RETRY_AFTER = timedelta(minutes=1)
|
||||
|
||||
# Сколько `created`-чеков опрашивать за один прогон cron'а (раз в минуту):
|
||||
# при паузе ~1 с между запросами к Checkbox прогон укладывается в минуту и
|
||||
# не наслаивается на следующий.
|
||||
_ETTN_POLL_BATCH = 30
|
||||
|
||||
# Статус заказа в CRM, когда Checkbox принял ЕТТН-чек: заказ можно собирать.
|
||||
CRM_STATUS_AFTER_RECEIPT = "PACKED"
|
||||
# Чеки, при которых заказу нужен этот статус в CRM (Checkbox чек принял).
|
||||
@@ -256,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}
|
||||
|
||||
|
||||
# --- Создание ----------------------------------------------------------------
|
||||
@@ -295,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
|
||||
@@ -318,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)
|
||||
@@ -354,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,
|
||||
},
|
||||
@@ -495,9 +509,20 @@ async def cancel_receipt(
|
||||
|
||||
|
||||
async def sync_ettn_statuses(session: AsyncSession, client: CheckboxClient) -> None:
|
||||
"""Опрашивает Checkbox по чекам, ожидающим оплаты посылки (`created`)."""
|
||||
"""Опрашивает Checkbox по чекам, ожидающим оплаты посылки (`created`).
|
||||
|
||||
За прогон — не больше `_ETTN_POLL_BATCH` давно не проверенных чеков, с
|
||||
коммитом после каждого: запросы к Checkbox идут с паузой, и опрос всех
|
||||
чеков разом не укладывался в таймаут cron-задачи, а откат по таймауту
|
||||
терял весь прогон — статусы не обновлялись вовсе.
|
||||
"""
|
||||
receipts = list(
|
||||
await session.scalars(select(Receipt).where(Receipt.status == ReceiptStatus.CREATED))
|
||||
await session.scalars(
|
||||
select(Receipt)
|
||||
.where(Receipt.status == ReceiptStatus.CREATED)
|
||||
.order_by(Receipt.last_checked_at.asc().nulls_first())
|
||||
.limit(_ETTN_POLL_BATCH)
|
||||
)
|
||||
)
|
||||
registers: dict[uuid.UUID, CheckboxCredentials] = {}
|
||||
for receipt in receipts:
|
||||
@@ -521,7 +546,6 @@ async def sync_ettn_statuses(session: AsyncSession, client: CheckboxClient) -> N
|
||||
_apply_ettn(receipt, ettn)
|
||||
if receipt.status in CLOSED_STATUSES:
|
||||
await _release_order(session, receipt.order_id)
|
||||
|
||||
await session.commit()
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -8,7 +8,7 @@ import pytest
|
||||
import respx
|
||||
from httpx import Response
|
||||
|
||||
from app.core.config import Settings
|
||||
from app.schemas.tracking import TrackingStatusOut
|
||||
from app.services.nova_poshta.client import NovaPoshtaError
|
||||
from app.services.nova_poshta.np_client import _API_URL, NpTrackingClient
|
||||
|
||||
@@ -27,12 +27,7 @@ STATUS_PAYLOAD = {
|
||||
}
|
||||
|
||||
|
||||
def _settings() -> Settings:
|
||||
return Settings(
|
||||
secret_key="test-secret-key",
|
||||
encryption_key="dGVzdC1lbmNyeXB0aW9uLWtleS0zMi1ieXRlcyEh",
|
||||
nova_poshta_api_key="np-apikey-123",
|
||||
) # type: ignore[arg-type]
|
||||
API_KEY = "np-apikey-123"
|
||||
|
||||
|
||||
class TestNpTrackingClientGetStatuses:
|
||||
@@ -41,13 +36,13 @@ class TestNpTrackingClientGetStatuses:
|
||||
route = respx.post(_API_URL).mock(
|
||||
return_value=Response(200, json={"success": True, "data": [], "errors": []})
|
||||
)
|
||||
client = NpTrackingClient(_settings())
|
||||
client = NpTrackingClient()
|
||||
|
||||
await client.get_statuses(waybill_numbers=["20451540916703"])
|
||||
await client.get_statuses(api_key=API_KEY, waybill_numbers=["20451540916703"])
|
||||
|
||||
sent = route.calls.last.request
|
||||
body = json.loads(sent.content)
|
||||
assert body["apiKey"] == "np-apikey-123"
|
||||
assert body["apiKey"] == API_KEY
|
||||
assert body["modelName"] == "TrackingDocument"
|
||||
assert body["calledMethod"] == "getStatusDocuments"
|
||||
assert body["methodProperties"]["Documents"] == [
|
||||
@@ -63,9 +58,9 @@ class TestNpTrackingClientGetStatuses:
|
||||
200, json={"success": True, "data": [STATUS_PAYLOAD], "errors": []}
|
||||
)
|
||||
)
|
||||
client = NpTrackingClient(_settings())
|
||||
client = NpTrackingClient()
|
||||
|
||||
statuses = await client.get_statuses(waybill_numbers=["20451540916703"])
|
||||
statuses = await client.get_statuses(api_key=API_KEY, waybill_numbers=["20451540916703"])
|
||||
|
||||
assert len(statuses) == 1
|
||||
status = statuses[0]
|
||||
@@ -75,6 +70,13 @@ class TestNpTrackingClientGetStatuses:
|
||||
# не "сколько заплатить сейчас" (`ExpressWaybillAmountToPay` = 827.48).
|
||||
assert status.cod_amount == "699"
|
||||
assert status.payment_status == "NeedPayment"
|
||||
assert not status.is_own # в STATUS_PAYLOAD нет PhoneSender
|
||||
|
||||
def test_is_own_by_sender_phone(self) -> None:
|
||||
own = TrackingStatusOut.model_validate({**STATUS_PAYLOAD, "PhoneSender": "380961112233"})
|
||||
foreign = TrackingStatusOut.model_validate({**STATUS_PAYLOAD, "PhoneSender": ""})
|
||||
assert own.is_own
|
||||
assert not foreign.is_own
|
||||
|
||||
@respx.mock
|
||||
async def test_falls_back_to_amount_to_pay_when_afterpayment_missing(self) -> None:
|
||||
@@ -82,9 +84,9 @@ class TestNpTrackingClientGetStatuses:
|
||||
respx.post(_API_URL).mock(
|
||||
return_value=Response(200, json={"success": True, "data": [payload], "errors": []})
|
||||
)
|
||||
client = NpTrackingClient(_settings())
|
||||
client = NpTrackingClient()
|
||||
|
||||
statuses = await client.get_statuses(waybill_numbers=["20451540916703"])
|
||||
statuses = await client.get_statuses(api_key=API_KEY, waybill_numbers=["20451540916703"])
|
||||
|
||||
assert statuses[0].cod_amount == "827.48"
|
||||
|
||||
@@ -95,18 +97,20 @@ class TestNpTrackingClientGetStatuses:
|
||||
200, json={"success": False, "data": [], "errors": ["Invalid apiKey"]}
|
||||
)
|
||||
)
|
||||
client = NpTrackingClient(_settings())
|
||||
client = NpTrackingClient()
|
||||
|
||||
with pytest.raises(NovaPoshtaError, match="Invalid apiKey"):
|
||||
await client.get_statuses(waybill_numbers=["20451540916703"])
|
||||
await client.get_statuses(api_key=API_KEY, waybill_numbers=["20451540916703"])
|
||||
|
||||
async def test_returns_empty_list_for_no_documents(self) -> None:
|
||||
client = NpTrackingClient(_settings())
|
||||
client = NpTrackingClient()
|
||||
|
||||
assert await client.get_statuses(waybill_numbers=[]) == []
|
||||
assert await client.get_statuses(api_key=API_KEY, waybill_numbers=[]) == []
|
||||
|
||||
async def test_rejects_too_many_documents(self) -> None:
|
||||
client = NpTrackingClient(_settings())
|
||||
client = NpTrackingClient()
|
||||
|
||||
with pytest.raises(NovaPoshtaError, match="Слишком много"):
|
||||
await client.get_statuses(waybill_numbers=[str(i) for i in range(101)])
|
||||
await client.get_statuses(
|
||||
api_key=API_KEY, waybill_numbers=[str(i) for i in range(101)]
|
||||
)
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Опрос статусов НП ключами касс и привязка заказов к кассам (`sync_np_statuses`)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from app.core import crypto
|
||||
from app.db.models.cash_register import CashRegister
|
||||
from app.db.models.order import Order
|
||||
from app.services.nova_poshta.stub_client import StubNovaPoshtaClient
|
||||
from app.services.orders import sync_np_statuses
|
||||
|
||||
|
||||
def _register(name: str, np_key: str | None, *, is_default: bool = False) -> CashRegister:
|
||||
return CashRegister(
|
||||
id=uuid.uuid4(),
|
||||
name=name,
|
||||
license_key_enc=crypto.encrypt("lic"),
|
||||
cashier_pin_enc=crypto.encrypt("1111"),
|
||||
np_api_key_enc=crypto.encrypt(np_key) if np_key else None,
|
||||
tax_codes=[],
|
||||
is_active=True,
|
||||
is_default=is_default,
|
||||
created_at=datetime(2026, 9, 1, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
def _order(waybill: str, register_id: uuid.UUID | None = None) -> Order:
|
||||
return Order(
|
||||
id=waybill[-4:],
|
||||
create_date_time=datetime(2026, 9, 20, tzinfo=UTC),
|
||||
waybill_number=waybill,
|
||||
total_amount_kopecks=120000,
|
||||
goods=[],
|
||||
is_deleted=False,
|
||||
cash_register_id=register_id,
|
||||
)
|
||||
|
||||
|
||||
def _status(waybill: str, *, own: bool) -> dict[str, Any]:
|
||||
status = {"Number": waybill, "Status": "В дорозі", "StatusCode": "5"}
|
||||
if own:
|
||||
status |= {"PhoneSender": "380961112233", "AfterpaymentOnGoodsCost": 1200}
|
||||
return status
|
||||
|
||||
|
||||
class FakeSession:
|
||||
"""`scalars` отдаёт кассы (с ключом НП, активные) или заказы — по сущности запроса."""
|
||||
|
||||
def __init__(self, registers: list[CashRegister], orders: list[Order]) -> None:
|
||||
self.registers = registers
|
||||
self.orders = orders
|
||||
self.commits = 0
|
||||
|
||||
async def scalars(self, statement: Any) -> list[Any]:
|
||||
entity = statement.column_descriptions[0]["entity"]
|
||||
if entity is CashRegister:
|
||||
return [r for r in self.registers if r.np_api_key_enc and r.is_active]
|
||||
return self.orders
|
||||
|
||||
async def commit(self) -> None:
|
||||
self.commits += 1
|
||||
|
||||
|
||||
async def test_unbound_order_is_claimed_by_register_that_sees_it_as_own() -> None:
|
||||
first, second = _register("A", "key-a", is_default=True), _register("B", "key-b")
|
||||
order = _order("20450000000001")
|
||||
np = StubNovaPoshtaClient(
|
||||
by_key={
|
||||
"key-a": {order.waybill_number: _status(order.waybill_number, own=False)},
|
||||
"key-b": {order.waybill_number: _status(order.waybill_number, own=True)},
|
||||
}
|
||||
)
|
||||
session = FakeSession([first, second], [order])
|
||||
|
||||
await sync_np_statuses(session, np) # type: ignore[arg-type]
|
||||
|
||||
assert order.cash_register_id == second.id
|
||||
assert order.np_cod_amount_kopecks == 120000
|
||||
assert session.commits == 1
|
||||
|
||||
|
||||
async def test_bound_order_is_polled_only_with_its_register_key() -> None:
|
||||
first, second = _register("A", "key-a", is_default=True), _register("B", "key-b")
|
||||
order = _order("20450000000002", register_id=second.id)
|
||||
np = StubNovaPoshtaClient(
|
||||
by_key={"key-b": {order.waybill_number: _status(order.waybill_number, own=True)}}
|
||||
)
|
||||
|
||||
await sync_np_statuses(FakeSession([first, second], [order]), np) # type: ignore[arg-type]
|
||||
|
||||
assert [key for key, _ in np.calls] == ["key-b"]
|
||||
assert order.cash_register_id == second.id
|
||||
|
||||
|
||||
async def test_foreign_waybill_keeps_status_but_no_register_or_cod() -> None:
|
||||
register = _register("A", "key-a", is_default=True)
|
||||
order = _order("20450000000003", register_id=register.id)
|
||||
np = StubNovaPoshtaClient(
|
||||
by_key={"key-a": {order.waybill_number: _status(order.waybill_number, own=False)}}
|
||||
)
|
||||
|
||||
await sync_np_statuses(FakeSession([register], [order]), np) # type: ignore[arg-type]
|
||||
|
||||
assert order.cash_register_id is None
|
||||
assert order.np_status_code == "5"
|
||||
assert order.np_cod_amount_kopecks is None
|
||||
|
||||
|
||||
async def test_no_registers_with_np_key_does_nothing() -> None:
|
||||
order = _order("20450000000004")
|
||||
np = StubNovaPoshtaClient()
|
||||
session = FakeSession([_register("A", None)], [order])
|
||||
|
||||
await sync_np_statuses(session, np) # type: ignore[arg-type]
|
||||
|
||||
assert np.calls == []
|
||||
assert session.commits == 0
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -44,3 +44,7 @@
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.cr-missing {
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ interface FormState {
|
||||
fiscal_number: string
|
||||
license_key: string
|
||||
pin_code: string
|
||||
np_api_key: string
|
||||
tax_codes: string
|
||||
is_default: boolean
|
||||
}
|
||||
@@ -28,6 +29,7 @@ const EMPTY_FORM: FormState = {
|
||||
fiscal_number: '',
|
||||
license_key: '',
|
||||
pin_code: '',
|
||||
np_api_key: '',
|
||||
tax_codes: '',
|
||||
is_default: true,
|
||||
}
|
||||
@@ -75,6 +77,7 @@ export function CashRegistersPage() {
|
||||
fiscal_number: register.fiscal_number ?? '',
|
||||
license_key: '',
|
||||
pin_code: '',
|
||||
np_api_key: '',
|
||||
tax_codes: register.tax_codes.join(', '),
|
||||
is_default: register.is_default,
|
||||
})
|
||||
@@ -88,7 +91,7 @@ export function CashRegistersPage() {
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
if (editingId) {
|
||||
// Пустые ключ/PIN при редактировании — «не менять».
|
||||
// Пустые ключи/PIN при редактировании — «не менять».
|
||||
const payload: CashRegisterUpdate = {
|
||||
name: form.name,
|
||||
fiscal_number: form.fiscal_number || null,
|
||||
@@ -97,6 +100,7 @@ export function CashRegistersPage() {
|
||||
}
|
||||
if (form.license_key) payload.license_key = form.license_key
|
||||
if (form.pin_code) payload.pin_code = form.pin_code
|
||||
if (form.np_api_key) payload.np_api_key = form.np_api_key
|
||||
await run(() => updateCashRegister(editingId, payload), 'Касса обновлена')
|
||||
} else {
|
||||
await run(
|
||||
@@ -106,6 +110,7 @@ export function CashRegistersPage() {
|
||||
fiscal_number: form.fiscal_number || null,
|
||||
license_key: form.license_key,
|
||||
pin_code: form.pin_code,
|
||||
np_api_key: form.np_api_key,
|
||||
tax_codes: parseTaxCodes(form.tax_codes),
|
||||
is_default: form.is_default,
|
||||
}),
|
||||
@@ -145,6 +150,7 @@ export function CashRegistersPage() {
|
||||
<th>Название</th>
|
||||
<th>Фиск. номер</th>
|
||||
<th>Ключ лицензии</th>
|
||||
<th>Ключ Новой Почты</th>
|
||||
<th>Налоги</th>
|
||||
<th>Статус</th>
|
||||
<th>Действия</th>
|
||||
@@ -153,21 +159,21 @@ export function CashRegistersPage() {
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td colSpan={6} className="orders-empty">
|
||||
<td colSpan={7} className="orders-empty">
|
||||
Загрузка…
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{isError && (
|
||||
<tr>
|
||||
<td colSpan={6} className="orders-empty">
|
||||
<td colSpan={7} className="orders-empty">
|
||||
Не удалось загрузить кассы
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{registers?.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={6} className="orders-empty">
|
||||
<td colSpan={7} className="orders-empty">
|
||||
Кассы не настроены — чеки создавать нельзя
|
||||
</td>
|
||||
</tr>
|
||||
@@ -180,6 +186,7 @@ export function CashRegistersPage() {
|
||||
</td>
|
||||
<td>{register.fiscal_number || '—'}</td>
|
||||
<td>{register.license_key_masked}</td>
|
||||
<td>{register.np_api_key_masked ?? <span className="cr-missing">не задан</span>}</td>
|
||||
<td>{register.tax_codes.length ? register.tax_codes.join(', ') : 'без налога'}</td>
|
||||
<td>
|
||||
<span className={`orders-status orders-status--${register.is_active ? 'delivered' : 'new'}`}>
|
||||
@@ -249,6 +256,16 @@ export function CashRegistersPage() {
|
||||
onChange={(e) => setForm({ ...form, pin_code: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Ключ API Новой Почты (посылки этого кабинета пробиваются через эту кассу)
|
||||
<input
|
||||
required={!editingId}
|
||||
autoComplete="off"
|
||||
placeholder={editingId ? 'не менять' : ''}
|
||||
value={form.np_api_key}
|
||||
onChange={(e) => setForm({ ...form, np_api_key: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Коды налогов (через запятую, пусто — без налога)
|
||||
<input value={form.tax_codes} onChange={(e) => setForm({ ...form, tax_codes: e.target.value })} />
|
||||
@@ -259,7 +276,7 @@ export function CashRegistersPage() {
|
||||
checked={form.is_default}
|
||||
onChange={(e) => setForm({ ...form, is_default: e.target.checked })}
|
||||
/>
|
||||
Основная касса (по ней создаются чеки)
|
||||
Основная касса (первой проверяет новые посылки)
|
||||
</label>
|
||||
<div className="cr-form-actions">
|
||||
<button type="submit" className="orders-create-btn" disabled={busy}>
|
||||
|
||||
Reference in New Issue
Block a user