diff --git a/backend/app/api/deps.py b/backend/app/api/deps.py index 5c9eacd..484e6dd 100644 --- a/backend/app/api/deps.py +++ b/backend/app/api/deps.py @@ -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 diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index f489acd..e346d3f 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -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) diff --git a/backend/app/api/v1/cash_registers.py b/backend/app/api/v1/cash_registers.py index 3b5b180..edbe0e5 100644 --- a/backend/app/api/v1/cash_registers.py +++ b/backend/app/api/v1/cash_registers.py @@ -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: diff --git a/backend/app/api/v1/orders.py b/backend/app/api/v1/orders.py index caa97c8..33db176 100644 --- a/backend/app/api/v1/orders.py +++ b/backend/app/api/v1/orders.py @@ -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, diff --git a/backend/app/api/v1/receipts.py b/backend/app/api/v1/receipts.py index 2b44e0c..713dcf6 100644 --- a/backend/app/api/v1/receipts.py +++ b/backend/app/api/v1/receipts.py @@ -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) diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py index 6e3e4a1..676c930 100644 --- a/backend/app/api/v1/users.py +++ b/backend/app/api/v1/users.py @@ -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: diff --git a/backend/app/cli.py b/backend/app/cli.py index 6b2f222..65d42dd 100644 --- a/backend/app/cli.py +++ b/backend/app/cli.py @@ -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() diff --git a/backend/app/core/config.py b/backend/app/core/config.py index a5bf2d9..2a0c2bd 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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/" diff --git a/backend/app/core/crypto.py b/backend/app/core/crypto.py index 2eb418d..b71b00f 100644 --- a/backend/app/core/crypto.py +++ b/backend/app/core/crypto.py @@ -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 diff --git a/backend/app/core/security.py b/backend/app/core/security.py index 075e6af..10630d9 100644 --- a/backend/app/core/security.py +++ b/backend/app/core/security.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py index ca94427..0a1498d 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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 — лишняя # подсказка для того, кто ищет незащищённый эндпоинт. diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index e57a056..3164515 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -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): diff --git a/backend/app/schemas/receipts.py b/backend/app/schemas/receipts.py index 074e2ff..1f8bd85 100644 --- a/backend/app/schemas/receipts.py +++ b/backend/app/schemas/receipts.py @@ -74,7 +74,7 @@ def _masked(encrypted: str) -> str: try: return crypto.mask(crypto.decrypt(encrypted)) except crypto.DecryptionError: - return "не расшифровывается — введите заново" + return "не розшифровується — введіть заново" class CashRegisterOut(BaseModel): diff --git a/backend/app/services/auth.py b/backend/app/services/auth.py index 2e8cd10..15ce5f3 100644 --- a/backend/app/services/auth.py +++ b/backend/app/services/auth.py @@ -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( diff --git a/backend/app/services/checkbox/client.py b/backend/app/services/checkbox/client.py index 509b008..9cd96c1 100644 --- a/backend/app/services/checkbox/client.py +++ b/backend/app/services/checkbox/client.py @@ -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) diff --git a/backend/app/services/checkbox/http_client.py b/backend/app/services/checkbox/http_client.py index 229d8e2..856a470 100644 --- a/backend/app/services/checkbox/http_client.py +++ b/backend/app/services/checkbox/http_client.py @@ -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 diff --git a/backend/app/services/checkbox/stub_client.py b/backend/app/services/checkbox/stub_client.py index 71d66fa..29ce6f3 100644 --- a/backend/app/services/checkbox/stub_client.py +++ b/backend/app/services/checkbox/stub_client.py @@ -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 diff --git a/backend/app/services/crm/exo_client.py b/backend/app/services/crm/exo_client.py index ca7ccb3..afcea79 100644 --- a/backend/app/services/crm/exo_client.py +++ b/backend/app/services/crm/exo_client.py @@ -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}") diff --git a/backend/app/services/crm/stub_client.py b/backend/app/services/crm/stub_client.py index 04b4d88..f6c0a68 100644 --- a/backend/app/services/crm/stub_client.py +++ b/backend/app/services/crm/stub_client.py @@ -12,7 +12,7 @@ _FIXTURE_ORDERS: list[dict] = [ "RecipientPhone": "+380501112233", "RecipientEmail": None, "Waybill_Number": "20450123456789", - "Notes": "Тестовий заказ", + "Notes": "Тестове замовлення", "Total": { "Cost": "0.00", "Quantity": "2", diff --git a/backend/app/services/nova_poshta/np_client.py b/backend/app/services/nova_poshta/np_client.py index d6e57d7..57e3526 100644 --- a/backend/app/services/nova_poshta/np_client.py +++ b/backend/app/services/nova_poshta/np_client.py @@ -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", [])] diff --git a/backend/app/services/orders.py b/backend/app/services/orders.py index 3f6c636..9188775 100644 --- a/backend/app/services/orders.py +++ b/backend/app/services/orders.py @@ -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 = [ diff --git a/backend/app/services/receipts.py b/backend/app/services/receipts.py index c968212..effa5a2 100644 --- a/backend/app/services/receipts.py +++ b/backend/app/services/receipts.py @@ -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) diff --git a/backend/tests/test_nova_poshta_client.py b/backend/tests/test_nova_poshta_client.py index c132bc3..c9b33a4 100644 --- a/backend/tests/test_nova_poshta_client.py +++ b/backend/tests/test_nova_poshta_client.py @@ -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)] ) diff --git a/backend/tests/test_orders_router.py b/backend/tests/test_orders_router.py index b2b14cb..87ea009 100644 --- a/backend/tests/test_orders_router.py +++ b/backend/tests/test_orders_router.py @@ -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) diff --git a/backend/tests/test_orders_service.py b/backend/tests/test_orders_service.py index 391de36..6109804 100644 --- a/backend/tests/test_orders_service.py +++ b/backend/tests/test_orders_service.py @@ -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: diff --git a/backend/tests/test_receipts_router.py b/backend/tests/test_receipts_router.py index 88a4dd6..67d5cfa 100644 --- a/backend/tests/test_receipts_router.py +++ b/backend/tests/test_receipts_router.py @@ -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"],))] diff --git a/backend/tests/test_receipts_service.py b/backend/tests/test_receipts_service.py index 1746696..bc8de44 100644 --- a/backend/tests/test_receipts_service.py +++ b/backend/tests/test_receipts_service.py @@ -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: diff --git a/frontend/index.html b/frontend/index.html index 8bc8b22..cd9a1f5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -1,10 +1,10 @@ - + - lux_fiscal + Assistant System
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 63d80ef..3fc4a8a 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -27,10 +27,10 @@ async function readErrorMessage(response: Response): Promise { // Тело не JSON или пустое — используем сообщение по статусу ниже. } - if (response.status === 401) return 'Требуется вход в систему' - if (response.status === 403) return 'Недостаточно прав для этого действия' - if (response.status >= 500) return 'Сервер временно недоступен, попробуйте позже' - return `Ошибка запроса (${response.status})` + if (response.status === 401) return 'Потрібно увійти в систему' + if (response.status === 403) return 'Недостатньо прав для цієї дії' + if (response.status >= 500) return 'Сервер тимчасово недоступний, спробуйте пізніше' + return `Помилка запиту (${response.status})` } // Параллельные 401 не должны порождать несколько запросов на refresh — @@ -42,7 +42,7 @@ async function refreshAccessToken(): Promise { const refreshToken = getRefreshToken() if (!refreshToken) { - throw new ApiError(401, 'Сессия истекла, войдите снова') + throw new ApiError(401, 'Сесія закінчилася, увійдіть знову') } refreshPromise = (async () => { diff --git a/frontend/src/features/auth/AuthContext.tsx b/frontend/src/features/auth/AuthContext.tsx index 1145285..6ce81df 100644 --- a/frontend/src/features/auth/AuthContext.tsx +++ b/frontend/src/features/auth/AuthContext.tsx @@ -65,7 +65,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { setUser(result.user) setStatus('authenticated') } catch (err) { - const message = err instanceof ApiError ? err.message : 'Не удалось подключиться к серверу' + const message = err instanceof ApiError ? err.message : 'Не вдалося підключитися до сервера' setError(message) throw err } diff --git a/frontend/src/features/auth/LoginPage.css b/frontend/src/features/auth/LoginPage.css index 58cc62a..23b51e0 100644 --- a/frontend/src/features/auth/LoginPage.css +++ b/frontend/src/features/auth/LoginPage.css @@ -1,68 +1,230 @@ .login-page { + --login-accent: #7c3aed; + --login-glass: rgba(255, 255, 255, 0.72); + --login-glass-border: rgba(255, 255, 255, 0.6); + --login-input-bg: rgba(255, 255, 255, 0.85); + + position: relative; min-height: 100svh; display: flex; align-items: center; justify-content: center; - padding: 24px; + padding: 24px 16px; + overflow: hidden; + isolation: isolate; + background: + radial-gradient(1200px 600px at 10% -10%, rgba(37, 99, 235, 0.14), transparent 60%), + radial-gradient(900px 500px at 110% 110%, rgba(124, 58, 237, 0.14), transparent 60%), + var(--color-bg); +} + +@media (prefers-color-scheme: dark) { + .login-page { + --login-accent: #a78bfa; + --login-glass: rgba(23, 26, 33, 0.7); + --login-glass-border: rgba(255, 255, 255, 0.08); + --login-input-bg: rgba(15, 17, 21, 0.7); + } +} + +/* Декоративные размытые пятна на фоне */ +.login-backdrop { + position: absolute; + inset: 0; + z-index: -1; + pointer-events: none; +} + +.login-blob { + position: absolute; + border-radius: 50%; + filter: blur(70px); + opacity: 0.55; + animation: login-float 18s ease-in-out infinite alternate; +} + +.login-blob--a { + width: 380px; + height: 380px; + top: -80px; + left: -60px; + background: var(--color-primary); +} + +.login-blob--b { + width: 320px; + height: 320px; + bottom: -90px; + right: -40px; + background: var(--login-accent); + animation-delay: -6s; +} + +.login-blob--c { + width: 220px; + height: 220px; + top: 55%; + left: 60%; + background: #06b6d4; + opacity: 0.3; + animation-delay: -12s; +} + +@keyframes login-float { + from { + transform: translate3d(0, 0, 0) scale(1); + } + to { + transform: translate3d(40px, 30px, 0) scale(1.1); + } +} + +@media (prefers-reduced-motion: reduce) { + .login-blob { + animation: none; + } } .login-card { width: 100%; - max-width: 380px; - background: var(--color-surface); - border: 1px solid var(--color-border); - border-radius: 12px; - box-shadow: var(--shadow-card); - padding: 32px; + max-width: 400px; + background: var(--login-glass); + border: 1px solid var(--login-glass-border); + border-radius: 20px; + box-shadow: + 0 20px 50px -12px rgba(16, 24, 40, 0.25), + 0 1px 2px rgba(16, 24, 40, 0.06); + backdrop-filter: blur(18px) saturate(140%); + -webkit-backdrop-filter: blur(18px) saturate(140%); + padding: 40px 32px 32px; + animation: login-enter 0.45s cubic-bezier(0.2, 0.8, 0.2, 1) both; +} + +@keyframes login-enter { + from { + opacity: 0; + transform: translateY(12px) scale(0.98); + } +} + +@media (max-width: 420px) { + .login-card { + padding: 32px 20px 24px; + border-radius: 16px; + } } .login-header { - margin-bottom: 24px; + margin-bottom: 28px; text-align: center; } +.login-logo { + width: 56px; + height: 56px; + margin: 0 auto 16px; + display: grid; + place-items: center; + border-radius: 16px; + color: #fff; + background: linear-gradient(135deg, var(--color-primary), var(--login-accent)); + box-shadow: 0 10px 24px -8px var(--color-primary); +} + .login-header h1 { - font-size: 22px; - margin-bottom: 4px; + font-size: 26px; + font-weight: 700; + letter-spacing: -0.02em; + margin-bottom: 6px; + background: linear-gradient(135deg, var(--color-text) 30%, var(--color-primary)); + -webkit-background-clip: text; + background-clip: text; + color: transparent; } .login-header p { color: var(--color-text-muted); - font-size: 14px; + font-size: 15px; } .login-field { - margin-bottom: 16px; + margin-bottom: 18px; } .login-field label { display: block; font-size: 13px; - font-weight: 500; - margin-bottom: 6px; + font-weight: 600; + margin-bottom: 8px; color: var(--color-text); } .login-field input { width: 100%; - padding: 10px 12px; + height: 46px; + padding: 0 14px; border: 1px solid var(--color-border); - border-radius: 8px; - background: var(--color-bg); + border-radius: 12px; + background: var(--login-input-bg); color: var(--color-text); - font-size: 14px; + font-size: 15px; outline: none; - transition: border-color 0.15s ease; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; +} + +.login-field input::placeholder { + color: var(--color-text-muted); + opacity: 0.7; } .login-field input:focus { border-color: var(--color-primary); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-primary) 18%, transparent); } .login-field input:disabled { opacity: 0.6; } +.login-password { + position: relative; +} + +.login-password input { + padding-right: 46px; +} + +.login-toggle { + position: absolute; + top: 50%; + right: 6px; + transform: translateY(-50%); + width: 34px; + height: 34px; + display: grid; + place-items: center; + border: none; + border-radius: 8px; + background: transparent; + color: var(--color-text-muted); + cursor: pointer; + transition: + color 0.15s ease, + background-color 0.15s ease; +} + +.login-toggle:hover { + color: var(--color-text); + background: color-mix(in srgb, var(--color-text) 8%, transparent); +} + +.login-toggle:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 1px; +} + .login-error { display: flex; gap: 8px; @@ -70,23 +232,29 @@ background: var(--color-danger-bg); border: 1px solid var(--color-danger-border); color: var(--color-danger); - border-radius: 8px; - padding: 10px 12px; + border-radius: 12px; + padding: 10px 14px; font-size: 13px; - margin-bottom: 16px; + margin-bottom: 18px; } .login-submit { width: 100%; - padding: 11px 16px; + height: 48px; + margin-top: 8px; + padding: 0 16px; border: none; - border-radius: 8px; - background: var(--color-primary); + border-radius: 12px; + background: linear-gradient(135deg, var(--color-primary), var(--login-accent)); color: #fff; - font-size: 14px; + font-size: 15px; font-weight: 600; cursor: pointer; - transition: background-color 0.15s ease; + box-shadow: 0 10px 24px -10px var(--color-primary); + transition: + transform 0.15s ease, + box-shadow 0.15s ease, + filter 0.15s ease; display: flex; align-items: center; justify-content: center; @@ -94,7 +262,18 @@ } .login-submit:hover:not(:disabled) { - background: var(--color-primary-hover); + transform: translateY(-1px); + filter: brightness(1.06); + box-shadow: 0 14px 28px -10px var(--color-primary); +} + +.login-submit:active:not(:disabled) { + transform: translateY(0); +} + +.login-submit:focus-visible { + outline: 2px solid var(--color-primary); + outline-offset: 2px; } .login-submit:disabled { diff --git a/frontend/src/features/auth/LoginPage.tsx b/frontend/src/features/auth/LoginPage.tsx index c058bec..f74ff12 100644 --- a/frontend/src/features/auth/LoginPage.tsx +++ b/frontend/src/features/auth/LoginPage.tsx @@ -19,6 +19,7 @@ export function LoginPage() { const [email, setEmail] = useState('') const [password, setPassword] = useState('') + const [showPassword, setShowPassword] = useState(false) const [submitting, setSubmitting] = useState(false) const [formError, setFormError] = useState(null) @@ -36,7 +37,7 @@ export function LoginPage() { try { await login(email, password) } catch (err) { - const message = err instanceof ApiError ? err.message : 'Не удалось подключиться к серверу' + const message = err instanceof ApiError ? err.message : 'Не вдалося підключитися до сервера' setFormError(message) } finally { setSubmitting(false) @@ -45,10 +46,22 @@ export function LoginPage() { return (
+ +
-

lux_fiscal

-

Вход в систему фискализации заказов

+ +

Assistant System

+

Вхід у систему

@@ -74,21 +87,36 @@ export function LoginPage() {
- setPassword(e.target.value)} - placeholder="••••••••" - /> +
+ setPassword(e.target.value)} + placeholder="••••••••" + /> + +
diff --git a/frontend/src/features/auth/ProtectedRoute.tsx b/frontend/src/features/auth/ProtectedRoute.tsx index 864d94d..e31c613 100644 --- a/frontend/src/features/auth/ProtectedRoute.tsx +++ b/frontend/src/features/auth/ProtectedRoute.tsx @@ -10,7 +10,7 @@ export function ProtectedRoute() { if (status === 'loading') { return (
- +
) } diff --git a/frontend/src/features/auth/useAuth.ts b/frontend/src/features/auth/useAuth.ts index f868fd9..b4a7c31 100644 --- a/frontend/src/features/auth/useAuth.ts +++ b/frontend/src/features/auth/useAuth.ts @@ -5,7 +5,7 @@ import { AuthContext } from '@/features/auth/AuthContext' export function useAuth() { const ctx = useContext(AuthContext) if (!ctx) { - throw new Error('useAuth должен вызываться внутри ') + throw new Error('useAuth має викликатися всередині ') } return ctx } diff --git a/frontend/src/features/orders/OrderDetailModal.tsx b/frontend/src/features/orders/OrderDetailModal.tsx index 67a257e..97a6983 100644 --- a/frontend/src/features/orders/OrderDetailModal.tsx +++ b/frontend/src/features/orders/OrderDetailModal.tsx @@ -96,18 +96,18 @@ function orderDiscountOf(draft: Draft): number { /** Проверяет черновик и собирает тело PATCH; строка — текст ошибки для кассира. */ function toPayload(draft: Draft): OrderUpdate | string { - if (draft.goods.length === 0) return 'Добавьте хотя бы один товар' + if (draft.goods.length === 0) return 'Додайте хоча б один товар' const goods = [] for (const good of draft.goods) { - const label = good.name.trim() || 'без названия' - if (!good.name.trim()) return 'У товара не указано наименование' + const label = good.name.trim() || 'без назви' + if (!good.name.trim()) return 'У товару не вказано найменування' const price = toKopecks(good.price) const quantity = toThousandths(good.quantity) const discount = toKopecks(good.discount) - if (price === null) return `Товар «${label}»: некорректная цена` - if (quantity === null || quantity <= 0) return `Товар «${label}»: некорректное количество` - if (discount === null) return `Товар «${label}»: некорректная скидка` - if ((lineAmount(good) ?? 0) < 0) return `Товар «${label}»: скидка больше суммы строки` + if (price === null) return `Товар «${label}»: некоректна ціна` + if (quantity === null || quantity <= 0) return `Товар «${label}»: некоректна кількість` + if (discount === null) return `Товар «${label}»: некоректна знижка` + if ((lineAmount(good) ?? 0) < 0) return `Товар «${label}»: знижка більша за суму рядка` goods.push({ id: good.id, sku: good.sku.trim(), @@ -118,9 +118,9 @@ function toPayload(draft: Draft): OrderUpdate | string { }) } const total = toKopecks(draft.total) - if (total === null) return 'Некорректная сумма заказа' + if (total === null) return 'Некоректна сума замовлення' const sum = goodsSum(draft.goods) ?? 0 - if (total > sum) return `Сумма заказа больше суммы товаров (${formatKopecks(sum)} ₴)` + if (total > sum) return `Сума замовлення більша за суму товарів (${formatKopecks(sum)} ₴)` return { recipient_name: draft.recipient_name, @@ -134,7 +134,7 @@ function toPayload(draft: Draft): OrderUpdate | string { } const TEXT_FIELDS: { field: TextField; label: string; type?: string }[] = [ - { field: 'recipient_name', label: 'Клиент' }, + { field: 'recipient_name', label: 'Клієнт' }, { field: 'recipient_phone', label: 'Телефон', type: 'tel' }, { field: 'recipient_email', label: 'Email', type: 'email' }, { field: 'waybill_number', label: 'Номер ТТН' }, @@ -153,7 +153,7 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr const editable = canEdit && !order.has_receipt function requestClose() { - if (dirty && !window.confirm('Есть несохранённые изменения. Закрыть без сохранения?')) return + if (dirty && !window.confirm('Є незбережені зміни. Закрити без збереження?')) return onClose() } @@ -203,10 +203,10 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr setDraft(next) setSaved(next) setOrderDiscount(orderDiscountOf(next)) - setMessage({ tone: 'ok', text: 'Изменения сохранены' }) + setMessage({ tone: 'ok', text: 'Зміни збережено' }) await queryClient.invalidateQueries({ queryKey: ['orders'] }) } catch (err) { - setMessage({ tone: 'error', text: err instanceof Error ? err.message : 'Не удалось сохранить заказ' }) + setMessage({ tone: 'error', text: err instanceof Error ? err.message : 'Не вдалося зберегти замовлення' }) } finally { setSaving(false) } @@ -217,10 +217,10 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr

- Заказ {order.id} - {order.edited && изменён вручную} + Замовлення {order.id} + {order.edited && змінено вручну}

-
@@ -232,19 +232,19 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
Статус ТТН - {order.np_status || 'Нет данных'} + {order.np_status || 'Немає даних'}
- Наложенный платёж + Накладений платіж {order.np_cod_amount ? `${order.np_cod_amount} ₴` : '—'}
Оплачено {order.np_payment_status === 'Payed' - ? 'Да' + ? 'Так' : order.np_payment_status === 'NeedPayment' - ? 'Нет' + ? 'Ні' : '—'}
@@ -272,7 +272,7 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr {editable ? (