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>
This commit is contained in:
@@ -53,7 +53,7 @@ async def authenticate(
|
||||
request=request,
|
||||
)
|
||||
await session.commit()
|
||||
raise AuthError("Неверный email или пароль")
|
||||
raise AuthError("Невірний email або пароль")
|
||||
|
||||
if not user.is_active:
|
||||
await audit.record(
|
||||
@@ -64,7 +64,7 @@ async def authenticate(
|
||||
request=request,
|
||||
)
|
||||
await session.commit()
|
||||
raise AuthError("Учётная запись отключена")
|
||||
raise AuthError("Обліковий запис вимкнено")
|
||||
|
||||
# Параметры argon2 со временем ужесточаются — обновляем хеш на живом пароле.
|
||||
if password_needs_rehash(user.password_hash):
|
||||
@@ -117,7 +117,7 @@ async def rotate_refresh_token(
|
||||
stored = await session.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash))
|
||||
|
||||
if stored is None:
|
||||
raise AuthError("Некорректный refresh-токен")
|
||||
raise AuthError("Некоректний refresh-токен")
|
||||
|
||||
if stored.revoked_at is not None:
|
||||
await revoke_all_for_user(session, stored.user_id)
|
||||
@@ -131,14 +131,14 @@ async def rotate_refresh_token(
|
||||
)
|
||||
await session.commit()
|
||||
log.warning("refresh_token_reuse_detected", user_id=str(stored.user_id))
|
||||
raise AuthError("Сессия отозвана, требуется повторный вход")
|
||||
raise AuthError("Сесію відкликано, потрібен повторний вхід")
|
||||
|
||||
if stored.expires_at <= datetime.now(UTC):
|
||||
raise AuthError("Срок действия refresh-токена истёк")
|
||||
raise AuthError("Термін дії refresh-токена минув")
|
||||
|
||||
user = await session.get(User, stored.user_id)
|
||||
if user is None or not user.is_active:
|
||||
raise AuthError("Учётная запись недоступна")
|
||||
raise AuthError("Обліковий запис недоступний")
|
||||
|
||||
pair = await issue_token_pair(session, user, request=request, replaces=stored)
|
||||
await audit.record(
|
||||
|
||||
@@ -63,7 +63,7 @@ def get_checkbox_client() -> CheckboxClient:
|
||||
"""Один экземпляр на процесс: внутри — кэш токенов кассиров."""
|
||||
if settings.checkbox_use_stub:
|
||||
if settings.is_production:
|
||||
raise RuntimeError("CHECKBOX_USE_STUB=true запрещён в production")
|
||||
raise RuntimeError("CHECKBOX_USE_STUB=true заборонено в production")
|
||||
from app.services.checkbox.stub_client import StubCheckboxClient
|
||||
|
||||
return StubCheckboxClient(auto_complete_after=2)
|
||||
|
||||
@@ -57,7 +57,7 @@ def _error_message(response: httpx.Response) -> str:
|
||||
# {"code": "third_party.*", "message": "Internal Server Error"} —
|
||||
# без кода кассир видит только бесполезное «Internal Server Error».
|
||||
if code := data.get("code"):
|
||||
message = f"{message or 'Ошибка'} ({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')}"
|
||||
@@ -130,7 +130,7 @@ class HttpCheckboxClient:
|
||||
method, path, headers=headers, json=json, params=params
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
raise CheckboxUnavailableError(f"Checkbox недоступен: {exc!r}") from exc
|
||||
raise CheckboxUnavailableError(f"Checkbox недоступний: {exc!r}") from exc
|
||||
if error := _transient_error(response):
|
||||
raise error
|
||||
return response
|
||||
@@ -155,7 +155,7 @@ class HttpCheckboxClient:
|
||||
json={"pin_code": creds.pin_code},
|
||||
)
|
||||
if response.status_code != 200:
|
||||
raise CheckboxError(f"Вход кассира не удался: {_error_message(response)}")
|
||||
raise CheckboxError(f"Вхід касира не вдався: {_error_message(response)}")
|
||||
token = response.json()["access_token"]
|
||||
self._tokens[creds.cash_register_id] = token
|
||||
return token
|
||||
|
||||
@@ -32,12 +32,12 @@ class StubCheckboxClient:
|
||||
|
||||
async def sign_in(self, creds: CheckboxCredentials) -> None:
|
||||
if not creds.license_key or not creds.pin_code:
|
||||
raise CheckboxError("Вход кассира не удался: пустой ключ или PIN")
|
||||
raise CheckboxError("Вхід касира не вдався: порожній ключ або PIN")
|
||||
|
||||
async def create_ettn(self, creds: CheckboxCredentials, body: dict[str, Any]) -> EttnOut:
|
||||
waybill = body["receipt_body"]["payments"][0]["ettn"]
|
||||
if await self.find_ettn(creds, waybill) is not None:
|
||||
raise CheckboxError(f"ЕТТН {waybill} уже привязана к чеку")
|
||||
raise CheckboxError(f"ЕТТН {waybill} вже прив'язана до чека")
|
||||
ettn = EttnOut(
|
||||
id=str(uuid.uuid4()),
|
||||
status=EttnStatus.CREATED,
|
||||
@@ -50,7 +50,7 @@ class StubCheckboxClient:
|
||||
|
||||
async def get_ettn(self, creds: CheckboxCredentials, ettn_id: str) -> EttnOut:
|
||||
if ettn_id not in self.orders:
|
||||
raise CheckboxError(f"ЕТТН-чек {ettn_id} не найден")
|
||||
raise CheckboxError(f"ЕТТН-чек {ettn_id} не знайдено")
|
||||
self._polls[ettn_id] = self._polls.get(ettn_id, 0) + 1
|
||||
if (
|
||||
self.auto_complete_after is not None
|
||||
|
||||
@@ -39,10 +39,10 @@ class ExoCrmClient:
|
||||
data = response.json()
|
||||
except ValueError as exc:
|
||||
# PHP-notice'ы CRM перед JSON — признак неверных параметров.
|
||||
raise CrmError(f"CRM вернула не JSON: {response.text[:300]}") from exc
|
||||
raise CrmError(f"CRM повернула не JSON: {response.text[:300]}") from exc
|
||||
|
||||
if not isinstance(data, dict):
|
||||
raise CrmError(f"Неожиданный ответ CRM: {str(data)[:300]}")
|
||||
raise CrmError(f"Неочікувана відповідь CRM: {str(data)[:300]}")
|
||||
return data
|
||||
|
||||
async def get_orders(self, *, status: str) -> list[OrderOut]:
|
||||
@@ -57,7 +57,7 @@ class ExoCrmClient:
|
||||
},
|
||||
)
|
||||
if data.get("status") != "OK":
|
||||
raise CrmError(f"CRM вернула ошибку: {_errors_text(data) or 'неизвестная ошибка'}")
|
||||
raise CrmError(f"CRM повернула помилку: {_errors_text(data) or 'невідома помилка'}")
|
||||
return [OrderOut.model_validate(order) for order in data.get("result") or []]
|
||||
|
||||
async def set_status(self, *, order_id: str, status: str) -> None:
|
||||
@@ -73,6 +73,6 @@ class ExoCrmClient:
|
||||
if isinstance(result, dict) and result.get("Status") == "Success":
|
||||
return
|
||||
details = _errors_text(data) or (
|
||||
str(result) if result is not None else "нет ответа по заказу"
|
||||
str(result) if result is not None else "немає відповіді щодо замовлення"
|
||||
)
|
||||
raise CrmError(f"CRM не сменила статус заказа {order_id} на {status}: {details}")
|
||||
raise CrmError(f"CRM не змінила статус замовлення {order_id} на {status}: {details}")
|
||||
|
||||
@@ -12,7 +12,7 @@ _FIXTURE_ORDERS: list[dict] = [
|
||||
"RecipientPhone": "+380501112233",
|
||||
"RecipientEmail": None,
|
||||
"Waybill_Number": "20450123456789",
|
||||
"Notes": "Тестовий заказ",
|
||||
"Notes": "Тестове замовлення",
|
||||
"Total": {
|
||||
"Cost": "0.00",
|
||||
"Quantity": "2",
|
||||
|
||||
@@ -21,7 +21,7 @@ class NpTrackingClient:
|
||||
return []
|
||||
if len(waybill_numbers) > _MAX_DOCUMENTS_PER_REQUEST:
|
||||
raise NovaPoshtaError(
|
||||
f"Слишком много ТТН за один запрос: {len(waybill_numbers)} "
|
||||
f"Забагато ТТН за один запит: {len(waybill_numbers)} "
|
||||
f"(максимум {_MAX_DOCUMENTS_PER_REQUEST})"
|
||||
)
|
||||
|
||||
@@ -42,6 +42,6 @@ class NpTrackingClient:
|
||||
if not data.get("success"):
|
||||
errors = data.get("errors") or []
|
||||
message = "; ".join(str(error) for error in errors)
|
||||
raise NovaPoshtaError(f"NP вернул ошибку: {message or 'неизвестная ошибка'}")
|
||||
raise NovaPoshtaError(f"NP повернув помилку: {message or 'невідома помилка'}")
|
||||
|
||||
return [TrackingStatusOut.model_validate(item) for item in data.get("data", [])]
|
||||
|
||||
@@ -216,7 +216,7 @@ async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None:
|
||||
"""
|
||||
accounts = await _np_accounts(session)
|
||||
if not accounts:
|
||||
log.warning("np_no_api_keys", hint="Укажите ключ API Новой Почты у кассы")
|
||||
log.warning("np_no_api_keys", hint="Вкажіть ключ API Нової Пошти в касі")
|
||||
return
|
||||
|
||||
orders = await session.scalars(
|
||||
@@ -327,7 +327,7 @@ def _build_goods(data: OrderUpdateIn) -> tuple[list[dict[str, Any]], int]:
|
||||
gross = int((price * good.quantity).to_integral_value(ROUND_HALF_UP))
|
||||
net = gross - discount
|
||||
if net < 0:
|
||||
raise OrderEditError(f"Товар «{good.name}»: скидка больше суммы строки")
|
||||
raise OrderEditError(f"Товар «{good.name}»: знижка більша за суму рядка")
|
||||
goods.append(
|
||||
{
|
||||
"id": good.id or f"new-{uuid.uuid4().hex[:8]}",
|
||||
@@ -357,13 +357,13 @@ async def update_order(
|
||||
if order is None or order.is_deleted:
|
||||
return None
|
||||
if order.receipt_created_at is not None:
|
||||
raise OrderEditError("По заказу уже создан чек — редактирование недоступно")
|
||||
raise OrderEditError("За замовленням уже створено чек — редагування недоступне")
|
||||
|
||||
goods, goods_total = _build_goods(data)
|
||||
total = _to_kopecks(str(data.total_amount))
|
||||
if total > goods_total:
|
||||
raise OrderEditError(
|
||||
f"Сумма заказа {total / 100:.2f} ₴ больше суммы товаров {goods_total / 100:.2f} ₴"
|
||||
f"Сума замовлення {total / 100:.2f} ₴ більша за суму товарів {goods_total / 100:.2f} ₴"
|
||||
)
|
||||
|
||||
changed = [
|
||||
|
||||
@@ -133,7 +133,7 @@ def build_goods(order: Order, tax_codes: list[Any]) -> tuple[list[dict[str, Any]
|
||||
price = to_kopecks(good["price"])
|
||||
quantity = to_thousandths(good["quantity"])
|
||||
if quantity <= 0:
|
||||
raise ReceiptValidationError(f"Товар «{good['name']}»: количество должно быть больше 0")
|
||||
raise ReceiptValidationError(f"Товар «{good['name']}»: кількість має бути більшою за 0")
|
||||
gross = _line_sum(price, quantity)
|
||||
# `amount` — сумма строки после скидки CRM; скидку выводим из неё,
|
||||
# а не из discount_amount/percent, чтобы не расходиться с итогом CRM.
|
||||
@@ -141,7 +141,7 @@ def build_goods(order: Order, tax_codes: list[Any]) -> tuple[list[dict[str, Any]
|
||||
discount = gross - net
|
||||
if discount < 0:
|
||||
raise ReceiptValidationError(
|
||||
f"Товар «{good['name']}»: сумма строки больше цены × количество"
|
||||
f"Товар «{good['name']}»: сума рядка більша за ціну × кількість"
|
||||
)
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
@@ -168,7 +168,7 @@ def build_ettn_body(
|
||||
order_discount = goods_total - amounts.total_kopecks
|
||||
if order_discount < 0:
|
||||
raise ReceiptValidationError(
|
||||
"Сумма товаров меньше суммы заказа — проверьте заказ в CRM"
|
||||
"Сума товарів менша за суму замовлення — перевірте замовлення в CRM"
|
||||
)
|
||||
# Предоплата и скидка заказа — одной обычной скидкой «Знижка», как в чеках из
|
||||
# портала Checkbox. Тип `PRE_PAYMENT` для ЕТТН не годится: Checkbox отвечает
|
||||
@@ -185,7 +185,7 @@ def build_ettn_body(
|
||||
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} ₴"
|
||||
f"Сума чека {receipt_total / 100:.2f} ₴ ≠ післяплата {amounts.cod_kopecks / 100:.2f} ₴"
|
||||
)
|
||||
|
||||
receipt_body: dict[str, Any] = {
|
||||
@@ -222,30 +222,30 @@ def resolve_amounts(order: Order, prepayment_kopecks: int | None) -> ReceiptAmou
|
||||
`prepayment_kopecks=None` — взять разницу между суммой заказа и наложкой.
|
||||
"""
|
||||
if order.is_deleted:
|
||||
raise ReceiptValidationError("Заказ удалён")
|
||||
raise ReceiptValidationError("Замовлення видалено")
|
||||
if not order.waybill_number:
|
||||
raise ReceiptValidationError("У заказа нет ТТН")
|
||||
raise ReceiptValidationError("У замовлення немає ТТН")
|
||||
if not order.np_cod_amount_kopecks:
|
||||
raise ReceiptValidationError("По ТТН нет суммы контроля оплаты (наложки)")
|
||||
raise ReceiptValidationError("За ТТН немає суми контролю оплати (післяплати)")
|
||||
if order.np_status_code in NP_FINAL_STATUS_CODES:
|
||||
raise ReceiptValidationError(
|
||||
f"Посылка уже не в пути ({order.np_status or order.np_status_code}) — "
|
||||
"ЕТТН-чек создать нельзя"
|
||||
f"Посилка вже не в дорозі ({order.np_status or order.np_status_code}) — "
|
||||
"ЕТТН-чек створити неможливо"
|
||||
)
|
||||
if not order.goods:
|
||||
raise ReceiptValidationError("В заказе нет товаров")
|
||||
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} ₴"
|
||||
f"Післяплата {cod / 100:.2f} ₴ більша за суму замовлення {total / 100:.2f} ₴"
|
||||
)
|
||||
if total - prepayment != cod:
|
||||
raise ReceiptValidationError(
|
||||
f"Сумма заказа {total / 100:.2f} ₴ − предоплата {prepayment / 100:.2f} ₴ "
|
||||
f"≠ наложка {cod / 100:.2f} ₴"
|
||||
f"Сума замовлення {total / 100:.2f} ₴ − передоплата {prepayment / 100:.2f} ₴ "
|
||||
f"≠ післяплата {cod / 100:.2f} ₴"
|
||||
)
|
||||
return ReceiptAmounts(total_kopecks=total, prepayment_kopecks=prepayment, cod_kopecks=cod)
|
||||
|
||||
@@ -306,7 +306,7 @@ async def request_receipts(
|
||||
registers = await _active_registers(session)
|
||||
if not registers:
|
||||
for order_id, _ in items:
|
||||
result.errors[order_id] = "Не настроена касса Checkbox"
|
||||
result.errors[order_id] = "Не налаштовано касу Checkbox"
|
||||
return result
|
||||
|
||||
order_ids = [order_id for order_id, _ in items]
|
||||
@@ -319,16 +319,16 @@ async def request_receipts(
|
||||
for order_id, prepayment in items:
|
||||
order = orders.get(order_id)
|
||||
if order is None:
|
||||
result.errors[order_id] = "Заказ не найден"
|
||||
result.errors[order_id] = "Замовлення не знайдено"
|
||||
continue
|
||||
if order_id in busy:
|
||||
result.errors[order_id] = "По заказу уже есть чек"
|
||||
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:
|
||||
@@ -485,7 +485,7 @@ async def cancel_receipt(
|
||||
if receipt is None:
|
||||
return None
|
||||
if receipt.status not in (ReceiptStatus.CREATED, ReceiptStatus.RECEIPT_ERROR):
|
||||
raise ReceiptStateError(f"Чек в статусе «{receipt.status}» отменить нельзя")
|
||||
raise ReceiptStateError(f"Чек у статусі «{receipt.status}» скасувати неможливо")
|
||||
|
||||
if receipt.checkbox_ettn_id:
|
||||
register = await session.get(CashRegister, receipt.cash_register_id)
|
||||
|
||||
Reference in New Issue
Block a user