Set CRM order status to PACKED after Checkbox accepts the receipt

- ExoCrmClient.set_status: live SetStatus needs {Orders: [id], Status} and
  replies per order, unlike the documented {ID, Status}
- receipts.crm_status_set_at (migration 0006); set right after creation,
  retried by cron, row-locked to avoid a repeat PACKED overwriting a newer status
- CRM errors under capitalized 'Errors' and non-JSON replies are reported

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-24 14:04:26 +03:00
co-authored by Claude Opus 5.5
parent 3a6ad85d11
commit cbf9832e5b
12 changed files with 266 additions and 19 deletions
+2
View File
@@ -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 вместо опроса.
--- ---
+1
View File
@@ -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")
+1
View File
@@ -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):
+3
View File
@@ -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__ = (
# Не больше одного «живого» чека на заказ — защита от двойного нажатия # Не больше одного «живого» чека на заказ — защита от двойного нажатия
+2
View File
@@ -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: ...
+50 -18
View File
@@ -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}")
+5
View File
@@ -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 [
+60
View File
@@ -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]:
+7 -1
View File
@@ -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")
+40
View File
@@ -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")
+67
View File
@@ -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"}