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:
2026-09-25 15:27:39 +03:00
co-authored by Claude Opus 5.5
parent 47df3a78dc
commit b8f2fe6b6e
38 changed files with 232 additions and 230 deletions
+2 -2
View File
@@ -25,7 +25,7 @@ SessionDep = Annotated[AsyncSession, Depends(get_session)]
_UNAUTHORIZED = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Требуется аутентификация",
detail="Потрібна автентифікація",
headers={"WWW-Authenticate": "Bearer"},
)
@@ -76,7 +76,7 @@ def require_roles(*roles: UserRole) -> Callable[..., Coroutine[Any, Any, User]]:
if user.role not in allowed:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Недостаточно прав для этого действия",
detail="Недостатньо прав для цієї дії",
)
return user
+1 -1
View File
@@ -70,7 +70,7 @@ async def change_password(
) -> Response:
if not verify_password(payload.current_password, user.password_hash):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail="Текущий пароль указан неверно"
status_code=status.HTTP_400_BAD_REQUEST, detail="Поточний пароль вказано неправильно"
)
user.password_hash = hash_password(payload.new_password)
+3 -3
View File
@@ -44,7 +44,7 @@ async def _ensure_np_key_unique(
if same:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Этот ключ Новой Почты уже привязан к кассе «{register.name}»",
detail=f"Цей ключ Нової Пошти вже прив'язаний до каси «{register.name}»",
)
@@ -101,7 +101,7 @@ async def update_cash_register(
) -> CashRegisterOut:
register = await session.get(CashRegister, register_id)
if register is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Касса не найдена")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Касу не знайдено")
changes = payload.model_dump(exclude_unset=True)
if "license_key" in changes:
@@ -139,7 +139,7 @@ async def check_cash_register(
"""Пробный вход кассира в Checkbox — проверка ключа лицензии и PIN."""
register = await session.get(CashRegister, register_id)
if register is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Касса не найдена")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Касу не знайдено")
try:
await checkbox.sign_in(credentials(register))
except (CheckboxError, crypto.DecryptionError) as exc:
+2 -2
View File
@@ -50,7 +50,7 @@ async def update_order(
except orders_service.OrderEditError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
if result is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Заказ не найден")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Замовлення не знайдено")
order, changed = result
if changed:
@@ -75,7 +75,7 @@ async def delete_order(
) -> None:
order = await orders_service.delete_order(session, order_id)
if order is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Заказ не найден")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Замовлення не знайдено")
await audit.record(
session,
+2 -2
View File
@@ -60,7 +60,7 @@ async def list_receipts(session: SessionDep, order_id: str | None = None) -> lis
async def get_receipt(receipt_id: uuid.UUID, session: SessionDep) -> ReceiptOut:
receipt = await session.get(Receipt, receipt_id)
if receipt is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Чек не найден")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Чек не знайдено")
return ReceiptOut.from_receipt(receipt)
@@ -83,5 +83,5 @@ async def cancel_receipt(
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Checkbox: {exc}"
) from exc
if receipt is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Чек не найден")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Чек не знайдено")
return ReceiptOut.from_receipt(receipt)
+4 -4
View File
@@ -71,7 +71,7 @@ async def create_user(
await session.rollback()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Пользователь с таким email уже существует",
detail="Користувач із таким email уже існує",
) from exc
return UserOut.model_validate(user)
@@ -87,7 +87,7 @@ async def update_user(
) -> UserOut:
user = await session.get(User, user_id)
if user is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Пользователь не найден")
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Користувача не знайдено")
changes = payload.model_dump(exclude_unset=True)
@@ -97,12 +97,12 @@ async def update_user(
if changes.get("is_active") is False:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Нельзя отключить собственную учётную запись",
detail="Не можна вимкнути власний обліковий запис",
)
if "role" in changes and changes["role"] != UserRole.ADMIN:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Нельзя снять с себя роль администратора",
detail="Не можна зняти із себе роль адміністратора",
)
if (new_password := changes.pop("password", None)) is not None:
+5 -5
View File
@@ -25,7 +25,7 @@ async def bootstrap() -> int:
Идемпотентна: повторный запуск ничего не меняет и пароль не сбрасывает.
"""
if not settings.first_admin_password:
print("FIRST_ADMIN_PASSWORD не задан в .env", file=sys.stderr)
print("FIRST_ADMIN_PASSWORD не задано в .env", file=sys.stderr)
return 1
email = settings.first_admin_email.strip().lower()
@@ -33,7 +33,7 @@ async def bootstrap() -> int:
async with session_scope() as session:
existing = await session.scalar(select(User).where(User.email == email))
if existing is not None:
print(f"Пользователь {email} уже существует — ничего не изменено.")
print(f"Користувач {email} уже існує — нічого не змінено.")
return 0
session.add(
@@ -45,8 +45,8 @@ async def bootstrap() -> int:
)
)
print(f"Администратор {email} создан.")
print("Смените пароль после первого входа и уберите FIRST_ADMIN_PASSWORD из .env.")
print(f"Адміністратора {email} створено.")
print("Змініть пароль після першого входу та приберіть FIRST_ADMIN_PASSWORD з .env.")
return 0
@@ -59,7 +59,7 @@ def gen_keys() -> int:
def main() -> int:
parser = argparse.ArgumentParser(prog="app.cli", description="Служебные команды lux_fiscal")
parser = argparse.ArgumentParser(prog="app.cli", description="Службові команди lux_fiscal")
parser.add_argument("command", choices=["bootstrap", "gen-keys"])
args = parser.parse_args()
+1 -1
View File
@@ -53,7 +53,7 @@ class Settings(BaseSettings):
# --- Первый администратор (только для команды bootstrap) ---
first_admin_email: str = "admin@example.com"
first_admin_password: str = ""
first_admin_name: str = "Администратор"
first_admin_name: str = "Адміністратор"
# --- CRM ---
crm_base_url: str = "https://optstore.exocrm.com/api/1.1/"
+3 -3
View File
@@ -26,7 +26,7 @@ def _fernet() -> Fernet:
return Fernet(settings.encryption_key.encode())
except (ValueError, TypeError) as exc:
raise RuntimeError(
"ENCRYPTION_KEY некорректен. Сгенерируйте валидный ключ: "
"ENCRYPTION_KEY некоректний. Згенеруйте валідний ключ: "
"python -c \"from cryptography.fernet import Fernet; "
'print(Fernet.generate_key().decode())"'
) from exc
@@ -41,8 +41,8 @@ def decrypt(value: str) -> str:
return _fernet().decrypt(value.encode()).decode()
except InvalidToken as exc:
raise DecryptionError(
"Не удалось расшифровать значение. Вероятная причина — ENCRYPTION_KEY "
"изменился с момента сохранения. Секрет нужно ввести заново."
"Не вдалося розшифрувати значення. Імовірна причина — ENCRYPTION_KEY "
"змінився з моменту збереження. Секрет потрібно ввести заново."
) from exc
+3 -3
View File
@@ -67,13 +67,13 @@ def decode_access_token(token: str) -> dict[str, Any]:
try:
payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
except jwt.ExpiredSignatureError as exc:
raise TokenError("Срок действия токена истёк") from exc
raise TokenError("Термін дії токена минув") from exc
except jwt.InvalidTokenError as exc:
raise TokenError("Некорректный токен") from exc
raise TokenError("Некоректний токен") from exc
# Без этой проверки refresh-токен можно было бы предъявить как access.
if payload.get("type") != "access":
raise TokenError("Ожидался access-токен")
raise TokenError("Очікувався access-токен")
return payload
+1 -1
View File
@@ -78,7 +78,7 @@ def create_app() -> FastAPI:
app = FastAPI(
title=settings.project_name,
version="0.1.0",
description="Фискализация заказов через Checkbox",
description="Фіскалізація замовлень через Checkbox",
lifespan=lifespan,
# В проде интерактивная документация закрыта: схема API — лишняя
# подсказка для того, кто ищет незащищённый эндпоинт.
+1 -1
View File
@@ -22,7 +22,7 @@ class TokenPair(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int = Field(description="Время жизни access-токена в секундах")
expires_in: int = Field(description="Час життя access-токена в секундах")
class UserOut(BaseModel):
+1 -1
View File
@@ -74,7 +74,7 @@ def _masked(encrypted: str) -> str:
try:
return crypto.mask(crypto.decrypt(encrypted))
except crypto.DecryptionError:
return "не расшифровывается — введите заново"
return "не розшифровується — введіть заново"
class CashRegisterOut(BaseModel):
+6 -6
View File
@@ -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(
+1 -1
View File
@@ -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)
+3 -3
View File
@@ -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
+3 -3
View File
@@ -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
+5 -5
View File
@@ -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}")
+1 -1
View File
@@ -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", [])]
+4 -4
View File
@@ -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 = [
+19 -19
View File
@@ -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)
+1 -1
View File
@@ -110,7 +110,7 @@ class TestNpTrackingClientGetStatuses:
async def test_rejects_too_many_documents(self) -> None:
client = NpTrackingClient()
with pytest.raises(NovaPoshtaError, match="Слишком много"):
with pytest.raises(NovaPoshtaError, match="Забагато"):
await client.get_statuses(
api_key=API_KEY, waybill_numbers=[str(i) for i in range(101)]
)
+1 -1
View File
@@ -88,7 +88,7 @@ def _patch_orders_service(monkeypatch: pytest.MonkeyPatch) -> None:
session: object, order_id: str, data: object
) -> tuple[Order, list[str]] | None:
if order_id == "locked":
raise orders_router.orders_service.OrderEditError("По заказу уже создан чек")
raise orders_router.orders_service.OrderEditError("За замовленням уже створено чек")
if order_id != "1":
return None
order = _order(order_id)
+3 -3
View File
@@ -140,16 +140,16 @@ class TestUpdateOrder:
assert order.goods == [stored]
def test_total_above_goods_sum_is_rejected(self) -> None:
with pytest.raises(OrderEditError, match="больше суммы товаров"):
with pytest.raises(OrderEditError, match="більша за суму товарів"):
_update(_order(), _payload(total_amount="1200.00"))
def test_discount_above_line_sum_is_rejected(self) -> None:
good = {"name": "Товар", "price": "10.00", "quantity": "1", "discount_amount": "11"}
with pytest.raises(OrderEditError, match="скидка больше"):
with pytest.raises(OrderEditError, match="знижка більша"):
_update(_order(), _payload(goods=[good], total_amount="0"))
def test_order_with_receipt_is_locked(self) -> None:
with pytest.raises(OrderEditError, match="уже создан чек"):
with pytest.raises(OrderEditError, match="уже створено чек"):
_update(_order(receipt_created_at=datetime.now(UTC)), _payload())
def test_missing_or_deleted_order_returns_none(self) -> None:
+4 -2
View File
@@ -45,7 +45,9 @@ def queue(monkeypatch: pytest.MonkeyPatch) -> FakeQueue:
)
async def fake_request(session: Any, items: Any, *, user: Any, request: Any) -> Any:
return receipts_service.RequestResult(created=[receipt], errors={"2": "У заказа нет ТТН"})
return receipts_service.RequestResult(
created=[receipt], errors={"2": "У замовлення немає ТТН"}
)
async def fake_commit(self: AsyncSession) -> None:
return None
@@ -74,5 +76,5 @@ def test_cashier_creates_and_enqueues(queue: FakeQueue) -> None:
assert response.status_code == 202
body = response.json()
assert [r["order_id"] for r in body["created"]] == ["1"]
assert body["errors"] == {"2": "У заказа нет ТТН"}
assert body["errors"] == {"2": "У замовлення немає ТТН"}
assert queue.jobs == [("create_ettn_receipt", (body["created"][0]["id"],))]
+8 -8
View File
@@ -77,17 +77,17 @@ class TestResolveAmounts:
assert amounts.cod_kopecks == 100000
def test_explicit_prepayment_must_match_cod(self) -> None:
with pytest.raises(svc.ReceiptValidationError, match="≠ наложка"):
with pytest.raises(svc.ReceiptValidationError, match="≠ післяплата"):
svc.resolve_amounts(_order(np_cod_amount_kopecks=100000), 10000)
@pytest.mark.parametrize(
("overrides", "message"),
[
({"waybill_number": None}, "нет ТТН"),
({"np_cod_amount_kopecks": None}, "контроля оплаты"),
({"np_status_code": "9"}, "не в пути"),
({"np_cod_amount_kopecks": 130000}, "больше суммы заказа"),
({"is_deleted": True}, "удалён"),
({"waybill_number": None}, "немає ТТН"),
({"np_cod_amount_kopecks": None}, "контролю оплати"),
({"np_status_code": "9"}, "не в дорозі"),
({"np_cod_amount_kopecks": 130000}, "більша за суму замовлення"),
({"is_deleted": True}, "видалено"),
],
)
def test_rejects(self, overrides: dict[str, Any], message: str) -> None:
@@ -226,7 +226,7 @@ class TestRequestReceipts:
)
assert result.created == []
assert "Касса не определена" in result.errors[order.id]
assert "Касу не визначено" in result.errors[order.id]
class FakeSession:
@@ -289,7 +289,7 @@ class TestCreateEttn:
await svc.create_ettn_for_receipt(FakeSession(order, register, receipt), client, receipt.id)
assert receipt.status == ReceiptStatus.FAILED
assert "уже привязана" in (receipt.error or "")
assert "вже прив'язана" in (receipt.error or "")
assert order.receipt_created_at is None
async def test_unavailable_keeps_pending_then_reconciles(self) -> None: