Files
lauadminandClaude Opus 5.5 b8f2fe6b6e Translate UI and user-facing messages to Ukrainian (#5)
- All frontend pages, labels, notices and errors; html lang=uk, uk-UA money format
- Brand "Assistant System" in the top bar and page title
- Backend error details returned to the UI (auth, orders, receipts,
  cash registers, Checkbox/CRM/NP errors) and CLI output
- Tests updated for the new messages

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:27:39 +03:00

610 lines
27 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""ЕТТН-чеки Checkbox: создание по ТТН с контролем оплаты, отмена, опрос статусов.
Чек не фискализируется нами — Checkbox делает это сам, когда клиент оплачивает
посылку в отделении НП. Поэтому ЕТТН-чек должен быть создан до получения посылки,
а сумма к оплате (наложка НП) обязана совпасть с суммой чека:
сумма товаров − скидки − предоплата == np_cod_amount_kopecks
Создание двухфазное: API пишет `Receipt(pending)` (+ аудит) и ставит задачу,
worker вызывает Checkbox (`create_ettn_for_receipt`). Так кассир не ждёт сеть,
а сбой Checkbox не теряет запрос — `retry_pending_receipts` его повторит.
"""
from __future__ import annotations
import re
import uuid
from dataclasses import dataclass
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
from app.core import crypto
from app.core.logging import get_logger
from app.db.models.audit import AuditAction
from app.db.models.cash_register import CashRegister
from app.db.models.order import Order
from app.db.models.receipt import CLOSED_STATUSES, Receipt, ReceiptStatus
from app.db.models.user import User
from app.schemas.checkbox import EttnOut, EttnStatus
from app.services import audit
from app.services.checkbox.client import (
CheckboxClient,
CheckboxCredentials,
CheckboxError,
CheckboxRateLimitedError,
CheckboxUnavailableError,
)
from app.services.crm.client import CrmClient, CrmError
log = get_logger(__name__)
# Коды статусов НП, при которых ЕТТН-чек создавать поздно или бессмысленно:
# посылка уже получена, возвращается/возвращена или ТТН удалена.
NP_FINAL_STATUS_CODES = frozenset(
{"2", "9", "10", "11", "102", "103", "105", "106", "108"}
)
# Способ оплаты в ЕТТН-чеке (`ETTNPaymentSchema`).
ETTN_PAYMENT_TYPE = "ETTN"
ETTN_PAYMENT_LABEL = "Експрес-накладна"
# Статус Checkbox → наш статус. CREATED не меняет ничего.
_CHECKBOX_TO_STATUS = {
EttnStatus.DONE: ReceiptStatus.DONE,
EttnStatus.DONE_WITHOUT_SMS: ReceiptStatus.DONE,
EttnStatus.RETURNED: ReceiptStatus.RETURNED,
EttnStatus.RECEIPT_ERROR: ReceiptStatus.RECEIPT_ERROR,
EttnStatus.CANCELLED: ReceiptStatus.CANCELLED,
}
# Сколько ждать, прежде чем считать `pending`-чек зависшим (задача потеряна
# или Checkbox был недоступен) и повторить его из cron'а.
_PENDING_RETRY_AFTER = timedelta(minutes=1)
# Сколько `created`-чеков опрашивать за один прогон cron'а (раз в минуту):
# при паузе ~1 с между запросами к Checkbox прогон укладывается в минуту и
# не наслаивается на следующий.
_ETTN_POLL_BATCH = 30
# Статус заказа в 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 — сообщение показывается кассиру."""
# --- Деньги и количества -----------------------------------------------------
def to_kopecks(amount: str | None) -> int:
if not amount:
return 0
return int((Decimal(amount) * 100).to_integral_value(ROUND_HALF_UP))
def to_thousandths(quantity: str) -> int:
return int((Decimal(quantity) * 1000).to_integral_value(ROUND_HALF_UP))
def _line_sum(price_kopecks: int, quantity: int) -> int:
return int((Decimal(price_kopecks) * quantity / 1000).to_integral_value(ROUND_HALF_UP))
def _normalize_phone(phone: str | None) -> str | None:
"""Телефон для отправки чека в формате `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 digits if re.fullmatch(r"380\d{9}", digits) else None
# --- Тело запроса ------------------------------------------------------------
@dataclass(frozen=True)
class ReceiptAmounts:
total_kopecks: int # к оплате по заказу после всех скидок
prepayment_kopecks: int
cod_kopecks: int # наложка НП = total − prepayment
def build_goods(order: Order, tax_codes: list[Any]) -> tuple[list[dict[str, Any]], int]:
"""Товары CRM → `goods` Checkbox. Возвращает позиции и их сумму после скидок."""
items: list[dict[str, Any]] = []
total = 0
for good in order.goods:
price = to_kopecks(good["price"])
quantity = to_thousandths(good["quantity"])
if quantity <= 0:
raise ReceiptValidationError(f"Товар «{good['name']}»: кількість має бути більшою за 0")
gross = _line_sum(price, quantity)
# `amount` — сумма строки после скидки CRM; скидку выводим из неё,
# а не из discount_amount/percent, чтобы не расходиться с итогом CRM.
net = to_kopecks(good.get("amount")) if good.get("amount") else gross
discount = gross - net
if discount < 0:
raise ReceiptValidationError(
f"Товар «{good['name']}»: сума рядка більша за ціну × кількість"
)
payload: dict[str, Any] = {
"code": good.get("sku") or good["id"],
"name": good["name"],
"price": price,
}
if tax_codes:
payload["tax"] = list(tax_codes)
item: dict[str, Any] = {"good": payload, "quantity": quantity, "is_return": False}
if discount:
item["discounts"] = [{"type": "DISCOUNT", "mode": "VALUE", "value": discount}]
items.append(item)
total += net
return items, total
def build_ettn_body(
order: Order, register: CashRegister, amounts: ReceiptAmounts
) -> dict[str, Any]:
goods, goods_total = build_goods(order, register.tax_codes)
# Скидка на весь заказ в CRM (не распределённая по строкам).
order_discount = goods_total - amounts.total_kopecks
if order_discount < 0:
raise ReceiptValidationError(
"Сума товарів менша за суму замовлення — перевірте замовлення в CRM"
)
# Предоплата и скидка заказа — одной обычной скидкой «Знижка», как в чеках из
# портала 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": discount_total, "name": "Знижка"}
)
# Последняя страховка перед отправкой: сумма чека (товары − скидки − предоплата)
# обязана совпасть с оплатой по ЕТТН, иначе Checkbox не сможет фискализировать.
receipt_total = goods_total - order_discount - amounts.prepayment_kopecks
if receipt_total != amounts.cod_kopecks:
raise ReceiptValidationError(
f"Сума чека {receipt_total / 100:.2f} ₴ ≠ післяплата {amounts.cod_kopecks / 100:.2f} ₴"
)
receipt_body: dict[str, Any] = {
"goods": goods,
# Оплата «Експрес-накладна»: деньги примет НП при выдаче посылки,
# тогда Checkbox и фискализирует чек. Тип и подпись — явно, не полагаясь
# на значения по умолчанию Checkbox.
"payments": [
{
"type": ETTN_PAYMENT_TYPE,
"label": ETTN_PAYMENT_LABEL,
"value": amounts.cod_kopecks,
"ettn": order.waybill_number,
}
],
}
if discounts:
receipt_body["discounts"] = discounts
delivery: dict[str, Any] = {}
if phone := _normalize_phone(order.recipient_phone):
delivery["phone"] = phone
if order.recipient_email:
delivery["emails"] = [order.recipient_email]
if delivery:
receipt_body["delivery"] = delivery
return {"provider": "novapost", "receipt_body": receipt_body}
def resolve_amounts(order: Order, prepayment_kopecks: int | None) -> ReceiptAmounts:
"""Проверяет, что по заказу можно создать ЕТТН-чек, и считает суммы.
`prepayment_kopecks=None` — взять разницу между суммой заказа и наложкой.
"""
if order.is_deleted:
raise ReceiptValidationError("Замовлення видалено")
if not order.waybill_number:
raise ReceiptValidationError("У замовлення немає ТТН")
if not order.np_cod_amount_kopecks:
raise ReceiptValidationError("За ТТН немає суми контролю оплати (післяплати)")
if order.np_status_code in NP_FINAL_STATUS_CODES:
raise ReceiptValidationError(
f"Посилка вже не в дорозі ({order.np_status or order.np_status_code}) — "
"ЕТТН-чек створити неможливо"
)
if not order.goods:
raise ReceiptValidationError("У замовленні немає товарів")
total = order.total_amount_kopecks
cod = order.np_cod_amount_kopecks
prepayment = total - cod if prepayment_kopecks is None else prepayment_kopecks
if prepayment < 0:
raise ReceiptValidationError(
f"Післяплата {cod / 100:.2f} ₴ більша за суму замовлення {total / 100:.2f} ₴"
)
if total - prepayment != cod:
raise ReceiptValidationError(
f"Сума замовлення {total / 100:.2f} ₴ − передоплата {prepayment / 100:.2f} ₴ "
f"≠ післяплата {cod / 100:.2f} ₴"
)
return ReceiptAmounts(total_kopecks=total, prepayment_kopecks=prepayment, cod_kopecks=cod)
# --- Кассы -------------------------------------------------------------------
def credentials(register: CashRegister) -> CheckboxCredentials:
return CheckboxCredentials(
cash_register_id=register.id,
license_key=crypto.decrypt(register.license_key_enc),
pin_code=crypto.decrypt(register.cashier_pin_enc),
)
async def _active_registers(session: AsyncSession) -> dict[uuid.UUID, CashRegister]:
registers = await session.scalars(
select(CashRegister).where(CashRegister.is_active.is_(True))
)
return {register.id: register for register in registers}
# --- Создание ----------------------------------------------------------------
@dataclass
class RequestResult:
created: list[Receipt]
errors: dict[str, str]
async def _active_receipt_order_ids(session: AsyncSession, order_ids: list[str]) -> set[str]:
result = await session.scalars(
select(Receipt.order_id)
.where(Receipt.order_id.in_(order_ids))
.where(Receipt.status.not_in(CLOSED_STATUSES))
)
return set(result)
async def request_receipts(
session: AsyncSession,
items: list[tuple[str, int | None]],
*,
user: User,
request: Request | None = None,
) -> RequestResult:
"""Создаёт `Receipt(pending)` по каждому подходящему заказу.
Без commit и без обращения к Checkbox: вызывающий код коммитит и ставит
задачи worker'у (`create_ettn_for_receipt`) по `result.created`.
Ошибки по отдельным заказам не мешают остальным — массовое действие.
Чек создаётся от кассы, чей ключ НП видит ТТН заказа как свою
(`orders.cash_register_id`, проставляет `orders.sync_np_statuses`).
"""
result = RequestResult(created=[], errors={})
registers = await _active_registers(session)
if not registers:
for order_id, _ in items:
result.errors[order_id] = "Не налаштовано касу Checkbox"
return result
order_ids = [order_id for order_id, _ in items]
orders = {
order.id: order
for order in await session.scalars(select(Order).where(Order.id.in_(order_ids)))
}
busy = await _active_receipt_order_ids(session, order_ids)
for order_id, prepayment in items:
order = orders.get(order_id)
if order is None:
result.errors[order_id] = "Замовлення не знайдено"
continue
if order_id in busy:
result.errors[order_id] = "За замовленням уже є чек"
continue
register = registers.get(order.cash_register_id) if order.cash_register_id else None
if register is None:
result.errors[order_id] = (
"Касу не визначено: ТТН не знайдено жодним ключем Нової Пошти "
"активних кас"
)
continue
try:
amounts = resolve_amounts(order, prepayment)
body = build_ettn_body(order, register, amounts)
except ReceiptValidationError as exc:
result.errors[order_id] = str(exc)
continue
now = datetime.now(UTC)
receipt = Receipt(
id=uuid.uuid4(),
# Явно, а не server_default: после commit ответ API читает created_at,
# а незагруженный server_default в async-сессии не подгружается лениво.
created_at=now,
updated_at=now,
order_id=order.id,
cash_register_id=register.id,
created_by_id=user.id,
waybill_number=order.waybill_number,
total_kopecks=amounts.total_kopecks,
prepayment_kopecks=amounts.prepayment_kopecks,
cod_kopecks=amounts.cod_kopecks,
status=ReceiptStatus.PENDING,
request_body=body,
)
session.add(receipt)
order.receipt_created_at = now
busy.add(order_id)
await audit.record(
session,
action=AuditAction.RECEIPT_CREATE_REQUESTED,
user=user,
entity_type="receipt",
entity_id=receipt.id,
payload={
"order_id": order.id,
"waybill_number": order.waybill_number,
"cash_register_id": str(register.id),
"cod_kopecks": amounts.cod_kopecks,
"prepayment_kopecks": amounts.prepayment_kopecks,
},
request=request,
)
result.created.append(receipt)
return result
async def _release_order(session: AsyncSession, order_id: str) -> None:
"""Возвращает заказ в очередь «Без чека»."""
order = await session.get(Order, order_id)
if order is not None:
order.receipt_created_at = None
def _apply_ettn(receipt: Receipt, ettn: EttnOut) -> None:
receipt.checkbox_ettn_id = ettn.id
receipt.checkbox_status = ettn.status
receipt.last_checked_at = datetime.now(UTC)
if ettn.receipt_id:
receipt.checkbox_receipt_id = ettn.receipt_id
new_status = _CHECKBOX_TO_STATUS.get(ettn.status)
if new_status is not None:
receipt.status = new_status
elif receipt.status == ReceiptStatus.PENDING:
receipt.status = ReceiptStatus.CREATED
receipt.error = ettn.raw_error if new_status == ReceiptStatus.RECEIPT_ERROR else None
async def create_ettn_for_receipt(
session: AsyncSession, client: CheckboxClient, receipt_id: uuid.UUID
) -> Receipt | None:
"""Отправляет `pending`-чек в Checkbox. Идемпотентна — безопасно вызывать повторно.
На лимит частоты пробрасывает `CheckboxRateLimitedError`, чек остаётся `pending`.
"""
receipt = await session.get(Receipt, receipt_id, with_for_update=True)
if receipt is None or receipt.status != ReceiptStatus.PENDING:
return receipt
register = await session.get(CashRegister, receipt.cash_register_id)
assert register is not None # FK
creds = credentials(register)
try:
# Непустой `error` у pending-чека — прошлая попытка закончилась
# неизвестным исходом: сначала ищем, не создан ли чек уже.
existing = (
await client.find_ettn(creds, receipt.waybill_number) if receipt.error else None
)
ettn = existing or await client.create_ettn(creds, receipt.request_body)
except CheckboxRateLimitedError as exc:
# Запрос отклонён, чек точно не создан: остаётся `pending` без пометки
# «исход неизвестен». Повтор с задержкой — забота вызывающего (worker).
await session.commit() # снять блокировку строки
log.info("ettn_rate_limited", receipt_id=str(receipt_id), retry_after=exc.retry_after)
raise
except CheckboxUnavailableError as exc:
receipt.error = str(exc)
await session.commit()
log.warning("checkbox_unavailable", receipt_id=str(receipt.id), error=str(exc))
return receipt
except CheckboxError as exc:
receipt.status = ReceiptStatus.FAILED
receipt.error = str(exc)
await _release_order(session, receipt.order_id)
await session.commit()
log.warning("ettn_create_failed", receipt_id=str(receipt.id), error=str(exc))
return receipt
_apply_ettn(receipt, ettn)
if receipt.status in CLOSED_STATUSES:
await _release_order(session, receipt.order_id)
await session.commit()
log.info("ettn_created", receipt_id=str(receipt.id), ettn_id=ettn.id)
return receipt
async def retry_pending_receipts(session: AsyncSession, client: CheckboxClient) -> None:
"""Повторяет зависшие `pending`-чеки (потерянная задача, недоступный Checkbox)."""
threshold = datetime.now(UTC) - _PENDING_RETRY_AFTER
receipt_ids = list(
await session.scalars(
select(Receipt.id)
.where(Receipt.status == ReceiptStatus.PENDING)
.where(Receipt.updated_at < threshold)
)
)
for receipt_id in receipt_ids:
try:
await create_ettn_for_receipt(session, client, receipt_id)
except CheckboxRateLimitedError:
break # остальные — в следующем проходе cron'а
# --- Отмена и опрос ----------------------------------------------------------
class ReceiptStateError(Exception):
"""Действие недопустимо в текущем статусе чека."""
async def cancel_receipt(
session: AsyncSession,
client: CheckboxClient,
receipt_id: uuid.UUID,
*,
user: User,
request: Request | None = None,
) -> Receipt | None:
"""Удаляет ЕТТН-чек в Checkbox и возвращает заказ в очередь. Коммитит сам."""
receipt = await session.get(Receipt, receipt_id, with_for_update=True)
if receipt is None:
return None
if receipt.status not in (ReceiptStatus.CREATED, ReceiptStatus.RECEIPT_ERROR):
raise ReceiptStateError(f"Чек у статусі «{receipt.status}» скасувати неможливо")
if receipt.checkbox_ettn_id:
register = await session.get(CashRegister, receipt.cash_register_id)
assert register is not None
await client.delete_ettn(credentials(register), receipt.checkbox_ettn_id)
receipt.status = ReceiptStatus.CANCELLED
receipt.checkbox_status = EttnStatus.CANCELLED
await _release_order(session, receipt.order_id)
await audit.record(
session,
action=AuditAction.RECEIPT_CANCELLED,
user=user,
entity_type="receipt",
entity_id=receipt.id,
payload={"order_id": receipt.order_id, "waybill_number": receipt.waybill_number},
request=request,
)
await session.commit()
return receipt
async def sync_ettn_statuses(session: AsyncSession, client: CheckboxClient) -> None:
"""Опрашивает Checkbox по чекам, ожидающим оплаты посылки (`created`).
За прогон — не больше `_ETTN_POLL_BATCH` давно не проверенных чеков, с
коммитом после каждого: запросы к Checkbox идут с паузой, и опрос всех
чеков разом не укладывался в таймаут cron-задачи, а откат по таймауту
терял весь прогон — статусы не обновлялись вовсе.
"""
receipts = list(
await session.scalars(
select(Receipt)
.where(Receipt.status == ReceiptStatus.CREATED)
.order_by(Receipt.last_checked_at.asc().nulls_first())
.limit(_ETTN_POLL_BATCH)
)
)
registers: dict[uuid.UUID, CheckboxCredentials] = {}
for receipt in receipts:
if not receipt.checkbox_ettn_id:
continue
creds = registers.get(receipt.cash_register_id)
if creds is None:
register = await session.get(CashRegister, receipt.cash_register_id)
assert register is not None
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
_apply_ettn(receipt, ettn)
if receipt.status in CLOSED_STATUSES:
await _release_order(session, receipt.order_id)
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]:
"""Последний чек по каждому заказу — для колонок очереди."""
if not order_ids:
return {}
receipts = await session.scalars(
select(Receipt).where(Receipt.order_id.in_(order_ids)).order_by(Receipt.created_at)
)
return {receipt.order_id: receipt for receipt in receipts}