Checkbox ETTN receipts, order editing, dashboard tabs and COD summary #1
@@ -138,6 +138,8 @@ webhook в Checkbox, и Checkbox **сам фискализирует** чек.
|
|||||||
как в чеках из портала. Боевой API отдаёт статусы строчными (`created`/`done`) — нормализуются.
|
как в чеках из портала. Боевой API отдаёт статусы строчными (`created`/`done`) — нормализуются.
|
||||||
Лимит списка — 50 за страницу; 429 «Занадто часто» — повторяемая ошибка.
|
Лимит списка — 50 за страницу; 429 «Занадто часто» — повторяемая ошибка.
|
||||||
- [x] `alembic upgrade head` на живом Postgres (0004 → 0005 применена в Docker).
|
- [x] `alembic upgrade head` на живом Postgres (0004 → 0005 применена в Docker).
|
||||||
|
- [x] После создания чека — статус заказа в CRM `PACKED` (`SetStatus`, миграция 0006 `receipts.crm_status_set_at`,
|
||||||
|
повтор cron'ом при сбое CRM). Проверено на боевой CRM: заказы 123901, 123793.
|
||||||
- [ ] Позже (не в этом этапе): `fiscalize-manually`, PDF/ссылка на фискальный чек, webhook вместо опроса.
|
- [ ] Позже (не в этом этапе): `fiscalize-manually`, PDF/ссылка на фискальный чек, webhook вместо опроса.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -125,4 +125,5 @@ Stages 1–4 (scaffolding, auth/audit, CRM order queue, Nova Poshta tracking) ar
|
|||||||
- We never fiscalize ourselves: the cashier creates an **ETTN receipt** in Checkbox bound to a Nova Poshta TTN with payment control; Checkbox fiscalizes it when the customer pays at the NP branch. Invariant: `order total − prepayment == np_cod_amount_kopecks`. Prepayment goes into the receipt as a plain `DISCOUNT` («Знижка»), **not** `PRE_PAYMENT` — the live API rejects `PRE_PAYMENT` on ETTN with 400 `third_party.generic`. The live API also returns statuses lowercase (`EttnOut` upper-cases them).
|
- We never fiscalize ourselves: the cashier creates an **ETTN receipt** in Checkbox bound to a Nova Poshta TTN with payment control; Checkbox fiscalizes it when the customer pays at the NP branch. Invariant: `order total − prepayment == np_cod_amount_kopecks`. Prepayment goes into the receipt as a plain `DISCOUNT` («Знижка»), **not** `PRE_PAYMENT` — the live API rejects `PRE_PAYMENT` on ETTN with 400 `third_party.generic`. The live API also returns statuses lowercase (`EttnOut` upper-cases them).
|
||||||
- Two-phase create: `POST /receipts` writes `Receipt(pending)` + audit and commits, then enqueues `create_ettn_receipt`; the worker calls Checkbox. A timeout leaves the row `pending` with `error` set — the retry first looks the TTN up via `find_ettn` instead of blindly re-posting (would create a second receipt). Keep this.
|
- Two-phase create: `POST /receipts` writes `Receipt(pending)` + audit and commits, then enqueues `create_ettn_receipt`; the worker calls Checkbox. A timeout leaves the row `pending` with `error` set — the retry first looks the TTN up via `find_ettn` instead of blindly re-posting (would create a second receipt). Keep this.
|
||||||
- State machine and the "one live receipt per order" partial unique index live in `app/db/models/receipt.py`. `orders.receipt_created_at` is set on request and reset to NULL when a receipt ends `failed`/`cancelled` (order goes back to the queue).
|
- State machine and the "one live receipt per order" partial unique index live in `app/db/models/receipt.py`. `orders.receipt_created_at` is set on request and reset to NULL when a receipt ends `failed`/`cancelled` (order goes back to the queue).
|
||||||
|
- Once Checkbox accepts the receipt, the order is moved to `PACKED` in the CRM (`services/receipts.sync_crm_statuses`, marked by `receipts.crm_status_set_at`; runs right after creation and is retried by cron). The live exoCRM `SetStatus` differs from its docs: params must be `{"Orders": [id], "Status": ...}` (the documented `{"ID": ...}` returns "Undefined order list."), and the reply has no `status: OK` — success is `{"<id>": {"Status": "Success"}}`.
|
||||||
- ETTN does **not** work on a Checkbox test cash register. Locally use `CHECKBOX_USE_STUB=true`; client selection is only in `services/checkbox/client.get_checkbox_client()`.
|
- ETTN does **not** work on a Checkbox test cash register. Locally use `CHECKBOX_USE_STUB=true`; client selection is only in `services/checkbox/client.get_checkbox_client()`.
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Отметка о смене статуса заказа в CRM после создания чека
|
||||||
|
|
||||||
|
Revision ID: 0006
|
||||||
|
Revises: 0005
|
||||||
|
Create Date: 2026-09-24
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0006"
|
||||||
|
down_revision: str | None = "0005"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column(
|
||||||
|
"receipts", sa.Column("crm_status_set_at", sa.DateTime(timezone=True), nullable=True)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column("receipts", "crm_status_set_at")
|
||||||
@@ -36,6 +36,7 @@ class AuditAction(str):
|
|||||||
CASH_REGISTER_UPDATED = "cash_register.updated"
|
CASH_REGISTER_UPDATED = "cash_register.updated"
|
||||||
RECEIPT_CREATE_REQUESTED = "receipt.create_requested"
|
RECEIPT_CREATE_REQUESTED = "receipt.create_requested"
|
||||||
RECEIPT_CANCELLED = "receipt.cancelled"
|
RECEIPT_CANCELLED = "receipt.cancelled"
|
||||||
|
ORDER_CRM_STATUS_SET = "order.crm_status_set"
|
||||||
|
|
||||||
|
|
||||||
class AuditLog(UUIDPrimaryKeyMixin, Base):
|
class AuditLog(UUIDPrimaryKeyMixin, Base):
|
||||||
|
|||||||
@@ -70,6 +70,9 @@ class Receipt(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
# Снимок отправленного в Checkbox тела — для разбора спорных случаев.
|
# Снимок отправленного в Checkbox тела — для разбора спорных случаев.
|
||||||
request_body: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
|
request_body: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict)
|
||||||
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
last_checked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
# Когда заказу в CRM выставлен статус после создания чека (PACKED). NULL при
|
||||||
|
# живом чеке — ещё не выставлен (CRM была недоступна), cron повторит.
|
||||||
|
crm_status_set_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
__table_args__ = (
|
__table_args__ = (
|
||||||
# Не больше одного «живого» чека на заказ — защита от двойного нажатия
|
# Не больше одного «живого» чека на заказ — защита от двойного нажатия
|
||||||
|
|||||||
@@ -13,3 +13,5 @@ class CrmError(Exception):
|
|||||||
|
|
||||||
class CrmClient(Protocol):
|
class CrmClient(Protocol):
|
||||||
async def get_orders(self, *, status: str) -> list[OrderOut]: ...
|
async def get_orders(self, *, status: str) -> list[OrderOut]: ...
|
||||||
|
|
||||||
|
async def set_status(self, *, order_id: str, status: str) -> None: ...
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
"""Реальный клиент exoCRM (`GetOrders`)."""
|
"""Реальный клиент exoCRM (`GetOrders`, `SetStatus`)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
||||||
from app.core.config import Settings
|
from app.core.config import Settings
|
||||||
@@ -10,6 +12,14 @@ from app.services.crm.checksum import compute_md5sum
|
|||||||
from app.services.crm.client import CrmError
|
from app.services.crm.client import CrmError
|
||||||
|
|
||||||
|
|
||||||
|
def _errors_text(data: dict[str, Any]) -> str:
|
||||||
|
# Ключ бывает и `errors` (dict код → текст), и `Errors` (список строк).
|
||||||
|
errors = data.get("errors") or data.get("Errors") or {}
|
||||||
|
if isinstance(errors, dict):
|
||||||
|
return "; ".join(f"{code}: {text}" for code, text in errors.items())
|
||||||
|
return "; ".join(str(error) for error in errors)
|
||||||
|
|
||||||
|
|
||||||
class ExoCrmClient:
|
class ExoCrmClient:
|
||||||
def __init__(self, settings: Settings) -> None:
|
def __init__(self, settings: Settings) -> None:
|
||||||
self._base_url = settings.crm_base_url
|
self._base_url = settings.crm_base_url
|
||||||
@@ -18,29 +28,51 @@ class ExoCrmClient:
|
|||||||
self._shop_key = settings.crm_shop_key
|
self._shop_key = settings.crm_shop_key
|
||||||
self._sid = settings.crm_sid
|
self._sid = settings.crm_sid
|
||||||
|
|
||||||
|
async def _post(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
body = {"apikey": self._api_key, "object": "Orders", "method": method, "params": params}
|
||||||
|
body["md5sum"] = compute_md5sum(body, self._secret_key)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=30) as client:
|
||||||
|
response = await client.post(self._base_url, json=body)
|
||||||
|
response.raise_for_status()
|
||||||
|
try:
|
||||||
|
data = response.json()
|
||||||
|
except ValueError as exc:
|
||||||
|
# PHP-notice'ы CRM перед JSON — признак неверных параметров.
|
||||||
|
raise CrmError(f"CRM вернула не JSON: {response.text[:300]}") from exc
|
||||||
|
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise CrmError(f"Неожиданный ответ CRM: {str(data)[:300]}")
|
||||||
|
return data
|
||||||
|
|
||||||
async def get_orders(self, *, status: str) -> list[OrderOut]:
|
async def get_orders(self, *, status: str) -> list[OrderOut]:
|
||||||
body = {
|
data = await self._post(
|
||||||
"apikey": self._api_key,
|
"GetOrders",
|
||||||
"object": "Orders",
|
{
|
||||||
"method": "GetOrders",
|
|
||||||
"params": {
|
|
||||||
"sid": self._sid,
|
"sid": self._sid,
|
||||||
"key": self._shop_key,
|
"key": self._shop_key,
|
||||||
"Status": status,
|
"Status": status,
|
||||||
"ReturnGoods": True,
|
"ReturnGoods": True,
|
||||||
"ReturnTotals": True,
|
"ReturnTotals": True,
|
||||||
},
|
},
|
||||||
}
|
)
|
||||||
body["md5sum"] = compute_md5sum(body, self._secret_key)
|
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=30) as client:
|
|
||||||
response = await client.post(self._base_url, json=body)
|
|
||||||
response.raise_for_status()
|
|
||||||
data = response.json()
|
|
||||||
|
|
||||||
if data.get("status") != "OK":
|
if data.get("status") != "OK":
|
||||||
errors = data.get("errors") or {}
|
raise CrmError(f"CRM вернула ошибку: {_errors_text(data) or 'неизвестная ошибка'}")
|
||||||
message = "; ".join(f"{code}: {text}" for code, text in errors.items())
|
return [OrderOut.model_validate(order) for order in data.get("result") or []]
|
||||||
raise CrmError(f"CRM вернула ошибку: {message or 'неизвестная ошибка'}")
|
|
||||||
|
|
||||||
return [OrderOut.model_validate(order) for order in data.get("result", [])]
|
async def set_status(self, *, order_id: str, status: str) -> None:
|
||||||
|
"""`SetStatus`. Формат проверен на боевой CRM и расходится с документацией.
|
||||||
|
|
||||||
|
Документация показывает `params: {"ID": ..., "Status": ...}`, но CRM на это
|
||||||
|
отвечает «Undefined order list.». Рабочий вариант — список ID в `Orders`,
|
||||||
|
а ответ — результат по каждому заказу без общего `status: OK`:
|
||||||
|
{"123901": {"Status": "Success", "ChangeStatus": "Success"}}
|
||||||
|
"""
|
||||||
|
data = await self._post("SetStatus", {"Orders": [order_id], "Status": status})
|
||||||
|
result = data.get(order_id)
|
||||||
|
if isinstance(result, dict) and result.get("Status") == "Success":
|
||||||
|
return
|
||||||
|
details = _errors_text(data) or (
|
||||||
|
str(result) if result is not None else "нет ответа по заказу"
|
||||||
|
)
|
||||||
|
raise CrmError(f"CRM не сменила статус заказа {order_id} на {status}: {details}")
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ _FIXTURE_ORDERS: list[dict] = [
|
|||||||
class StubCrmClient:
|
class StubCrmClient:
|
||||||
def __init__(self, orders: list[dict] | None = None) -> None:
|
def __init__(self, orders: list[dict] | None = None) -> None:
|
||||||
self._orders = orders if orders is not None else _FIXTURE_ORDERS
|
self._orders = orders if orders is not None else _FIXTURE_ORDERS
|
||||||
|
# order_id → статус, выставленный через set_status (для проверок в тестах).
|
||||||
|
self.statuses: dict[str, str] = {}
|
||||||
|
|
||||||
|
async def set_status(self, *, order_id: str, status: str) -> None:
|
||||||
|
self.statuses[order_id] = status
|
||||||
|
|
||||||
async def get_orders(self, *, status: str) -> list[OrderOut]:
|
async def get_orders(self, *, status: str) -> list[OrderOut]:
|
||||||
return [
|
return [
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from datetime import UTC, datetime, timedelta
|
|||||||
from decimal import ROUND_HALF_UP, Decimal
|
from decimal import ROUND_HALF_UP, Decimal
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
import httpx
|
||||||
from fastapi import Request
|
from fastapi import Request
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -39,6 +40,7 @@ from app.services.checkbox.client import (
|
|||||||
CheckboxError,
|
CheckboxError,
|
||||||
CheckboxUnavailableError,
|
CheckboxUnavailableError,
|
||||||
)
|
)
|
||||||
|
from app.services.crm.client import CrmClient, CrmError
|
||||||
|
|
||||||
log = get_logger(__name__)
|
log = get_logger(__name__)
|
||||||
|
|
||||||
@@ -65,6 +67,16 @@ _CHECKBOX_TO_STATUS = {
|
|||||||
# или Checkbox был недоступен) и повторить его из cron'а.
|
# или Checkbox был недоступен) и повторить его из cron'а.
|
||||||
_PENDING_RETRY_AFTER = timedelta(minutes=1)
|
_PENDING_RETRY_AFTER = timedelta(minutes=1)
|
||||||
|
|
||||||
|
# Статус заказа в CRM, когда Checkbox принял ЕТТН-чек: заказ можно собирать.
|
||||||
|
CRM_STATUS_AFTER_RECEIPT = "PACKED"
|
||||||
|
# Чеки, при которых заказу нужен этот статус в CRM (Checkbox чек принял).
|
||||||
|
_CRM_STATUS_RECEIPT_STATUSES = (
|
||||||
|
ReceiptStatus.CREATED,
|
||||||
|
ReceiptStatus.DONE,
|
||||||
|
ReceiptStatus.RECEIPT_ERROR,
|
||||||
|
ReceiptStatus.RETURNED,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class ReceiptValidationError(Exception):
|
class ReceiptValidationError(Exception):
|
||||||
"""Заказ нельзя отправить в Checkbox — сообщение показывается кассиру."""
|
"""Заказ нельзя отправить в Checkbox — сообщение показывается кассиру."""
|
||||||
@@ -500,6 +512,54 @@ async def sync_ettn_statuses(session: AsyncSession, client: CheckboxClient) -> N
|
|||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|
||||||
|
async def sync_crm_statuses(session: AsyncSession, crm: CrmClient) -> None:
|
||||||
|
"""Переводит в CRM заказы с принятым Checkbox чеком в статус PACKED.
|
||||||
|
|
||||||
|
Отдельно от создания чека и идемпотентно по `crm_status_set_at`: сбой CRM
|
||||||
|
не должен ни откатывать уже созданный в Checkbox чек, ни теряться —
|
||||||
|
вызывается сразу после создания и повторяется cron'ом до успеха.
|
||||||
|
"""
|
||||||
|
receipt_ids = list(
|
||||||
|
await session.scalars(
|
||||||
|
select(Receipt.id)
|
||||||
|
.where(Receipt.status.in_(_CRM_STATUS_RECEIPT_STATUSES))
|
||||||
|
.where(Receipt.crm_status_set_at.is_(None))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for receipt_id in receipt_ids:
|
||||||
|
# Строка блокируется на время вызова CRM: задача создания и cron могут
|
||||||
|
# сработать одновременно, а повторный PACKED откатил бы статус, который
|
||||||
|
# менеджер уже успел сменить дальше. Занято или уже выставлено — пропуск.
|
||||||
|
receipt = await session.scalar(
|
||||||
|
select(Receipt)
|
||||||
|
.where(Receipt.id == receipt_id)
|
||||||
|
.where(Receipt.crm_status_set_at.is_(None))
|
||||||
|
.with_for_update(skip_locked=True)
|
||||||
|
)
|
||||||
|
if receipt is None:
|
||||||
|
continue
|
||||||
|
order_id = receipt.order_id # после rollback атрибуты истекают
|
||||||
|
try:
|
||||||
|
await crm.set_status(order_id=order_id, status=CRM_STATUS_AFTER_RECEIPT)
|
||||||
|
except (CrmError, httpx.HTTPError) as exc:
|
||||||
|
await session.rollback() # снять блокировку строки
|
||||||
|
log.warning("crm_status_set_failed", order_id=order_id, error=repr(exc))
|
||||||
|
continue
|
||||||
|
receipt.crm_status_set_at = datetime.now(UTC)
|
||||||
|
await audit.record(
|
||||||
|
session,
|
||||||
|
action=AuditAction.ORDER_CRM_STATUS_SET,
|
||||||
|
actor_label="worker",
|
||||||
|
entity_type="order",
|
||||||
|
entity_id=receipt.order_id,
|
||||||
|
payload={"status": CRM_STATUS_AFTER_RECEIPT, "receipt_id": str(receipt.id)},
|
||||||
|
)
|
||||||
|
# Коммит на каждый заказ: статус в CRM уже сменён, отметку нельзя терять
|
||||||
|
# из-за сбоя на следующем заказе.
|
||||||
|
await session.commit()
|
||||||
|
log.info("crm_status_set", order_id=receipt.order_id, status=CRM_STATUS_AFTER_RECEIPT)
|
||||||
|
|
||||||
|
|
||||||
async def latest_receipts_by_order(
|
async def latest_receipts_by_order(
|
||||||
session: AsyncSession, order_ids: list[str]
|
session: AsyncSession, order_ids: list[str]
|
||||||
) -> dict[str, Receipt]:
|
) -> dict[str, Receipt]:
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
Запускается отдельным процессом: `arq app.worker.WorkerSettings`.
|
Запускается отдельным процессом: `arq app.worker.WorkerSettings`.
|
||||||
- `create_ettn_receipt` — задача, которую ставит API после запроса кассира;
|
- `create_ettn_receipt` — задача, которую ставит API после запроса кассира;
|
||||||
- `poll_np_statuses` — раз в минуту статусы ТТН по заказам без чека;
|
- `poll_np_statuses` — раз в минуту статусы ТТН по заказам без чека;
|
||||||
- `poll_receipts` — раз в минуту повтор зависших `pending` и статусы `created`-чеков.
|
- `poll_receipts` — раз в минуту повтор зависших `pending`, статусы `created`-чеков
|
||||||
|
и повтор смены статуса заказа в CRM (PACKED), если CRM была недоступна.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -19,6 +20,7 @@ from app.core.logging import configure_logging, get_logger
|
|||||||
from app.db.session import SessionFactory
|
from app.db.session import SessionFactory
|
||||||
from app.services import receipts as receipts_service
|
from app.services import receipts as receipts_service
|
||||||
from app.services.checkbox.client import get_checkbox_client
|
from app.services.checkbox.client import get_checkbox_client
|
||||||
|
from app.services.crm.exo_client import ExoCrmClient
|
||||||
from app.services.nova_poshta.np_client import NpTrackingClient
|
from app.services.nova_poshta.np_client import NpTrackingClient
|
||||||
from app.services.orders import sync_np_statuses
|
from app.services.orders import sync_np_statuses
|
||||||
|
|
||||||
@@ -29,6 +31,7 @@ async def startup(ctx: dict[str, Any]) -> None:
|
|||||||
configure_logging()
|
configure_logging()
|
||||||
ctx["np_client"] = NpTrackingClient(settings)
|
ctx["np_client"] = NpTrackingClient(settings)
|
||||||
ctx["checkbox_client"] = get_checkbox_client()
|
ctx["checkbox_client"] = get_checkbox_client()
|
||||||
|
ctx["crm_client"] = ExoCrmClient(settings)
|
||||||
log.info("worker_starting", environment=settings.environment)
|
log.info("worker_starting", environment=settings.environment)
|
||||||
|
|
||||||
|
|
||||||
@@ -43,12 +46,15 @@ async def create_ettn_receipt(ctx: dict[str, Any], receipt_id: str) -> None:
|
|||||||
await receipts_service.create_ettn_for_receipt(
|
await receipts_service.create_ettn_for_receipt(
|
||||||
session, ctx["checkbox_client"], uuid.UUID(receipt_id)
|
session, ctx["checkbox_client"], uuid.UUID(receipt_id)
|
||||||
)
|
)
|
||||||
|
# Чек принят Checkbox — сразу переводим заказ в CRM в PACKED.
|
||||||
|
await receipts_service.sync_crm_statuses(session, ctx["crm_client"])
|
||||||
|
|
||||||
|
|
||||||
async def poll_receipts(ctx: dict[str, Any]) -> None:
|
async def poll_receipts(ctx: dict[str, Any]) -> None:
|
||||||
async with SessionFactory() as session:
|
async with SessionFactory() as session:
|
||||||
await receipts_service.retry_pending_receipts(session, ctx["checkbox_client"])
|
await receipts_service.retry_pending_receipts(session, ctx["checkbox_client"])
|
||||||
await receipts_service.sync_ettn_statuses(session, ctx["checkbox_client"])
|
await receipts_service.sync_ettn_statuses(session, ctx["checkbox_client"])
|
||||||
|
await receipts_service.sync_crm_statuses(session, ctx["crm_client"])
|
||||||
log.info("receipts_polled")
|
log.info("receipts_polled")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -106,3 +106,43 @@ class TestExoCrmClientGetOrders:
|
|||||||
|
|
||||||
with pytest.raises(CrmError, match="Checksum Error"):
|
with pytest.raises(CrmError, match="Checksum Error"):
|
||||||
await client.get_orders(status="APPROVED")
|
await client.get_orders(status="APPROVED")
|
||||||
|
|
||||||
|
|
||||||
|
class TestExoCrmClientSetStatus:
|
||||||
|
@respx.mock
|
||||||
|
async def test_sends_order_list_and_parses_per_order_result(self) -> None:
|
||||||
|
import json
|
||||||
|
|
||||||
|
route = respx.post(BASE_URL).mock(
|
||||||
|
return_value=Response(
|
||||||
|
200,
|
||||||
|
json={
|
||||||
|
"<": "<",
|
||||||
|
"123901": {"Status": "Success", "ChangeStatus": "Success"},
|
||||||
|
">": ">",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await ExoCrmClient(_settings()).set_status(order_id="123901", status="PACKED")
|
||||||
|
|
||||||
|
body = json.loads(route.calls.last.request.content)
|
||||||
|
assert body["object"] == "Orders"
|
||||||
|
assert body["method"] == "SetStatus"
|
||||||
|
# Не {"ID": ...} из документации — боевая CRM принимает только список в Orders.
|
||||||
|
assert body["params"] == {"Orders": ["123901"], "Status": "PACKED"}
|
||||||
|
assert "md5sum" in body
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_raises_on_capitalized_errors(self) -> None:
|
||||||
|
respx.post(BASE_URL).mock(
|
||||||
|
return_value=Response(200, json={"<": "<", "Errors": ["Undefined order list."]})
|
||||||
|
)
|
||||||
|
with pytest.raises(CrmError, match="Undefined order list"):
|
||||||
|
await ExoCrmClient(_settings()).set_status(order_id="1", status="PACKED")
|
||||||
|
|
||||||
|
@respx.mock
|
||||||
|
async def test_raises_on_non_json_reply(self) -> None:
|
||||||
|
respx.post(BASE_URL).mock(return_value=Response(200, text="<b>Notice</b>: ..."))
|
||||||
|
with pytest.raises(CrmError, match="не JSON"):
|
||||||
|
await ExoCrmClient(_settings()).set_status(order_id="1", status="PACKED")
|
||||||
|
|||||||
@@ -323,3 +323,70 @@ class TestCancel:
|
|||||||
receipt.id,
|
receipt.id,
|
||||||
user=None, # type: ignore[arg-type]
|
user=None, # type: ignore[arg-type]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CrmSession:
|
||||||
|
"""Фейковая сессия для sync_crm_statuses: scalars → id, scalar → строка с «блокировкой»."""
|
||||||
|
|
||||||
|
def __init__(self, *receipts: Receipt) -> None:
|
||||||
|
self.receipts = {r.id: r for r in receipts}
|
||||||
|
self.added: list[Any] = []
|
||||||
|
self.commits = 0
|
||||||
|
self.rollbacks = 0
|
||||||
|
|
||||||
|
async def scalars(self, _: Any) -> list[uuid.UUID]:
|
||||||
|
return [r.id for r in self.receipts.values() if r.crm_status_set_at is None]
|
||||||
|
|
||||||
|
async def scalar(self, query: Any) -> Receipt | None:
|
||||||
|
receipt_id = query.whereclause.clauses[0].right.value
|
||||||
|
receipt = self.receipts[receipt_id]
|
||||||
|
return receipt if receipt.crm_status_set_at is None else None
|
||||||
|
|
||||||
|
def add(self, obj: Any) -> None:
|
||||||
|
self.added.append(obj)
|
||||||
|
|
||||||
|
async def commit(self) -> None:
|
||||||
|
self.commits += 1
|
||||||
|
|
||||||
|
async def rollback(self) -> None:
|
||||||
|
self.rollbacks += 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestSyncCrmStatuses:
|
||||||
|
async def test_sets_packed_once(self) -> None:
|
||||||
|
from app.services.crm.stub_client import StubCrmClient
|
||||||
|
|
||||||
|
order, register = _order(), _register()
|
||||||
|
receipt = _pending(order, register)
|
||||||
|
receipt.status = ReceiptStatus.CREATED
|
||||||
|
session = CrmSession(receipt)
|
||||||
|
crm = StubCrmClient()
|
||||||
|
|
||||||
|
await svc.sync_crm_statuses(session, crm) # type: ignore[arg-type]
|
||||||
|
await svc.sync_crm_statuses(session, crm) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
assert crm.statuses == {"100": "PACKED"}
|
||||||
|
assert receipt.crm_status_set_at is not None
|
||||||
|
assert session.commits == 1 # второй проход ничего не делает
|
||||||
|
|
||||||
|
async def test_crm_failure_is_retried_later(self) -> None:
|
||||||
|
from app.services.crm.client import CrmError
|
||||||
|
from app.services.crm.stub_client import StubCrmClient
|
||||||
|
|
||||||
|
order, register = _order(), _register()
|
||||||
|
receipt = _pending(order, register)
|
||||||
|
receipt.status = ReceiptStatus.CREATED
|
||||||
|
session = CrmSession(receipt)
|
||||||
|
crm = StubCrmClient()
|
||||||
|
|
||||||
|
async def broken(**_: Any) -> None:
|
||||||
|
raise CrmError("CRM вернула ошибку")
|
||||||
|
|
||||||
|
real_set_status = crm.set_status
|
||||||
|
crm.set_status = broken # type: ignore[method-assign]
|
||||||
|
await svc.sync_crm_statuses(session, crm) # type: ignore[arg-type]
|
||||||
|
assert receipt.crm_status_set_at is None and session.rollbacks == 1
|
||||||
|
|
||||||
|
crm.set_status = real_set_status # type: ignore[method-assign]
|
||||||
|
await svc.sync_crm_statuses(session, crm) # type: ignore[arg-type]
|
||||||
|
assert crm.statuses == {"100": "PACKED"}
|
||||||
|
|||||||
Reference in New Issue
Block a user