"""Синхронизация локальной очереди заказов с 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