diff --git a/.plans/checkbox-ettn-receipts.md b/.plans/checkbox-ettn-receipts.md index db6b5a2..8751775 100644 --- a/.plans/checkbox-ettn-receipts.md +++ b/.plans/checkbox-ettn-receipts.md @@ -133,7 +133,10 @@ webhook в Checkbox, и Checkbox **сам фискализирует** чек. - [ ] На первом боевом чеке (реальная касса): нужна ли открытая смена для ЕТТН; как ведёт себя `RECEIPT_ERROR`. - [ ] Сверить реальное значение `np_payment_status` (`Payed` vs `Paid` — фронт и стаб расходятся). - [x] Обновить `CLAUDE.md`/`README.md`/`.env.example` (статус, новые env-переменные). -- [ ] Проверить на первом боевом чеке, что `value` скидок (`DISCOUNT`/`PRE_PAYMENT`, mode `VALUE`) — в копейках. +- [x] Первый боевой чек (ТТН 20451543715206, 2026-09-24): `value` скидок — в копейках (подтверждено). + `PRE_PAYMENT` → 400 `third_party.generic`; предоплата теперь идёт обычной скидкой `DISCOUNT` «Знижка», + как в чеках из портала. Боевой API отдаёт статусы строчными (`created`/`done`) — нормализуются. + Лимит списка — 50 за страницу; 429 «Занадто часто» — повторяемая ошибка. - [x] `alembic upgrade head` на живом Postgres (0004 → 0005 применена в Docker). - [ ] Позже (не в этом этапе): `fiscalize-manually`, PDF/ссылка на фискальный чек, webhook вместо опроса. diff --git a/CLAUDE.md b/CLAUDE.md index 686d46f..28f137a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,7 +122,7 @@ Stages 1–4 (scaffolding, auth/audit, CRM order queue, Nova Poshta tracking) ar ### Checkbox ETTN receipts -- 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`. +- 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). - 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/app/schemas/checkbox.py b/backend/app/schemas/checkbox.py index 7c475d1..73c76ab 100644 --- a/backend/app/schemas/checkbox.py +++ b/backend/app/schemas/checkbox.py @@ -7,7 +7,7 @@ from __future__ import annotations -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, field_validator class EttnStatus: @@ -30,3 +30,10 @@ class EttnOut(BaseModel): total_sum: int | None = Field(default=None, alias="totalSum") receipt_id: str | None = Field(default=None, alias="receiptId") raw_error: str | None = Field(default=None, alias="rawError") + + # В OpenAPI статусы заглавные, а боевой API отдаёт строчные (`"done"`, + # `"created"`) — приводим к одному регистру, иначе статусы не сопоставятся. + @field_validator("status", mode="before") + @classmethod + def _upper_status(cls, value: object) -> object: + return value.upper() if isinstance(value, str) else value diff --git a/backend/app/services/checkbox/http_client.py b/backend/app/services/checkbox/http_client.py index 3521239..c567ca0 100644 --- a/backend/app/services/checkbox/http_client.py +++ b/backend/app/services/checkbox/http_client.py @@ -23,8 +23,9 @@ from app.services.checkbox.client import ( _PROVIDER = "novapost" _TIMEOUT = httpx.Timeout(30, connect=10) # Сколько страниц списка ЕТТН просматривать при сверке после таймаута. -_FIND_PAGES = 5 -_FIND_PAGE_SIZE = 100 +# Список отдаётся от новых к старым; больше 50 за страницу Checkbox не принимает. +_FIND_PAGES = 3 +_FIND_PAGE_SIZE = 50 def _error_message(response: httpx.Response) -> str: @@ -35,6 +36,11 @@ def _error_message(response: httpx.Response) -> str: if isinstance(data, dict): message = data.get("message") detail = data.get("detail") + # Ошибки со стороны Новой Почты Checkbox отдаёт как + # {"code": "third_party.*", "message": "Internal Server Error"} — + # без кода кассир видит только бесполезное «Internal Server Error». + if code := data.get("code"): + message = f"{message or 'Ошибка'} ({code})" if isinstance(detail, list) and detail: parts = [ f"{'.'.join(str(p) for p in item.get('loc', []))}: {item.get('msg')}" @@ -74,7 +80,8 @@ class HttpCheckboxClient: ) except httpx.HTTPError as exc: raise CheckboxUnavailableError(f"Checkbox недоступен: {exc!r}") from exc - if response.status_code >= 500: + # 429 — «Занадто часто виконуються запити»: исход не ошибка данных, повторяемо. + if response.status_code >= 500 or response.status_code == 429: raise CheckboxUnavailableError(_error_message(response)) return response diff --git a/backend/app/services/receipts.py b/backend/app/services/receipts.py index a97dc67..d8b857b 100644 --- a/backend/app/services/receipts.py +++ b/backend/app/services/receipts.py @@ -88,13 +88,13 @@ def _line_sum(price_kopecks: int, quantity: int) -> int: def _normalize_phone(phone: str | None) -> str | None: - """Телефон для отправки чека: Checkbox принимает только `+?380\\d{9}`.""" + """Телефон для отправки чека в формате `380XXXXXXXXX`, как в чеках из портала Checkbox.""" if not phone: return None digits = re.sub(r"\D", "", phone) if len(digits) == 10 and digits.startswith("0"): digits = "38" + digits - return f"+{digits}" if re.fullmatch(r"380\d{9}", digits) else None + return digits if re.fullmatch(r"380\d{9}", digits) else None # --- Тело запроса ------------------------------------------------------------ @@ -145,7 +145,6 @@ def build_ettn_body( order: Order, register: CashRegister, amounts: ReceiptAmounts ) -> dict[str, Any]: goods, goods_total = build_goods(order, register.tax_codes) - discounts: list[dict[str, Any]] = [] # Скидка на весь заказ в CRM (не распределённая по строкам). order_discount = goods_total - amounts.total_kopecks @@ -153,18 +152,14 @@ def build_ettn_body( raise ReceiptValidationError( "Сумма товаров меньше суммы заказа — проверьте заказ в CRM" ) - if order_discount: + # Предоплата и скидка заказа — одной обычной скидкой «Знижка», как в чеках из + # портала Checkbox. Тип `PRE_PAYMENT` для ЕТТН не годится: Checkbox отвечает + # 400 `third_party.generic` (проверено на боевой кассе). + discount_total = order_discount + amounts.prepayment_kopecks + discounts: list[dict[str, Any]] = [] + if discount_total: discounts.append( - {"type": "DISCOUNT", "mode": "VALUE", "value": order_discount, "name": "Знижка"} - ) - if amounts.prepayment_kopecks: - discounts.append( - { - "type": "PRE_PAYMENT", - "mode": "VALUE", - "value": amounts.prepayment_kopecks, - "name": "Передоплата", - } + {"type": "DISCOUNT", "mode": "VALUE", "value": discount_total, "name": "Знижка"} ) # Последняя страховка перед отправкой: сумма чека (товары − скидки − предоплата) @@ -490,6 +485,11 @@ async def sync_ettn_statuses(session: AsyncSession, client: CheckboxClient) -> N creds = registers[receipt.cash_register_id] = credentials(register) try: ettn = await client.get_ettn(creds, receipt.checkbox_ettn_id) + except CheckboxUnavailableError as exc: + # Недоступен или лимит частоты запросов — остальные чеки опросим + # в следующий раз, а не будем добивать Checkbox прямо сейчас. + log.warning("ettn_poll_paused", receipt_id=str(receipt.id), error=str(exc)) + break except CheckboxError as exc: log.warning("ettn_poll_failed", receipt_id=str(receipt.id), error=str(exc)) continue diff --git a/backend/tests/test_checkbox_client.py b/backend/tests/test_checkbox_client.py index d9b9652..93b6f84 100644 --- a/backend/tests/test_checkbox_client.py +++ b/backend/tests/test_checkbox_client.py @@ -148,3 +148,35 @@ async def test_bad_pin_raises() -> None: ) with pytest.raises(CheckboxError, match="Невірний пін-код"): await _client().sign_in(CREDS) + + +@respx.mock +async def test_lowercase_status_from_live_api_is_normalized() -> None: + _signin() + respx.get(f"{BASE}/api/v1/ettn/e-1").mock( + return_value=Response(200, json={**ETTN, "status": "done"}) + ) + ettn = await _client().get_ettn(CREDS, "e-1") + assert ettn.status == "DONE" + + +@respx.mock +async def test_third_party_error_code_is_shown() -> None: + _signin() + respx.post(f"{BASE}/api/v1/ettn").mock( + return_value=Response( + 400, json={"code": "third_party.generic", "message": "Internal Server Error"} + ) + ) + with pytest.raises(CheckboxError, match=r"third_party\.generic"): + await _client().create_ettn(CREDS, {}) + + +@respx.mock +async def test_rate_limit_is_retryable() -> None: + _signin() + respx.post(f"{BASE}/api/v1/ettn").mock( + return_value=Response(429, json={"message": "Занадто часто виконуються запити"}) + ) + with pytest.raises(CheckboxUnavailableError): + await _client().create_ettn(CREDS, {}) diff --git a/backend/tests/test_receipts_service.py b/backend/tests/test_receipts_service.py index 61648c1..18b0098 100644 --- a/backend/tests/test_receipts_service.py +++ b/backend/tests/test_receipts_service.py @@ -117,7 +117,7 @@ class TestBuildBody: } ] assert "discounts" not in rb - assert rb["delivery"] == {"phone": "+380501112233", "emails": ["a@b.ua"]} + assert rb["delivery"] == {"phone": "380501112233", "emails": ["a@b.ua"]} def test_prepayment_line_and_order_discounts_and_tax(self) -> None: order = _order( @@ -132,10 +132,8 @@ class TestBuildBody: assert rb["goods"][0]["discounts"] == [ {"type": "DISCOUNT", "mode": "VALUE", "value": 10000} ] - assert [(d["type"], d["value"]) for d in rb["discounts"]] == [ - ("DISCOUNT", 5000), - ("PRE_PAYMENT", 20000), - ] + # Скидка заказа 50 ₴ + предоплата 200 ₴ — одной «Знижкой», без PRE_PAYMENT. + assert [(d["type"], d["value"]) for d in rb["discounts"]] == [("DISCOUNT", 25000)] assert rb["payments"][0]["value"] == 85000 def test_all_goods_with_prices_quantities_and_sums(self) -> None: @@ -166,7 +164,7 @@ class TestBuildBody: goods_total = sum(svc._line_sum(g["good"]["price"], g["quantity"]) for g in rb["goods"]) assert goods_total - 20000 == rb["payments"][0]["value"] == 130049 assert rb["discounts"] == [ - {"type": "PRE_PAYMENT", "mode": "VALUE", "value": 20000, "name": "Передоплата"} + {"type": "DISCOUNT", "mode": "VALUE", "value": 20000, "name": "Знижка"} ] def test_fractional_quantity(self) -> None: