Files
lux_fiscal/backend/app/services/orders.py
T
lauadminandClaude Sonnet 5 d13e7ce2b3 Add Nova Poshta tracking: TTN status, COD amount, payment status
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>
2026-09-23 22:33:58 +03:00

122 lines
4.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Синхронизация локальной очереди заказов с 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