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:
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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/"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
@@ -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 — лишняя
|
||||
# подсказка для того, кто ищет незащищённый эндпоинт.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -74,7 +74,7 @@ def _masked(encrypted: str) -> str:
|
||||
try:
|
||||
return crypto.mask(crypto.decrypt(encrypted))
|
||||
except crypto.DecryptionError:
|
||||
return "не расшифровывается — введите заново"
|
||||
return "не розшифровується — введіть заново"
|
||||
|
||||
|
||||
class CashRegisterOut(BaseModel):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)]
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"],))]
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user