Bind Nova Poshta API key to cash register (#3)
Each cash register stores its own encrypted NP API key. Status polling uses register keys and binds an order to the register whose key sees the TTN as its own (PhoneSender present); ETTN receipts are created from that register. Migration 0008 moves the old NOVA_POSHTA_API_KEY into the default register. Closes #3 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+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
|
||||
|
||||
Reference in New Issue
Block a user