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>
396 lines
17 KiB
Python
396 lines
17 KiB
Python
"""Синхронизация локальной очереди заказов с CRM и работа с ней."""
|
||
|
||
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, NovaPoshtaError
|
||
|
||
log = get_logger(__name__)
|
||
|
||
_CRM_STATUS = "APPROVED"
|
||
|
||
# NP отклоняет запросы с более чем 100 накладными за раз (см. np_client.py).
|
||
_NP_BATCH_SIZE = 100
|
||
|
||
# Коды NP "відмова одержувача" / "припинено зберігання" — клиент не забрал
|
||
# посылку, заказ уходит во вкладку «Отказы».
|
||
NP_REFUSAL_STATUS_CODES = ("102", "103", "105", "108")
|
||
|
||
# Коды NP "відправлення отримано" (106 — отримано і створено ЄН зворотньої
|
||
# доставки) — заказ уходит во вкладку «Полученные».
|
||
NP_RECEIVED_STATUS_CODES = ("9", "10", "11", "106")
|
||
|
||
# Получено или отказ — после этого статус ТТН больше не опрашивается.
|
||
_NP_FINAL_STATUS_CODES = (*NP_RECEIVED_STATUS_CODES, *NP_REFUSAL_STATUS_CODES)
|
||
|
||
# "Видалено" / "Номер не знайдено" — денег по такой ТТН не будет.
|
||
_NP_DEAD_STATUS_CODES = ("2", "3")
|
||
|
||
# Поля, которые кассир может править в карточке заказа (кроме goods/total).
|
||
_EDITABLE_FIELDS = (
|
||
"recipient_name",
|
||
"recipient_phone",
|
||
"recipient_email",
|
||
"waybill_number",
|
||
"notes",
|
||
)
|
||
|
||
|
||
class OrderTab(enum.StrEnum):
|
||
NO_RECEIPT = "no_receipt"
|
||
HAS_RECEIPT = "has_receipt"
|
||
REFUSED = "refused"
|
||
RECEIVED = "received"
|
||
|
||
|
||
class OrderEditError(Exception):
|
||
"""Заказ нельзя сохранить — сообщение показывается кассиру."""
|
||
|
||
|
||
def _to_kopecks(amount: str) -> int:
|
||
return int((Decimal(amount) * 100).to_integral_value())
|
||
|
||
|
||
def _parse_crm_datetime(value: str) -> datetime:
|
||
return datetime.strptime(value, "%Y-%m-%d %H:%M:%S")
|
||
|
||
|
||
async def sync_orders_from_crm(session: AsyncSession, crm: CrmClient) -> None:
|
||
"""Подтягивает заказы CRM (статус APPROVED) в локальную таблицу.
|
||
|
||
Уже скрытые (`is_deleted`) заказы не восстанавливаются и не перезаписываются
|
||
— иначе кнопка «Удалить» переставала бы работать при следующем открытии
|
||
дашборда, т.к. CRM продолжает возвращать эти заказы как есть. По той же
|
||
причине не трогаются заказы, отредактированные вручную (`edited_at`).
|
||
"""
|
||
crm_orders = await crm.get_orders(status=_CRM_STATUS)
|
||
if not crm_orders:
|
||
return
|
||
|
||
existing = await session.scalars(
|
||
select(Order).where(Order.id.in_(order.id for order in crm_orders))
|
||
)
|
||
existing_by_id = {order.id: order for order in existing}
|
||
|
||
for crm_order in crm_orders:
|
||
local = existing_by_id.get(crm_order.id)
|
||
if local is not None:
|
||
if local.is_deleted or local.edited_at is not None:
|
||
continue
|
||
else:
|
||
local = Order(id=crm_order.id)
|
||
session.add(local)
|
||
|
||
local.create_date_time = _parse_crm_datetime(crm_order.create_date_time)
|
||
local.recipient_name = crm_order.recipient_name
|
||
local.recipient_phone = crm_order.recipient_phone
|
||
local.recipient_email = crm_order.recipient_email
|
||
local.waybill_number = crm_order.waybill_number
|
||
local.notes = crm_order.notes
|
||
local.total_amount_kopecks = _to_kopecks(crm_order.total.amount)
|
||
local.goods = [good.model_dump(by_alias=False) for good in crm_order.goods]
|
||
|
||
await session.commit()
|
||
|
||
|
||
async def list_orders(session: AsyncSession, *, tab: OrderTab) -> list[Order]:
|
||
"""Заказы вкладки. Статус посылки важнее наличия чека: полученный заказ
|
||
показывается только в «Полученных», отказ — только в «Отказах», даже если по
|
||
нему уже создан ЕТТН-чек."""
|
||
refused = Order.np_status_code.in_(NP_REFUSAL_STATUS_CODES)
|
||
received = Order.np_status_code.in_(NP_RECEIVED_STATUS_CODES)
|
||
in_transit = Order.np_status_code.is_(None) | Order.np_status_code.not_in(
|
||
_NP_FINAL_STATUS_CODES
|
||
)
|
||
tab_filter = {
|
||
OrderTab.REFUSED: refused,
|
||
OrderTab.RECEIVED: received,
|
||
OrderTab.NO_RECEIPT: Order.receipt_created_at.is_(None) & in_transit,
|
||
OrderTab.HAS_RECEIPT: Order.receipt_created_at.is_not(None) & in_transit,
|
||
}[tab]
|
||
result = await session.scalars(
|
||
select(Order)
|
||
.where(Order.is_deleted.is_(False))
|
||
.where(tab_filter)
|
||
.order_by(Order.create_date_time.desc())
|
||
)
|
||
return list(result)
|
||
|
||
|
||
async def cod_in_transit(session: AsyncSession) -> tuple[int, int]:
|
||
"""Наложка в пути: (кол-во посылок, сумма в копейках).
|
||
|
||
Учитываются только посылки, которые ещё не забрали и по которым нет отказа:
|
||
финальный статус NP ("отримано"/отказ) или оплата наложки их исключают.
|
||
"""
|
||
result = await session.execute(
|
||
select(func.count(), func.coalesce(func.sum(Order.np_cod_amount_kopecks), 0))
|
||
.where(Order.is_deleted.is_(False))
|
||
.where(Order.np_cod_amount_kopecks.is_not(None))
|
||
.where(Order.np_status_code.not_in((*_NP_FINAL_STATUS_CODES, *_NP_DEAD_STATUS_CODES)))
|
||
.where(Order.np_payment_status.is_distinct_from("Payed"))
|
||
)
|
||
count, total = result.one()
|
||
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`), его забирает.
|
||
Если ТТН не своя ни для одной кассы, статус доставки всё равно сохраняется,
|
||
но без наложки и без кассы — чек по такой посылке не создать.
|
||
|
||
Опрашиваются и заказы с уже созданным ЕТТН-чеком: именно после создания
|
||
чека посылка доезжает и оплачивается, и этот переход кассир должен видеть.
|
||
Опрос прекращается, когда NP вернул финальный статус "отримано" или отказ.
|
||
|
||
Вызывается ARQ worker'ом раз в минуту (см. `app/worker.py`), а не из
|
||
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))
|
||
.where(Order.waybill_number.is_not(None))
|
||
.where(
|
||
Order.np_status_code.is_(None)
|
||
| Order.np_status_code.not_in(_NP_FINAL_STATUS_CODES)
|
||
)
|
||
)
|
||
orders_by_waybill: dict[str, Order] = {order.waybill_number: order for order in orders}
|
||
if not orders_by_waybill:
|
||
return
|
||
|
||
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
|
||
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()
|
||
|
||
|
||
async def delete_order(session: AsyncSession, order_id: str) -> Order | None:
|
||
order = await session.get(Order, order_id)
|
||
if order is None or order.is_deleted:
|
||
return None
|
||
|
||
order.is_deleted = True
|
||
order.deleted_at = datetime.now(UTC)
|
||
return order
|
||
|
||
|
||
def _good_key(good: dict[str, Any]) -> tuple[Any, ...]:
|
||
"""Позиция без учёта формата строк: CRM пишет "1", форма — "1.000"."""
|
||
return (
|
||
good["id"],
|
||
good.get("sku") or "",
|
||
good["name"],
|
||
_to_kopecks(good["price"]),
|
||
Decimal(good["quantity"]),
|
||
_to_kopecks(good["amount"]) if good.get("amount") else None,
|
||
)
|
||
|
||
|
||
def _build_goods(data: OrderUpdateIn) -> tuple[list[dict[str, Any]], int]:
|
||
"""Позиции формы → снимок `orders.goods` в формате CRM и их сумма в копейках."""
|
||
goods: list[dict[str, Any]] = []
|
||
total = 0
|
||
for good in data.goods:
|
||
price = int(good.price * 100)
|
||
discount = int(good.discount_amount * 100)
|
||
gross = int((price * good.quantity).to_integral_value(ROUND_HALF_UP))
|
||
net = gross - discount
|
||
if net < 0:
|
||
raise OrderEditError(f"Товар «{good.name}»: скидка больше суммы строки")
|
||
goods.append(
|
||
{
|
||
"id": good.id or f"new-{uuid.uuid4().hex[:8]}",
|
||
"sku": good.sku,
|
||
"name": good.name,
|
||
"price": f"{good.price:.2f}",
|
||
"quantity": f"{good.quantity:.3f}",
|
||
"discount_amount": f"{good.discount_amount:.2f}",
|
||
"discount_percent": None,
|
||
"amount": f"{net / 100:.2f}",
|
||
}
|
||
)
|
||
total += net
|
||
return goods, total
|
||
|
||
|
||
async def update_order(
|
||
session: AsyncSession, order_id: str, data: OrderUpdateIn
|
||
) -> tuple[Order, list[str]] | None:
|
||
"""Сохраняет ручные правки заказа. Возвращает заказ и список изменённых полей.
|
||
|
||
Править можно только заказ без чека: у заказа с чеком (в т.ч. `pending`)
|
||
данные уже ушли или уходят в Checkbox. Строка блокируется, чтобы
|
||
параллельный запрос на создание чека не прочитал заказ посреди правки.
|
||
"""
|
||
order = await session.scalar(select(Order).where(Order.id == order_id).with_for_update())
|
||
if order is None or order.is_deleted:
|
||
return None
|
||
if order.receipt_created_at is not None:
|
||
raise OrderEditError("По заказу уже создан чек — редактирование недоступно")
|
||
|
||
goods, goods_total = _build_goods(data)
|
||
total = _to_kopecks(str(data.total_amount))
|
||
if total > goods_total:
|
||
raise OrderEditError(
|
||
f"Сумма заказа {total / 100:.2f} ₴ больше суммы товаров {goods_total / 100:.2f} ₴"
|
||
)
|
||
|
||
changed = [
|
||
field for field in _EDITABLE_FIELDS if getattr(order, field) != getattr(data, field)
|
||
]
|
||
goods_changed = [_good_key(good) for good in order.goods] != [_good_key(good) for good in goods]
|
||
if goods_changed:
|
||
changed.append("goods")
|
||
if order.total_amount_kopecks != total:
|
||
changed.append("total_amount")
|
||
if not changed:
|
||
return order, changed
|
||
|
||
if "waybill_number" in changed:
|
||
# Статус и касса старой ТТН к новой не относятся — worker опросит новую за минуту.
|
||
order.cash_register_id = None
|
||
order.np_status = None
|
||
order.np_status_code = None
|
||
order.np_cod_amount_kopecks = None
|
||
order.np_payment_status = None
|
||
|
||
for field in _EDITABLE_FIELDS:
|
||
setattr(order, field, getattr(data, field))
|
||
if goods_changed:
|
||
# Иначе оставляем снимок CRM как есть — форматы строк у CRM свои ("1" vs "1.000").
|
||
order.goods = goods
|
||
order.total_amount_kopecks = total
|
||
order.edited_at = datetime.now(UTC)
|
||
return order, changed
|