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:
@@ -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
|
||||
|
||||
@@ -261,13 +261,11 @@ def credentials(register: CashRegister) -> CheckboxCredentials:
|
||||
)
|
||||
|
||||
|
||||
async def get_default_register(session: AsyncSession) -> CashRegister | None:
|
||||
return await session.scalar(
|
||||
select(CashRegister)
|
||||
.where(CashRegister.is_active.is_(True))
|
||||
.order_by(CashRegister.is_default.desc(), CashRegister.created_at)
|
||||
.limit(1)
|
||||
async def _active_registers(session: AsyncSession) -> dict[uuid.UUID, CashRegister]:
|
||||
registers = await session.scalars(
|
||||
select(CashRegister).where(CashRegister.is_active.is_(True))
|
||||
)
|
||||
return {register.id: register for register in registers}
|
||||
|
||||
|
||||
# --- Создание ----------------------------------------------------------------
|
||||
@@ -300,10 +298,13 @@ async def request_receipts(
|
||||
Без commit и без обращения к Checkbox: вызывающий код коммитит и ставит
|
||||
задачи worker'у (`create_ettn_for_receipt`) по `result.created`.
|
||||
Ошибки по отдельным заказам не мешают остальным — массовое действие.
|
||||
|
||||
Чек создаётся от кассы, чей ключ НП видит ТТН заказа как свою
|
||||
(`orders.cash_register_id`, проставляет `orders.sync_np_statuses`).
|
||||
"""
|
||||
result = RequestResult(created=[], errors={})
|
||||
register = await get_default_register(session)
|
||||
if register is None:
|
||||
registers = await _active_registers(session)
|
||||
if not registers:
|
||||
for order_id, _ in items:
|
||||
result.errors[order_id] = "Не настроена касса Checkbox"
|
||||
return result
|
||||
@@ -323,6 +324,13 @@ async def request_receipts(
|
||||
if order_id in busy:
|
||||
result.errors[order_id] = "По заказу уже есть чек"
|
||||
continue
|
||||
register = registers.get(order.cash_register_id) if order.cash_register_id else None
|
||||
if register is None:
|
||||
result.errors[order_id] = (
|
||||
"Касса не определена: ТТН не найдена ни одним ключом Новой Почты "
|
||||
"активных касс"
|
||||
)
|
||||
continue
|
||||
try:
|
||||
amounts = resolve_amounts(order, prepayment)
|
||||
body = build_ettn_body(order, register, amounts)
|
||||
@@ -359,6 +367,7 @@ async def request_receipts(
|
||||
payload={
|
||||
"order_id": order.id,
|
||||
"waybill_number": order.waybill_number,
|
||||
"cash_register_id": str(register.id),
|
||||
"cod_kopecks": amounts.cod_kopecks,
|
||||
"prepayment_kopecks": amounts.prepayment_kopecks,
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user