From cbf9832e5b848db0df7494cee2668aee72139bd8 Mon Sep 17 00:00:00 2001 From: Liutenko Oleksandr Date: Thu, 24 Sep 2026 14:04:26 +0300 Subject: [PATCH] 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 --- .plans/checkbox-ettn-receipts.md | 2 + CLAUDE.md | 1 + .../0006_receipt_crm_status_set_at.py | 28 ++++++++ backend/app/db/models/audit.py | 1 + backend/app/db/models/receipt.py | 3 + backend/app/services/crm/client.py | 2 + backend/app/services/crm/exo_client.py | 68 ++++++++++++++----- backend/app/services/crm/stub_client.py | 5 ++ backend/app/services/receipts.py | 60 ++++++++++++++++ backend/app/worker.py | 8 ++- backend/tests/test_crm_client.py | 40 +++++++++++ backend/tests/test_receipts_service.py | 67 ++++++++++++++++++ 12 files changed, 266 insertions(+), 19 deletions(-) create mode 100644 backend/alembic/versions/0006_receipt_crm_status_set_at.py diff --git a/.plans/checkbox-ettn-receipts.md b/.plans/checkbox-ettn-receipts.md index 8751775..0931193 100644 --- a/.plans/checkbox-ettn-receipts.md +++ b/.plans/checkbox-ettn-receipts.md @@ -138,6 +138,8 @@ webhook в Checkbox, и Checkbox **сам фискализирует** чек. как в чеках из портала. Боевой API отдаёт статусы строчными (`created`/`done`) — нормализуются. Лимит списка — 50 за страницу; 429 «Занадто часто» — повторяемая ошибка. - [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 вместо опроса. --- diff --git a/CLAUDE.md b/CLAUDE.md index 28f137a..d1ea60d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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). - 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). +- 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 `{"": {"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()`. diff --git a/backend/alembic/versions/0006_receipt_crm_status_set_at.py b/backend/alembic/versions/0006_receipt_crm_status_set_at.py new file mode 100644 index 0000000..e3d7d4e --- /dev/null +++ b/backend/alembic/versions/0006_receipt_crm_status_set_at.py @@ -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") diff --git a/backend/app/db/models/audit.py b/backend/app/db/models/audit.py index 39a2a42..41164f9 100644 --- a/backend/app/db/models/audit.py +++ b/backend/app/db/models/audit.py @@ -36,6 +36,7 @@ class AuditAction(str): CASH_REGISTER_UPDATED = "cash_register.updated" RECEIPT_CREATE_REQUESTED = "receipt.create_requested" RECEIPT_CANCELLED = "receipt.cancelled" + ORDER_CRM_STATUS_SET = "order.crm_status_set" class AuditLog(UUIDPrimaryKeyMixin, Base): diff --git a/backend/app/db/models/receipt.py b/backend/app/db/models/receipt.py index b42177b..27b33a5 100644 --- a/backend/app/db/models/receipt.py +++ b/backend/app/db/models/receipt.py @@ -70,6 +70,9 @@ class Receipt(UUIDPrimaryKeyMixin, TimestampMixin, Base): # Снимок отправленного в Checkbox тела — для разбора спорных случаев. request_body: Mapped[dict[str, Any]] = mapped_column(JSONB, nullable=False, default=dict) 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__ = ( # Не больше одного «живого» чека на заказ — защита от двойного нажатия diff --git a/backend/app/services/crm/client.py b/backend/app/services/crm/client.py index 91d1b26..1e53f0e 100644 --- a/backend/app/services/crm/client.py +++ b/backend/app/services/crm/client.py @@ -13,3 +13,5 @@ class CrmError(Exception): class CrmClient(Protocol): async def get_orders(self, *, status: str) -> list[OrderOut]: ... + + async def set_status(self, *, order_id: str, status: str) -> None: ... diff --git a/backend/app/services/crm/exo_client.py b/backend/app/services/crm/exo_client.py index 074f49d..ca7ccb3 100644 --- a/backend/app/services/crm/exo_client.py +++ b/backend/app/services/crm/exo_client.py @@ -1,7 +1,9 @@ -"""Реальный клиент exoCRM (`GetOrders`).""" +"""Реальный клиент exoCRM (`GetOrders`, `SetStatus`).""" from __future__ import annotations +from typing import Any + import httpx 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 +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: def __init__(self, settings: Settings) -> None: self._base_url = settings.crm_base_url @@ -18,29 +28,51 @@ class ExoCrmClient: self._shop_key = settings.crm_shop_key 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]: - body = { - "apikey": self._api_key, - "object": "Orders", - "method": "GetOrders", - "params": { + data = await self._post( + "GetOrders", + { "sid": self._sid, "key": self._shop_key, "Status": status, "ReturnGoods": 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": - errors = data.get("errors") or {} - message = "; ".join(f"{code}: {text}" for code, text in errors.items()) - raise CrmError(f"CRM вернула ошибку: {message or 'неизвестная ошибка'}") + raise CrmError(f"CRM вернула ошибку: {_errors_text(data) or 'неизвестная ошибка'}") + return [OrderOut.model_validate(order) for order in data.get("result") 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}") diff --git a/backend/app/services/crm/stub_client.py b/backend/app/services/crm/stub_client.py index 75baf70..04b4d88 100644 --- a/backend/app/services/crm/stub_client.py +++ b/backend/app/services/crm/stub_client.py @@ -40,6 +40,11 @@ _FIXTURE_ORDERS: list[dict] = [ class StubCrmClient: def __init__(self, orders: list[dict] | None = None) -> None: 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]: return [ diff --git a/backend/app/services/receipts.py b/backend/app/services/receipts.py index d8b857b..5668ccd 100644 --- a/backend/app/services/receipts.py +++ b/backend/app/services/receipts.py @@ -20,6 +20,7 @@ from datetime import UTC, datetime, timedelta from decimal import ROUND_HALF_UP, Decimal from typing import Any +import httpx from fastapi import Request from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -39,6 +40,7 @@ from app.services.checkbox.client import ( CheckboxError, CheckboxUnavailableError, ) +from app.services.crm.client import CrmClient, CrmError log = get_logger(__name__) @@ -65,6 +67,16 @@ _CHECKBOX_TO_STATUS = { # или Checkbox был недоступен) и повторить его из cron'а. _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): """Заказ нельзя отправить в Checkbox — сообщение показывается кассиру.""" @@ -500,6 +512,54 @@ async def sync_ettn_statuses(session: AsyncSession, client: CheckboxClient) -> N 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( session: AsyncSession, order_ids: list[str] ) -> dict[str, Receipt]: diff --git a/backend/app/worker.py b/backend/app/worker.py index b7e9069..7a41573 100644 --- a/backend/app/worker.py +++ b/backend/app/worker.py @@ -3,7 +3,8 @@ Запускается отдельным процессом: `arq app.worker.WorkerSettings`. - `create_ettn_receipt` — задача, которую ставит API после запроса кассира; - `poll_np_statuses` — раз в минуту статусы ТТН по заказам без чека; -- `poll_receipts` — раз в минуту повтор зависших `pending` и статусы `created`-чеков. +- `poll_receipts` — раз в минуту повтор зависших `pending`, статусы `created`-чеков + и повтор смены статуса заказа в CRM (PACKED), если CRM была недоступна. """ 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.services import receipts as receipts_service 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.orders import sync_np_statuses @@ -29,6 +31,7 @@ async def startup(ctx: dict[str, Any]) -> None: configure_logging() ctx["np_client"] = NpTrackingClient(settings) ctx["checkbox_client"] = get_checkbox_client() + ctx["crm_client"] = ExoCrmClient(settings) 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( 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 with SessionFactory() as session: await receipts_service.retry_pending_receipts(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") diff --git a/backend/tests/test_crm_client.py b/backend/tests/test_crm_client.py index 08b2974..0e5cbfc 100644 --- a/backend/tests/test_crm_client.py +++ b/backend/tests/test_crm_client.py @@ -106,3 +106,43 @@ class TestExoCrmClientGetOrders: with pytest.raises(CrmError, match="Checksum Error"): 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="Notice: ...")) + with pytest.raises(CrmError, match="не JSON"): + await ExoCrmClient(_settings()).set_status(order_id="1", status="PACKED") diff --git a/backend/tests/test_receipts_service.py b/backend/tests/test_receipts_service.py index 18b0098..5e33dc5 100644 --- a/backend/tests/test_receipts_service.py +++ b/backend/tests/test_receipts_service.py @@ -323,3 +323,70 @@ class TestCancel: receipt.id, 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"}