Merge pull request 'Привязка ключа Новой Почты к кассе (#3)' (#4) from feature/np-key-per-register into main
This commit was merged in pull request #4.
This commit is contained in:
+2
-1
@@ -58,7 +58,8 @@ CRM_SHOP_KEY=change-me-crm-shopkey
|
|||||||
CRM_SID=1
|
CRM_SID=1
|
||||||
|
|
||||||
# --- Nova Poshta ----------------------------------------------------------
|
# --- Nova Poshta ----------------------------------------------------------
|
||||||
# Ключ API кабинета Nova Poshta (my.novaposhta.ua → Настройки → API).
|
# Ключи API Nova Poshta задаются у касс (страница «Кассы»). Эта переменная нужна
|
||||||
|
# только миграции 0008: переносит старый общий ключ в кассу по умолчанию.
|
||||||
NOVA_POSHTA_API_KEY=change-me-novaposhta-apikey
|
NOVA_POSHTA_API_KEY=change-me-novaposhta-apikey
|
||||||
|
|
||||||
# --- Checkbox -------------------------------------------------------------
|
# --- Checkbox -------------------------------------------------------------
|
||||||
|
|||||||
@@ -127,4 +127,5 @@ Stages 1–4 (scaffolding, auth/audit, CRM order queue, Nova Poshta tracking) ar
|
|||||||
- State machine and the "one live receipt per order" partial unique index live in `app/db/models/receipt.py`. `orders.receipt_created_at` is set on request and reset to NULL when a receipt ends `failed`/`cancelled` (order goes back to the queue).
|
- State machine and the "one live receipt per order" partial unique index live in `app/db/models/receipt.py`. `orders.receipt_created_at` is set on request and reset to NULL when a receipt ends `failed`/`cancelled` (order goes back to the queue).
|
||||||
- Once Checkbox accepts the receipt, the order is moved to `PACKED` in the CRM (`services/receipts.sync_crm_statuses`, marked by `receipts.crm_status_set_at`; runs right after creation and is retried by cron). The live exoCRM `SetStatus` differs from its docs: params must be `{"Orders": [id], "Status": ...}` (the documented `{"ID": ...}` returns "Undefined order list."), and the reply has no `status: OK` — success is `{"<id>": {"Status": "Success"}}`.
|
- Once Checkbox accepts the receipt, the order is moved to `PACKED` in the CRM (`services/receipts.sync_crm_statuses`, marked by `receipts.crm_status_set_at`; runs right after creation and is retried by cron). The live exoCRM `SetStatus` differs from its docs: params must be `{"Orders": [id], "Status": ...}` (the documented `{"ID": ...}` returns "Undefined order list."), and the reply has no `status: OK` — success is `{"<id>": {"Status": "Success"}}`.
|
||||||
- Nova Poshta's rate limit comes back through Checkbox as a 4xx with `code=third_party.generic` and «To many requests» / `20000401501`, not as a 429. `http_client._transient_error` maps it to `CheckboxRateLimitedError`: the receipt stays `pending` and the worker retries with `arq.Retry`. The client also sends requests one at a time with a `CHECKBOX_MIN_REQUEST_INTERVAL_MS` pause, so don't parallelize Checkbox calls in the worker.
|
- Nova Poshta's rate limit comes back through Checkbox as a 4xx with `code=third_party.generic` and «To many requests» / `20000401501`, not as a 429. `http_client._transient_error` maps it to `CheckboxRateLimitedError`: the receipt stays `pending` and the worker retries with `arq.Retry`. The client also sends requests one at a time with a `CHECKBOX_MIN_REQUEST_INTERVAL_MS` pause, so don't parallelize Checkbox calls in the worker.
|
||||||
|
- Each cash register has its own Nova Poshta API key (`cash_registers.np_api_key_enc`, Fernet). `sync_np_statuses` polls TTNs with register keys and binds the order to the register whose key sees the TTN as its own (`orders.cash_register_id`; ownership = response contains `PhoneSender` — a foreign key gets a truncated reply without sender/`AfterpaymentOnGoodsCost`). Receipts are created from the order's register, not the default one; an unbound order is rejected. `NOVA_POSHTA_API_KEY` env is only read by migration 0008.
|
||||||
- ETTN does **not** work on a Checkbox test cash register. Locally use `CHECKBOX_USE_STUB=true`; client selection is only in `services/checkbox/client.get_checkbox_client()`.
|
- ETTN does **not** work on a Checkbox test cash register. Locally use `CHECKBOX_USE_STUB=true`; client selection is only in `services/checkbox/client.get_checkbox_client()`.
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
"""Ключ API Новой Почты у кассы и привязка заказа к кассе
|
||||||
|
|
||||||
|
Revision ID: 0008
|
||||||
|
Revises: 0007
|
||||||
|
Create Date: 2026-09-25
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
from app.core import crypto
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
revision: str = "0008"
|
||||||
|
down_revision: str | None = "0007"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"cash_registers", sa.Column("np_api_key_enc", sa.String(length=512), nullable=True)
|
||||||
|
)
|
||||||
|
op.add_column("orders", sa.Column("cash_register_id", sa.Uuid(), nullable=True))
|
||||||
|
op.create_foreign_key(
|
||||||
|
op.f("fk_orders_cash_register_id_cash_registers"),
|
||||||
|
"orders",
|
||||||
|
"cash_registers",
|
||||||
|
["cash_register_id"],
|
||||||
|
["id"],
|
||||||
|
ondelete="SET NULL",
|
||||||
|
)
|
||||||
|
op.create_index(op.f("ix_orders_cash_register_id"), "orders", ["cash_register_id"])
|
||||||
|
|
||||||
|
# Раньше ключ НП был один на всё приложение (NOVA_POSHTA_API_KEY) — переносим
|
||||||
|
# его в кассу по умолчанию, чтобы опрос статусов не остановился после обновления.
|
||||||
|
if settings.nova_poshta_api_key:
|
||||||
|
op.get_bind().execute(
|
||||||
|
sa.text(
|
||||||
|
"UPDATE cash_registers SET np_api_key_enc = :key "
|
||||||
|
"WHERE id = (SELECT id FROM cash_registers "
|
||||||
|
"ORDER BY is_default DESC, created_at LIMIT 1)"
|
||||||
|
),
|
||||||
|
{"key": crypto.encrypt(settings.nova_poshta_api_key)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index(op.f("ix_orders_cash_register_id"), table_name="orders")
|
||||||
|
op.drop_constraint(
|
||||||
|
op.f("fk_orders_cash_register_id_cash_registers"), "orders", type_="foreignkey"
|
||||||
|
)
|
||||||
|
op.drop_column("orders", "cash_register_id")
|
||||||
|
op.drop_column("cash_registers", "np_api_key_enc")
|
||||||
@@ -27,6 +27,27 @@ async def _clear_default(session: SessionDep, keep_id: uuid.UUID) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_np_key_unique(
|
||||||
|
session: SessionDep, api_key: str, exclude_id: uuid.UUID | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Один кабинет НП — одна касса, иначе неясно, от какой кассы пробивать его посылки."""
|
||||||
|
registers = await session.scalars(
|
||||||
|
select(CashRegister).where(CashRegister.np_api_key_enc.is_not(None))
|
||||||
|
)
|
||||||
|
for register in registers:
|
||||||
|
if register.id == exclude_id:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
same = crypto.decrypt(register.np_api_key_enc) == api_key
|
||||||
|
except crypto.DecryptionError:
|
||||||
|
continue
|
||||||
|
if same:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_409_CONFLICT,
|
||||||
|
detail=f"Этот ключ Новой Почты уже привязан к кассе «{register.name}»",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=list[CashRegisterOut])
|
@router.get("", response_model=list[CashRegisterOut])
|
||||||
async def list_cash_registers(session: SessionDep) -> list[CashRegisterOut]:
|
async def list_cash_registers(session: SessionDep) -> list[CashRegisterOut]:
|
||||||
registers = await session.scalars(select(CashRegister).order_by(CashRegister.created_at))
|
registers = await session.scalars(select(CashRegister).order_by(CashRegister.created_at))
|
||||||
@@ -37,12 +58,15 @@ async def list_cash_registers(session: SessionDep) -> list[CashRegisterOut]:
|
|||||||
async def create_cash_register(
|
async def create_cash_register(
|
||||||
payload: CashRegisterCreate, request: Request, session: SessionDep, actor: AdminUser
|
payload: CashRegisterCreate, request: Request, session: SessionDep, actor: AdminUser
|
||||||
) -> CashRegisterOut:
|
) -> CashRegisterOut:
|
||||||
|
if payload.np_api_key:
|
||||||
|
await _ensure_np_key_unique(session, payload.np_api_key)
|
||||||
register = CashRegister(
|
register = CashRegister(
|
||||||
id=uuid.uuid4(),
|
id=uuid.uuid4(),
|
||||||
name=payload.name,
|
name=payload.name,
|
||||||
fiscal_number=payload.fiscal_number,
|
fiscal_number=payload.fiscal_number,
|
||||||
license_key_enc=crypto.encrypt(payload.license_key),
|
license_key_enc=crypto.encrypt(payload.license_key),
|
||||||
cashier_pin_enc=crypto.encrypt(payload.pin_code),
|
cashier_pin_enc=crypto.encrypt(payload.pin_code),
|
||||||
|
np_api_key_enc=crypto.encrypt(payload.np_api_key) if payload.np_api_key else None,
|
||||||
tax_codes=payload.tax_codes,
|
tax_codes=payload.tax_codes,
|
||||||
is_active=True,
|
is_active=True,
|
||||||
is_default=payload.is_default,
|
is_default=payload.is_default,
|
||||||
@@ -56,7 +80,11 @@ async def create_cash_register(
|
|||||||
user=actor,
|
user=actor,
|
||||||
entity_type="cash_register",
|
entity_type="cash_register",
|
||||||
entity_id=register.id,
|
entity_id=register.id,
|
||||||
payload={"name": register.name, "fiscal_number": register.fiscal_number},
|
payload={
|
||||||
|
"name": register.name,
|
||||||
|
"fiscal_number": register.fiscal_number,
|
||||||
|
"np_api_key_set": register.np_api_key_enc is not None,
|
||||||
|
},
|
||||||
request=request,
|
request=request,
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
@@ -80,6 +108,9 @@ async def update_cash_register(
|
|||||||
register.license_key_enc = crypto.encrypt(changes["license_key"])
|
register.license_key_enc = crypto.encrypt(changes["license_key"])
|
||||||
if "pin_code" in changes:
|
if "pin_code" in changes:
|
||||||
register.cashier_pin_enc = crypto.encrypt(changes["pin_code"])
|
register.cashier_pin_enc = crypto.encrypt(changes["pin_code"])
|
||||||
|
if changes.get("np_api_key"):
|
||||||
|
await _ensure_np_key_unique(session, changes["np_api_key"], exclude_id=register.id)
|
||||||
|
register.np_api_key_enc = crypto.encrypt(changes["np_api_key"])
|
||||||
if "fiscal_number" in changes:
|
if "fiscal_number" in changes:
|
||||||
register.fiscal_number = changes["fiscal_number"]
|
register.fiscal_number = changes["fiscal_number"]
|
||||||
for field in ("name", "tax_codes", "is_active", "is_default"):
|
for field in ("name", "tax_codes", "is_active", "is_default"):
|
||||||
@@ -94,7 +125,7 @@ async def update_cash_register(
|
|||||||
user=actor,
|
user=actor,
|
||||||
entity_type="cash_register",
|
entity_type="cash_register",
|
||||||
entity_id=register.id,
|
entity_id=register.id,
|
||||||
payload=changes, # license_key / pin_code редактируются в audit._sanitize
|
payload=changes, # license_key / pin_code / np_api_key редактируются в audit._sanitize
|
||||||
request=request,
|
request=request,
|
||||||
)
|
)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|||||||
@@ -63,6 +63,8 @@ class Settings(BaseSettings):
|
|||||||
crm_sid: int = 1
|
crm_sid: int = 1
|
||||||
|
|
||||||
# --- Nova Poshta ---
|
# --- Nova Poshta ---
|
||||||
|
# Ключи НП хранятся у касс (`cash_registers.np_api_key_enc`). Эта переменная
|
||||||
|
# читается только миграцией 0008: переносит старый общий ключ в кассу по умолчанию.
|
||||||
nova_poshta_api_key: str = ""
|
nova_poshta_api_key: str = ""
|
||||||
|
|
||||||
# --- Checkbox ---
|
# --- Checkbox ---
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"""Кассы (ПРРО) Checkbox.
|
"""Кассы (ПРРО) Checkbox.
|
||||||
|
|
||||||
Ключ лицензии и PIN кассира хранятся только в зашифрованном виде
|
Ключ лицензии, PIN кассира и ключ API Новой Почты хранятся только в
|
||||||
(`app/core/crypto.py`): утечка дампа БД не должна давать доступ к кассе.
|
зашифрованном виде (`app/core/crypto.py`): утечка дампа БД не должна давать
|
||||||
|
доступ к кассе и кабинету отправителя.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -22,6 +23,10 @@ class CashRegister(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
fiscal_number: Mapped[str | None] = mapped_column(String(64))
|
fiscal_number: Mapped[str | None] = mapped_column(String(64))
|
||||||
license_key_enc: Mapped[str] = mapped_column(String(512), nullable=False)
|
license_key_enc: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||||
cashier_pin_enc: Mapped[str] = mapped_column(String(512), nullable=False)
|
cashier_pin_enc: Mapped[str] = mapped_column(String(512), nullable=False)
|
||||||
|
# Ключ API кабинета отправителя НП. Посылки, которые этот ключ видит как
|
||||||
|
# свои, привязываются к кассе (`orders.cash_register_id`), и ЕТТН-чеки по
|
||||||
|
# ним создаются от неё. Без ключа касса статусы ТТН не опрашивает.
|
||||||
|
np_api_key_enc: Mapped[str | None] = mapped_column(String(512))
|
||||||
# Коды налоговых ставок Checkbox для всех товаров чека; пусто — поле `tax`
|
# Коды налоговых ставок Checkbox для всех товаров чека; пусто — поле `tax`
|
||||||
# не передаётся (неплательщик ПДВ).
|
# не передаётся (неплательщик ПДВ).
|
||||||
tax_codes: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list)
|
tax_codes: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list)
|
||||||
|
|||||||
@@ -7,10 +7,11 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import BigInteger, Boolean, DateTime, String, Text
|
from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, String, Text
|
||||||
from sqlalchemy.dialects.postgresql import JSONB
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
@@ -54,3 +55,9 @@ class Order(TimestampMixin, Base):
|
|||||||
np_status_code: Mapped[str | None] = mapped_column(String(16))
|
np_status_code: Mapped[str | None] = mapped_column(String(16))
|
||||||
np_cod_amount_kopecks: Mapped[int | None] = mapped_column(BigInteger)
|
np_cod_amount_kopecks: Mapped[int | None] = mapped_column(BigInteger)
|
||||||
np_payment_status: Mapped[str | None] = mapped_column(String(32))
|
np_payment_status: Mapped[str | None] = mapped_column(String(32))
|
||||||
|
|
||||||
|
# Касса, чей ключ НП видит ТТН как свою (определяется при опросе статусов,
|
||||||
|
# см. `services/orders.sync_np_statuses`). От неё создаётся ЕТТН-чек.
|
||||||
|
cash_register_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||||
|
ForeignKey("cash_registers.id", ondelete="SET NULL"), index=True
|
||||||
|
)
|
||||||
|
|||||||
@@ -70,26 +70,34 @@ class ReceiptCreateResponse(BaseModel):
|
|||||||
# --- Кассы -------------------------------------------------------------------
|
# --- Кассы -------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _masked(encrypted: str) -> str:
|
||||||
|
try:
|
||||||
|
return crypto.mask(crypto.decrypt(encrypted))
|
||||||
|
except crypto.DecryptionError:
|
||||||
|
return "не расшифровывается — введите заново"
|
||||||
|
|
||||||
|
|
||||||
class CashRegisterOut(BaseModel):
|
class CashRegisterOut(BaseModel):
|
||||||
id: uuid.UUID
|
id: uuid.UUID
|
||||||
name: str
|
name: str
|
||||||
fiscal_number: str | None
|
fiscal_number: str | None
|
||||||
license_key_masked: str
|
license_key_masked: str
|
||||||
|
# None — ключ НП не задан: касса не опрашивает посылки и не получает заказы.
|
||||||
|
np_api_key_masked: str | None
|
||||||
tax_codes: list[Any]
|
tax_codes: list[Any]
|
||||||
is_active: bool
|
is_active: bool
|
||||||
is_default: bool
|
is_default: bool
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_register(cls, register: CashRegister) -> CashRegisterOut:
|
def from_register(cls, register: CashRegister) -> CashRegisterOut:
|
||||||
try:
|
|
||||||
masked = crypto.mask(crypto.decrypt(register.license_key_enc))
|
|
||||||
except crypto.DecryptionError:
|
|
||||||
masked = "не расшифровывается — введите заново"
|
|
||||||
return cls(
|
return cls(
|
||||||
id=register.id,
|
id=register.id,
|
||||||
name=register.name,
|
name=register.name,
|
||||||
fiscal_number=register.fiscal_number,
|
fiscal_number=register.fiscal_number,
|
||||||
license_key_masked=masked,
|
license_key_masked=_masked(register.license_key_enc),
|
||||||
|
np_api_key_masked=(
|
||||||
|
_masked(register.np_api_key_enc) if register.np_api_key_enc else None
|
||||||
|
),
|
||||||
tax_codes=register.tax_codes,
|
tax_codes=register.tax_codes,
|
||||||
is_active=register.is_active,
|
is_active=register.is_active,
|
||||||
is_default=register.is_default,
|
is_default=register.is_default,
|
||||||
@@ -101,6 +109,7 @@ class CashRegisterCreate(BaseModel):
|
|||||||
fiscal_number: str | None = Field(default=None, max_length=64)
|
fiscal_number: str | None = Field(default=None, max_length=64)
|
||||||
license_key: str = Field(min_length=1, max_length=255)
|
license_key: str = Field(min_length=1, max_length=255)
|
||||||
pin_code: str = Field(min_length=1, max_length=32)
|
pin_code: str = Field(min_length=1, max_length=32)
|
||||||
|
np_api_key: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
tax_codes: list[int | str] = Field(default_factory=list, max_length=2)
|
tax_codes: list[int | str] = Field(default_factory=list, max_length=2)
|
||||||
is_default: bool = True
|
is_default: bool = True
|
||||||
|
|
||||||
@@ -112,6 +121,7 @@ class CashRegisterUpdate(BaseModel):
|
|||||||
fiscal_number: str | None = Field(default=None, max_length=64)
|
fiscal_number: str | None = Field(default=None, max_length=64)
|
||||||
license_key: str | None = Field(default=None, min_length=1, max_length=255)
|
license_key: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
pin_code: str | None = Field(default=None, min_length=1, max_length=32)
|
pin_code: str | None = Field(default=None, min_length=1, max_length=32)
|
||||||
|
np_api_key: str | None = Field(default=None, min_length=1, max_length=255)
|
||||||
tax_codes: list[int | str] | None = Field(default=None, max_length=2)
|
tax_codes: list[int | str] | None = Field(default=None, max_length=2)
|
||||||
is_active: bool | None = None
|
is_active: bool | None = None
|
||||||
is_default: bool | None = None
|
is_default: bool | None = None
|
||||||
|
|||||||
@@ -32,9 +32,19 @@ class TrackingStatusOut(BaseModel):
|
|||||||
# какого-то типа накладной не возвращает `AfterpaymentOnGoodsCost`.
|
# какого-то типа накладной не возвращает `AfterpaymentOnGoodsCost`.
|
||||||
cod_amount: str | None = Field(default=None)
|
cod_amount: str | None = Field(default=None)
|
||||||
|
|
||||||
|
# Телефон отправителя NP отдаёт только кабинету отправителя (или получателя):
|
||||||
|
# по чужому ключу без телефона ответ урезан — нет ни отправителя, ни
|
||||||
|
# `AfterpaymentOnGoodsCost`. По этому полю определяется, чья это посылка.
|
||||||
|
phone_sender: str | None = Field(default=None, alias="PhoneSender")
|
||||||
|
|
||||||
# Статус оплаты аналогично раздвоен на `PaymentStatus`/`ExpressWaybillPaymentStatus`.
|
# Статус оплаты аналогично раздвоен на `PaymentStatus`/`ExpressWaybillPaymentStatus`.
|
||||||
payment_status: str | None = Field(default=None)
|
payment_status: str | None = Field(default=None)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_own(self) -> bool:
|
||||||
|
"""ТТН видна ключу как своя — отправлена из кабинета этого ключа."""
|
||||||
|
return bool(self.phone_sender)
|
||||||
|
|
||||||
@model_validator(mode="before")
|
@model_validator(mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _fold_express_fields(cls, data: Any) -> Any:
|
def _fold_express_fields(cls, data: Any) -> Any:
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ _REDACTED_KEYS = {
|
|||||||
"secret",
|
"secret",
|
||||||
"license_key",
|
"license_key",
|
||||||
"api_key",
|
"api_key",
|
||||||
|
"np_api_key",
|
||||||
"pin",
|
"pin",
|
||||||
"pin_code",
|
"pin_code",
|
||||||
"cashier_pin",
|
"cashier_pin",
|
||||||
|
|||||||
@@ -15,4 +15,8 @@ class NovaPoshtaError(Exception):
|
|||||||
|
|
||||||
|
|
||||||
class NovaPoshtaClient(Protocol):
|
class NovaPoshtaClient(Protocol):
|
||||||
async def get_statuses(self, *, waybill_numbers: list[str]) -> list[TrackingStatusOut]: ...
|
"""Ключ API передаётся в каждый вызов: у каждой кассы свой кабинет отправителя."""
|
||||||
|
|
||||||
|
async def get_statuses(
|
||||||
|
self, *, api_key: str, waybill_numbers: list[str]
|
||||||
|
) -> list[TrackingStatusOut]: ...
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.core.config import Settings
|
|
||||||
from app.schemas.tracking import TrackingStatusOut
|
from app.schemas.tracking import TrackingStatusOut
|
||||||
from app.services.nova_poshta.client import NovaPoshtaError
|
from app.services.nova_poshta.client import NovaPoshtaError
|
||||||
|
|
||||||
@@ -15,10 +14,9 @@ _MAX_DOCUMENTS_PER_REQUEST = 100
|
|||||||
|
|
||||||
|
|
||||||
class NpTrackingClient:
|
class NpTrackingClient:
|
||||||
def __init__(self, settings: Settings) -> None:
|
async def get_statuses(
|
||||||
self._api_key = settings.nova_poshta_api_key
|
self, *, api_key: str, waybill_numbers: list[str]
|
||||||
|
) -> list[TrackingStatusOut]:
|
||||||
async def get_statuses(self, *, waybill_numbers: list[str]) -> list[TrackingStatusOut]:
|
|
||||||
if not waybill_numbers:
|
if not waybill_numbers:
|
||||||
return []
|
return []
|
||||||
if len(waybill_numbers) > _MAX_DOCUMENTS_PER_REQUEST:
|
if len(waybill_numbers) > _MAX_DOCUMENTS_PER_REQUEST:
|
||||||
@@ -28,7 +26,7 @@ class NpTrackingClient:
|
|||||||
)
|
)
|
||||||
|
|
||||||
body = {
|
body = {
|
||||||
"apiKey": self._api_key,
|
"apiKey": api_key,
|
||||||
"modelName": "TrackingDocument",
|
"modelName": "TrackingDocument",
|
||||||
"calledMethod": "getStatusDocuments",
|
"calledMethod": "getStatusDocuments",
|
||||||
"methodProperties": {
|
"methodProperties": {
|
||||||
|
|||||||
@@ -15,17 +15,32 @@ _FIXTURE_STATUSES: dict[str, dict] = {
|
|||||||
"AmountToPay": "1200.00",
|
"AmountToPay": "1200.00",
|
||||||
"AfterpaymentOnGoodsCost": 1200,
|
"AfterpaymentOnGoodsCost": 1200,
|
||||||
"PaymentStatus": "Paid",
|
"PaymentStatus": "Paid",
|
||||||
|
"PhoneSender": "380501112233",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class StubNovaPoshtaClient:
|
class StubNovaPoshtaClient:
|
||||||
def __init__(self, statuses: dict[str, dict] | None = None) -> None:
|
"""`statuses` — ответы NP по ТТН; `by_key` — отдельные ответы для конкретных
|
||||||
self._statuses = statuses if statuses is not None else _FIXTURE_STATUSES
|
ключей API (кабинет отправителя видит свои ТТН полностью, чужие — урезанно)."""
|
||||||
|
|
||||||
async def get_statuses(self, *, waybill_numbers: list[str]) -> list[TrackingStatusOut]:
|
def __init__(
|
||||||
|
self,
|
||||||
|
statuses: dict[str, dict] | None = None,
|
||||||
|
*,
|
||||||
|
by_key: dict[str, dict[str, dict]] | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._statuses = statuses if statuses is not None else _FIXTURE_STATUSES
|
||||||
|
self._by_key = by_key or {}
|
||||||
|
self.calls: list[tuple[str, list[str]]] = []
|
||||||
|
|
||||||
|
async def get_statuses(
|
||||||
|
self, *, api_key: str, waybill_numbers: list[str]
|
||||||
|
) -> list[TrackingStatusOut]:
|
||||||
|
self.calls.append((api_key, list(waybill_numbers)))
|
||||||
|
statuses = self._by_key.get(api_key, self._statuses)
|
||||||
return [
|
return [
|
||||||
TrackingStatusOut.model_validate(self._statuses[number])
|
TrackingStatusOut.model_validate(statuses[number])
|
||||||
for number in waybill_numbers
|
for number in waybill_numbers
|
||||||
if number in self._statuses
|
if number in statuses
|
||||||
]
|
]
|
||||||
|
|||||||
+126
-15
@@ -4,17 +4,25 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import enum
|
import enum
|
||||||
import uuid
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from decimal import ROUND_HALF_UP, Decimal
|
from decimal import ROUND_HALF_UP, Decimal
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core import crypto
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
from app.db.models.cash_register import CashRegister
|
||||||
from app.db.models.order import Order
|
from app.db.models.order import Order
|
||||||
from app.schemas.orders import OrderUpdateIn
|
from app.schemas.orders import OrderUpdateIn
|
||||||
|
from app.schemas.tracking import TrackingStatusOut
|
||||||
from app.services.crm.client import CrmClient
|
from app.services.crm.client import CrmClient
|
||||||
from app.services.nova_poshta.client import NovaPoshtaClient
|
from app.services.nova_poshta.client import NovaPoshtaClient, NovaPoshtaError
|
||||||
|
|
||||||
|
log = get_logger(__name__)
|
||||||
|
|
||||||
_CRM_STATUS = "APPROVED"
|
_CRM_STATUS = "APPROVED"
|
||||||
|
|
||||||
@@ -143,8 +151,60 @@ async def cod_in_transit(session: AsyncSession) -> tuple[int, int]:
|
|||||||
return count, total
|
return count, total
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _NpAccount:
|
||||||
|
"""Кабинет отправителя НП, привязанный к кассе."""
|
||||||
|
|
||||||
|
register_id: uuid.UUID
|
||||||
|
api_key: str
|
||||||
|
|
||||||
|
|
||||||
|
async def _np_accounts(session: AsyncSession) -> list[_NpAccount]:
|
||||||
|
"""Активные кассы с ключом НП; касса по умолчанию — первой."""
|
||||||
|
registers = await session.scalars(
|
||||||
|
select(CashRegister)
|
||||||
|
.where(CashRegister.is_active.is_(True))
|
||||||
|
.where(CashRegister.np_api_key_enc.is_not(None))
|
||||||
|
.order_by(CashRegister.is_default.desc(), CashRegister.created_at)
|
||||||
|
)
|
||||||
|
accounts = []
|
||||||
|
for register in registers:
|
||||||
|
try:
|
||||||
|
accounts.append(_NpAccount(register.id, crypto.decrypt(register.np_api_key_enc)))
|
||||||
|
except crypto.DecryptionError:
|
||||||
|
log.warning("np_api_key_undecryptable", cash_register_id=str(register.id))
|
||||||
|
return accounts
|
||||||
|
|
||||||
|
|
||||||
|
async def _fetch_statuses(
|
||||||
|
np: NovaPoshtaClient, api_key: str, waybill_numbers: list[str]
|
||||||
|
) -> dict[str, TrackingStatusOut]:
|
||||||
|
statuses: dict[str, TrackingStatusOut] = {}
|
||||||
|
for i in range(0, len(waybill_numbers), _NP_BATCH_SIZE):
|
||||||
|
batch = waybill_numbers[i : i + _NP_BATCH_SIZE]
|
||||||
|
for tracking_status in await np.get_statuses(api_key=api_key, waybill_numbers=batch):
|
||||||
|
statuses[tracking_status.number] = tracking_status
|
||||||
|
return statuses
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_status(order: Order, tracking_status: TrackingStatusOut) -> None:
|
||||||
|
order.np_status = tracking_status.status
|
||||||
|
order.np_status_code = tracking_status.status_code
|
||||||
|
order.np_cod_amount_kopecks = (
|
||||||
|
_to_kopecks(tracking_status.cod_amount) if tracking_status.cod_amount else None
|
||||||
|
)
|
||||||
|
order.np_payment_status = tracking_status.payment_status
|
||||||
|
|
||||||
|
|
||||||
async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None:
|
async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None:
|
||||||
"""Обновляет статус ТТН, сумму и статус оплаты наложки по заказам в пути.
|
"""Обновляет статус ТТН, сумму и статус оплаты наложки по заказам в пути
|
||||||
|
и привязывает каждый заказ к кассе, чей ключ НП видит ТТН как свою.
|
||||||
|
|
||||||
|
Заказ, уже привязанный к кассе, опрашивается её ключом. Непривязанный (или
|
||||||
|
переставший быть «своим» для своей кассы) — ключами всех касс по очереди:
|
||||||
|
первая, для которой ТТН своя (`TrackingStatusOut.is_own`), его забирает.
|
||||||
|
Если ТТН не своя ни для одной кассы, статус доставки всё равно сохраняется,
|
||||||
|
но без наложки и без кассы — чек по такой посылке не создать.
|
||||||
|
|
||||||
Опрашиваются и заказы с уже созданным ЕТТН-чеком: именно после создания
|
Опрашиваются и заказы с уже созданным ЕТТН-чеком: именно после создания
|
||||||
чека посылка доезжает и оплачивается, и этот переход кассир должен видеть.
|
чека посылка доезжает и оплачивается, и этот переход кассир должен видеть.
|
||||||
@@ -154,6 +214,11 @@ async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None:
|
|||||||
HTTP-запроса: опрос статусов не должен зависеть от того, открыт ли сейчас
|
HTTP-запроса: опрос статусов не должен зависеть от того, открыт ли сейчас
|
||||||
дашборд.
|
дашборд.
|
||||||
"""
|
"""
|
||||||
|
accounts = await _np_accounts(session)
|
||||||
|
if not accounts:
|
||||||
|
log.warning("np_no_api_keys", hint="Укажите ключ API Новой Почты у кассы")
|
||||||
|
return
|
||||||
|
|
||||||
orders = await session.scalars(
|
orders = await session.scalars(
|
||||||
select(Order)
|
select(Order)
|
||||||
.where(Order.is_deleted.is_(False))
|
.where(Order.is_deleted.is_(False))
|
||||||
@@ -167,20 +232,65 @@ async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None:
|
|||||||
if not orders_by_waybill:
|
if not orders_by_waybill:
|
||||||
return
|
return
|
||||||
|
|
||||||
waybill_numbers = list(orders_by_waybill)
|
account_ids = {account.register_id for account in accounts}
|
||||||
for i in range(0, len(waybill_numbers), _NP_BATCH_SIZE):
|
unresolved = {
|
||||||
batch = waybill_numbers[i : i + _NP_BATCH_SIZE]
|
waybill: order
|
||||||
statuses = await np.get_statuses(waybill_numbers=batch)
|
for waybill, order in orders_by_waybill.items()
|
||||||
for tracking_status in statuses:
|
if order.cash_register_id not in account_ids
|
||||||
order = orders_by_waybill.get(tracking_status.number)
|
}
|
||||||
|
# Урезанный ответ по чужому ключу — на случай, если ТТН не своя ни для кого.
|
||||||
|
foreign: dict[str, TrackingStatusOut] = {}
|
||||||
|
complete = True
|
||||||
|
|
||||||
|
for account in accounts:
|
||||||
|
bound = {
|
||||||
|
waybill: order
|
||||||
|
for waybill, order in orders_by_waybill.items()
|
||||||
|
if order.cash_register_id == account.register_id
|
||||||
|
}
|
||||||
|
if not bound:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
statuses = await _fetch_statuses(np, account.api_key, list(bound))
|
||||||
|
except (NovaPoshtaError, httpx.HTTPError) as exc:
|
||||||
|
# Заказы кассы остаются как есть до следующего опроса.
|
||||||
|
log.warning("np_poll_failed", cash_register_id=str(account.register_id), error=str(exc))
|
||||||
|
continue
|
||||||
|
for waybill, order in bound.items():
|
||||||
|
tracking_status = statuses.get(waybill)
|
||||||
|
if tracking_status is not None and tracking_status.is_own:
|
||||||
|
_apply_status(order, tracking_status)
|
||||||
|
else:
|
||||||
|
unresolved[waybill] = order
|
||||||
|
if tracking_status is not None:
|
||||||
|
foreign[waybill] = tracking_status
|
||||||
|
|
||||||
|
for account in accounts:
|
||||||
|
if not unresolved:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
statuses = await _fetch_statuses(np, account.api_key, list(unresolved))
|
||||||
|
except (NovaPoshtaError, httpx.HTTPError) as exc:
|
||||||
|
log.warning("np_poll_failed", cash_register_id=str(account.register_id), error=str(exc))
|
||||||
|
complete = False
|
||||||
|
continue
|
||||||
|
for waybill, tracking_status in statuses.items():
|
||||||
|
order = unresolved.get(waybill)
|
||||||
if order is None:
|
if order is None:
|
||||||
continue
|
continue
|
||||||
order.np_status = tracking_status.status
|
if tracking_status.is_own:
|
||||||
order.np_status_code = tracking_status.status_code
|
order.cash_register_id = account.register_id
|
||||||
order.np_cod_amount_kopecks = (
|
_apply_status(order, tracking_status)
|
||||||
_to_kopecks(tracking_status.cod_amount) if tracking_status.cod_amount else None
|
del unresolved[waybill]
|
||||||
)
|
else:
|
||||||
order.np_payment_status = tracking_status.payment_status
|
foreign.setdefault(waybill, tracking_status)
|
||||||
|
|
||||||
|
# Если какой-то ключ не ответил, не отвязываем: ТТН могла быть его.
|
||||||
|
if complete:
|
||||||
|
for waybill, order in unresolved.items():
|
||||||
|
order.cash_register_id = None
|
||||||
|
if waybill in foreign:
|
||||||
|
_apply_status(order, foreign[waybill])
|
||||||
|
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
@@ -268,7 +378,8 @@ async def update_order(
|
|||||||
return order, changed
|
return order, changed
|
||||||
|
|
||||||
if "waybill_number" in changed:
|
if "waybill_number" in changed:
|
||||||
# Статус старой ТТН к новой не относится — worker опросит новую за минуту.
|
# Статус и касса старой ТТН к новой не относятся — worker опросит новую за минуту.
|
||||||
|
order.cash_register_id = None
|
||||||
order.np_status = None
|
order.np_status = None
|
||||||
order.np_status_code = None
|
order.np_status_code = None
|
||||||
order.np_cod_amount_kopecks = None
|
order.np_cod_amount_kopecks = None
|
||||||
|
|||||||
@@ -261,13 +261,11 @@ def credentials(register: CashRegister) -> CheckboxCredentials:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def get_default_register(session: AsyncSession) -> CashRegister | None:
|
async def _active_registers(session: AsyncSession) -> dict[uuid.UUID, CashRegister]:
|
||||||
return await session.scalar(
|
registers = await session.scalars(
|
||||||
select(CashRegister)
|
select(CashRegister).where(CashRegister.is_active.is_(True))
|
||||||
.where(CashRegister.is_active.is_(True))
|
|
||||||
.order_by(CashRegister.is_default.desc(), CashRegister.created_at)
|
|
||||||
.limit(1)
|
|
||||||
)
|
)
|
||||||
|
return {register.id: register for register in registers}
|
||||||
|
|
||||||
|
|
||||||
# --- Создание ----------------------------------------------------------------
|
# --- Создание ----------------------------------------------------------------
|
||||||
@@ -300,10 +298,13 @@ async def request_receipts(
|
|||||||
Без commit и без обращения к Checkbox: вызывающий код коммитит и ставит
|
Без commit и без обращения к Checkbox: вызывающий код коммитит и ставит
|
||||||
задачи worker'у (`create_ettn_for_receipt`) по `result.created`.
|
задачи worker'у (`create_ettn_for_receipt`) по `result.created`.
|
||||||
Ошибки по отдельным заказам не мешают остальным — массовое действие.
|
Ошибки по отдельным заказам не мешают остальным — массовое действие.
|
||||||
|
|
||||||
|
Чек создаётся от кассы, чей ключ НП видит ТТН заказа как свою
|
||||||
|
(`orders.cash_register_id`, проставляет `orders.sync_np_statuses`).
|
||||||
"""
|
"""
|
||||||
result = RequestResult(created=[], errors={})
|
result = RequestResult(created=[], errors={})
|
||||||
register = await get_default_register(session)
|
registers = await _active_registers(session)
|
||||||
if register is None:
|
if not registers:
|
||||||
for order_id, _ in items:
|
for order_id, _ in items:
|
||||||
result.errors[order_id] = "Не настроена касса Checkbox"
|
result.errors[order_id] = "Не настроена касса Checkbox"
|
||||||
return result
|
return result
|
||||||
@@ -323,6 +324,13 @@ async def request_receipts(
|
|||||||
if order_id in busy:
|
if order_id in busy:
|
||||||
result.errors[order_id] = "По заказу уже есть чек"
|
result.errors[order_id] = "По заказу уже есть чек"
|
||||||
continue
|
continue
|
||||||
|
register = registers.get(order.cash_register_id) if order.cash_register_id else None
|
||||||
|
if register is None:
|
||||||
|
result.errors[order_id] = (
|
||||||
|
"Касса не определена: ТТН не найдена ни одним ключом Новой Почты "
|
||||||
|
"активных касс"
|
||||||
|
)
|
||||||
|
continue
|
||||||
try:
|
try:
|
||||||
amounts = resolve_amounts(order, prepayment)
|
amounts = resolve_amounts(order, prepayment)
|
||||||
body = build_ettn_body(order, register, amounts)
|
body = build_ettn_body(order, register, amounts)
|
||||||
@@ -359,6 +367,7 @@ async def request_receipts(
|
|||||||
payload={
|
payload={
|
||||||
"order_id": order.id,
|
"order_id": order.id,
|
||||||
"waybill_number": order.waybill_number,
|
"waybill_number": order.waybill_number,
|
||||||
|
"cash_register_id": str(register.id),
|
||||||
"cod_kopecks": amounts.cod_kopecks,
|
"cod_kopecks": amounts.cod_kopecks,
|
||||||
"prepayment_kopecks": amounts.prepayment_kopecks,
|
"prepayment_kopecks": amounts.prepayment_kopecks,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Запускается отдельным процессом: `arq app.worker.WorkerSettings`.
|
Запускается отдельным процессом: `arq app.worker.WorkerSettings`.
|
||||||
- `create_ettn_receipt` — задача, которую ставит API после запроса кассира;
|
- `create_ettn_receipt` — задача, которую ставит API после запроса кассира;
|
||||||
- `poll_np_statuses` — раз в минуту статусы ТТН по заказам без чека;
|
- `poll_np_statuses` — раз в минуту статусы ТТН по ключам НП касс и привязка заказов к кассам;
|
||||||
- `poll_receipts` — раз в минуту повтор зависших `pending`, статусы `created`-чеков
|
- `poll_receipts` — раз в минуту повтор зависших `pending`, статусы `created`-чеков
|
||||||
и повтор смены статуса заказа в CRM (PACKED), если CRM была недоступна.
|
и повтор смены статуса заказа в CRM (PACKED), если CRM была недоступна.
|
||||||
"""
|
"""
|
||||||
@@ -33,7 +33,7 @@ _CREATE_ETTN_MAX_TRIES = 6
|
|||||||
|
|
||||||
async def startup(ctx: dict[str, Any]) -> None:
|
async def startup(ctx: dict[str, Any]) -> None:
|
||||||
configure_logging()
|
configure_logging()
|
||||||
ctx["np_client"] = NpTrackingClient(settings)
|
ctx["np_client"] = NpTrackingClient()
|
||||||
ctx["checkbox_client"] = get_checkbox_client()
|
ctx["checkbox_client"] = get_checkbox_client()
|
||||||
ctx["crm_client"] = ExoCrmClient(settings)
|
ctx["crm_client"] = ExoCrmClient(settings)
|
||||||
log.info("worker_starting", environment=settings.environment)
|
log.info("worker_starting", environment=settings.environment)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import pytest
|
|||||||
import respx
|
import respx
|
||||||
from httpx import Response
|
from httpx import Response
|
||||||
|
|
||||||
from app.core.config import Settings
|
from app.schemas.tracking import TrackingStatusOut
|
||||||
from app.services.nova_poshta.client import NovaPoshtaError
|
from app.services.nova_poshta.client import NovaPoshtaError
|
||||||
from app.services.nova_poshta.np_client import _API_URL, NpTrackingClient
|
from app.services.nova_poshta.np_client import _API_URL, NpTrackingClient
|
||||||
|
|
||||||
@@ -27,12 +27,7 @@ STATUS_PAYLOAD = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _settings() -> Settings:
|
API_KEY = "np-apikey-123"
|
||||||
return Settings(
|
|
||||||
secret_key="test-secret-key",
|
|
||||||
encryption_key="dGVzdC1lbmNyeXB0aW9uLWtleS0zMi1ieXRlcyEh",
|
|
||||||
nova_poshta_api_key="np-apikey-123",
|
|
||||||
) # type: ignore[arg-type]
|
|
||||||
|
|
||||||
|
|
||||||
class TestNpTrackingClientGetStatuses:
|
class TestNpTrackingClientGetStatuses:
|
||||||
@@ -41,13 +36,13 @@ class TestNpTrackingClientGetStatuses:
|
|||||||
route = respx.post(_API_URL).mock(
|
route = respx.post(_API_URL).mock(
|
||||||
return_value=Response(200, json={"success": True, "data": [], "errors": []})
|
return_value=Response(200, json={"success": True, "data": [], "errors": []})
|
||||||
)
|
)
|
||||||
client = NpTrackingClient(_settings())
|
client = NpTrackingClient()
|
||||||
|
|
||||||
await client.get_statuses(waybill_numbers=["20451540916703"])
|
await client.get_statuses(api_key=API_KEY, waybill_numbers=["20451540916703"])
|
||||||
|
|
||||||
sent = route.calls.last.request
|
sent = route.calls.last.request
|
||||||
body = json.loads(sent.content)
|
body = json.loads(sent.content)
|
||||||
assert body["apiKey"] == "np-apikey-123"
|
assert body["apiKey"] == API_KEY
|
||||||
assert body["modelName"] == "TrackingDocument"
|
assert body["modelName"] == "TrackingDocument"
|
||||||
assert body["calledMethod"] == "getStatusDocuments"
|
assert body["calledMethod"] == "getStatusDocuments"
|
||||||
assert body["methodProperties"]["Documents"] == [
|
assert body["methodProperties"]["Documents"] == [
|
||||||
@@ -63,9 +58,9 @@ class TestNpTrackingClientGetStatuses:
|
|||||||
200, json={"success": True, "data": [STATUS_PAYLOAD], "errors": []}
|
200, json={"success": True, "data": [STATUS_PAYLOAD], "errors": []}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
client = NpTrackingClient(_settings())
|
client = NpTrackingClient()
|
||||||
|
|
||||||
statuses = await client.get_statuses(waybill_numbers=["20451540916703"])
|
statuses = await client.get_statuses(api_key=API_KEY, waybill_numbers=["20451540916703"])
|
||||||
|
|
||||||
assert len(statuses) == 1
|
assert len(statuses) == 1
|
||||||
status = statuses[0]
|
status = statuses[0]
|
||||||
@@ -75,6 +70,13 @@ class TestNpTrackingClientGetStatuses:
|
|||||||
# не "сколько заплатить сейчас" (`ExpressWaybillAmountToPay` = 827.48).
|
# не "сколько заплатить сейчас" (`ExpressWaybillAmountToPay` = 827.48).
|
||||||
assert status.cod_amount == "699"
|
assert status.cod_amount == "699"
|
||||||
assert status.payment_status == "NeedPayment"
|
assert status.payment_status == "NeedPayment"
|
||||||
|
assert not status.is_own # в STATUS_PAYLOAD нет PhoneSender
|
||||||
|
|
||||||
|
def test_is_own_by_sender_phone(self) -> None:
|
||||||
|
own = TrackingStatusOut.model_validate({**STATUS_PAYLOAD, "PhoneSender": "380961112233"})
|
||||||
|
foreign = TrackingStatusOut.model_validate({**STATUS_PAYLOAD, "PhoneSender": ""})
|
||||||
|
assert own.is_own
|
||||||
|
assert not foreign.is_own
|
||||||
|
|
||||||
@respx.mock
|
@respx.mock
|
||||||
async def test_falls_back_to_amount_to_pay_when_afterpayment_missing(self) -> None:
|
async def test_falls_back_to_amount_to_pay_when_afterpayment_missing(self) -> None:
|
||||||
@@ -82,9 +84,9 @@ class TestNpTrackingClientGetStatuses:
|
|||||||
respx.post(_API_URL).mock(
|
respx.post(_API_URL).mock(
|
||||||
return_value=Response(200, json={"success": True, "data": [payload], "errors": []})
|
return_value=Response(200, json={"success": True, "data": [payload], "errors": []})
|
||||||
)
|
)
|
||||||
client = NpTrackingClient(_settings())
|
client = NpTrackingClient()
|
||||||
|
|
||||||
statuses = await client.get_statuses(waybill_numbers=["20451540916703"])
|
statuses = await client.get_statuses(api_key=API_KEY, waybill_numbers=["20451540916703"])
|
||||||
|
|
||||||
assert statuses[0].cod_amount == "827.48"
|
assert statuses[0].cod_amount == "827.48"
|
||||||
|
|
||||||
@@ -95,18 +97,20 @@ class TestNpTrackingClientGetStatuses:
|
|||||||
200, json={"success": False, "data": [], "errors": ["Invalid apiKey"]}
|
200, json={"success": False, "data": [], "errors": ["Invalid apiKey"]}
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
client = NpTrackingClient(_settings())
|
client = NpTrackingClient()
|
||||||
|
|
||||||
with pytest.raises(NovaPoshtaError, match="Invalid apiKey"):
|
with pytest.raises(NovaPoshtaError, match="Invalid apiKey"):
|
||||||
await client.get_statuses(waybill_numbers=["20451540916703"])
|
await client.get_statuses(api_key=API_KEY, waybill_numbers=["20451540916703"])
|
||||||
|
|
||||||
async def test_returns_empty_list_for_no_documents(self) -> None:
|
async def test_returns_empty_list_for_no_documents(self) -> None:
|
||||||
client = NpTrackingClient(_settings())
|
client = NpTrackingClient()
|
||||||
|
|
||||||
assert await client.get_statuses(waybill_numbers=[]) == []
|
assert await client.get_statuses(api_key=API_KEY, waybill_numbers=[]) == []
|
||||||
|
|
||||||
async def test_rejects_too_many_documents(self) -> None:
|
async def test_rejects_too_many_documents(self) -> None:
|
||||||
client = NpTrackingClient(_settings())
|
client = NpTrackingClient()
|
||||||
|
|
||||||
with pytest.raises(NovaPoshtaError, match="Слишком много"):
|
with pytest.raises(NovaPoshtaError, match="Слишком много"):
|
||||||
await client.get_statuses(waybill_numbers=[str(i) for i in range(101)])
|
await client.get_statuses(
|
||||||
|
api_key=API_KEY, waybill_numbers=[str(i) for i in range(101)]
|
||||||
|
)
|
||||||
|
|||||||
@@ -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.cash_register import CashRegister
|
||||||
from app.db.models.order import Order
|
from app.db.models.order import Order
|
||||||
from app.db.models.receipt import Receipt, ReceiptStatus
|
from app.db.models.receipt import Receipt, ReceiptStatus
|
||||||
|
from app.db.models.user import User
|
||||||
from app.schemas.checkbox import EttnStatus
|
from app.schemas.checkbox import EttnStatus
|
||||||
from app.services import receipts as svc
|
from app.services import receipts as svc
|
||||||
from app.services.checkbox.client import CheckboxRateLimitedError, CheckboxUnavailableError
|
from app.services.checkbox.client import CheckboxRateLimitedError, CheckboxUnavailableError
|
||||||
@@ -182,6 +183,52 @@ class TestBuildBody:
|
|||||||
assert "delivery" not in body["receipt_body"]
|
assert "delivery" not in body["receipt_body"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestRequestReceipts:
|
||||||
|
"""Чек создаётся от кассы, к которой опрос НП привязал заказ."""
|
||||||
|
|
||||||
|
class Session:
|
||||||
|
def __init__(self, registers: list[CashRegister], orders: list[Order]) -> None:
|
||||||
|
self.registers, self.orders, self.added = registers, orders, []
|
||||||
|
|
||||||
|
async def scalars(self, statement: Any) -> list[Any]:
|
||||||
|
entity = statement.column_descriptions[0]["entity"]
|
||||||
|
if entity is CashRegister:
|
||||||
|
return self.registers
|
||||||
|
if entity is Order:
|
||||||
|
return self.orders
|
||||||
|
return [] # активные чеки заказов
|
||||||
|
|
||||||
|
def add(self, obj: Any) -> None:
|
||||||
|
self.added.append(obj)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _user() -> User:
|
||||||
|
return User(id=uuid.uuid4(), email="c@x.ua")
|
||||||
|
|
||||||
|
async def test_uses_order_register(self) -> None:
|
||||||
|
default, other = _register(), _register()
|
||||||
|
order = _order(cash_register_id=other.id)
|
||||||
|
session = self.Session([default, other], [order])
|
||||||
|
|
||||||
|
result = await svc.request_receipts(
|
||||||
|
session, [(order.id, None)], user=self._user() # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.errors == {}
|
||||||
|
assert result.created[0].cash_register_id == other.id
|
||||||
|
|
||||||
|
async def test_unbound_order_is_rejected(self) -> None:
|
||||||
|
order = _order(cash_register_id=None)
|
||||||
|
session = self.Session([_register()], [order])
|
||||||
|
|
||||||
|
result = await svc.request_receipts(
|
||||||
|
session, [(order.id, None)], user=self._user() # type: ignore[arg-type]
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.created == []
|
||||||
|
assert "Касса не определена" in result.errors[order.id]
|
||||||
|
|
||||||
|
|
||||||
class FakeSession:
|
class FakeSession:
|
||||||
def __init__(self, *objects: Any) -> None:
|
def __init__(self, *objects: Any) -> None:
|
||||||
self.objects = {(type(o), o.id): o for o in objects}
|
self.objects = {(type(o), o.id): o for o in objects}
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ export interface CashRegister {
|
|||||||
name: string
|
name: string
|
||||||
fiscal_number: string | null
|
fiscal_number: string | null
|
||||||
license_key_masked: string
|
license_key_masked: string
|
||||||
|
/** null — ключ Новой Почты не задан: касса не получает посылки. */
|
||||||
|
np_api_key_masked: string | null
|
||||||
tax_codes: (number | string)[]
|
tax_codes: (number | string)[]
|
||||||
is_active: boolean
|
is_active: boolean
|
||||||
is_default: boolean
|
is_default: boolean
|
||||||
@@ -15,6 +17,7 @@ export interface CashRegisterCreate {
|
|||||||
fiscal_number: string | null
|
fiscal_number: string | null
|
||||||
license_key: string
|
license_key: string
|
||||||
pin_code: string
|
pin_code: string
|
||||||
|
np_api_key: string | null
|
||||||
tax_codes: (number | string)[]
|
tax_codes: (number | string)[]
|
||||||
is_default: boolean
|
is_default: boolean
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,3 +44,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cr-missing {
|
||||||
|
color: var(--color-danger);
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ interface FormState {
|
|||||||
fiscal_number: string
|
fiscal_number: string
|
||||||
license_key: string
|
license_key: string
|
||||||
pin_code: string
|
pin_code: string
|
||||||
|
np_api_key: string
|
||||||
tax_codes: string
|
tax_codes: string
|
||||||
is_default: boolean
|
is_default: boolean
|
||||||
}
|
}
|
||||||
@@ -28,6 +29,7 @@ const EMPTY_FORM: FormState = {
|
|||||||
fiscal_number: '',
|
fiscal_number: '',
|
||||||
license_key: '',
|
license_key: '',
|
||||||
pin_code: '',
|
pin_code: '',
|
||||||
|
np_api_key: '',
|
||||||
tax_codes: '',
|
tax_codes: '',
|
||||||
is_default: true,
|
is_default: true,
|
||||||
}
|
}
|
||||||
@@ -75,6 +77,7 @@ export function CashRegistersPage() {
|
|||||||
fiscal_number: register.fiscal_number ?? '',
|
fiscal_number: register.fiscal_number ?? '',
|
||||||
license_key: '',
|
license_key: '',
|
||||||
pin_code: '',
|
pin_code: '',
|
||||||
|
np_api_key: '',
|
||||||
tax_codes: register.tax_codes.join(', '),
|
tax_codes: register.tax_codes.join(', '),
|
||||||
is_default: register.is_default,
|
is_default: register.is_default,
|
||||||
})
|
})
|
||||||
@@ -88,7 +91,7 @@ export function CashRegistersPage() {
|
|||||||
async function handleSubmit(event: FormEvent) {
|
async function handleSubmit(event: FormEvent) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
if (editingId) {
|
if (editingId) {
|
||||||
// Пустые ключ/PIN при редактировании — «не менять».
|
// Пустые ключи/PIN при редактировании — «не менять».
|
||||||
const payload: CashRegisterUpdate = {
|
const payload: CashRegisterUpdate = {
|
||||||
name: form.name,
|
name: form.name,
|
||||||
fiscal_number: form.fiscal_number || null,
|
fiscal_number: form.fiscal_number || null,
|
||||||
@@ -97,6 +100,7 @@ export function CashRegistersPage() {
|
|||||||
}
|
}
|
||||||
if (form.license_key) payload.license_key = form.license_key
|
if (form.license_key) payload.license_key = form.license_key
|
||||||
if (form.pin_code) payload.pin_code = form.pin_code
|
if (form.pin_code) payload.pin_code = form.pin_code
|
||||||
|
if (form.np_api_key) payload.np_api_key = form.np_api_key
|
||||||
await run(() => updateCashRegister(editingId, payload), 'Касса обновлена')
|
await run(() => updateCashRegister(editingId, payload), 'Касса обновлена')
|
||||||
} else {
|
} else {
|
||||||
await run(
|
await run(
|
||||||
@@ -106,6 +110,7 @@ export function CashRegistersPage() {
|
|||||||
fiscal_number: form.fiscal_number || null,
|
fiscal_number: form.fiscal_number || null,
|
||||||
license_key: form.license_key,
|
license_key: form.license_key,
|
||||||
pin_code: form.pin_code,
|
pin_code: form.pin_code,
|
||||||
|
np_api_key: form.np_api_key,
|
||||||
tax_codes: parseTaxCodes(form.tax_codes),
|
tax_codes: parseTaxCodes(form.tax_codes),
|
||||||
is_default: form.is_default,
|
is_default: form.is_default,
|
||||||
}),
|
}),
|
||||||
@@ -145,6 +150,7 @@ export function CashRegistersPage() {
|
|||||||
<th>Название</th>
|
<th>Название</th>
|
||||||
<th>Фиск. номер</th>
|
<th>Фиск. номер</th>
|
||||||
<th>Ключ лицензии</th>
|
<th>Ключ лицензии</th>
|
||||||
|
<th>Ключ Новой Почты</th>
|
||||||
<th>Налоги</th>
|
<th>Налоги</th>
|
||||||
<th>Статус</th>
|
<th>Статус</th>
|
||||||
<th>Действия</th>
|
<th>Действия</th>
|
||||||
@@ -153,21 +159,21 @@ export function CashRegistersPage() {
|
|||||||
<tbody>
|
<tbody>
|
||||||
{isLoading && (
|
{isLoading && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="orders-empty">
|
<td colSpan={7} className="orders-empty">
|
||||||
Загрузка…
|
Загрузка…
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{isError && (
|
{isError && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="orders-empty">
|
<td colSpan={7} className="orders-empty">
|
||||||
Не удалось загрузить кассы
|
Не удалось загрузить кассы
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
{registers?.length === 0 && (
|
{registers?.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="orders-empty">
|
<td colSpan={7} className="orders-empty">
|
||||||
Кассы не настроены — чеки создавать нельзя
|
Кассы не настроены — чеки создавать нельзя
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -180,6 +186,7 @@ export function CashRegistersPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td>{register.fiscal_number || '—'}</td>
|
<td>{register.fiscal_number || '—'}</td>
|
||||||
<td>{register.license_key_masked}</td>
|
<td>{register.license_key_masked}</td>
|
||||||
|
<td>{register.np_api_key_masked ?? <span className="cr-missing">не задан</span>}</td>
|
||||||
<td>{register.tax_codes.length ? register.tax_codes.join(', ') : 'без налога'}</td>
|
<td>{register.tax_codes.length ? register.tax_codes.join(', ') : 'без налога'}</td>
|
||||||
<td>
|
<td>
|
||||||
<span className={`orders-status orders-status--${register.is_active ? 'delivered' : 'new'}`}>
|
<span className={`orders-status orders-status--${register.is_active ? 'delivered' : 'new'}`}>
|
||||||
@@ -249,6 +256,16 @@ export function CashRegistersPage() {
|
|||||||
onChange={(e) => setForm({ ...form, pin_code: e.target.value })}
|
onChange={(e) => setForm({ ...form, pin_code: e.target.value })}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
Ключ API Новой Почты (посылки этого кабинета пробиваются через эту кассу)
|
||||||
|
<input
|
||||||
|
required={!editingId}
|
||||||
|
autoComplete="off"
|
||||||
|
placeholder={editingId ? 'не менять' : ''}
|
||||||
|
value={form.np_api_key}
|
||||||
|
onChange={(e) => setForm({ ...form, np_api_key: e.target.value })}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Коды налогов (через запятую, пусто — без налога)
|
Коды налогов (через запятую, пусто — без налога)
|
||||||
<input value={form.tax_codes} onChange={(e) => setForm({ ...form, tax_codes: e.target.value })} />
|
<input value={form.tax_codes} onChange={(e) => setForm({ ...form, tax_codes: e.target.value })} />
|
||||||
@@ -259,7 +276,7 @@ export function CashRegistersPage() {
|
|||||||
checked={form.is_default}
|
checked={form.is_default}
|
||||||
onChange={(e) => setForm({ ...form, is_default: e.target.checked })}
|
onChange={(e) => setForm({ ...form, is_default: e.target.checked })}
|
||||||
/>
|
/>
|
||||||
Основная касса (по ней создаются чеки)
|
Основная касса (первой проверяет новые посылки)
|
||||||
</label>
|
</label>
|
||||||
<div className="cr-form-actions">
|
<div className="cr-form-actions">
|
||||||
<button type="submit" className="orders-create-btn" disabled={busy}>
|
<button type="submit" className="orders-create-btn" disabled={busy}>
|
||||||
|
|||||||
Reference in New Issue
Block a user