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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:27:39 +03:00
lauadminandClaude Opus 5.5 47df3a78dc Redesign login page (#5)
- Rename header to "Assistant System", subtitle to "Вхід у систему"
- Glassmorphism card over animated gradient backdrop, logo mark
- Password visibility toggle, larger inputs, focus rings

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 15:21:11 +03:00
lauadmin f6971a612f Merge pull request 'Привязка ключа Новой Почты к кассе (#3)' (#4) from feature/np-key-per-register into main 2026-09-25 10:17:10 +00:00
lauadminandClaude Opus 5.5 8b3c2b6d63 Bind Nova Poshta API key to cash register (#3)
Each cash register stores its own encrypted NP API key. Status polling uses
register keys and binds an order to the register whose key sees the TTN as
its own (PhoneSender present); ETTN receipts are created from that register.
Migration 0008 moves the old NOVA_POSHTA_API_KEY into the default register.

Closes #3

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 13:02:40 +03:00
52 changed files with 998 additions and 331 deletions
+2 -1
View File
@@ -58,7 +58,8 @@ CRM_SHOP_KEY=change-me-crm-shopkey
CRM_SID=1 CRM_SID=1
# --- Nova Poshta ---------------------------------------------------------- # --- Nova Poshta ----------------------------------------------------------
# Ключ API кабинета Nova Poshta (my.novaposhta.ua → Настройки → API). # Ключи API Nova Poshta задаются у касс (страница «Кассы»). Эта переменная нужна
# только миграции 0008: переносит старый общий ключ в кассу по умолчанию.
NOVA_POSHTA_API_KEY=change-me-novaposhta-apikey NOVA_POSHTA_API_KEY=change-me-novaposhta-apikey
# --- Checkbox ------------------------------------------------------------- # --- Checkbox -------------------------------------------------------------
+1
View File
@@ -127,4 +127,5 @@ Stages 1–4 (scaffolding, auth/audit, CRM order queue, Nova Poshta tracking) ar
- State machine and the "one live receipt per order" partial unique index live in `app/db/models/receipt.py`. `orders.receipt_created_at` is set on request and reset to NULL when a receipt ends `failed`/`cancelled` (order goes back to the queue). - State machine and the "one live receipt per order" partial unique index live in `app/db/models/receipt.py`. `orders.receipt_created_at` is set on request and reset to NULL when a receipt ends `failed`/`cancelled` (order goes back to the queue).
- Once Checkbox accepts the receipt, the order is moved to `PACKED` in the CRM (`services/receipts.sync_crm_statuses`, marked by `receipts.crm_status_set_at`; runs right after creation and is retried by cron). The live exoCRM `SetStatus` differs from its docs: params must be `{"Orders": [id], "Status": ...}` (the documented `{"ID": ...}` returns "Undefined order list."), and the reply has no `status: OK` — success is `{"<id>": {"Status": "Success"}}`. - Once Checkbox accepts the receipt, the order is moved to `PACKED` in the CRM (`services/receipts.sync_crm_statuses`, marked by `receipts.crm_status_set_at`; runs right after creation and is retried by cron). The live exoCRM `SetStatus` differs from its docs: params must be `{"Orders": [id], "Status": ...}` (the documented `{"ID": ...}` returns "Undefined order list."), and the reply has no `status: OK` — success is `{"<id>": {"Status": "Success"}}`.
- Nova Poshta's rate limit comes back through Checkbox as a 4xx with `code=third_party.generic` and «To many requests» / `20000401501`, not as a 429. `http_client._transient_error` maps it to `CheckboxRateLimitedError`: the receipt stays `pending` and the worker retries with `arq.Retry`. The client also sends requests one at a time with a `CHECKBOX_MIN_REQUEST_INTERVAL_MS` pause, so don't parallelize Checkbox calls in the worker. - Nova Poshta's rate limit comes back through Checkbox as a 4xx with `code=third_party.generic` and «To many requests» / `20000401501`, not as a 429. `http_client._transient_error` maps it to `CheckboxRateLimitedError`: the receipt stays `pending` and the worker retries with `arq.Retry`. The client also sends requests one at a time with a `CHECKBOX_MIN_REQUEST_INTERVAL_MS` pause, so don't parallelize Checkbox calls in the worker.
- Each cash register has its own Nova Poshta API key (`cash_registers.np_api_key_enc`, Fernet). `sync_np_statuses` polls TTNs with register keys and binds the order to the register whose key sees the TTN as its own (`orders.cash_register_id`; ownership = response contains `PhoneSender` — a foreign key gets a truncated reply without sender/`AfterpaymentOnGoodsCost`). Receipts are created from the order's register, not the default one; an unbound order is rejected. `NOVA_POSHTA_API_KEY` env is only read by migration 0008.
- ETTN does **not** work on a Checkbox test cash register. Locally use `CHECKBOX_USE_STUB=true`; client selection is only in `services/checkbox/client.get_checkbox_client()`. - ETTN does **not** work on a Checkbox test cash register. Locally use `CHECKBOX_USE_STUB=true`; client selection is only in `services/checkbox/client.get_checkbox_client()`.
@@ -0,0 +1,58 @@
"""Ключ API Новой Почты у кассы и привязка заказа к кассе
Revision ID: 0008
Revises: 0007
Create Date: 2026-09-25
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
from app.core import crypto
from app.core.config import settings
revision: str = "0008"
down_revision: str | None = "0007"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"cash_registers", sa.Column("np_api_key_enc", sa.String(length=512), nullable=True)
)
op.add_column("orders", sa.Column("cash_register_id", sa.Uuid(), nullable=True))
op.create_foreign_key(
op.f("fk_orders_cash_register_id_cash_registers"),
"orders",
"cash_registers",
["cash_register_id"],
["id"],
ondelete="SET NULL",
)
op.create_index(op.f("ix_orders_cash_register_id"), "orders", ["cash_register_id"])
# Раньше ключ НП был один на всё приложение (NOVA_POSHTA_API_KEY) — переносим
# его в кассу по умолчанию, чтобы опрос статусов не остановился после обновления.
if settings.nova_poshta_api_key:
op.get_bind().execute(
sa.text(
"UPDATE cash_registers SET np_api_key_enc = :key "
"WHERE id = (SELECT id FROM cash_registers "
"ORDER BY is_default DESC, created_at LIMIT 1)"
),
{"key": crypto.encrypt(settings.nova_poshta_api_key)},
)
def downgrade() -> None:
op.drop_index(op.f("ix_orders_cash_register_id"), table_name="orders")
op.drop_constraint(
op.f("fk_orders_cash_register_id_cash_registers"), "orders", type_="foreignkey"
)
op.drop_column("orders", "cash_register_id")
op.drop_column("cash_registers", "np_api_key_enc")
+2 -2
View File
@@ -25,7 +25,7 @@ SessionDep = Annotated[AsyncSession, Depends(get_session)]
_UNAUTHORIZED = HTTPException( _UNAUTHORIZED = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail="Требуется аутентификация", detail="Потрібна автентифікація",
headers={"WWW-Authenticate": "Bearer"}, headers={"WWW-Authenticate": "Bearer"},
) )
@@ -76,7 +76,7 @@ def require_roles(*roles: UserRole) -> Callable[..., Coroutine[Any, Any, User]]:
if user.role not in allowed: if user.role not in allowed:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, status_code=status.HTTP_403_FORBIDDEN,
detail="Недостаточно прав для этого действия", detail="Недостатньо прав для цієї дії",
) )
return user return user
+1 -1
View File
@@ -70,7 +70,7 @@ async def change_password(
) -> Response: ) -> Response:
if not verify_password(payload.current_password, user.password_hash): if not verify_password(payload.current_password, user.password_hash):
raise HTTPException( 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) user.password_hash = hash_password(payload.new_password)
+35 -4
View File
@@ -27,6 +27,27 @@ async def _clear_default(session: SessionDep, keep_id: uuid.UUID) -> None:
) )
async def _ensure_np_key_unique(
session: SessionDep, api_key: str, exclude_id: uuid.UUID | None = None
) -> None:
"""Один кабинет НП — одна касса, иначе неясно, от какой кассы пробивать его посылки."""
registers = await session.scalars(
select(CashRegister).where(CashRegister.np_api_key_enc.is_not(None))
)
for register in registers:
if register.id == exclude_id:
continue
try:
same = crypto.decrypt(register.np_api_key_enc) == api_key
except crypto.DecryptionError:
continue
if same:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail=f"Цей ключ Нової Пошти вже прив'язаний до каси «{register.name}»",
)
@router.get("", response_model=list[CashRegisterOut]) @router.get("", response_model=list[CashRegisterOut])
async def list_cash_registers(session: SessionDep) -> list[CashRegisterOut]: async def list_cash_registers(session: SessionDep) -> list[CashRegisterOut]:
registers = await session.scalars(select(CashRegister).order_by(CashRegister.created_at)) registers = await session.scalars(select(CashRegister).order_by(CashRegister.created_at))
@@ -37,12 +58,15 @@ async def list_cash_registers(session: SessionDep) -> list[CashRegisterOut]:
async def create_cash_register( async def create_cash_register(
payload: CashRegisterCreate, request: Request, session: SessionDep, actor: AdminUser payload: CashRegisterCreate, request: Request, session: SessionDep, actor: AdminUser
) -> CashRegisterOut: ) -> CashRegisterOut:
if payload.np_api_key:
await _ensure_np_key_unique(session, payload.np_api_key)
register = CashRegister( register = CashRegister(
id=uuid.uuid4(), id=uuid.uuid4(),
name=payload.name, name=payload.name,
fiscal_number=payload.fiscal_number, fiscal_number=payload.fiscal_number,
license_key_enc=crypto.encrypt(payload.license_key), license_key_enc=crypto.encrypt(payload.license_key),
cashier_pin_enc=crypto.encrypt(payload.pin_code), cashier_pin_enc=crypto.encrypt(payload.pin_code),
np_api_key_enc=crypto.encrypt(payload.np_api_key) if payload.np_api_key else None,
tax_codes=payload.tax_codes, tax_codes=payload.tax_codes,
is_active=True, is_active=True,
is_default=payload.is_default, is_default=payload.is_default,
@@ -56,7 +80,11 @@ async def create_cash_register(
user=actor, user=actor,
entity_type="cash_register", entity_type="cash_register",
entity_id=register.id, entity_id=register.id,
payload={"name": register.name, "fiscal_number": register.fiscal_number}, payload={
"name": register.name,
"fiscal_number": register.fiscal_number,
"np_api_key_set": register.np_api_key_enc is not None,
},
request=request, request=request,
) )
await session.commit() await session.commit()
@@ -73,13 +101,16 @@ async def update_cash_register(
) -> CashRegisterOut: ) -> CashRegisterOut:
register = await session.get(CashRegister, register_id) register = await session.get(CashRegister, register_id)
if register is None: 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) changes = payload.model_dump(exclude_unset=True)
if "license_key" in changes: if "license_key" in changes:
register.license_key_enc = crypto.encrypt(changes["license_key"]) register.license_key_enc = crypto.encrypt(changes["license_key"])
if "pin_code" in changes: if "pin_code" in changes:
register.cashier_pin_enc = crypto.encrypt(changes["pin_code"]) register.cashier_pin_enc = crypto.encrypt(changes["pin_code"])
if changes.get("np_api_key"):
await _ensure_np_key_unique(session, changes["np_api_key"], exclude_id=register.id)
register.np_api_key_enc = crypto.encrypt(changes["np_api_key"])
if "fiscal_number" in changes: if "fiscal_number" in changes:
register.fiscal_number = changes["fiscal_number"] register.fiscal_number = changes["fiscal_number"]
for field in ("name", "tax_codes", "is_active", "is_default"): for field in ("name", "tax_codes", "is_active", "is_default"):
@@ -94,7 +125,7 @@ async def update_cash_register(
user=actor, user=actor,
entity_type="cash_register", entity_type="cash_register",
entity_id=register.id, entity_id=register.id,
payload=changes, # license_key / pin_code редактируются в audit._sanitize payload=changes, # license_key / pin_code / np_api_key редактируются в audit._sanitize
request=request, request=request,
) )
await session.commit() await session.commit()
@@ -108,7 +139,7 @@ async def check_cash_register(
"""Пробный вход кассира в Checkbox — проверка ключа лицензии и PIN.""" """Пробный вход кассира в Checkbox — проверка ключа лицензии и PIN."""
register = await session.get(CashRegister, register_id) register = await session.get(CashRegister, register_id)
if register is None: 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: try:
await checkbox.sign_in(credentials(register)) await checkbox.sign_in(credentials(register))
except (CheckboxError, crypto.DecryptionError) as exc: except (CheckboxError, crypto.DecryptionError) as exc:
+2 -2
View File
@@ -50,7 +50,7 @@ async def update_order(
except orders_service.OrderEditError as exc: except orders_service.OrderEditError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
if result is None: 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 order, changed = result
if changed: if changed:
@@ -75,7 +75,7 @@ async def delete_order(
) -> None: ) -> None:
order = await orders_service.delete_order(session, order_id) order = await orders_service.delete_order(session, order_id)
if order is None: 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( await audit.record(
session, 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: async def get_receipt(receipt_id: uuid.UUID, session: SessionDep) -> ReceiptOut:
receipt = await session.get(Receipt, receipt_id) receipt = await session.get(Receipt, receipt_id)
if receipt is None: 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) return ReceiptOut.from_receipt(receipt)
@@ -83,5 +83,5 @@ async def cancel_receipt(
status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Checkbox: {exc}" status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Checkbox: {exc}"
) from exc ) from exc
if receipt is None: 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) return ReceiptOut.from_receipt(receipt)
+4 -4
View File
@@ -71,7 +71,7 @@ async def create_user(
await session.rollback() await session.rollback()
raise HTTPException( raise HTTPException(
status_code=status.HTTP_409_CONFLICT, status_code=status.HTTP_409_CONFLICT,
detail="Пользователь с таким email уже существует", detail="Користувач із таким email уже існує",
) from exc ) from exc
return UserOut.model_validate(user) return UserOut.model_validate(user)
@@ -87,7 +87,7 @@ async def update_user(
) -> UserOut: ) -> UserOut:
user = await session.get(User, user_id) user = await session.get(User, user_id)
if user is None: 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) changes = payload.model_dump(exclude_unset=True)
@@ -97,12 +97,12 @@ async def update_user(
if changes.get("is_active") is False: if changes.get("is_active") is False:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Нельзя отключить собственную учётную запись", detail="Не можна вимкнути власний обліковий запис",
) )
if "role" in changes and changes["role"] != UserRole.ADMIN: if "role" in changes and changes["role"] != UserRole.ADMIN:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Нельзя снять с себя роль администратора", detail="Не можна зняти із себе роль адміністратора",
) )
if (new_password := changes.pop("password", None)) is not None: 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: if not settings.first_admin_password:
print("FIRST_ADMIN_PASSWORD не задан в .env", file=sys.stderr) print("FIRST_ADMIN_PASSWORD не задано в .env", file=sys.stderr)
return 1 return 1
email = settings.first_admin_email.strip().lower() email = settings.first_admin_email.strip().lower()
@@ -33,7 +33,7 @@ async def bootstrap() -> int:
async with session_scope() as session: async with session_scope() as session:
existing = await session.scalar(select(User).where(User.email == email)) existing = await session.scalar(select(User).where(User.email == email))
if existing is not None: if existing is not None:
print(f"Пользователь {email} уже существует — ничего не изменено.") print(f"Користувач {email} уже існує — нічого не змінено.")
return 0 return 0
session.add( session.add(
@@ -45,8 +45,8 @@ async def bootstrap() -> int:
) )
) )
print(f"Администратор {email} создан.") print(f"Адміністратора {email} створено.")
print("Смените пароль после первого входа и уберите FIRST_ADMIN_PASSWORD из .env.") print("Змініть пароль після першого входу та приберіть FIRST_ADMIN_PASSWORD з .env.")
return 0 return 0
@@ -59,7 +59,7 @@ def gen_keys() -> int:
def main() -> 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"]) parser.add_argument("command", choices=["bootstrap", "gen-keys"])
args = parser.parse_args() args = parser.parse_args()
+3 -1
View File
@@ -53,7 +53,7 @@ class Settings(BaseSettings):
# --- Первый администратор (только для команды bootstrap) --- # --- Первый администратор (только для команды bootstrap) ---
first_admin_email: str = "admin@example.com" first_admin_email: str = "admin@example.com"
first_admin_password: str = "" first_admin_password: str = ""
first_admin_name: str = "Администратор" first_admin_name: str = "Адміністратор"
# --- CRM --- # --- CRM ---
crm_base_url: str = "https://optstore.exocrm.com/api/1.1/" crm_base_url: str = "https://optstore.exocrm.com/api/1.1/"
@@ -63,6 +63,8 @@ class Settings(BaseSettings):
crm_sid: int = 1 crm_sid: int = 1
# --- Nova Poshta --- # --- Nova Poshta ---
# Ключи НП хранятся у касс (`cash_registers.np_api_key_enc`). Эта переменная
# читается только миграцией 0008: переносит старый общий ключ в кассу по умолчанию.
nova_poshta_api_key: str = "" nova_poshta_api_key: str = ""
# --- Checkbox --- # --- Checkbox ---
+3 -3
View File
@@ -26,7 +26,7 @@ def _fernet() -> Fernet:
return Fernet(settings.encryption_key.encode()) return Fernet(settings.encryption_key.encode())
except (ValueError, TypeError) as exc: except (ValueError, TypeError) as exc:
raise RuntimeError( raise RuntimeError(
"ENCRYPTION_KEY некорректен. Сгенерируйте валидный ключ: " "ENCRYPTION_KEY некоректний. Згенеруйте валідний ключ: "
"python -c \"from cryptography.fernet import Fernet; " "python -c \"from cryptography.fernet import Fernet; "
'print(Fernet.generate_key().decode())"' 'print(Fernet.generate_key().decode())"'
) from exc ) from exc
@@ -41,8 +41,8 @@ def decrypt(value: str) -> str:
return _fernet().decrypt(value.encode()).decode() return _fernet().decrypt(value.encode()).decode()
except InvalidToken as exc: except InvalidToken as exc:
raise DecryptionError( raise DecryptionError(
"Не удалось расшифровать значение. Вероятная причина — ENCRYPTION_KEY " "Не вдалося розшифрувати значення. Імовірна причина — ENCRYPTION_KEY "
"изменился с момента сохранения. Секрет нужно ввести заново." "змінився з моменту збереження. Секрет потрібно ввести заново."
) from exc ) from exc
+3 -3
View File
@@ -67,13 +67,13 @@ def decode_access_token(token: str) -> dict[str, Any]:
try: try:
payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM]) payload = jwt.decode(token, settings.secret_key, algorithms=[ALGORITHM])
except jwt.ExpiredSignatureError as exc: except jwt.ExpiredSignatureError as exc:
raise TokenError("Срок действия токена истёк") from exc raise TokenError("Термін дії токена минув") from exc
except jwt.InvalidTokenError as exc: except jwt.InvalidTokenError as exc:
raise TokenError("Некорректный токен") from exc raise TokenError("Некоректний токен") from exc
# Без этой проверки refresh-токен можно было бы предъявить как access. # Без этой проверки refresh-токен можно было бы предъявить как access.
if payload.get("type") != "access": if payload.get("type") != "access":
raise TokenError("Ожидался access-токен") raise TokenError("Очікувався access-токен")
return payload return payload
+7 -2
View File
@@ -1,7 +1,8 @@
"""Кассы (ПРРО) Checkbox. """Кассы (ПРРО) Checkbox.
Ключ лицензии и PIN кассира хранятся только в зашифрованном виде Ключ лицензии, PIN кассира и ключ API Новой Почты хранятся только в
(`app/core/crypto.py`): утечка дампа БД не должна давать доступ к кассе. зашифрованном виде (`app/core/crypto.py`): утечка дампа БД не должна давать
доступ к кассе и кабинету отправителя.
""" """
from __future__ import annotations from __future__ import annotations
@@ -22,6 +23,10 @@ class CashRegister(UUIDPrimaryKeyMixin, TimestampMixin, Base):
fiscal_number: Mapped[str | None] = mapped_column(String(64)) fiscal_number: Mapped[str | None] = mapped_column(String(64))
license_key_enc: Mapped[str] = mapped_column(String(512), nullable=False) license_key_enc: Mapped[str] = mapped_column(String(512), nullable=False)
cashier_pin_enc: Mapped[str] = mapped_column(String(512), nullable=False) cashier_pin_enc: Mapped[str] = mapped_column(String(512), nullable=False)
# Ключ API кабинета отправителя НП. Посылки, которые этот ключ видит как
# свои, привязываются к кассе (`orders.cash_register_id`), и ЕТТН-чеки по
# ним создаются от неё. Без ключа касса статусы ТТН не опрашивает.
np_api_key_enc: Mapped[str | None] = mapped_column(String(512))
# Коды налоговых ставок Checkbox для всех товаров чека; пусто — поле `tax` # Коды налоговых ставок Checkbox для всех товаров чека; пусто — поле `tax`
# не передаётся (неплательщик ПДВ). # не передаётся (неплательщик ПДВ).
tax_codes: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list) tax_codes: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list)
+8 -1
View File
@@ -7,10 +7,11 @@
from __future__ import annotations from __future__ import annotations
import uuid
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
from sqlalchemy import BigInteger, Boolean, DateTime, String, Text from sqlalchemy import BigInteger, Boolean, DateTime, ForeignKey, String, Text
from sqlalchemy.dialects.postgresql import JSONB from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
@@ -54,3 +55,9 @@ class Order(TimestampMixin, Base):
np_status_code: Mapped[str | None] = mapped_column(String(16)) np_status_code: Mapped[str | None] = mapped_column(String(16))
np_cod_amount_kopecks: Mapped[int | None] = mapped_column(BigInteger) np_cod_amount_kopecks: Mapped[int | None] = mapped_column(BigInteger)
np_payment_status: Mapped[str | None] = mapped_column(String(32)) np_payment_status: Mapped[str | None] = mapped_column(String(32))
# Касса, чей ключ НП видит ТТН как свою (определяется при опросе статусов,
# см. `services/orders.sync_np_statuses`). От неё создаётся ЕТТН-чек.
cash_register_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("cash_registers.id", ondelete="SET NULL"), index=True
)
+1 -1
View File
@@ -78,7 +78,7 @@ def create_app() -> FastAPI:
app = FastAPI( app = FastAPI(
title=settings.project_name, title=settings.project_name,
version="0.1.0", version="0.1.0",
description="Фискализация заказов через Checkbox", description="Фіскалізація замовлень через Checkbox",
lifespan=lifespan, lifespan=lifespan,
# В проде интерактивная документация закрыта: схема API — лишняя # В проде интерактивная документация закрыта: схема API — лишняя
# подсказка для того, кто ищет незащищённый эндпоинт. # подсказка для того, кто ищет незащищённый эндпоинт.
+1 -1
View File
@@ -22,7 +22,7 @@ class TokenPair(BaseModel):
access_token: str access_token: str
refresh_token: str refresh_token: str
token_type: str = "bearer" token_type: str = "bearer"
expires_in: int = Field(description="Время жизни access-токена в секундах") expires_in: int = Field(description="Час життя access-токена в секундах")
class UserOut(BaseModel): class UserOut(BaseModel):
+15 -5
View File
@@ -70,26 +70,34 @@ class ReceiptCreateResponse(BaseModel):
# --- Кассы ------------------------------------------------------------------- # --- Кассы -------------------------------------------------------------------
def _masked(encrypted: str) -> str:
try:
return crypto.mask(crypto.decrypt(encrypted))
except crypto.DecryptionError:
return "не розшифровується — введіть заново"
class CashRegisterOut(BaseModel): class CashRegisterOut(BaseModel):
id: uuid.UUID id: uuid.UUID
name: str name: str
fiscal_number: str | None fiscal_number: str | None
license_key_masked: str license_key_masked: str
# None — ключ НП не задан: касса не опрашивает посылки и не получает заказы.
np_api_key_masked: str | None
tax_codes: list[Any] tax_codes: list[Any]
is_active: bool is_active: bool
is_default: bool is_default: bool
@classmethod @classmethod
def from_register(cls, register: CashRegister) -> CashRegisterOut: def from_register(cls, register: CashRegister) -> CashRegisterOut:
try:
masked = crypto.mask(crypto.decrypt(register.license_key_enc))
except crypto.DecryptionError:
masked = "не расшифровывается — введите заново"
return cls( return cls(
id=register.id, id=register.id,
name=register.name, name=register.name,
fiscal_number=register.fiscal_number, fiscal_number=register.fiscal_number,
license_key_masked=masked, license_key_masked=_masked(register.license_key_enc),
np_api_key_masked=(
_masked(register.np_api_key_enc) if register.np_api_key_enc else None
),
tax_codes=register.tax_codes, tax_codes=register.tax_codes,
is_active=register.is_active, is_active=register.is_active,
is_default=register.is_default, is_default=register.is_default,
@@ -101,6 +109,7 @@ class CashRegisterCreate(BaseModel):
fiscal_number: str | None = Field(default=None, max_length=64) fiscal_number: str | None = Field(default=None, max_length=64)
license_key: str = Field(min_length=1, max_length=255) license_key: str = Field(min_length=1, max_length=255)
pin_code: str = Field(min_length=1, max_length=32) pin_code: str = Field(min_length=1, max_length=32)
np_api_key: str | None = Field(default=None, min_length=1, max_length=255)
tax_codes: list[int | str] = Field(default_factory=list, max_length=2) tax_codes: list[int | str] = Field(default_factory=list, max_length=2)
is_default: bool = True is_default: bool = True
@@ -112,6 +121,7 @@ class CashRegisterUpdate(BaseModel):
fiscal_number: str | None = Field(default=None, max_length=64) fiscal_number: str | None = Field(default=None, max_length=64)
license_key: str | None = Field(default=None, min_length=1, max_length=255) license_key: str | None = Field(default=None, min_length=1, max_length=255)
pin_code: str | None = Field(default=None, min_length=1, max_length=32) pin_code: str | None = Field(default=None, min_length=1, max_length=32)
np_api_key: str | None = Field(default=None, min_length=1, max_length=255)
tax_codes: list[int | str] | None = Field(default=None, max_length=2) tax_codes: list[int | str] | None = Field(default=None, max_length=2)
is_active: bool | None = None is_active: bool | None = None
is_default: bool | None = None is_default: bool | None = None
+10
View File
@@ -32,9 +32,19 @@ class TrackingStatusOut(BaseModel):
# какого-то типа накладной не возвращает `AfterpaymentOnGoodsCost`. # какого-то типа накладной не возвращает `AfterpaymentOnGoodsCost`.
cod_amount: str | None = Field(default=None) cod_amount: str | None = Field(default=None)
# Телефон отправителя NP отдаёт только кабинету отправителя (или получателя):
# по чужому ключу без телефона ответ урезан — нет ни отправителя, ни
# `AfterpaymentOnGoodsCost`. По этому полю определяется, чья это посылка.
phone_sender: str | None = Field(default=None, alias="PhoneSender")
# Статус оплаты аналогично раздвоен на `PaymentStatus`/`ExpressWaybillPaymentStatus`. # Статус оплаты аналогично раздвоен на `PaymentStatus`/`ExpressWaybillPaymentStatus`.
payment_status: str | None = Field(default=None) payment_status: str | None = Field(default=None)
@property
def is_own(self) -> bool:
"""ТТН видна ключу как своя — отправлена из кабинета этого ключа."""
return bool(self.phone_sender)
@model_validator(mode="before") @model_validator(mode="before")
@classmethod @classmethod
def _fold_express_fields(cls, data: Any) -> Any: def _fold_express_fields(cls, data: Any) -> Any:
+1
View File
@@ -22,6 +22,7 @@ _REDACTED_KEYS = {
"secret", "secret",
"license_key", "license_key",
"api_key", "api_key",
"np_api_key",
"pin", "pin",
"pin_code", "pin_code",
"cashier_pin", "cashier_pin",
+6 -6
View File
@@ -53,7 +53,7 @@ async def authenticate(
request=request, request=request,
) )
await session.commit() await session.commit()
raise AuthError("Неверный email или пароль") raise AuthError("Невірний email або пароль")
if not user.is_active: if not user.is_active:
await audit.record( await audit.record(
@@ -64,7 +64,7 @@ async def authenticate(
request=request, request=request,
) )
await session.commit() await session.commit()
raise AuthError("Учётная запись отключена") raise AuthError("Обліковий запис вимкнено")
# Параметры argon2 со временем ужесточаются — обновляем хеш на живом пароле. # Параметры argon2 со временем ужесточаются — обновляем хеш на живом пароле.
if password_needs_rehash(user.password_hash): 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)) stored = await session.scalar(select(RefreshToken).where(RefreshToken.token_hash == token_hash))
if stored is None: if stored is None:
raise AuthError("Некорректный refresh-токен") raise AuthError("Некоректний refresh-токен")
if stored.revoked_at is not None: if stored.revoked_at is not None:
await revoke_all_for_user(session, stored.user_id) await revoke_all_for_user(session, stored.user_id)
@@ -131,14 +131,14 @@ async def rotate_refresh_token(
) )
await session.commit() await session.commit()
log.warning("refresh_token_reuse_detected", user_id=str(stored.user_id)) log.warning("refresh_token_reuse_detected", user_id=str(stored.user_id))
raise AuthError("Сессия отозвана, требуется повторный вход") raise AuthError("Сесію відкликано, потрібен повторний вхід")
if stored.expires_at <= datetime.now(UTC): if stored.expires_at <= datetime.now(UTC):
raise AuthError("Срок действия refresh-токена истёк") raise AuthError("Термін дії refresh-токена минув")
user = await session.get(User, stored.user_id) user = await session.get(User, stored.user_id)
if user is None or not user.is_active: if user is None or not user.is_active:
raise AuthError("Учётная запись недоступна") raise AuthError("Обліковий запис недоступний")
pair = await issue_token_pair(session, user, request=request, replaces=stored) pair = await issue_token_pair(session, user, request=request, replaces=stored)
await audit.record( await audit.record(
+1 -1
View File
@@ -63,7 +63,7 @@ def get_checkbox_client() -> CheckboxClient:
"""Один экземпляр на процесс: внутри — кэш токенов кассиров.""" """Один экземпляр на процесс: внутри — кэш токенов кассиров."""
if settings.checkbox_use_stub: if settings.checkbox_use_stub:
if settings.is_production: 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 from app.services.checkbox.stub_client import StubCheckboxClient
return StubCheckboxClient(auto_complete_after=2) 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"} — # {"code": "third_party.*", "message": "Internal Server Error"} —
# без кода кассир видит только бесполезное «Internal Server Error». # без кода кассир видит только бесполезное «Internal Server Error».
if code := data.get("code"): if code := data.get("code"):
message = f"{message or 'Ошибка'} ({code})" message = f"{message or 'Помилка'} ({code})"
if isinstance(detail, list) and detail: if isinstance(detail, list) and detail:
parts = [ parts = [
f"{'.'.join(str(p) for p in item.get('loc', []))}: {item.get('msg')}" 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 method, path, headers=headers, json=json, params=params
) )
except httpx.HTTPError as exc: 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): if error := _transient_error(response):
raise error raise error
return response return response
@@ -155,7 +155,7 @@ class HttpCheckboxClient:
json={"pin_code": creds.pin_code}, json={"pin_code": creds.pin_code},
) )
if response.status_code != 200: if response.status_code != 200:
raise CheckboxError(f"Вход кассира не удался: {_error_message(response)}") raise CheckboxError(f"Вхід касира не вдався: {_error_message(response)}")
token = response.json()["access_token"] token = response.json()["access_token"]
self._tokens[creds.cash_register_id] = token self._tokens[creds.cash_register_id] = token
return token return token
+3 -3
View File
@@ -32,12 +32,12 @@ class StubCheckboxClient:
async def sign_in(self, creds: CheckboxCredentials) -> None: async def sign_in(self, creds: CheckboxCredentials) -> None:
if not creds.license_key or not creds.pin_code: 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: async def create_ettn(self, creds: CheckboxCredentials, body: dict[str, Any]) -> EttnOut:
waybill = body["receipt_body"]["payments"][0]["ettn"] waybill = body["receipt_body"]["payments"][0]["ettn"]
if await self.find_ettn(creds, waybill) is not None: if await self.find_ettn(creds, waybill) is not None:
raise CheckboxError(f"ЕТТН {waybill} уже привязана к чеку") raise CheckboxError(f"ЕТТН {waybill} вже прив'язана до чека")
ettn = EttnOut( ettn = EttnOut(
id=str(uuid.uuid4()), id=str(uuid.uuid4()),
status=EttnStatus.CREATED, status=EttnStatus.CREATED,
@@ -50,7 +50,7 @@ class StubCheckboxClient:
async def get_ettn(self, creds: CheckboxCredentials, ettn_id: str) -> EttnOut: async def get_ettn(self, creds: CheckboxCredentials, ettn_id: str) -> EttnOut:
if ettn_id not in self.orders: 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 self._polls[ettn_id] = self._polls.get(ettn_id, 0) + 1
if ( if (
self.auto_complete_after is not None self.auto_complete_after is not None
+5 -5
View File
@@ -39,10 +39,10 @@ class ExoCrmClient:
data = response.json() data = response.json()
except ValueError as exc: except ValueError as exc:
# PHP-notice'ы CRM перед JSON — признак неверных параметров. # 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): if not isinstance(data, dict):
raise CrmError(f"Неожиданный ответ CRM: {str(data)[:300]}") raise CrmError(f"Неочікувана відповідь CRM: {str(data)[:300]}")
return data return data
async def get_orders(self, *, status: str) -> list[OrderOut]: async def get_orders(self, *, status: str) -> list[OrderOut]:
@@ -57,7 +57,7 @@ class ExoCrmClient:
}, },
) )
if data.get("status") != "OK": 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 []] return [OrderOut.model_validate(order) for order in data.get("result") or []]
async def set_status(self, *, order_id: str, status: str) -> None: 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": if isinstance(result, dict) and result.get("Status") == "Success":
return return
details = _errors_text(data) or ( 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", "RecipientPhone": "+380501112233",
"RecipientEmail": None, "RecipientEmail": None,
"Waybill_Number": "20450123456789", "Waybill_Number": "20450123456789",
"Notes": "Тестовий заказ", "Notes": "Тестове замовлення",
"Total": { "Total": {
"Cost": "0.00", "Cost": "0.00",
"Quantity": "2", "Quantity": "2",
+5 -1
View File
@@ -15,4 +15,8 @@ class NovaPoshtaError(Exception):
class NovaPoshtaClient(Protocol): class NovaPoshtaClient(Protocol):
async def get_statuses(self, *, waybill_numbers: list[str]) -> list[TrackingStatusOut]: ... """Ключ API передаётся в каждый вызов: у каждой кассы свой кабинет отправителя."""
async def get_statuses(
self, *, api_key: str, waybill_numbers: list[str]
) -> list[TrackingStatusOut]: ...
@@ -4,7 +4,6 @@ from __future__ import annotations
import httpx import httpx
from app.core.config import Settings
from app.schemas.tracking import TrackingStatusOut from app.schemas.tracking import TrackingStatusOut
from app.services.nova_poshta.client import NovaPoshtaError from app.services.nova_poshta.client import NovaPoshtaError
@@ -15,20 +14,19 @@ _MAX_DOCUMENTS_PER_REQUEST = 100
class NpTrackingClient: class NpTrackingClient:
def __init__(self, settings: Settings) -> None: async def get_statuses(
self._api_key = settings.nova_poshta_api_key self, *, api_key: str, waybill_numbers: list[str]
) -> list[TrackingStatusOut]:
async def get_statuses(self, *, waybill_numbers: list[str]) -> list[TrackingStatusOut]:
if not waybill_numbers: if not waybill_numbers:
return [] return []
if len(waybill_numbers) > _MAX_DOCUMENTS_PER_REQUEST: if len(waybill_numbers) > _MAX_DOCUMENTS_PER_REQUEST:
raise NovaPoshtaError( raise NovaPoshtaError(
f"Слишком много ТТН за один запрос: {len(waybill_numbers)} " f"Забагато ТТН за один запит: {len(waybill_numbers)} "
f"(максимум {_MAX_DOCUMENTS_PER_REQUEST})" f"(максимум {_MAX_DOCUMENTS_PER_REQUEST})"
) )
body = { body = {
"apiKey": self._api_key, "apiKey": api_key,
"modelName": "TrackingDocument", "modelName": "TrackingDocument",
"calledMethod": "getStatusDocuments", "calledMethod": "getStatusDocuments",
"methodProperties": { "methodProperties": {
@@ -44,6 +42,6 @@ class NpTrackingClient:
if not data.get("success"): if not data.get("success"):
errors = data.get("errors") or [] errors = data.get("errors") or []
message = "; ".join(str(error) for error in errors) 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", [])] return [TrackingStatusOut.model_validate(item) for item in data.get("data", [])]
@@ -15,17 +15,32 @@ _FIXTURE_STATUSES: dict[str, dict] = {
"AmountToPay": "1200.00", "AmountToPay": "1200.00",
"AfterpaymentOnGoodsCost": 1200, "AfterpaymentOnGoodsCost": 1200,
"PaymentStatus": "Paid", "PaymentStatus": "Paid",
"PhoneSender": "380501112233",
} }
} }
class StubNovaPoshtaClient: class StubNovaPoshtaClient:
def __init__(self, statuses: dict[str, dict] | None = None) -> None: """`statuses` — ответы NP по ТТН; `by_key` — отдельные ответы для конкретных
self._statuses = statuses if statuses is not None else _FIXTURE_STATUSES ключей API (кабинет отправителя видит свои ТТН полностью, чужие — урезанно)."""
async def get_statuses(self, *, waybill_numbers: list[str]) -> list[TrackingStatusOut]: def __init__(
self,
statuses: dict[str, dict] | None = None,
*,
by_key: dict[str, dict[str, dict]] | None = None,
) -> None:
self._statuses = statuses if statuses is not None else _FIXTURE_STATUSES
self._by_key = by_key or {}
self.calls: list[tuple[str, list[str]]] = []
async def get_statuses(
self, *, api_key: str, waybill_numbers: list[str]
) -> list[TrackingStatusOut]:
self.calls.append((api_key, list(waybill_numbers)))
statuses = self._by_key.get(api_key, self._statuses)
return [ return [
TrackingStatusOut.model_validate(self._statuses[number]) TrackingStatusOut.model_validate(statuses[number])
for number in waybill_numbers for number in waybill_numbers
if number in self._statuses if number in statuses
] ]
+129 -18
View File
@@ -4,17 +4,25 @@ from __future__ import annotations
import enum import enum
import uuid import uuid
from dataclasses import dataclass
from datetime import UTC, datetime from datetime import UTC, datetime
from decimal import ROUND_HALF_UP, Decimal from decimal import ROUND_HALF_UP, Decimal
from typing import Any from typing import Any
import httpx
from sqlalchemy import func, select from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core import crypto
from app.core.logging import get_logger
from app.db.models.cash_register import CashRegister
from app.db.models.order import Order from app.db.models.order import Order
from app.schemas.orders import OrderUpdateIn from app.schemas.orders import OrderUpdateIn
from app.schemas.tracking import TrackingStatusOut
from app.services.crm.client import CrmClient from app.services.crm.client import CrmClient
from app.services.nova_poshta.client import NovaPoshtaClient from app.services.nova_poshta.client import NovaPoshtaClient, NovaPoshtaError
log = get_logger(__name__)
_CRM_STATUS = "APPROVED" _CRM_STATUS = "APPROVED"
@@ -143,8 +151,60 @@ async def cod_in_transit(session: AsyncSession) -> tuple[int, int]:
return count, total return count, total
@dataclass(frozen=True)
class _NpAccount:
"""Кабинет отправителя НП, привязанный к кассе."""
register_id: uuid.UUID
api_key: str
async def _np_accounts(session: AsyncSession) -> list[_NpAccount]:
"""Активные кассы с ключом НП; касса по умолчанию — первой."""
registers = await session.scalars(
select(CashRegister)
.where(CashRegister.is_active.is_(True))
.where(CashRegister.np_api_key_enc.is_not(None))
.order_by(CashRegister.is_default.desc(), CashRegister.created_at)
)
accounts = []
for register in registers:
try:
accounts.append(_NpAccount(register.id, crypto.decrypt(register.np_api_key_enc)))
except crypto.DecryptionError:
log.warning("np_api_key_undecryptable", cash_register_id=str(register.id))
return accounts
async def _fetch_statuses(
np: NovaPoshtaClient, api_key: str, waybill_numbers: list[str]
) -> dict[str, TrackingStatusOut]:
statuses: dict[str, TrackingStatusOut] = {}
for i in range(0, len(waybill_numbers), _NP_BATCH_SIZE):
batch = waybill_numbers[i : i + _NP_BATCH_SIZE]
for tracking_status in await np.get_statuses(api_key=api_key, waybill_numbers=batch):
statuses[tracking_status.number] = tracking_status
return statuses
def _apply_status(order: Order, tracking_status: TrackingStatusOut) -> None:
order.np_status = tracking_status.status
order.np_status_code = tracking_status.status_code
order.np_cod_amount_kopecks = (
_to_kopecks(tracking_status.cod_amount) if tracking_status.cod_amount else None
)
order.np_payment_status = tracking_status.payment_status
async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None: async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None:
"""Обновляет статус ТТН, сумму и статус оплаты наложки по заказам в пути. """Обновляет статус ТТН, сумму и статус оплаты наложки по заказам в пути
и привязывает каждый заказ к кассе, чей ключ НП видит ТТН как свою.
Заказ, уже привязанный к кассе, опрашивается её ключом. Непривязанный (или
переставший быть «своим» для своей кассы) — ключами всех касс по очереди:
первая, для которой ТТН своя (`TrackingStatusOut.is_own`), его забирает.
Если ТТН не своя ни для одной кассы, статус доставки всё равно сохраняется,
но без наложки и без кассы — чек по такой посылке не создать.
Опрашиваются и заказы с уже созданным ЕТТН-чеком: именно после создания Опрашиваются и заказы с уже созданным ЕТТН-чеком: именно после создания
чека посылка доезжает и оплачивается, и этот переход кассир должен видеть. чека посылка доезжает и оплачивается, и этот переход кассир должен видеть.
@@ -154,6 +214,11 @@ async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None:
HTTP-запроса: опрос статусов не должен зависеть от того, открыт ли сейчас HTTP-запроса: опрос статусов не должен зависеть от того, открыт ли сейчас
дашборд. дашборд.
""" """
accounts = await _np_accounts(session)
if not accounts:
log.warning("np_no_api_keys", hint="Вкажіть ключ API Нової Пошти в касі")
return
orders = await session.scalars( orders = await session.scalars(
select(Order) select(Order)
.where(Order.is_deleted.is_(False)) .where(Order.is_deleted.is_(False))
@@ -167,20 +232,65 @@ async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None:
if not orders_by_waybill: if not orders_by_waybill:
return return
waybill_numbers = list(orders_by_waybill) account_ids = {account.register_id for account in accounts}
for i in range(0, len(waybill_numbers), _NP_BATCH_SIZE): unresolved = {
batch = waybill_numbers[i : i + _NP_BATCH_SIZE] waybill: order
statuses = await np.get_statuses(waybill_numbers=batch) for waybill, order in orders_by_waybill.items()
for tracking_status in statuses: if order.cash_register_id not in account_ids
order = orders_by_waybill.get(tracking_status.number) }
# Урезанный ответ по чужому ключу — на случай, если ТТН не своя ни для кого.
foreign: dict[str, TrackingStatusOut] = {}
complete = True
for account in accounts:
bound = {
waybill: order
for waybill, order in orders_by_waybill.items()
if order.cash_register_id == account.register_id
}
if not bound:
continue
try:
statuses = await _fetch_statuses(np, account.api_key, list(bound))
except (NovaPoshtaError, httpx.HTTPError) as exc:
# Заказы кассы остаются как есть до следующего опроса.
log.warning("np_poll_failed", cash_register_id=str(account.register_id), error=str(exc))
continue
for waybill, order in bound.items():
tracking_status = statuses.get(waybill)
if tracking_status is not None and tracking_status.is_own:
_apply_status(order, tracking_status)
else:
unresolved[waybill] = order
if tracking_status is not None:
foreign[waybill] = tracking_status
for account in accounts:
if not unresolved:
break
try:
statuses = await _fetch_statuses(np, account.api_key, list(unresolved))
except (NovaPoshtaError, httpx.HTTPError) as exc:
log.warning("np_poll_failed", cash_register_id=str(account.register_id), error=str(exc))
complete = False
continue
for waybill, tracking_status in statuses.items():
order = unresolved.get(waybill)
if order is None: if order is None:
continue continue
order.np_status = tracking_status.status if tracking_status.is_own:
order.np_status_code = tracking_status.status_code order.cash_register_id = account.register_id
order.np_cod_amount_kopecks = ( _apply_status(order, tracking_status)
_to_kopecks(tracking_status.cod_amount) if tracking_status.cod_amount else None del unresolved[waybill]
) else:
order.np_payment_status = tracking_status.payment_status foreign.setdefault(waybill, tracking_status)
# Если какой-то ключ не ответил, не отвязываем: ТТН могла быть его.
if complete:
for waybill, order in unresolved.items():
order.cash_register_id = None
if waybill in foreign:
_apply_status(order, foreign[waybill])
await session.commit() await session.commit()
@@ -217,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)) gross = int((price * good.quantity).to_integral_value(ROUND_HALF_UP))
net = gross - discount net = gross - discount
if net < 0: if net < 0:
raise OrderEditError(f"Товар «{good.name}»: скидка больше суммы строки") raise OrderEditError(f"Товар «{good.name}»: знижка більша за суму рядка")
goods.append( goods.append(
{ {
"id": good.id or f"new-{uuid.uuid4().hex[:8]}", "id": good.id or f"new-{uuid.uuid4().hex[:8]}",
@@ -247,13 +357,13 @@ async def update_order(
if order is None or order.is_deleted: if order is None or order.is_deleted:
return None return None
if order.receipt_created_at is not None: if order.receipt_created_at is not None:
raise OrderEditError("По заказу уже создан чек — редактирование недоступно") raise OrderEditError("За замовленням уже створено чек — редагування недоступне")
goods, goods_total = _build_goods(data) goods, goods_total = _build_goods(data)
total = _to_kopecks(str(data.total_amount)) total = _to_kopecks(str(data.total_amount))
if total > goods_total: if total > goods_total:
raise OrderEditError( raise OrderEditError(
f"Сумма заказа {total / 100:.2f} ₴ больше суммы товаров {goods_total / 100:.2f} ₴" f"Сума замовлення {total / 100:.2f} ₴ більша за суму товарів {goods_total / 100:.2f} ₴"
) )
changed = [ changed = [
@@ -268,7 +378,8 @@ async def update_order(
return order, changed return order, changed
if "waybill_number" in changed: if "waybill_number" in changed:
# Статус старой ТТН к новой не относится — worker опросит новую за минуту. # Статус и касса старой ТТН к новой не относятся — worker опросит новую за минуту.
order.cash_register_id = None
order.np_status = None order.np_status = None
order.np_status_code = None order.np_status_code = None
order.np_cod_amount_kopecks = None order.np_cod_amount_kopecks = None
+34 -25
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"]) price = to_kopecks(good["price"])
quantity = to_thousandths(good["quantity"]) quantity = to_thousandths(good["quantity"])
if quantity <= 0: if quantity <= 0:
raise ReceiptValidationError(f"Товар «{good['name']}»: количество должно быть больше 0") raise ReceiptValidationError(f"Товар «{good['name']}»: кількість має бути більшою за 0")
gross = _line_sum(price, quantity) gross = _line_sum(price, quantity)
# `amount` — сумма строки после скидки CRM; скидку выводим из неё, # `amount` — сумма строки после скидки CRM; скидку выводим из неё,
# а не из discount_amount/percent, чтобы не расходиться с итогом 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 discount = gross - net
if discount < 0: if discount < 0:
raise ReceiptValidationError( raise ReceiptValidationError(
f"Товар «{good['name']}»: сумма строки больше цены × количество" f"Товар «{good['name']}»: сума рядка більша за ціну × кількість"
) )
payload: dict[str, Any] = { payload: dict[str, Any] = {
@@ -168,7 +168,7 @@ def build_ettn_body(
order_discount = goods_total - amounts.total_kopecks order_discount = goods_total - amounts.total_kopecks
if order_discount < 0: if order_discount < 0:
raise ReceiptValidationError( raise ReceiptValidationError(
"Сумма товаров меньше суммы заказа — проверьте заказ в CRM" "Сума товарів менша за суму замовлення — перевірте замовлення в CRM"
) )
# Предоплата и скидка заказа — одной обычной скидкой «Знижка», как в чеках из # Предоплата и скидка заказа — одной обычной скидкой «Знижка», как в чеках из
# портала Checkbox. Тип `PRE_PAYMENT` для ЕТТН не годится: Checkbox отвечает # портала Checkbox. Тип `PRE_PAYMENT` для ЕТТН не годится: Checkbox отвечает
@@ -185,7 +185,7 @@ def build_ettn_body(
receipt_total = goods_total - order_discount - amounts.prepayment_kopecks receipt_total = goods_total - order_discount - amounts.prepayment_kopecks
if receipt_total != amounts.cod_kopecks: if receipt_total != amounts.cod_kopecks:
raise ReceiptValidationError( 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] = { receipt_body: dict[str, Any] = {
@@ -222,30 +222,30 @@ def resolve_amounts(order: Order, prepayment_kopecks: int | None) -> ReceiptAmou
`prepayment_kopecks=None` — взять разницу между суммой заказа и наложкой. `prepayment_kopecks=None` — взять разницу между суммой заказа и наложкой.
""" """
if order.is_deleted: if order.is_deleted:
raise ReceiptValidationError("Заказ удалён") raise ReceiptValidationError("Замовлення видалено")
if not order.waybill_number: if not order.waybill_number:
raise ReceiptValidationError("У заказа нет ТТН") raise ReceiptValidationError("У замовлення немає ТТН")
if not order.np_cod_amount_kopecks: if not order.np_cod_amount_kopecks:
raise ReceiptValidationError("По ТТН нет суммы контроля оплаты (наложки)") raise ReceiptValidationError("За ТТН немає суми контролю оплати (післяплати)")
if order.np_status_code in NP_FINAL_STATUS_CODES: if order.np_status_code in NP_FINAL_STATUS_CODES:
raise ReceiptValidationError( raise ReceiptValidationError(
f"Посылка уже не в пути ({order.np_status or order.np_status_code}) — " f"Посилка вже не в дорозі ({order.np_status or order.np_status_code}) — "
"ЕТТН-чек создать нельзя" "ЕТТН-чек створити неможливо"
) )
if not order.goods: if not order.goods:
raise ReceiptValidationError("В заказе нет товаров") raise ReceiptValidationError("У замовленні немає товарів")
total = order.total_amount_kopecks total = order.total_amount_kopecks
cod = order.np_cod_amount_kopecks cod = order.np_cod_amount_kopecks
prepayment = total - cod if prepayment_kopecks is None else prepayment_kopecks prepayment = total - cod if prepayment_kopecks is None else prepayment_kopecks
if prepayment < 0: if prepayment < 0:
raise ReceiptValidationError( raise ReceiptValidationError(
f"Наложка {cod / 100:.2f} ₴ больше суммы заказа {total / 100:.2f} ₴" f"Післяплата {cod / 100:.2f} ₴ більша за суму замовлення {total / 100:.2f} ₴"
) )
if total - prepayment != cod: if total - prepayment != cod:
raise ReceiptValidationError( raise ReceiptValidationError(
f"Сумма заказа {total / 100:.2f} ₴ − предоплата {prepayment / 100:.2f} ₴ " f"Сума замовлення {total / 100:.2f} ₴ − передоплата {prepayment / 100:.2f} ₴ "
f"≠ наложка {cod / 100:.2f} ₴" f"≠ післяплата {cod / 100:.2f} ₴"
) )
return ReceiptAmounts(total_kopecks=total, prepayment_kopecks=prepayment, cod_kopecks=cod) return ReceiptAmounts(total_kopecks=total, prepayment_kopecks=prepayment, cod_kopecks=cod)
@@ -261,13 +261,11 @@ def credentials(register: CashRegister) -> CheckboxCredentials:
) )
async def get_default_register(session: AsyncSession) -> CashRegister | None: async def _active_registers(session: AsyncSession) -> dict[uuid.UUID, CashRegister]:
return await session.scalar( registers = await session.scalars(
select(CashRegister) select(CashRegister).where(CashRegister.is_active.is_(True))
.where(CashRegister.is_active.is_(True))
.order_by(CashRegister.is_default.desc(), CashRegister.created_at)
.limit(1)
) )
return {register.id: register for register in registers}
# --- Создание ---------------------------------------------------------------- # --- Создание ----------------------------------------------------------------
@@ -300,12 +298,15 @@ async def request_receipts(
Без commit и без обращения к Checkbox: вызывающий код коммитит и ставит Без commit и без обращения к Checkbox: вызывающий код коммитит и ставит
задачи worker'у (`create_ettn_for_receipt`) по `result.created`. задачи worker'у (`create_ettn_for_receipt`) по `result.created`.
Ошибки по отдельным заказам не мешают остальным — массовое действие. Ошибки по отдельным заказам не мешают остальным — массовое действие.
Чек создаётся от кассы, чей ключ НП видит ТТН заказа как свою
(`orders.cash_register_id`, проставляет `orders.sync_np_statuses`).
""" """
result = RequestResult(created=[], errors={}) result = RequestResult(created=[], errors={})
register = await get_default_register(session) registers = await _active_registers(session)
if register is None: if not registers:
for order_id, _ in items: for order_id, _ in items:
result.errors[order_id] = "Не настроена касса Checkbox" result.errors[order_id] = "Не налаштовано касу Checkbox"
return result return result
order_ids = [order_id for order_id, _ in items] order_ids = [order_id for order_id, _ in items]
@@ -318,10 +319,17 @@ async def request_receipts(
for order_id, prepayment in items: for order_id, prepayment in items:
order = orders.get(order_id) order = orders.get(order_id)
if order is None: if order is None:
result.errors[order_id] = "Заказ не найден" result.errors[order_id] = "Замовлення не знайдено"
continue continue
if order_id in busy: 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 continue
try: try:
amounts = resolve_amounts(order, prepayment) amounts = resolve_amounts(order, prepayment)
@@ -359,6 +367,7 @@ async def request_receipts(
payload={ payload={
"order_id": order.id, "order_id": order.id,
"waybill_number": order.waybill_number, "waybill_number": order.waybill_number,
"cash_register_id": str(register.id),
"cod_kopecks": amounts.cod_kopecks, "cod_kopecks": amounts.cod_kopecks,
"prepayment_kopecks": amounts.prepayment_kopecks, "prepayment_kopecks": amounts.prepayment_kopecks,
}, },
@@ -476,7 +485,7 @@ async def cancel_receipt(
if receipt is None: if receipt is None:
return None return None
if receipt.status not in (ReceiptStatus.CREATED, ReceiptStatus.RECEIPT_ERROR): 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: if receipt.checkbox_ettn_id:
register = await session.get(CashRegister, receipt.cash_register_id) register = await session.get(CashRegister, receipt.cash_register_id)
+2 -2
View File
@@ -2,7 +2,7 @@
Запускается отдельным процессом: `arq app.worker.WorkerSettings`. Запускается отдельным процессом: `arq app.worker.WorkerSettings`.
- `create_ettn_receipt` — задача, которую ставит API после запроса кассира; - `create_ettn_receipt` — задача, которую ставит API после запроса кассира;
- `poll_np_statuses` — раз в минуту статусы ТТН по заказам без чека; - `poll_np_statuses` — раз в минуту статусы ТТН по ключам НП касс и привязка заказов к кассам;
- `poll_receipts` — раз в минуту повтор зависших `pending`, статусы `created`-чеков - `poll_receipts` — раз в минуту повтор зависших `pending`, статусы `created`-чеков
и повтор смены статуса заказа в CRM (PACKED), если CRM была недоступна. и повтор смены статуса заказа в CRM (PACKED), если CRM была недоступна.
""" """
@@ -33,7 +33,7 @@ _CREATE_ETTN_MAX_TRIES = 6
async def startup(ctx: dict[str, Any]) -> None: async def startup(ctx: dict[str, Any]) -> None:
configure_logging() configure_logging()
ctx["np_client"] = NpTrackingClient(settings) ctx["np_client"] = NpTrackingClient()
ctx["checkbox_client"] = get_checkbox_client() ctx["checkbox_client"] = get_checkbox_client()
ctx["crm_client"] = ExoCrmClient(settings) ctx["crm_client"] = ExoCrmClient(settings)
log.info("worker_starting", environment=settings.environment) log.info("worker_starting", environment=settings.environment)
+25 -21
View File
@@ -8,7 +8,7 @@ import pytest
import respx import respx
from httpx import Response from httpx import Response
from app.core.config import Settings from app.schemas.tracking import TrackingStatusOut
from app.services.nova_poshta.client import NovaPoshtaError from app.services.nova_poshta.client import NovaPoshtaError
from app.services.nova_poshta.np_client import _API_URL, NpTrackingClient from app.services.nova_poshta.np_client import _API_URL, NpTrackingClient
@@ -27,12 +27,7 @@ STATUS_PAYLOAD = {
} }
def _settings() -> Settings: API_KEY = "np-apikey-123"
return Settings(
secret_key="test-secret-key",
encryption_key="dGVzdC1lbmNyeXB0aW9uLWtleS0zMi1ieXRlcyEh",
nova_poshta_api_key="np-apikey-123",
) # type: ignore[arg-type]
class TestNpTrackingClientGetStatuses: class TestNpTrackingClientGetStatuses:
@@ -41,13 +36,13 @@ class TestNpTrackingClientGetStatuses:
route = respx.post(_API_URL).mock( route = respx.post(_API_URL).mock(
return_value=Response(200, json={"success": True, "data": [], "errors": []}) return_value=Response(200, json={"success": True, "data": [], "errors": []})
) )
client = NpTrackingClient(_settings()) client = NpTrackingClient()
await client.get_statuses(waybill_numbers=["20451540916703"]) await client.get_statuses(api_key=API_KEY, waybill_numbers=["20451540916703"])
sent = route.calls.last.request sent = route.calls.last.request
body = json.loads(sent.content) body = json.loads(sent.content)
assert body["apiKey"] == "np-apikey-123" assert body["apiKey"] == API_KEY
assert body["modelName"] == "TrackingDocument" assert body["modelName"] == "TrackingDocument"
assert body["calledMethod"] == "getStatusDocuments" assert body["calledMethod"] == "getStatusDocuments"
assert body["methodProperties"]["Documents"] == [ assert body["methodProperties"]["Documents"] == [
@@ -63,9 +58,9 @@ class TestNpTrackingClientGetStatuses:
200, json={"success": True, "data": [STATUS_PAYLOAD], "errors": []} 200, json={"success": True, "data": [STATUS_PAYLOAD], "errors": []}
) )
) )
client = NpTrackingClient(_settings()) client = NpTrackingClient()
statuses = await client.get_statuses(waybill_numbers=["20451540916703"]) statuses = await client.get_statuses(api_key=API_KEY, waybill_numbers=["20451540916703"])
assert len(statuses) == 1 assert len(statuses) == 1
status = statuses[0] status = statuses[0]
@@ -75,6 +70,13 @@ class TestNpTrackingClientGetStatuses:
# не "сколько заплатить сейчас" (`ExpressWaybillAmountToPay` = 827.48). # не "сколько заплатить сейчас" (`ExpressWaybillAmountToPay` = 827.48).
assert status.cod_amount == "699" assert status.cod_amount == "699"
assert status.payment_status == "NeedPayment" assert status.payment_status == "NeedPayment"
assert not status.is_own # в STATUS_PAYLOAD нет PhoneSender
def test_is_own_by_sender_phone(self) -> None:
own = TrackingStatusOut.model_validate({**STATUS_PAYLOAD, "PhoneSender": "380961112233"})
foreign = TrackingStatusOut.model_validate({**STATUS_PAYLOAD, "PhoneSender": ""})
assert own.is_own
assert not foreign.is_own
@respx.mock @respx.mock
async def test_falls_back_to_amount_to_pay_when_afterpayment_missing(self) -> None: async def test_falls_back_to_amount_to_pay_when_afterpayment_missing(self) -> None:
@@ -82,9 +84,9 @@ class TestNpTrackingClientGetStatuses:
respx.post(_API_URL).mock( respx.post(_API_URL).mock(
return_value=Response(200, json={"success": True, "data": [payload], "errors": []}) return_value=Response(200, json={"success": True, "data": [payload], "errors": []})
) )
client = NpTrackingClient(_settings()) client = NpTrackingClient()
statuses = await client.get_statuses(waybill_numbers=["20451540916703"]) statuses = await client.get_statuses(api_key=API_KEY, waybill_numbers=["20451540916703"])
assert statuses[0].cod_amount == "827.48" assert statuses[0].cod_amount == "827.48"
@@ -95,18 +97,20 @@ class TestNpTrackingClientGetStatuses:
200, json={"success": False, "data": [], "errors": ["Invalid apiKey"]} 200, json={"success": False, "data": [], "errors": ["Invalid apiKey"]}
) )
) )
client = NpTrackingClient(_settings()) client = NpTrackingClient()
with pytest.raises(NovaPoshtaError, match="Invalid apiKey"): with pytest.raises(NovaPoshtaError, match="Invalid apiKey"):
await client.get_statuses(waybill_numbers=["20451540916703"]) await client.get_statuses(api_key=API_KEY, waybill_numbers=["20451540916703"])
async def test_returns_empty_list_for_no_documents(self) -> None: async def test_returns_empty_list_for_no_documents(self) -> None:
client = NpTrackingClient(_settings()) client = NpTrackingClient()
assert await client.get_statuses(waybill_numbers=[]) == [] assert await client.get_statuses(api_key=API_KEY, waybill_numbers=[]) == []
async def test_rejects_too_many_documents(self) -> None: async def test_rejects_too_many_documents(self) -> None:
client = NpTrackingClient(_settings()) client = NpTrackingClient()
with pytest.raises(NovaPoshtaError, match="Слишком много"): with pytest.raises(NovaPoshtaError, match="Забагато"):
await client.get_statuses(waybill_numbers=[str(i) for i in range(101)]) await client.get_statuses(
api_key=API_KEY, waybill_numbers=[str(i) for i in range(101)]
)
+120
View File
@@ -0,0 +1,120 @@
"""Опрос статусов НП ключами касс и привязка заказов к кассам (`sync_np_statuses`)."""
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from typing import Any
from app.core import crypto
from app.db.models.cash_register import CashRegister
from app.db.models.order import Order
from app.services.nova_poshta.stub_client import StubNovaPoshtaClient
from app.services.orders import sync_np_statuses
def _register(name: str, np_key: str | None, *, is_default: bool = False) -> CashRegister:
return CashRegister(
id=uuid.uuid4(),
name=name,
license_key_enc=crypto.encrypt("lic"),
cashier_pin_enc=crypto.encrypt("1111"),
np_api_key_enc=crypto.encrypt(np_key) if np_key else None,
tax_codes=[],
is_active=True,
is_default=is_default,
created_at=datetime(2026, 9, 1, tzinfo=UTC),
)
def _order(waybill: str, register_id: uuid.UUID | None = None) -> Order:
return Order(
id=waybill[-4:],
create_date_time=datetime(2026, 9, 20, tzinfo=UTC),
waybill_number=waybill,
total_amount_kopecks=120000,
goods=[],
is_deleted=False,
cash_register_id=register_id,
)
def _status(waybill: str, *, own: bool) -> dict[str, Any]:
status = {"Number": waybill, "Status": "В дорозі", "StatusCode": "5"}
if own:
status |= {"PhoneSender": "380961112233", "AfterpaymentOnGoodsCost": 1200}
return status
class FakeSession:
"""`scalars` отдаёт кассы (с ключом НП, активные) или заказы — по сущности запроса."""
def __init__(self, registers: list[CashRegister], orders: list[Order]) -> None:
self.registers = registers
self.orders = orders
self.commits = 0
async def scalars(self, statement: Any) -> list[Any]:
entity = statement.column_descriptions[0]["entity"]
if entity is CashRegister:
return [r for r in self.registers if r.np_api_key_enc and r.is_active]
return self.orders
async def commit(self) -> None:
self.commits += 1
async def test_unbound_order_is_claimed_by_register_that_sees_it_as_own() -> None:
first, second = _register("A", "key-a", is_default=True), _register("B", "key-b")
order = _order("20450000000001")
np = StubNovaPoshtaClient(
by_key={
"key-a": {order.waybill_number: _status(order.waybill_number, own=False)},
"key-b": {order.waybill_number: _status(order.waybill_number, own=True)},
}
)
session = FakeSession([first, second], [order])
await sync_np_statuses(session, np) # type: ignore[arg-type]
assert order.cash_register_id == second.id
assert order.np_cod_amount_kopecks == 120000
assert session.commits == 1
async def test_bound_order_is_polled_only_with_its_register_key() -> None:
first, second = _register("A", "key-a", is_default=True), _register("B", "key-b")
order = _order("20450000000002", register_id=second.id)
np = StubNovaPoshtaClient(
by_key={"key-b": {order.waybill_number: _status(order.waybill_number, own=True)}}
)
await sync_np_statuses(FakeSession([first, second], [order]), np) # type: ignore[arg-type]
assert [key for key, _ in np.calls] == ["key-b"]
assert order.cash_register_id == second.id
async def test_foreign_waybill_keeps_status_but_no_register_or_cod() -> None:
register = _register("A", "key-a", is_default=True)
order = _order("20450000000003", register_id=register.id)
np = StubNovaPoshtaClient(
by_key={"key-a": {order.waybill_number: _status(order.waybill_number, own=False)}}
)
await sync_np_statuses(FakeSession([register], [order]), np) # type: ignore[arg-type]
assert order.cash_register_id is None
assert order.np_status_code == "5"
assert order.np_cod_amount_kopecks is None
async def test_no_registers_with_np_key_does_nothing() -> None:
order = _order("20450000000004")
np = StubNovaPoshtaClient()
session = FakeSession([_register("A", None)], [order])
await sync_np_statuses(session, np) # type: ignore[arg-type]
assert np.calls == []
assert session.commits == 0
+1 -1
View File
@@ -88,7 +88,7 @@ def _patch_orders_service(monkeypatch: pytest.MonkeyPatch) -> None:
session: object, order_id: str, data: object session: object, order_id: str, data: object
) -> tuple[Order, list[str]] | None: ) -> tuple[Order, list[str]] | None:
if order_id == "locked": if order_id == "locked":
raise orders_router.orders_service.OrderEditError("По заказу уже создан чек") raise orders_router.orders_service.OrderEditError("За замовленням уже створено чек")
if order_id != "1": if order_id != "1":
return None return None
order = _order(order_id) order = _order(order_id)
+3 -3
View File
@@ -140,16 +140,16 @@ class TestUpdateOrder:
assert order.goods == [stored] assert order.goods == [stored]
def test_total_above_goods_sum_is_rejected(self) -> None: 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")) _update(_order(), _payload(total_amount="1200.00"))
def test_discount_above_line_sum_is_rejected(self) -> None: def test_discount_above_line_sum_is_rejected(self) -> None:
good = {"name": "Товар", "price": "10.00", "quantity": "1", "discount_amount": "11"} 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")) _update(_order(), _payload(goods=[good], total_amount="0"))
def test_order_with_receipt_is_locked(self) -> None: 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()) _update(_order(receipt_created_at=datetime.now(UTC)), _payload())
def test_missing_or_deleted_order_returns_none(self) -> None: 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: 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: async def fake_commit(self: AsyncSession) -> None:
return None return None
@@ -74,5 +76,5 @@ def test_cashier_creates_and_enqueues(queue: FakeQueue) -> None:
assert response.status_code == 202 assert response.status_code == 202
body = response.json() body = response.json()
assert [r["order_id"] for r in body["created"]] == ["1"] 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"],))] assert queue.jobs == [("create_ettn_receipt", (body["created"][0]["id"],))]
+54 -7
View File
@@ -16,6 +16,7 @@ from app.core import crypto
from app.db.models.cash_register import CashRegister from app.db.models.cash_register import CashRegister
from app.db.models.order import Order from app.db.models.order import Order
from app.db.models.receipt import Receipt, ReceiptStatus from app.db.models.receipt import Receipt, ReceiptStatus
from app.db.models.user import User
from app.schemas.checkbox import EttnStatus from app.schemas.checkbox import EttnStatus
from app.services import receipts as svc from app.services import receipts as svc
from app.services.checkbox.client import CheckboxRateLimitedError, CheckboxUnavailableError from app.services.checkbox.client import CheckboxRateLimitedError, CheckboxUnavailableError
@@ -76,17 +77,17 @@ class TestResolveAmounts:
assert amounts.cod_kopecks == 100000 assert amounts.cod_kopecks == 100000
def test_explicit_prepayment_must_match_cod(self) -> None: 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) svc.resolve_amounts(_order(np_cod_amount_kopecks=100000), 10000)
@pytest.mark.parametrize( @pytest.mark.parametrize(
("overrides", "message"), ("overrides", "message"),
[ [
({"waybill_number": None}, "нет ТТН"), ({"waybill_number": None}, "немає ТТН"),
({"np_cod_amount_kopecks": None}, "контроля оплаты"), ({"np_cod_amount_kopecks": None}, "контролю оплати"),
({"np_status_code": "9"}, "не в пути"), ({"np_status_code": "9"}, "не в дорозі"),
({"np_cod_amount_kopecks": 130000}, "больше суммы заказа"), ({"np_cod_amount_kopecks": 130000}, "більша за суму замовлення"),
({"is_deleted": True}, "удалён"), ({"is_deleted": True}, "видалено"),
], ],
) )
def test_rejects(self, overrides: dict[str, Any], message: str) -> None: def test_rejects(self, overrides: dict[str, Any], message: str) -> None:
@@ -182,6 +183,52 @@ class TestBuildBody:
assert "delivery" not in body["receipt_body"] assert "delivery" not in body["receipt_body"]
class TestRequestReceipts:
"""Чек создаётся от кассы, к которой опрос НП привязал заказ."""
class Session:
def __init__(self, registers: list[CashRegister], orders: list[Order]) -> None:
self.registers, self.orders, self.added = registers, orders, []
async def scalars(self, statement: Any) -> list[Any]:
entity = statement.column_descriptions[0]["entity"]
if entity is CashRegister:
return self.registers
if entity is Order:
return self.orders
return [] # активные чеки заказов
def add(self, obj: Any) -> None:
self.added.append(obj)
@staticmethod
def _user() -> User:
return User(id=uuid.uuid4(), email="c@x.ua")
async def test_uses_order_register(self) -> None:
default, other = _register(), _register()
order = _order(cash_register_id=other.id)
session = self.Session([default, other], [order])
result = await svc.request_receipts(
session, [(order.id, None)], user=self._user() # type: ignore[arg-type]
)
assert result.errors == {}
assert result.created[0].cash_register_id == other.id
async def test_unbound_order_is_rejected(self) -> None:
order = _order(cash_register_id=None)
session = self.Session([_register()], [order])
result = await svc.request_receipts(
session, [(order.id, None)], user=self._user() # type: ignore[arg-type]
)
assert result.created == []
assert "Касу не визначено" in result.errors[order.id]
class FakeSession: class FakeSession:
def __init__(self, *objects: Any) -> None: def __init__(self, *objects: Any) -> None:
self.objects = {(type(o), o.id): o for o in objects} self.objects = {(type(o), o.id): o for o in objects}
@@ -242,7 +289,7 @@ class TestCreateEttn:
await svc.create_ettn_for_receipt(FakeSession(order, register, receipt), client, receipt.id) await svc.create_ettn_for_receipt(FakeSession(order, register, receipt), client, receipt.id)
assert receipt.status == ReceiptStatus.FAILED assert receipt.status == ReceiptStatus.FAILED
assert "уже привязана" in (receipt.error or "") assert "вже прив'язана" in (receipt.error or "")
assert order.receipt_created_at is None assert order.receipt_created_at is None
async def test_unavailable_keeps_pending_then_reconciles(self) -> None: async def test_unavailable_keeps_pending_then_reconciles(self) -> None:
+2 -2
View File
@@ -1,10 +1,10 @@
<!doctype html> <!doctype html>
<html lang="ru"> <html lang="uk">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>lux_fiscal</title> <title>Assistant System</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+5 -5
View File
@@ -27,10 +27,10 @@ async function readErrorMessage(response: Response): Promise<string> {
// Тело не JSON или пустое — используем сообщение по статусу ниже. // Тело не JSON или пустое — используем сообщение по статусу ниже.
} }
if (response.status === 401) return 'Требуется вход в систему' if (response.status === 401) return 'Потрібно увійти в систему'
if (response.status === 403) return 'Недостаточно прав для этого действия' if (response.status === 403) return 'Недостатньо прав для цієї дії'
if (response.status >= 500) return 'Сервер временно недоступен, попробуйте позже' if (response.status >= 500) return 'Сервер тимчасово недоступний, спробуйте пізніше'
return `Ошибка запроса (${response.status})` return `Помилка запиту (${response.status})`
} }
// Параллельные 401 не должны порождать несколько запросов на refresh — // Параллельные 401 не должны порождать несколько запросов на refresh —
@@ -42,7 +42,7 @@ async function refreshAccessToken(): Promise<string> {
const refreshToken = getRefreshToken() const refreshToken = getRefreshToken()
if (!refreshToken) { if (!refreshToken) {
throw new ApiError(401, 'Сессия истекла, войдите снова') throw new ApiError(401, 'Сесія закінчилася, увійдіть знову')
} }
refreshPromise = (async () => { refreshPromise = (async () => {
+1 -1
View File
@@ -65,7 +65,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setUser(result.user) setUser(result.user)
setStatus('authenticated') setStatus('authenticated')
} catch (err) { } catch (err) {
const message = err instanceof ApiError ? err.message : 'Не удалось подключиться к серверу' const message = err instanceof ApiError ? err.message : 'Не вдалося підключитися до сервера'
setError(message) setError(message)
throw err throw err
} }
+207 -28
View File
@@ -1,68 +1,230 @@
.login-page { .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; min-height: 100svh;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: 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 { .login-card {
width: 100%; width: 100%;
max-width: 380px; max-width: 400px;
background: var(--color-surface); background: var(--login-glass);
border: 1px solid var(--color-border); border: 1px solid var(--login-glass-border);
border-radius: 12px; border-radius: 20px;
box-shadow: var(--shadow-card); box-shadow:
padding: 32px; 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 { .login-header {
margin-bottom: 24px; margin-bottom: 28px;
text-align: center; 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 { .login-header h1 {
font-size: 22px; font-size: 26px;
margin-bottom: 4px; 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 { .login-header p {
color: var(--color-text-muted); color: var(--color-text-muted);
font-size: 14px; font-size: 15px;
} }
.login-field { .login-field {
margin-bottom: 16px; margin-bottom: 18px;
} }
.login-field label { .login-field label {
display: block; display: block;
font-size: 13px; font-size: 13px;
font-weight: 500; font-weight: 600;
margin-bottom: 6px; margin-bottom: 8px;
color: var(--color-text); color: var(--color-text);
} }
.login-field input { .login-field input {
width: 100%; width: 100%;
padding: 10px 12px; height: 46px;
padding: 0 14px;
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: 8px; border-radius: 12px;
background: var(--color-bg); background: var(--login-input-bg);
color: var(--color-text); color: var(--color-text);
font-size: 14px; font-size: 15px;
outline: none; 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 { .login-field input:focus {
border-color: var(--color-primary); border-color: var(--color-primary);
box-shadow: 0 0 0 4px color-mix(in srgb, var(--color-primary) 18%, transparent);
} }
.login-field input:disabled { .login-field input:disabled {
opacity: 0.6; 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 { .login-error {
display: flex; display: flex;
gap: 8px; gap: 8px;
@@ -70,23 +232,29 @@
background: var(--color-danger-bg); background: var(--color-danger-bg);
border: 1px solid var(--color-danger-border); border: 1px solid var(--color-danger-border);
color: var(--color-danger); color: var(--color-danger);
border-radius: 8px; border-radius: 12px;
padding: 10px 12px; padding: 10px 14px;
font-size: 13px; font-size: 13px;
margin-bottom: 16px; margin-bottom: 18px;
} }
.login-submit { .login-submit {
width: 100%; width: 100%;
padding: 11px 16px; height: 48px;
margin-top: 8px;
padding: 0 16px;
border: none; border: none;
border-radius: 8px; border-radius: 12px;
background: var(--color-primary); background: linear-gradient(135deg, var(--color-primary), var(--login-accent));
color: #fff; color: #fff;
font-size: 14px; font-size: 15px;
font-weight: 600; font-weight: 600;
cursor: pointer; 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; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -94,7 +262,18 @@
} }
.login-submit:hover:not(:disabled) { .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 { .login-submit:disabled {
+33 -5
View File
@@ -19,6 +19,7 @@ export function LoginPage() {
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const [formError, setFormError] = useState<string | null>(null) const [formError, setFormError] = useState<string | null>(null)
@@ -36,7 +37,7 @@ export function LoginPage() {
try { try {
await login(email, password) await login(email, password)
} catch (err) { } catch (err) {
const message = err instanceof ApiError ? err.message : 'Не удалось подключиться к серверу' const message = err instanceof ApiError ? err.message : 'Не вдалося підключитися до сервера'
setFormError(message) setFormError(message)
} finally { } finally {
setSubmitting(false) setSubmitting(false)
@@ -45,10 +46,22 @@ export function LoginPage() {
return ( return (
<div className="login-page"> <div className="login-page">
<div className="login-backdrop" aria-hidden="true">
<span className="login-blob login-blob--a" />
<span className="login-blob login-blob--b" />
<span className="login-blob login-blob--c" />
</div>
<div className="login-card"> <div className="login-card">
<div className="login-header"> <div className="login-header">
<h1>lux_fiscal</h1> <div className="login-logo" aria-hidden="true">
<p>Вход в систему фискализации заказов</p> <svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 3l7 4v5c0 4.5-3 8-7 9-4-1-7-4.5-7-9V7l7-4z" />
<path d="M9 12l2 2 4-4" />
</svg>
</div>
<h1>Assistant System</h1>
<p>Вхід у систему</p>
</div> </div>
<form onSubmit={handleSubmit} noValidate> <form onSubmit={handleSubmit} noValidate>
@@ -74,9 +87,10 @@ export function LoginPage() {
<div className="login-field"> <div className="login-field">
<label htmlFor={passwordId}>Пароль</label> <label htmlFor={passwordId}>Пароль</label>
<div className="login-password">
<input <input
id={passwordId} id={passwordId}
type="password" type={showPassword ? 'text' : 'password'}
autoComplete="current-password" autoComplete="current-password"
required required
value={password} value={password}
@@ -84,11 +98,25 @@ export function LoginPage() {
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••" placeholder="••••••••"
/> />
<button
type="button"
className="login-toggle"
onClick={() => setShowPassword((v) => !v)}
aria-label={showPassword ? 'Приховати пароль' : 'Показати пароль'}
aria-pressed={showPassword}
>
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12z" />
<circle cx="12" cy="12" r="3" />
{showPassword && <path d="M3 3l18 18" />}
</svg>
</button>
</div>
</div> </div>
<button type="submit" className="login-submit" disabled={submitting}> <button type="submit" className="login-submit" disabled={submitting}>
{submitting && <span className="spinner" aria-hidden="true" />} {submitting && <span className="spinner" aria-hidden="true" />}
{submitting ? 'Входим…' : 'Войти'} {submitting ? 'Входимо…' : 'Увійти'}
</button> </button>
</form> </form>
</div> </div>
@@ -10,7 +10,7 @@ export function ProtectedRoute() {
if (status === 'loading') { if (status === 'loading') {
return ( return (
<div className="page-center"> <div className="page-center">
<span className="spinner" aria-label="Загрузка" /> <span className="spinner" aria-label="Завантаження" />
</div> </div>
) )
} }
+1 -1
View File
@@ -5,7 +5,7 @@ import { AuthContext } from '@/features/auth/AuthContext'
export function useAuth() { export function useAuth() {
const ctx = useContext(AuthContext) const ctx = useContext(AuthContext)
if (!ctx) { if (!ctx) {
throw new Error('useAuth должен вызываться внутри <AuthProvider>') throw new Error('useAuth має викликатися всередині <AuthProvider>')
} }
return ctx return ctx
} }
@@ -5,6 +5,8 @@ export interface CashRegister {
name: string name: string
fiscal_number: string | null fiscal_number: string | null
license_key_masked: string license_key_masked: string
/** null — ключ Новой Почты не задан: касса не получает посылки. */
np_api_key_masked: string | null
tax_codes: (number | string)[] tax_codes: (number | string)[]
is_active: boolean is_active: boolean
is_default: boolean is_default: boolean
@@ -15,6 +17,7 @@ export interface CashRegisterCreate {
fiscal_number: string | null fiscal_number: string | null
license_key: string license_key: string
pin_code: string pin_code: string
np_api_key: string | null
tax_codes: (number | string)[] tax_codes: (number | string)[]
is_default: boolean is_default: boolean
} }
@@ -96,18 +96,18 @@ function orderDiscountOf(draft: Draft): number {
/** Проверяет черновик и собирает тело PATCH; строка — текст ошибки для кассира. */ /** Проверяет черновик и собирает тело PATCH; строка — текст ошибки для кассира. */
function toPayload(draft: Draft): OrderUpdate | string { function toPayload(draft: Draft): OrderUpdate | string {
if (draft.goods.length === 0) return 'Добавьте хотя бы один товар' if (draft.goods.length === 0) return 'Додайте хоча б один товар'
const goods = [] const goods = []
for (const good of draft.goods) { for (const good of draft.goods) {
const label = good.name.trim() || 'без названия' const label = good.name.trim() || 'без назви'
if (!good.name.trim()) return 'У товара не указано наименование' if (!good.name.trim()) return 'У товару не вказано найменування'
const price = toKopecks(good.price) const price = toKopecks(good.price)
const quantity = toThousandths(good.quantity) const quantity = toThousandths(good.quantity)
const discount = toKopecks(good.discount) const discount = toKopecks(good.discount)
if (price === null) return `Товар «${label}»: некорректная цена` if (price === null) return `Товар «${label}»: некоректна ціна`
if (quantity === null || quantity <= 0) return `Товар «${label}»: некорректное количество` if (quantity === null || quantity <= 0) return `Товар «${label}»: некоректна кількість`
if (discount === null) return `Товар «${label}»: некорректная скидка` if (discount === null) return `Товар «${label}»: некоректна знижка`
if ((lineAmount(good) ?? 0) < 0) return `Товар «${label}»: скидка больше суммы строки` if ((lineAmount(good) ?? 0) < 0) return `Товар «${label}»: знижка більша за суму рядка`
goods.push({ goods.push({
id: good.id, id: good.id,
sku: good.sku.trim(), sku: good.sku.trim(),
@@ -118,9 +118,9 @@ function toPayload(draft: Draft): OrderUpdate | string {
}) })
} }
const total = toKopecks(draft.total) const total = toKopecks(draft.total)
if (total === null) return 'Некорректная сумма заказа' if (total === null) return 'Некоректна сума замовлення'
const sum = goodsSum(draft.goods) ?? 0 const sum = goodsSum(draft.goods) ?? 0
if (total > sum) return `Сумма заказа больше суммы товаров (${formatKopecks(sum)} ₴)` if (total > sum) return `Сума замовлення більша за суму товарів (${formatKopecks(sum)} ₴)`
return { return {
recipient_name: draft.recipient_name, recipient_name: draft.recipient_name,
@@ -134,7 +134,7 @@ function toPayload(draft: Draft): OrderUpdate | string {
} }
const TEXT_FIELDS: { field: TextField; label: string; type?: 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_phone', label: 'Телефон', type: 'tel' },
{ field: 'recipient_email', label: 'Email', type: 'email' }, { field: 'recipient_email', label: 'Email', type: 'email' },
{ field: 'waybill_number', label: 'Номер ТТН' }, { field: 'waybill_number', label: 'Номер ТТН' },
@@ -153,7 +153,7 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
const editable = canEdit && !order.has_receipt const editable = canEdit && !order.has_receipt
function requestClose() { function requestClose() {
if (dirty && !window.confirm('Есть несохранённые изменения. Закрыть без сохранения?')) return if (dirty && !window.confirm('Є незбережені зміни. Закрити без збереження?')) return
onClose() onClose()
} }
@@ -203,10 +203,10 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
setDraft(next) setDraft(next)
setSaved(next) setSaved(next)
setOrderDiscount(orderDiscountOf(next)) setOrderDiscount(orderDiscountOf(next))
setMessage({ tone: 'ok', text: 'Изменения сохранены' }) setMessage({ tone: 'ok', text: 'Зміни збережено' })
await queryClient.invalidateQueries({ queryKey: ['orders'] }) await queryClient.invalidateQueries({ queryKey: ['orders'] })
} catch (err) { } catch (err) {
setMessage({ tone: 'error', text: err instanceof Error ? err.message : 'Не удалось сохранить заказ' }) setMessage({ tone: 'error', text: err instanceof Error ? err.message : 'Не вдалося зберегти замовлення' })
} finally { } finally {
setSaving(false) setSaving(false)
} }
@@ -217,10 +217,10 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
<div className="order-modal"> <div className="order-modal">
<div className="order-modal-header"> <div className="order-modal-header">
<h3> <h3>
Заказ {order.id} Замовлення {order.id}
{order.edited && <span className="order-modal-edited">изменён вручную</span>} {order.edited && <span className="order-modal-edited">змінено вручну</span>}
</h3> </h3>
<button type="button" className="order-modal-close" onClick={requestClose} aria-label="Закрыть"> <button type="button" className="order-modal-close" onClick={requestClose} aria-label="Закрити">
× ×
</button> </button>
</div> </div>
@@ -232,19 +232,19 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
</div> </div>
<div> <div>
<span className="order-modal-label">Статус ТТН</span> <span className="order-modal-label">Статус ТТН</span>
<span>{order.np_status || 'Нет данных'}</span> <span>{order.np_status || 'Немає даних'}</span>
</div> </div>
<div> <div>
<span className="order-modal-label">Наложенный платёж</span> <span className="order-modal-label">Накладений платіж</span>
<span>{order.np_cod_amount ? `${order.np_cod_amount} ₴` : '—'}</span> <span>{order.np_cod_amount ? `${order.np_cod_amount} ₴` : '—'}</span>
</div> </div>
<div> <div>
<span className="order-modal-label">Оплачено</span> <span className="order-modal-label">Оплачено</span>
<span> <span>
{order.np_payment_status === 'Payed' {order.np_payment_status === 'Payed'
? 'Да' ? 'Так'
: order.np_payment_status === 'NeedPayment' : order.np_payment_status === 'NeedPayment'
? 'Нет' ? 'Ні'
: '—'} : '—'}
</span> </span>
</div> </div>
@@ -272,7 +272,7 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
{editable ? ( {editable ? (
<label className="order-modal-notes"> <label className="order-modal-notes">
<span className="order-modal-label">Заметки</span> <span className="order-modal-label">Нотатки</span>
<textarea <textarea
className="order-modal-input" className="order-modal-input"
rows={2} rows={2}
@@ -283,7 +283,7 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
) : ( ) : (
draft.notes && ( draft.notes && (
<div className="order-modal-notes"> <div className="order-modal-notes">
<span className="order-modal-label">Заметки</span> <span className="order-modal-label">Нотатки</span>
<p>{draft.notes}</p> <p>{draft.notes}</p>
</div> </div>
) )
@@ -292,13 +292,13 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
<table className="order-modal-goods"> <table className="order-modal-goods">
<thead> <thead>
<tr> <tr>
<th>Наименование</th> <th>Найменування</th>
<th>SKU</th> <th>SKU</th>
<th>Цена</th> <th>Ціна</th>
<th>Кол-во</th> <th>К-сть</th>
<th>Скидка</th> <th>Знижка</th>
<th>Сумма</th> <th>Сума</th>
{editable && <th aria-label="Удалить" />} {editable && <th aria-label="Видалити" />}
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -350,7 +350,7 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
<button <button
type="button" type="button"
className="order-modal-remove" className="order-modal-remove"
aria-label={`Удалить товар ${good.name}`} aria-label={`Видалити товар ${good.name}`}
onClick={() => setGoods((goods) => goods.filter((g) => g.key !== good.key))} onClick={() => setGoods((goods) => goods.filter((g) => g.key !== good.key))}
> >
× ×
@@ -363,7 +363,7 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
{draft.goods.length === 0 && ( {draft.goods.length === 0 && (
<tr> <tr>
<td colSpan={editable ? 7 : 6} className="order-modal-goods-empty"> <td colSpan={editable ? 7 : 6} className="order-modal-goods-empty">
Товары не указаны Товари не вказані
</td> </td>
</tr> </tr>
)} )}
@@ -372,19 +372,19 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
{editable && ( {editable && (
<button type="button" className="order-modal-add" onClick={addGood}> <button type="button" className="order-modal-add" onClick={addGood}>
+ Добавить товар + Додати товар
</button> </button>
)} )}
<div className="order-modal-total"> <div className="order-modal-total">
<span>Итого</span> <span>Разом</span>
{editable ? ( {editable ? (
<span> <span>
<input <input
className="order-modal-input order-modal-input--num" className="order-modal-input order-modal-input--num"
inputMode="decimal" inputMode="decimal"
value={draft.total} value={draft.total}
title="Сумма заказа после всех скидок" title="Сума замовлення після всіх знижок"
onChange={(e) => setTotal(e.target.value)} onChange={(e) => setTotal(e.target.value)}
/>{' '} />{' '}
₴ ₴
@@ -403,12 +403,12 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
</span> </span>
</div> </div>
<div> <div>
<span className="order-modal-label">Предоплата в чеке</span> <span className="order-modal-label">Передоплата в чеку</span>
<span>{order.receipt_prepayment ? `${order.receipt_prepayment} ₴` : '—'}</span> <span>{order.receipt_prepayment ? `${order.receipt_prepayment} ₴` : '—'}</span>
</div> </div>
{order.receipt_error && ( {order.receipt_error && (
<div> <div>
<span className="order-modal-label">Ошибка</span> <span className="order-modal-label">Помилка</span>
<span>{order.receipt_error}</span> <span>{order.receipt_error}</span>
</div> </div>
)} )}
@@ -425,11 +425,11 @@ export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalPr
disabled={!dirty || saving} disabled={!dirty || saving}
onClick={() => void handleSave()} onClick={() => void handleSave()}
> >
{saving ? 'Сохранение…' : 'Сохранить'} {saving ? 'Збереження…' : 'Зберегти'}
</button> </button>
)} )}
<button type="button" className="order-modal-close-btn" onClick={requestClose}> <button type="button" className="order-modal-close-btn" onClick={requestClose}>
Закрыть Закрити
</button> </button>
</div> </div>
</div> </div>
+7 -7
View File
@@ -43,13 +43,13 @@ export interface ReceiptCreateResponse {
export type Tone = 'delivered' | 'processing' | 'danger' | 'new' export type Tone = 'delivered' | 'processing' | 'danger' | 'new'
export const RECEIPT_STATUS: Record<ReceiptStatus, { label: string; tone: Tone }> = { export const RECEIPT_STATUS: Record<ReceiptStatus, { label: string; tone: Tone }> = {
pending: { label: 'Отправляется', tone: 'new' }, pending: { label: 'Надсилається', tone: 'new' },
created: { label: 'Ждёт оплаты', tone: 'processing' }, created: { label: 'Очікує оплати', tone: 'processing' },
done: { label: 'Фискализирован', tone: 'delivered' }, done: { label: 'Фіскалізовано', tone: 'delivered' },
returned: { label: 'Возврат посылки', tone: 'danger' }, returned: { label: 'Повернення посилки', tone: 'danger' },
receipt_error: { label: 'Ошибка фискализации', tone: 'danger' }, receipt_error: { label: 'Помилка фіскалізації', tone: 'danger' },
cancelled: { label: 'Отменён', tone: 'new' }, cancelled: { label: 'Скасовано', tone: 'new' },
failed: { label: 'Не создан', tone: 'danger' }, failed: { label: 'Не створено', tone: 'danger' },
} }
/** Статусы, из которых чек можно отменить (см. services/receipts.cancel_receipt). */ /** Статусы, из которых чек можно отменить (см. services/receipts.cancel_receipt). */
+1 -1
View File
@@ -6,7 +6,7 @@ import '@/index.css'
const rootElement = document.getElementById('root') const rootElement = document.getElementById('root')
if (!rootElement) { if (!rootElement) {
throw new Error('Элемент #root не найден в index.html') throw new Error('Елемент #root не знайдено в index.html')
} }
createRoot(rootElement).render( createRoot(rootElement).render(
+4
View File
@@ -44,3 +44,7 @@
display: flex; display: flex;
gap: 8px; gap: 8px;
} }
.cr-missing {
color: var(--color-danger);
}
+55 -38
View File
@@ -19,6 +19,7 @@ interface FormState {
fiscal_number: string fiscal_number: string
license_key: string license_key: string
pin_code: string pin_code: string
np_api_key: string
tax_codes: string tax_codes: string
is_default: boolean is_default: boolean
} }
@@ -28,6 +29,7 @@ const EMPTY_FORM: FormState = {
fiscal_number: '', fiscal_number: '',
license_key: '', license_key: '',
pin_code: '', pin_code: '',
np_api_key: '',
tax_codes: '', tax_codes: '',
is_default: true, is_default: true,
} }
@@ -62,7 +64,7 @@ export function CashRegistersPage() {
setMessage({ tone: 'ok', text: okText }) setMessage({ tone: 'ok', text: okText })
await queryClient.invalidateQueries({ queryKey: ['cash-registers'] }) await queryClient.invalidateQueries({ queryKey: ['cash-registers'] })
} catch (err) { } catch (err) {
setMessage({ tone: 'error', text: err instanceof Error ? err.message : 'Ошибка запроса' }) setMessage({ tone: 'error', text: err instanceof Error ? err.message : 'Помилка запиту' })
} finally { } finally {
setBusy(false) setBusy(false)
} }
@@ -75,6 +77,7 @@ export function CashRegistersPage() {
fiscal_number: register.fiscal_number ?? '', fiscal_number: register.fiscal_number ?? '',
license_key: '', license_key: '',
pin_code: '', pin_code: '',
np_api_key: '',
tax_codes: register.tax_codes.join(', '), tax_codes: register.tax_codes.join(', '),
is_default: register.is_default, is_default: register.is_default,
}) })
@@ -88,7 +91,7 @@ export function CashRegistersPage() {
async function handleSubmit(event: FormEvent) { async function handleSubmit(event: FormEvent) {
event.preventDefault() event.preventDefault()
if (editingId) { if (editingId) {
// Пустые ключ/PIN при редактировании — «не менять». // Пустые ключи/PIN при редактировании — «не менять».
const payload: CashRegisterUpdate = { const payload: CashRegisterUpdate = {
name: form.name, name: form.name,
fiscal_number: form.fiscal_number || null, fiscal_number: form.fiscal_number || null,
@@ -97,7 +100,8 @@ export function CashRegistersPage() {
} }
if (form.license_key) payload.license_key = form.license_key if (form.license_key) payload.license_key = form.license_key
if (form.pin_code) payload.pin_code = form.pin_code if (form.pin_code) payload.pin_code = form.pin_code
await run(() => updateCashRegister(editingId, payload), 'Касса обновлена') if (form.np_api_key) payload.np_api_key = form.np_api_key
await run(() => updateCashRegister(editingId, payload), 'Касу оновлено')
} else { } else {
await run( await run(
() => () =>
@@ -106,10 +110,11 @@ export function CashRegistersPage() {
fiscal_number: form.fiscal_number || null, fiscal_number: form.fiscal_number || null,
license_key: form.license_key, license_key: form.license_key,
pin_code: form.pin_code, pin_code: form.pin_code,
np_api_key: form.np_api_key,
tax_codes: parseTaxCodes(form.tax_codes), tax_codes: parseTaxCodes(form.tax_codes),
is_default: form.is_default, is_default: form.is_default,
}), }),
'Касса добавлена', 'Касу додано',
) )
} }
resetForm() resetForm()
@@ -118,21 +123,21 @@ export function CashRegistersPage() {
return ( return (
<div className="dashboard-shell"> <div className="dashboard-shell">
<header className="dashboard-topbar"> <header className="dashboard-topbar">
<span className="dashboard-brand">lux_fiscal</span> <span className="dashboard-brand">Assistant System</span>
<Link to="/" className="orders-link-btn"> <Link to="/" className="orders-link-btn">
← К заказам ← До замовлень
</Link> </Link>
</header> </header>
<main className="orders-body"> <main className="orders-body">
<div className="orders-toolbar"> <div className="orders-toolbar">
<h2>Кассы Checkbox</h2> <h2>Каси Checkbox</h2>
</div> </div>
{message && ( {message && (
<div className={`orders-notice orders-notice--${message.tone}`}> <div className={`orders-notice orders-notice--${message.tone}`}>
<span>{message.text}</span> <span>{message.text}</span>
<button type="button" className="orders-notice-close" onClick={() => setMessage(null)} aria-label="Скрыть"> <button type="button" className="orders-notice-close" onClick={() => setMessage(null)} aria-label="Сховати">
× ×
</button> </button>
</div> </div>
@@ -142,33 +147,34 @@ export function CashRegistersPage() {
<table className="orders-table"> <table className="orders-table">
<thead> <thead>
<tr> <tr>
<th>Название</th> <th>Назва</th>
<th>Фиск. номер</th> <th>Фіск. номер</th>
<th>Ключ лицензии</th> <th>Ключ ліцензії</th>
<th>Налоги</th> <th>Ключ Нової Пошти</th>
<th>Податки</th>
<th>Статус</th> <th>Статус</th>
<th>Действия</th> <th>Дії</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{isLoading && ( {isLoading && (
<tr> <tr>
<td colSpan={6} className="orders-empty"> <td colSpan={7} className="orders-empty">
Загрузка… Завантаження…
</td> </td>
</tr> </tr>
)} )}
{isError && ( {isError && (
<tr> <tr>
<td colSpan={6} className="orders-empty"> <td colSpan={7} className="orders-empty">
Не удалось загрузить кассы Не вдалося завантажити каси
</td> </td>
</tr> </tr>
)} )}
{registers?.length === 0 && ( {registers?.length === 0 && (
<tr> <tr>
<td colSpan={6} className="orders-empty"> <td colSpan={7} className="orders-empty">
Кассы не настроены — чеки создавать нельзя Каси не налаштовані — створювати чеки неможливо
</td> </td>
</tr> </tr>
)} )}
@@ -176,14 +182,15 @@ export function CashRegistersPage() {
<tr key={register.id}> <tr key={register.id}>
<td> <td>
{register.name} {register.name}
{register.is_default && <span className="orders-status orders-status--delivered cr-default">основная</span>} {register.is_default && <span className="orders-status orders-status--delivered cr-default">основна</span>}
</td> </td>
<td>{register.fiscal_number || '—'}</td> <td>{register.fiscal_number || '—'}</td>
<td>{register.license_key_masked}</td> <td>{register.license_key_masked}</td>
<td>{register.tax_codes.length ? register.tax_codes.join(', ') : 'без налога'}</td> <td>{register.np_api_key_masked ?? <span className="cr-missing">не задано</span>}</td>
<td>{register.tax_codes.length ? register.tax_codes.join(', ') : 'без податку'}</td>
<td> <td>
<span className={`orders-status orders-status--${register.is_active ? 'delivered' : 'new'}`}> <span className={`orders-status orders-status--${register.is_active ? 'delivered' : 'new'}`}>
{register.is_active ? 'Активна' : 'Отключена'} {register.is_active ? 'Активна' : 'Вимкнена'}
</span> </span>
</td> </td>
<td className="orders-actions-cell"> <td className="orders-actions-cell">
@@ -191,12 +198,12 @@ export function CashRegistersPage() {
type="button" type="button"
className="orders-view-btn" className="orders-view-btn"
disabled={busy} disabled={busy}
onClick={() => void run(() => checkCashRegister(register.id), `Вход в «${register.name}» успешен`)} onClick={() => void run(() => checkCashRegister(register.id), `Вхід у «${register.name}» успішний`)}
> >
Проверить Перевірити
</button> </button>
<button type="button" className="orders-view-btn" disabled={busy} onClick={() => startEdit(register)}> <button type="button" className="orders-view-btn" disabled={busy} onClick={() => startEdit(register)}>
Изменить Змінити
</button> </button>
<button <button
type="button" type="button"
@@ -205,11 +212,11 @@ export function CashRegistersPage() {
onClick={() => onClick={() =>
void run( void run(
() => updateCashRegister(register.id, { is_active: !register.is_active }), () => updateCashRegister(register.id, { is_active: !register.is_active }),
register.is_active ? 'Касса отключена' : 'Касса включена', register.is_active ? 'Касу вимкнено' : 'Касу увімкнено',
) )
} }
> >
{register.is_active ? 'Отключить' : 'Включить'} {register.is_active ? 'Вимкнути' : 'Увімкнути'}
</button> </button>
</td> </td>
</tr> </tr>
@@ -219,38 +226,48 @@ export function CashRegistersPage() {
</div> </div>
<form className="cr-form" onSubmit={(e) => void handleSubmit(e)}> <form className="cr-form" onSubmit={(e) => void handleSubmit(e)}>
<h3>{editingId ? 'Изменить кассу' : 'Добавить кассу'}</h3> <h3>{editingId ? 'Змінити касу' : 'Додати касу'}</h3>
<label> <label>
Название Назва
<input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /> <input required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</label> </label>
<label> <label>
Фискальный номер Фіскальний номер
<input value={form.fiscal_number} onChange={(e) => setForm({ ...form, fiscal_number: e.target.value })} /> <input value={form.fiscal_number} onChange={(e) => setForm({ ...form, fiscal_number: e.target.value })} />
</label> </label>
<label> <label>
Ключ лицензии кассы Ключ ліцензії каси
<input <input
required={!editingId} required={!editingId}
autoComplete="off" autoComplete="off"
placeholder={editingId ? 'не менять' : ''} placeholder={editingId ? 'не змінювати' : ''}
value={form.license_key} value={form.license_key}
onChange={(e) => setForm({ ...form, license_key: e.target.value })} onChange={(e) => setForm({ ...form, license_key: e.target.value })}
/> />
</label> </label>
<label> <label>
PIN-код кассира PIN-код касира
<input <input
required={!editingId} required={!editingId}
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
placeholder={editingId ? 'не менять' : ''} placeholder={editingId ? 'не змінювати' : ''}
value={form.pin_code} value={form.pin_code}
onChange={(e) => setForm({ ...form, pin_code: e.target.value })} onChange={(e) => setForm({ ...form, pin_code: e.target.value })}
/> />
</label> </label>
<label> <label>
Коды налогов (через запятую, пусто — без налога) Ключ API Нової Пошти (посилки цього кабінету пробиваються через цю касу)
<input
required={!editingId}
autoComplete="off"
placeholder={editingId ? 'не змінювати' : ''}
value={form.np_api_key}
onChange={(e) => setForm({ ...form, np_api_key: e.target.value })}
/>
</label>
<label>
Коди податків (через кому, порожньо — без податку)
<input value={form.tax_codes} onChange={(e) => setForm({ ...form, tax_codes: e.target.value })} /> <input value={form.tax_codes} onChange={(e) => setForm({ ...form, tax_codes: e.target.value })} />
</label> </label>
<label className="cr-checkbox"> <label className="cr-checkbox">
@@ -259,15 +276,15 @@ export function CashRegistersPage() {
checked={form.is_default} checked={form.is_default}
onChange={(e) => setForm({ ...form, is_default: e.target.checked })} onChange={(e) => setForm({ ...form, is_default: e.target.checked })}
/> />
Основная касса (по ней создаются чеки) Основна каса (першою перевіряє нові посилки)
</label> </label>
<div className="cr-form-actions"> <div className="cr-form-actions">
<button type="submit" className="orders-create-btn" disabled={busy}> <button type="submit" className="orders-create-btn" disabled={busy}>
{editingId ? 'Сохранить' : 'Добавить'} {editingId ? 'Зберегти' : 'Додати'}
</button> </button>
{editingId && ( {editingId && (
<button type="button" className="orders-view-btn" onClick={resetForm}> <button type="button" className="orders-view-btn" onClick={resetForm}>
Отмена Скасувати
</button> </button>
)} )}
</div> </div>
+46 -46
View File
@@ -14,16 +14,16 @@ import { CANCELLABLE, RECEIPT_STATUS } from '@/features/receipts/types'
import type { ReceiptRequestItem } from '@/features/receipts/types' import type { ReceiptRequestItem } from '@/features/receipts/types'
const ROLE_LABEL: Record<string, string> = { const ROLE_LABEL: Record<string, string> = {
admin: 'Администратор', admin: 'Адміністратор',
cashier: 'Кассир', cashier: 'Касир',
viewer: 'Наблюдатель', viewer: 'Спостерігач',
} }
const TABS: { key: OrderTab; label: string }[] = [ const TABS: { key: OrderTab; label: string }[] = [
{ key: 'no_receipt', label: 'Без чека' }, { key: 'no_receipt', label: 'Без чека' },
{ key: 'has_receipt', label: 'Выписаны чеки' }, { key: 'has_receipt', label: 'Виписані чеки' },
{ key: 'received', label: 'Полученные' }, { key: 'received', label: 'Отримані' },
{ key: 'refused', label: 'Отказы' }, { key: 'refused', label: 'Відмови' },
] ]
function npStatusTone(order: Order): 'delivered' | 'processing' | 'danger' | 'new' { function npStatusTone(order: Order): 'delivered' | 'processing' | 'danger' | 'new' {
@@ -45,7 +45,7 @@ function paymentBadge(order: Order): { label: string; tone: 'delivered' | 'proce
return null return null
} }
const MONEY_FORMAT = new Intl.NumberFormat('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) const MONEY_FORMAT = new Intl.NumberFormat('uk-UA', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
function formatMoney(amount: string): string { function formatMoney(amount: string): string {
return MONEY_FORMAT.format(Number(amount)) return MONEY_FORMAT.format(Number(amount))
@@ -80,7 +80,7 @@ export function DashboardPage() {
for (const order of targets) { for (const order of targets) {
const prepayment = toKopecks(prepaymentOf(order)) const prepayment = toKopecks(prepaymentOf(order))
if (prepayment === null) { if (prepayment === null) {
localErrors.push(`${order.id}: некорректная сумма предоплаты`) localErrors.push(`${order.id}: некоректна сума передоплати`)
continue continue
} }
items.push({ order_id: order.id, prepayment_kopecks: prepayment }) items.push({ order_id: order.id, prepayment_kopecks: prepayment })
@@ -95,14 +95,14 @@ export function DashboardPage() {
const result = await createReceipts(items) const result = await createReceipts(items)
const errors = [...localErrors, ...Object.entries(result.errors).map(([id, msg]) => `${id}: ${msg}`)] const errors = [...localErrors, ...Object.entries(result.errors).map(([id, msg]) => `${id}: ${msg}`)]
const lines = [ const lines = [
...(result.created.length ? [`Отправлено в Checkbox: ${result.created.length}`] : []), ...(result.created.length ? [`Надіслано в Checkbox: ${result.created.length}`] : []),
...errors, ...errors,
] ]
setNotice({ tone: errors.length ? 'error' : 'ok', lines }) setNotice({ tone: errors.length ? 'error' : 'ok', lines })
setSelected(new Set()) setSelected(new Set())
await queryClient.invalidateQueries({ queryKey: ['orders'] }) await queryClient.invalidateQueries({ queryKey: ['orders'] })
} catch (err) { } catch (err) {
setNotice({ tone: 'error', lines: [err instanceof Error ? err.message : 'Не удалось создать чеки'] }) setNotice({ tone: 'error', lines: [err instanceof Error ? err.message : 'Не вдалося створити чеки'] })
} finally { } finally {
setSubmitting(false) setSubmitting(false)
} }
@@ -111,19 +111,19 @@ export function DashboardPage() {
async function handleCancel(order: Order) { async function handleCancel(order: Order) {
if (!order.receipt_id) return if (!order.receipt_id) return
const outcome = { const outcome = {
no_receipt: 'Заказ вернётся в очередь.', no_receipt: 'Замовлення повернеться в чергу.',
has_receipt: 'Заказ вернётся в очередь.', has_receipt: 'Замовлення повернеться в чергу.',
received: 'Заказ останется в полученных.', received: 'Замовлення залишиться в отриманих.',
refused: 'Заказ останется в отказах.', refused: 'Замовлення залишиться у відмовах.',
}[tab] }[tab]
if (!window.confirm(`Отменить ЕТТН-чек по заказу ${order.id}? ${outcome}`)) return if (!window.confirm(`Скасувати ЕТТН-чек за замовленням ${order.id}? ${outcome}`)) return
setCancellingId(order.id) setCancellingId(order.id)
try { try {
await cancelReceipt(order.receipt_id) await cancelReceipt(order.receipt_id)
setNotice({ tone: 'ok', lines: [`Чек по заказу ${order.id} отменён`] }) setNotice({ tone: 'ok', lines: [`Чек за замовленням ${order.id} скасовано`] })
await queryClient.invalidateQueries({ queryKey: ['orders'] }) await queryClient.invalidateQueries({ queryKey: ['orders'] })
} catch (err) { } catch (err) {
setNotice({ tone: 'error', lines: [err instanceof Error ? err.message : 'Не удалось отменить чек'] }) setNotice({ tone: 'error', lines: [err instanceof Error ? err.message : 'Не вдалося скасувати чек'] })
} finally { } finally {
setCancellingId(null) setCancellingId(null)
} }
@@ -174,23 +174,23 @@ export function DashboardPage() {
return ( return (
<div className="dashboard-shell"> <div className="dashboard-shell">
<header className="dashboard-topbar"> <header className="dashboard-topbar">
<span className="dashboard-brand">lux_fiscal</span> <span className="dashboard-brand">Assistant System</span>
<div className="dashboard-user"> <div className="dashboard-user">
<span>{user?.full_name}</span> <span>{user?.full_name}</span>
<span className="dashboard-role">{user ? (ROLE_LABEL[user.role] ?? user.role) : ''}</span> <span className="dashboard-role">{user ? (ROLE_LABEL[user.role] ?? user.role) : ''}</span>
<button type="button" className="dashboard-logout" onClick={() => void logout()}> <button type="button" className="dashboard-logout" onClick={() => void logout()}>
Выйти Вийти
</button> </button>
</div> </div>
</header> </header>
<main className="orders-body"> <main className="orders-body">
<div className="orders-toolbar"> <div className="orders-toolbar">
<h2>Заказы</h2> <h2>Замовлення</h2>
<div className="orders-toolbar-actions"> <div className="orders-toolbar-actions">
{user?.role === 'admin' && ( {user?.role === 'admin' && (
<Link to="/cash-registers" className="orders-link-btn"> <Link to="/cash-registers" className="orders-link-btn">
Кассы Каси
</Link> </Link>
)} )}
{tab === 'no_receipt' && canFiscalize && ( {tab === 'no_receipt' && canFiscalize && (
@@ -200,20 +200,20 @@ export function DashboardPage() {
disabled={selected.size === 0 || submitting} disabled={selected.size === 0 || submitting}
onClick={() => void submitReceipts(filtered.filter((order) => selected.has(order.id)))} onClick={() => void submitReceipts(filtered.filter((order) => selected.has(order.id)))}
> >
Создать чеки по выбранным ({selected.size}) Створити чеки за вибраними ({selected.size})
</button> </button>
)} )}
</div> </div>
</div> </div>
<div className="orders-summary"> <div className="orders-summary">
<div className="orders-summary-card" title="Посылки с наложкой, которые ещё не забрали и по которым нет отказа"> <div className="orders-summary-card" title="Посилки з післяплатою, які ще не забрали і за якими немає відмови">
<span className="orders-summary-label">Наложка в пути</span> <span className="orders-summary-label">Післяплата в дорозі</span>
<span className="orders-summary-value"> <span className="orders-summary-value">
{summary ? `${formatMoney(summary.cod_in_transit_amount)} ₴` : '—'} {summary ? `${formatMoney(summary.cod_in_transit_amount)} ₴` : '—'}
</span> </span>
<span className="orders-summary-hint"> <span className="orders-summary-hint">
{summary ? `${summary.cod_in_transit_count} посылок` : ' '} {summary ? `${summary.cod_in_transit_count} посилок` : ' '}
</span> </span>
</div> </div>
</div> </div>
@@ -225,7 +225,7 @@ export function DashboardPage() {
<li key={line}>{line}</li> <li key={line}>{line}</li>
))} ))}
</ul> </ul>
<button type="button" className="orders-notice-close" onClick={() => setNotice(null)} aria-label="Скрыть"> <button type="button" className="orders-notice-close" onClick={() => setNotice(null)} aria-label="Сховати">
× ×
</button> </button>
</div> </div>
@@ -248,16 +248,16 @@ export function DashboardPage() {
</div> </div>
<div className="orders-filterbar"> <div className="orders-filterbar">
<span className="orders-selected-count">Выбрано: {selected.size} заказов</span> <span className="orders-selected-count">Вибрано: {selected.size} замовлень</span>
<input <input
type="search" type="search"
className="orders-search" className="orders-search"
placeholder="Поиск по ID, ТТН, клиенту" placeholder="Пошук за ID, ТТН, клієнтом"
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
/> />
<button type="button" className="orders-filter-btn"> <button type="button" className="orders-filter-btn">
Фильтр Фільтр
</button> </button>
</div> </div>
@@ -266,31 +266,31 @@ export function DashboardPage() {
<thead> <thead>
<tr> <tr>
<th className="orders-checkbox-col"> <th className="orders-checkbox-col">
<input type="checkbox" checked={allSelected} onChange={toggleAll} aria-label="Выбрать всё" /> <input type="checkbox" checked={allSelected} onChange={toggleAll} aria-label="Вибрати все" />
</th> </th>
<th>ID заказа</th> <th>ID замовлення</th>
<th>Номер ТТН</th> <th>Номер ТТН</th>
<th>Клиент</th> <th>Клієнт</th>
<th>Сумма</th> <th>Сума</th>
<th>Наложка</th> <th>Післяплата</th>
<th>Оплачено</th> <th>Оплачено</th>
<th>Предоплата, ₴</th> <th>Передоплата, ₴</th>
<th>Статус ТТН</th> <th>Статус ТТН</th>
<th>Действия</th> <th>Дії</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{isLoading && ( {isLoading && (
<tr> <tr>
<td colSpan={10} className="orders-empty"> <td colSpan={10} className="orders-empty">
Загрузка… Завантаження…
</td> </td>
</tr> </tr>
)} )}
{isError && ( {isError && (
<tr> <tr>
<td colSpan={10} className="orders-empty"> <td colSpan={10} className="orders-empty">
Не удалось загрузить заказы из CRM Не вдалося завантажити замовлення з CRM
</td> </td>
</tr> </tr>
)} )}
@@ -305,7 +305,7 @@ export function DashboardPage() {
type="checkbox" type="checkbox"
checked={selected.has(order.id)} checked={selected.has(order.id)}
onChange={() => toggleOne(order.id)} onChange={() => toggleOne(order.id)}
aria-label={`Выбрать заказ ${order.id}`} aria-label={`Вибрати замовлення ${order.id}`}
/> />
</td> </td>
<td>{order.id}</td> <td>{order.id}</td>
@@ -334,7 +334,7 @@ export function DashboardPage() {
placeholder="0.00" placeholder="0.00"
value={prepaymentOf(order)} value={prepaymentOf(order)}
disabled={!canFiscalize} disabled={!canFiscalize}
title="Сумма заказа − предоплата должна равняться наложке" title="Сума замовлення − передоплата має дорівнювати післяплаті"
onChange={(e) => setPrepayments((prev) => ({ ...prev, [order.id]: e.target.value }))} onChange={(e) => setPrepayments((prev) => ({ ...prev, [order.id]: e.target.value }))}
/> />
) : ( ) : (
@@ -343,19 +343,19 @@ export function DashboardPage() {
</td> </td>
<td> <td>
<span className={`orders-status orders-status--${npStatusTone(order)}`}> <span className={`orders-status orders-status--${npStatusTone(order)}`}>
{order.np_status || 'Нет данных'} {order.np_status || 'Немає даних'}
</span> </span>
</td> </td>
<td className="orders-actions-cell"> <td className="orders-actions-cell">
<button type="button" className="orders-view-btn" onClick={() => setViewingOrder(order)}> <button type="button" className="orders-view-btn" onClick={() => setViewingOrder(order)}>
Просмотр Перегляд
</button> </button>
{tab === 'no_receipt' && canFiscalize && ( {tab === 'no_receipt' && canFiscalize && (
<button <button
type="button" type="button"
className="orders-receipt-btn" className="orders-receipt-btn"
disabled={!canCreateReceipt(order) || submitting} disabled={!canCreateReceipt(order) || submitting}
title={canCreateReceipt(order) ? 'Создать ЕТТН-чек в Checkbox' : 'Нет ТТН или наложки'} title={canCreateReceipt(order) ? 'Створити ЕТТН-чек у Checkbox' : 'Немає ТТН або післяплати'}
onClick={() => void submitReceipts([order])} onClick={() => void submitReceipts([order])}
> >
Чек Чек
@@ -376,7 +376,7 @@ export function DashboardPage() {
disabled={cancellingId === order.id} disabled={cancellingId === order.id}
onClick={() => void handleCancel(order)} onClick={() => void handleCancel(order)}
> >
Отменить Скасувати
</button> </button>
)} )}
{canDelete && (tab === 'no_receipt' || ((tab === 'refused' || tab === 'received') && !order.has_receipt)) && ( {canDelete && (tab === 'no_receipt' || ((tab === 'refused' || tab === 'received') && !order.has_receipt)) && (
@@ -386,7 +386,7 @@ export function DashboardPage() {
disabled={deletingId === order.id} disabled={deletingId === order.id}
onClick={() => void handleDelete(order)} onClick={() => void handleDelete(order)}
> >
Удалить Видалити
</button> </button>
)} )}
</td> </td>
@@ -396,7 +396,7 @@ export function DashboardPage() {
{!isLoading && !isError && filtered.length === 0 && ( {!isLoading && !isError && filtered.length === 0 && (
<tr> <tr>
<td colSpan={10} className="orders-empty"> <td colSpan={10} className="orders-empty">
Ничего не найдено Нічого не знайдено
</td> </td>
</tr> </tr>
)} )}