- All frontend pages, labels, notices and errors; html lang=uk, uk-UA money format - Brand "Assistant System" in the top bar and page title - Backend error details returned to the UI (auth, orders, receipts, cash registers, Checkbox/CRM/NP errors) and CLI output - Tests updated for the new messages 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
|