Adds NpTrackingClient (Protocol + real/stub impls) and an ARQ worker that polls Nova Poshta every minute for orders without a receipt, writing status, net COD amount (Контроль оплати), and payment status onto the order. Surfaced in the orders table and detail modal. Marks plan stages 3-4 done in README. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
122 lines
4.6 KiB
Python
122 lines
4.6 KiB
Python
"""Синхронизация локальной очереди заказов с CRM и работа с ней."""
|
||
|
||
from __future__ import annotations
|
||
|
||
from datetime import UTC, datetime
|
||
from decimal import Decimal
|
||
|
||
from sqlalchemy import select
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from app.db.models.order import Order
|
||
from app.services.crm.client import CrmClient
|
||
from app.services.nova_poshta.client import NovaPoshtaClient
|
||
|
||
_CRM_STATUS = "APPROVED"
|
||
|
||
# NP отклоняет запросы с более чем 100 накладными за раз (см. np_client.py).
|
||
_NP_BATCH_SIZE = 100
|
||
|
||
|
||
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 продолжает возвращать эти заказы как есть.
|
||
"""
|
||
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:
|
||
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, *, has_receipt: bool) -> list[Order]:
|
||
receipt_filter = (
|
||
Order.receipt_created_at.is_not(None) if has_receipt else Order.receipt_created_at.is_(None)
|
||
)
|
||
result = await session.scalars(
|
||
select(Order)
|
||
.where(Order.is_deleted.is_(False))
|
||
.where(receipt_filter)
|
||
.order_by(Order.create_date_time.desc())
|
||
)
|
||
return list(result)
|
||
|
||
|
||
async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None:
|
||
"""Обновляет статус ТТН и сумму наложенного платежа для заказов без чека.
|
||
|
||
Вызывается ARQ worker'ом раз в минуту (см. `app/worker.py`), а не из
|
||
HTTP-запроса: опрос статусов не должен зависеть от того, открыт ли сейчас
|
||
дашборд.
|
||
"""
|
||
orders = await session.scalars(
|
||
select(Order)
|
||
.where(Order.is_deleted.is_(False))
|
||
.where(Order.receipt_created_at.is_(None))
|
||
.where(Order.waybill_number.is_not(None))
|
||
)
|
||
orders_by_waybill: dict[str, Order] = {order.waybill_number: order for order in orders}
|
||
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)
|
||
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
|
||
|
||
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
|