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
+40
View File
@@ -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="<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,
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"}