Author SHA1 Message Date
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
lauadmin 2b92d82693 Merge pull request 'Poll ETTN statuses in batches and commit per receipt' (#2) from feature/checkbox-ettn into main 2026-09-25 09:27:06 +00:00
lauadminandClaude Opus 5.5 a7cba7616a Poll ETTN statuses in batches and commit per receipt
The cron poll committed only at the end; with ~100 created receipts and a 1s
pause between Checkbox requests it hit arq's 300s timeout every run, so all
status updates were rolled back and fiscalized receipts stayed "created".
Now each run polls at most 30 least-recently-checked receipts and commits
after each one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:25:12 +03:00
lauadmin ed635cab94 Merge pull request 'Checkbox ETTN receipts, order editing, dashboard tabs and COD summary' (#1) from feature/checkbox-ettn into main 2026-09-24 22:36:51 +00:00
lauadminandClaude Opus 5.5 a6072800b2 Add COD-in-transit summary and received tab to the dashboard
- GET /orders/summary: count and sum of cash-on-delivery for parcels not
  yet picked up (excludes received, refused, deleted/unknown TTNs, paid COD);
  shown as a card above the tabs.
- New «Полученные» tab: orders with NP received codes (9, 10, 11, 106) move
  there automatically, regardless of receipt; 106 is now a final status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 01:23:39 +03:00
lauadminandClaude Opus 5.5 528a869657 Retry ETTN creation on Nova Poshta rate limit instead of failing
Checkbox relays Nova Poshta's "To many requests" (20000401501) as a 4xx
third_party.generic error, not a 429, so bursts of receipt requests ended
up as failed receipts. Such responses are now CheckboxRateLimitedError:
the receipt stays pending and the worker job is retried with arq.Retry
after the "Try again after N seconds" delay plus backoff. NP timeouts
relayed the same way are treated as unavailable (unknown outcome).

The HTTP client also sends Checkbox requests one at a time with a
CHECKBOX_MIN_REQUEST_INTERVAL_MS pause and signs the cashier in once for
concurrent jobs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 21:25:59 +03:00
lauadminandClaude Opus 5.5 f88b5cccc0 Add refusals tab to the order dashboard
Orders whose Nova Poshta status is a refusal (102/103/105/108) now show
only under «Отказы», whether or not they have a receipt. GET /orders takes
tab=no_receipt|has_receipt|refused instead of has_receipt. NP statuses are
also polled for orders that already have a receipt, until NP reports a
final status.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 21:25:59 +03:00
lauadminandClaude Opus 5.5 56ac0fc370 Allow editing orders in the order card
Cashiers can edit recipient, TTN, notes, goods and total in the order
modal and save via PATCH /orders/{id}. Edited orders get edited_at and
are no longer overwritten by CRM sync. Editing is blocked once a receipt
exists; changing the TTN resets Nova Poshta tracking fields.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 17:23:41 +03:00
41 changed files with 2013 additions and 189 deletions
+4 -1
View File
@@ -58,7 +58,8 @@ CRM_SHOP_KEY=change-me-crm-shopkey
CRM_SID=1
# --- Nova Poshta ----------------------------------------------------------
# Ключ API кабинета Nova Poshta (my.novaposhta.ua → Настройки → API).
# Ключи API Nova Poshta задаются у касс (страница «Кассы»). Эта переменная нужна
# только миграции 0008: переносит старый общий ключ в кассу по умолчанию.
NOVA_POSHTA_API_KEY=change-me-novaposhta-apikey
# --- Checkbox -------------------------------------------------------------
@@ -67,6 +68,8 @@ NOVA_POSHTA_API_KEY=change-me-novaposhta-apikey
CHECKBOX_BASE_URL=https://api.checkbox.ua
CHECKBOX_CLIENT_NAME=lux_fiscal
CHECKBOX_CLIENT_VERSION=0.1.0
# Пауза между запросами к Checkbox (мс): Новая Почта за Checkbox ограничивает частоту.
CHECKBOX_MIN_REQUEST_INTERVAL_MS=1000
# ЕТТН-чеки на тестовой кассе Checkbox не работают: локально весь цикл
# прогоняется через стаб (чек «фискализируется» на втором опросе). В production запрещено.
CHECKBOX_USE_STUB=false
+3 -1
View File
@@ -118,7 +118,7 @@ Not yet enforced by types anywhere in the current code, but is a hard project co
## Project status (see the plan for the full roadmap)
Stages 1–4 (scaffolding, auth/audit, CRM order queue, Nova Poshta tracking) are done. Checkbox ETTN receipts are implemented per `.plans/checkbox-ettn-receipts.md` but not yet verified on a real cash register.
Stages 1–4 (scaffolding, auth/audit, CRM order queue, Nova Poshta tracking) are done. The dashboard has four tabs (`GET /orders?tab=no_receipt|has_receipt|received|refused`, `services/orders.OrderTab`); NP status beats receipt presence: an order whose NP status code is «received» (`NP_RECEIVED_STATUS_CODES`) shows only under «Полученные», a refusal (`NP_REFUSAL_STATUS_CODES`) only under «Отказы», whether or not it has a receipt. Checkbox ETTN receipts are implemented per `.plans/checkbox-ettn-receipts.md` but not yet verified on a real cash register.
### Checkbox ETTN receipts
@@ -126,4 +126,6 @@ Stages 1–4 (scaffolding, auth/audit, CRM order queue, Nova Poshta tracking) ar
- Two-phase create: `POST /receipts` writes `Receipt(pending)` + audit and commits, then enqueues `create_ettn_receipt`; the worker calls Checkbox. A timeout leaves the row `pending` with `error` set — the retry first looks the TTN up via `find_ettn` instead of blindly re-posting (would create a second receipt). Keep this.
- 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"}}`.
- 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()`.
@@ -0,0 +1,26 @@
"""Отметка о ручном редактировании заказа
Revision ID: 0007
Revises: 0006
Create Date: 2026-09-24
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0007"
down_revision: str | None = "0006"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("orders", sa.Column("edited_at", sa.DateTime(timezone=True), nullable=True))
def downgrade() -> None:
op.drop_column("orders", "edited_at")
@@ -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")
+33 -2
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])
async def list_cash_registers(session: SessionDep) -> list[CashRegisterOut]:
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(
payload: CashRegisterCreate, request: Request, session: SessionDep, actor: AdminUser
) -> CashRegisterOut:
if payload.np_api_key:
await _ensure_np_key_unique(session, payload.np_api_key)
register = CashRegister(
id=uuid.uuid4(),
name=payload.name,
fiscal_number=payload.fiscal_number,
license_key_enc=crypto.encrypt(payload.license_key),
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,
is_active=True,
is_default=payload.is_default,
@@ -56,7 +80,11 @@ async def create_cash_register(
user=actor,
entity_type="cash_register",
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,
)
await session.commit()
@@ -80,6 +108,9 @@ async def update_cash_register(
register.license_key_enc = crypto.encrypt(changes["license_key"])
if "pin_code" in changes:
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:
register.fiscal_number = changes["fiscal_number"]
for field in ("name", "tax_codes", "is_active", "is_default"):
@@ -94,7 +125,7 @@ async def update_cash_register(
user=actor,
entity_type="cash_register",
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,
)
await session.commit()
+45 -4
View File
@@ -1,4 +1,4 @@
"""Локальная очередь заказов (синхронизируется из CRM) — список, вкладки, удаление."""
"""Локальная очередь заказов (синхронизируется из CRM) — список, вкладки, правка, удаление."""
from __future__ import annotations
@@ -6,28 +6,69 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
from app.api.deps import CashierUser, CrmClientDep, SessionDep, require_any
from app.db.models.audit import AuditAction
from app.schemas.orders import OrderRowOut
from app.schemas.orders import OrderRowOut, OrdersSummaryOut, OrderUpdateIn
from app.services import audit
from app.services import orders as orders_service
from app.services import receipts as receipts_service
from app.services.orders import OrderTab
router = APIRouter(prefix="/orders", tags=["orders"], dependencies=[Depends(require_any)])
@router.get("", response_model=list[OrderRowOut], response_model_by_alias=False)
async def list_orders(
session: SessionDep, crm: CrmClientDep, has_receipt: bool = False
session: SessionDep, crm: CrmClientDep, tab: OrderTab = OrderTab.NO_RECEIPT
) -> list[OrderRowOut]:
# by_alias=False: без этого FastAPI сериализует вложенные OrderGoodOut через
# CRM-алиасы (SKU/Name/...) вместо snake_case полей, которые ждёт фронтенд.
await orders_service.sync_orders_from_crm(session, crm)
orders = await orders_service.list_orders(session, has_receipt=has_receipt)
orders = await orders_service.list_orders(session, tab=tab)
receipts = await receipts_service.latest_receipts_by_order(
session, [order.id for order in orders]
)
return [OrderRowOut.from_order(order, receipts.get(order.id)) for order in orders]
@router.get("/summary", response_model=OrdersSummaryOut)
async def orders_summary(session: SessionDep) -> OrdersSummaryOut:
count, kopecks = await orders_service.cod_in_transit(session)
return OrdersSummaryOut(
cod_in_transit_count=count, cod_in_transit_amount=f"{kopecks / 100:.2f}"
)
@router.patch("/{order_id}", response_model=OrderRowOut, response_model_by_alias=False)
async def update_order(
order_id: str,
payload: OrderUpdateIn,
request: Request,
session: SessionDep,
actor: CashierUser,
) -> OrderRowOut:
try:
result = await orders_service.update_order(session, order_id, payload)
except orders_service.OrderEditError as exc:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc
if result is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Заказ не найден")
order, changed = result
if changed:
await audit.record(
session,
action=AuditAction.ORDER_UPDATED,
user=actor,
entity_type="order",
entity_id=order.id,
payload={"fields": changed},
request=request,
)
await session.commit()
receipts = await receipts_service.latest_receipts_by_order(session, [order.id])
return OrderRowOut.from_order(order, receipts.get(order.id))
@router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_order(
order_id: str, request: Request, session: SessionDep, actor: CashierUser
+5
View File
@@ -63,12 +63,17 @@ class Settings(BaseSettings):
crm_sid: int = 1
# --- Nova Poshta ---
# Ключи НП хранятся у касс (`cash_registers.np_api_key_enc`). Эта переменная
# читается только миграцией 0008: переносит старый общий ключ в кассу по умолчанию.
nova_poshta_api_key: str = ""
# --- Checkbox ---
checkbox_base_url: str = "https://api.checkbox.ua"
checkbox_client_name: str = "lux_fiscal"
checkbox_client_version: str = "0.1.0"
# Пауза между запросами к Checkbox из одного процесса: при создании ЕТТН
# Checkbox ходит в API Новой Почты, а та отвечает «To many requests» на частые вызовы.
checkbox_min_request_interval_ms: int = 1000
# Стаб вместо реального Checkbox: ЕТТН работает только на боевой кассе,
# поэтому локально весь цикл прогоняется через стаб. В проде запрещено.
checkbox_use_stub: bool = False
+1
View File
@@ -32,6 +32,7 @@ class AuditAction(str):
USER_UPDATED = "user.updated"
USER_DEACTIVATED = "user.deactivated"
ORDER_DELETED = "order.deleted"
ORDER_UPDATED = "order.updated"
CASH_REGISTER_CREATED = "cash_register.created"
CASH_REGISTER_UPDATED = "cash_register.updated"
RECEIPT_CREATE_REQUESTED = "receipt.create_requested"
+7 -2
View File
@@ -1,7 +1,8 @@
"""Кассы (ПРРО) Checkbox.
Ключ лицензии и PIN кассира хранятся только в зашифрованном виде
(`app/core/crypto.py`): утечка дампа БД не должна давать доступ к кассе.
Ключ лицензии, PIN кассира и ключ API Новой Почты хранятся только в
зашифрованном виде (`app/core/crypto.py`): утечка дампа БД не должна давать
доступ к кассе и кабинету отправителя.
"""
from __future__ import annotations
@@ -22,6 +23,10 @@ class CashRegister(UUIDPrimaryKeyMixin, TimestampMixin, Base):
fiscal_number: Mapped[str | None] = mapped_column(String(64))
license_key_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`
# не передаётся (неплательщик ПДВ).
tax_codes: Mapped[list[Any]] = mapped_column(JSONB, nullable=False, default=list)
+12 -1
View File
@@ -7,10 +7,11 @@
from __future__ import annotations
import uuid
from datetime import datetime
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.orm import Mapped, mapped_column
@@ -41,6 +42,10 @@ class Order(TimestampMixin, Base):
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Заказ правили вручную в карточке — синхронизация из CRM его больше не
# перезаписывает, иначе правки кассира затирались бы при следующем открытии.
edited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Заполняется будущей интеграцией с Checkbox — сейчас всегда NULL.
receipt_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
@@ -50,3 +55,9 @@ class Order(TimestampMixin, Base):
np_status_code: Mapped[str | None] = mapped_column(String(16))
np_cod_amount_kopecks: Mapped[int | None] = mapped_column(BigInteger)
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
)
+48
View File
@@ -10,6 +10,7 @@ integer-kopecks из CLAUDE.md относится к будущим персис
from __future__ import annotations
from datetime import datetime
from decimal import Decimal
from typing import TYPE_CHECKING, Any
from pydantic import BaseModel, ConfigDict, Field, field_validator
@@ -88,6 +89,7 @@ class OrderRowOut(BaseModel):
receipt_status: str | None = None
receipt_error: str | None = None
receipt_prepayment: str | None = None
edited: bool = False
@classmethod
def from_order(cls, order: Order, receipt: Receipt | None = None) -> OrderRowOut:
@@ -116,4 +118,50 @@ class OrderRowOut(BaseModel):
receipt_prepayment=(
f"{receipt.prepayment_kopecks / 100:.2f}" if receipt else None
),
edited=order.edited_at is not None,
)
class OrdersSummaryOut(BaseModel):
"""`GET /orders/summary` — сводка над таблицей заказов."""
cod_in_transit_count: int
cod_in_transit_amount: str
class OrderGoodIn(BaseModel):
"""Позиция заказа из формы редактирования. `amount` считает сервер."""
id: str | None = Field(default=None, max_length=64)
sku: str = Field(default="", max_length=128)
name: str = Field(min_length=1, max_length=512)
price: Decimal = Field(ge=0, max_digits=12, decimal_places=2)
quantity: Decimal = Field(gt=0, max_digits=12, decimal_places=3)
discount_amount: Decimal = Field(default=Decimal(0), ge=0, max_digits=12, decimal_places=2)
@field_validator("sku", "name", mode="after")
@classmethod
def _strip(cls, value: str) -> str:
return value.strip()
class OrderUpdateIn(BaseModel):
"""`PATCH /orders/{id}` — полная замена редактируемых полей заказа."""
recipient_name: str | None = Field(default=None, max_length=255)
recipient_phone: str | None = Field(default=None, max_length=32)
recipient_email: str | None = Field(default=None, max_length=320)
waybill_number: str | None = Field(default=None, max_length=64)
notes: str | None = None
total_amount: Decimal = Field(ge=0, max_digits=12, decimal_places=2)
goods: list[OrderGoodIn] = Field(min_length=1)
# Пустая строка из формы = «не указано».
@field_validator(
"recipient_name", "recipient_phone", "recipient_email", "waybill_number", "notes"
)
@classmethod
def _blank_to_none(cls, value: str | None) -> str | None:
if value is None:
return None
return value.strip() or None
+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):
id: uuid.UUID
name: str
fiscal_number: str | None
license_key_masked: str
# None — ключ НП не задан: касса не опрашивает посылки и не получает заказы.
np_api_key_masked: str | None
tax_codes: list[Any]
is_active: bool
is_default: bool
@classmethod
def from_register(cls, register: CashRegister) -> CashRegisterOut:
try:
masked = crypto.mask(crypto.decrypt(register.license_key_enc))
except crypto.DecryptionError:
masked = "не расшифровывается — введите заново"
return cls(
id=register.id,
name=register.name,
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,
is_active=register.is_active,
is_default=register.is_default,
@@ -101,6 +109,7 @@ class CashRegisterCreate(BaseModel):
fiscal_number: str | None = Field(default=None, max_length=64)
license_key: str = Field(min_length=1, max_length=255)
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)
is_default: bool = True
@@ -112,6 +121,7 @@ class CashRegisterUpdate(BaseModel):
fiscal_number: str | None = Field(default=None, max_length=64)
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)
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)
is_active: bool | None = None
is_default: bool | None = None
+10
View File
@@ -32,9 +32,19 @@ class TrackingStatusOut(BaseModel):
# какого-то типа накладной не возвращает `AfterpaymentOnGoodsCost`.
cod_amount: str | None = Field(default=None)
# Телефон отправителя NP отдаёт только кабинету отправителя (или получателя):
# по чужому ключу без телефона ответ урезан — нет ни отправителя, ни
# `AfterpaymentOnGoodsCost`. По этому полю определяется, чья это посылка.
phone_sender: str | None = Field(default=None, alias="PhoneSender")
# Статус оплаты аналогично раздвоен на `PaymentStatus`/`ExpressWaybillPaymentStatus`.
payment_status: str | None = Field(default=None)
@property
def is_own(self) -> bool:
"""ТТН видна ключу как своя — отправлена из кабинета этого ключа."""
return bool(self.phone_sender)
@model_validator(mode="before")
@classmethod
def _fold_express_fields(cls, data: Any) -> Any:
+1
View File
@@ -22,6 +22,7 @@ _REDACTED_KEYS = {
"secret",
"license_key",
"api_key",
"np_api_key",
"pin",
"pin_code",
"cashier_pin",
+12
View File
@@ -23,6 +23,18 @@ class CheckboxUnavailableError(CheckboxError):
"""Сеть, таймаут или 5xx — исход запроса неизвестен, можно повторить."""
class CheckboxRateLimitedError(CheckboxUnavailableError):
"""Лимит частоты запросов (Checkbox 429 или «To many requests» от Новой Почты).
В отличие от прочих `CheckboxUnavailableError` исход известен — запрос отклонён,
повторять можно не раньше чем через `retry_after` секунд.
"""
def __init__(self, message: str, retry_after: float) -> None:
super().__init__(message)
self.retry_after = retry_after
@dataclass(frozen=True)
class CheckboxCredentials:
"""Расшифрованные доступы одной кассы. Живут только в памяти."""
+67 -5
View File
@@ -2,10 +2,17 @@
Авторизация — токен кассира по PIN-коду (`/api/v1/cashier/signinPinCode`).
Токен кэшируется в памяти процесса по кассе; на 401 — один повторный вход.
Запросы из одного процесса идут строго по одному с паузой
`CHECKBOX_MIN_REQUEST_INTERVAL_MS`: при создании ЕТТН Checkbox синхронно ходит
в API Новой Почты, а та на частые вызовы отвечает «To many requests».
"""
from __future__ import annotations
import asyncio
import re
import time
import uuid
from datetime import UTC, datetime, timedelta
from typing import Any
@@ -17,6 +24,7 @@ from app.schemas.checkbox import EttnOut, EttnStatus
from app.services.checkbox.client import (
CheckboxCredentials,
CheckboxError,
CheckboxRateLimitedError,
CheckboxUnavailableError,
)
@@ -27,6 +35,15 @@ _TIMEOUT = httpx.Timeout(30, connect=10)
_FIND_PAGES = 3
_FIND_PAGE_SIZE = 50
# Ошибки Новой Почты, которые Checkbox отдаёт 4xx с `code=third_party.*`:
# {"message": "To many requests", "info": ["Try again after 1 seconds"],
# "errorCodes": ["20000401501"]} — лимит частоты НП;
# "cURL error 28: SSL connection timeout" — Checkbox не дождался НП.
_NP_RATE_LIMIT_RE = re.compile(r"20000401501|too? many requests", re.IGNORECASE)
_NP_RETRY_AFTER_RE = re.compile(r"try again after (\d+) second", re.IGNORECASE)
_NP_TIMEOUT_RE = re.compile(r"curl error|timed? ?out", re.IGNORECASE)
_MIN_RETRY_AFTER = 1.0
def _error_message(response: httpx.Response) -> str:
try:
@@ -55,6 +72,29 @@ def _error_message(response: httpx.Response) -> str:
return f"HTTP {response.status_code}"
def _retry_after(response: httpx.Response) -> float:
seconds: float = 0
if match := _NP_RETRY_AFTER_RE.search(response.text):
seconds = float(match.group(1))
elif (header := response.headers.get("Retry-After", "")).isdigit():
seconds = float(header)
return max(seconds, _MIN_RETRY_AFTER)
def _transient_error(response: httpx.Response) -> CheckboxUnavailableError | None:
"""Ответ, после которого запрос можно повторить, или None, если ошибка окончательная."""
if response.status_code == 429:
return CheckboxRateLimitedError(_error_message(response), _retry_after(response))
if response.status_code >= 500:
return CheckboxUnavailableError(_error_message(response))
if response.status_code >= 400 and '"third_party.' in response.text:
if _NP_RATE_LIMIT_RE.search(response.text):
return CheckboxRateLimitedError(_error_message(response), _retry_after(response))
if _NP_TIMEOUT_RE.search(response.text):
return CheckboxUnavailableError(_error_message(response))
return None
class HttpCheckboxClient:
def __init__(self, settings: Settings) -> None:
self._base_url = settings.checkbox_base_url.rstrip("/")
@@ -63,6 +103,10 @@ class HttpCheckboxClient:
"X-Client-Version": settings.checkbox_client_version,
}
self._tokens: dict[uuid.UUID, str] = {}
self._min_interval = settings.checkbox_min_request_interval_ms / 1000
self._send_lock = asyncio.Lock()
self._sign_in_lock = asyncio.Lock()
self._last_sent_at = float("-inf")
async def _send(
self,
@@ -73,6 +117,13 @@ class HttpCheckboxClient:
json: Any = None,
params: dict[str, Any] | None = None,
) -> httpx.Response:
# Строго по одному запросу с паузой — параллельные задачи worker'а
# иначе пачкой упираются в лимит Новой Почты.
async with self._send_lock:
delay = self._last_sent_at + self._min_interval - time.monotonic()
if delay > 0:
await asyncio.sleep(delay)
self._last_sent_at = time.monotonic()
try:
async with httpx.AsyncClient(base_url=self._base_url, timeout=_TIMEOUT) as client:
response = await client.request(
@@ -80,11 +131,22 @@ class HttpCheckboxClient:
)
except httpx.HTTPError as exc:
raise CheckboxUnavailableError(f"Checkbox недоступен: {exc!r}") from exc
# 429 — «Занадто часто виконуються запити»: исход не ошибка данных, повторяемо.
if response.status_code >= 500 or response.status_code == 429:
raise CheckboxUnavailableError(_error_message(response))
if error := _transient_error(response):
raise error
return response
async def _token(self, creds: CheckboxCredentials, *, stale: str | None = None) -> str:
"""Токен кассира; `stale` — отвергнутый Checkbox'ом (401), его не переиспользуем.
Под блокировкой: параллельные задачи без токена входят один раз,
остальные берут токен, полученный первой.
"""
async with self._sign_in_lock:
token = self._tokens.get(creds.cash_register_id)
if token is not None and token != stale:
return token
return await self._sign_in(creds)
async def _sign_in(self, creds: CheckboxCredentials) -> str:
response = await self._send(
"POST",
@@ -107,7 +169,7 @@ class HttpCheckboxClient:
json: Any = None,
params: dict[str, Any] | None = None,
) -> httpx.Response:
token = self._tokens.get(creds.cash_register_id) or await self._sign_in(creds)
token = await self._token(creds)
for attempt in range(2):
response = await self._send(
method,
@@ -121,7 +183,7 @@ class HttpCheckboxClient:
params=params,
)
if response.status_code == 401 and attempt == 0:
token = await self._sign_in(creds)
token = await self._token(creds, stale=token)
continue
break
if response.status_code >= 400:
+5 -1
View File
@@ -15,4 +15,8 @@ class NovaPoshtaError(Exception):
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
from app.core.config import Settings
from app.schemas.tracking import TrackingStatusOut
from app.services.nova_poshta.client import NovaPoshtaError
@@ -15,10 +14,9 @@ _MAX_DOCUMENTS_PER_REQUEST = 100
class NpTrackingClient:
def __init__(self, settings: Settings) -> None:
self._api_key = settings.nova_poshta_api_key
async def get_statuses(self, *, waybill_numbers: list[str]) -> list[TrackingStatusOut]:
async def get_statuses(
self, *, api_key: str, waybill_numbers: list[str]
) -> list[TrackingStatusOut]:
if not waybill_numbers:
return []
if len(waybill_numbers) > _MAX_DOCUMENTS_PER_REQUEST:
@@ -28,7 +26,7 @@ class NpTrackingClient:
)
body = {
"apiKey": self._api_key,
"apiKey": api_key,
"modelName": "TrackingDocument",
"calledMethod": "getStatusDocuments",
"methodProperties": {
@@ -15,17 +15,32 @@ _FIXTURE_STATUSES: dict[str, dict] = {
"AmountToPay": "1200.00",
"AfterpaymentOnGoodsCost": 1200,
"PaymentStatus": "Paid",
"PhoneSender": "380501112233",
}
}
class StubNovaPoshtaClient:
def __init__(self, statuses: dict[str, dict] | None = None) -> None:
self._statuses = statuses if statuses is not None else _FIXTURE_STATUSES
"""`statuses` — ответы NP по ТТН; `by_key` — отдельные ответы для конкретных
ключей 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 [
TrackingStatusOut.model_validate(self._statuses[number])
TrackingStatusOut.model_validate(statuses[number])
for number in waybill_numbers
if number in self._statuses
if number in statuses
]
+301 -27
View File
@@ -2,21 +2,67 @@
from __future__ import annotations
import enum
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime
from decimal import Decimal
from decimal import ROUND_HALF_UP, Decimal
from typing import Any
from sqlalchemy import select
import httpx
from sqlalchemy import func, select
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.schemas.orders import OrderUpdateIn
from app.schemas.tracking import TrackingStatusOut
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"
# NP отклоняет запросы с более чем 100 накладными за раз (см. np_client.py).
_NP_BATCH_SIZE = 100
# Коды NP "відмова одержувача" / "припинено зберігання" — клиент не забрал
# посылку, заказ уходит во вкладку «Отказы».
NP_REFUSAL_STATUS_CODES = ("102", "103", "105", "108")
# Коды NP "відправлення отримано" (106 — отримано і створено ЄН зворотньої
# доставки) — заказ уходит во вкладку «Полученные».
NP_RECEIVED_STATUS_CODES = ("9", "10", "11", "106")
# Получено или отказ — после этого статус ТТН больше не опрашивается.
_NP_FINAL_STATUS_CODES = (*NP_RECEIVED_STATUS_CODES, *NP_REFUSAL_STATUS_CODES)
# "Видалено" / "Номер не знайдено" — денег по такой ТТН не будет.
_NP_DEAD_STATUS_CODES = ("2", "3")
# Поля, которые кассир может править в карточке заказа (кроме goods/total).
_EDITABLE_FIELDS = (
"recipient_name",
"recipient_phone",
"recipient_email",
"waybill_number",
"notes",
)
class OrderTab(enum.StrEnum):
NO_RECEIPT = "no_receipt"
HAS_RECEIPT = "has_receipt"
REFUSED = "refused"
RECEIVED = "received"
class OrderEditError(Exception):
"""Заказ нельзя сохранить — сообщение показывается кассиру."""
def _to_kopecks(amount: str) -> int:
return int((Decimal(amount) * 100).to_integral_value())
@@ -31,7 +77,8 @@ async def sync_orders_from_crm(session: AsyncSession, crm: CrmClient) -> None:
Уже скрытые (`is_deleted`) заказы не восстанавливаются и не перезаписываются
— иначе кнопка «Удалить» переставала бы работать при следующем открытии
дашборда, т.к. CRM продолжает возвращать эти заказы как есть.
дашборда, т.к. CRM продолжает возвращать эти заказы как есть. По той же
причине не трогаются заказы, отредактированные вручную (`edited_at`).
"""
crm_orders = await crm.get_orders(status=_CRM_STATUS)
if not crm_orders:
@@ -45,7 +92,7 @@ async def sync_orders_from_crm(session: AsyncSession, crm: CrmClient) -> None:
for crm_order in crm_orders:
local = existing_by_id.get(crm_order.id)
if local is not None:
if local.is_deleted:
if local.is_deleted or local.edited_at is not None:
continue
else:
local = Order(id=crm_order.id)
@@ -63,44 +110,84 @@ async def sync_orders_from_crm(session: AsyncSession, crm: CrmClient) -> None:
await session.commit()
async def list_orders(session: AsyncSession, *, has_receipt: bool) -> list[Order]:
receipt_filter = (
Order.receipt_created_at.is_not(None) if has_receipt else Order.receipt_created_at.is_(None)
async def list_orders(session: AsyncSession, *, tab: OrderTab) -> list[Order]:
"""Заказы вкладки. Статус посылки важнее наличия чека: полученный заказ
показывается только в «Полученных», отказ — только в «Отказах», даже если по
нему уже создан ЕТТН-чек."""
refused = Order.np_status_code.in_(NP_REFUSAL_STATUS_CODES)
received = Order.np_status_code.in_(NP_RECEIVED_STATUS_CODES)
in_transit = Order.np_status_code.is_(None) | Order.np_status_code.not_in(
_NP_FINAL_STATUS_CODES
)
tab_filter = {
OrderTab.REFUSED: refused,
OrderTab.RECEIVED: received,
OrderTab.NO_RECEIPT: Order.receipt_created_at.is_(None) & in_transit,
OrderTab.HAS_RECEIPT: Order.receipt_created_at.is_not(None) & in_transit,
}[tab]
result = await session.scalars(
select(Order)
.where(Order.is_deleted.is_(False))
.where(receipt_filter)
.where(tab_filter)
.order_by(Order.create_date_time.desc())
)
return list(result)
async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None:
"""Обновляет статус ТТН и сумму наложенного платежа для заказов без чека.
async def cod_in_transit(session: AsyncSession) -> tuple[int, int]:
"""Наложка в пути: (кол-во посылок, сумма в копейках).
Вызывается ARQ worker'ом раз в минуту (см. `app/worker.py`), а не из
HTTP-запроса: опрос статусов не должен зависеть от того, открыт ли сейчас
дашборд.
Учитываются только посылки, которые ещё не забрали и по которым нет отказа:
финальный статус NP ("отримано"/отказ) или оплата наложки их исключают.
"""
orders = await session.scalars(
select(Order)
result = await session.execute(
select(func.count(), func.coalesce(func.sum(Order.np_cod_amount_kopecks), 0))
.where(Order.is_deleted.is_(False))
.where(Order.receipt_created_at.is_(None))
.where(Order.waybill_number.is_not(None))
.where(Order.np_cod_amount_kopecks.is_not(None))
.where(Order.np_status_code.not_in((*_NP_FINAL_STATUS_CODES, *_NP_DEAD_STATUS_CODES)))
.where(Order.np_payment_status.is_distinct_from("Payed"))
)
orders_by_waybill: dict[str, Order] = {order.waybill_number: order for order in orders}
if not orders_by_waybill:
return
count, total = result.one()
return count, total
waybill_numbers = list(orders_by_waybill)
@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]
statuses = await np.get_statuses(waybill_numbers=batch)
for tracking_status in statuses:
order = orders_by_waybill.get(tracking_status.number)
if order is None:
continue
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 = (
@@ -108,6 +195,103 @@ async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None:
)
order.np_payment_status = tracking_status.payment_status
async def sync_np_statuses(session: AsyncSession, np: NovaPoshtaClient) -> None:
"""Обновляет статус ТТН, сумму и статус оплаты наложки по заказам в пути
и привязывает каждый заказ к кассе, чей ключ НП видит ТТН как свою.
Заказ, уже привязанный к кассе, опрашивается её ключом. Непривязанный (или
переставший быть «своим» для своей кассы) — ключами всех касс по очереди:
первая, для которой ТТН своя (`TrackingStatusOut.is_own`), его забирает.
Если ТТН не своя ни для одной кассы, статус доставки всё равно сохраняется,
но без наложки и без кассы — чек по такой посылке не создать.
Опрашиваются и заказы с уже созданным ЕТТН-чеком: именно после создания
чека посылка доезжает и оплачивается, и этот переход кассир должен видеть.
Опрос прекращается, когда NP вернул финальный статус "отримано" или отказ.
Вызывается ARQ worker'ом раз в минуту (см. `app/worker.py`), а не из
HTTP-запроса: опрос статусов не должен зависеть от того, открыт ли сейчас
дашборд.
"""
accounts = await _np_accounts(session)
if not accounts:
log.warning("np_no_api_keys", hint="Укажите ключ API Новой Почты у кассы")
return
orders = await session.scalars(
select(Order)
.where(Order.is_deleted.is_(False))
.where(Order.waybill_number.is_not(None))
.where(
Order.np_status_code.is_(None)
| Order.np_status_code.not_in(_NP_FINAL_STATUS_CODES)
)
)
orders_by_waybill: dict[str, Order] = {order.waybill_number: order for order in orders}
if not orders_by_waybill:
return
account_ids = {account.register_id for account in accounts}
unresolved = {
waybill: order
for waybill, order in orders_by_waybill.items()
if order.cash_register_id not in account_ids
}
# Урезанный ответ по чужому ключу — на случай, если ТТН не своя ни для кого.
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:
continue
if tracking_status.is_own:
order.cash_register_id = account.register_id
_apply_status(order, tracking_status)
del unresolved[waybill]
else:
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()
@@ -119,3 +303,93 @@ async def delete_order(session: AsyncSession, order_id: str) -> Order | None:
order.is_deleted = True
order.deleted_at = datetime.now(UTC)
return order
def _good_key(good: dict[str, Any]) -> tuple[Any, ...]:
"""Позиция без учёта формата строк: CRM пишет "1", форма — "1.000"."""
return (
good["id"],
good.get("sku") or "",
good["name"],
_to_kopecks(good["price"]),
Decimal(good["quantity"]),
_to_kopecks(good["amount"]) if good.get("amount") else None,
)
def _build_goods(data: OrderUpdateIn) -> tuple[list[dict[str, Any]], int]:
"""Позиции формы → снимок `orders.goods` в формате CRM и их сумма в копейках."""
goods: list[dict[str, Any]] = []
total = 0
for good in data.goods:
price = int(good.price * 100)
discount = int(good.discount_amount * 100)
gross = int((price * good.quantity).to_integral_value(ROUND_HALF_UP))
net = gross - discount
if net < 0:
raise OrderEditError(f"Товар «{good.name}»: скидка больше суммы строки")
goods.append(
{
"id": good.id or f"new-{uuid.uuid4().hex[:8]}",
"sku": good.sku,
"name": good.name,
"price": f"{good.price:.2f}",
"quantity": f"{good.quantity:.3f}",
"discount_amount": f"{good.discount_amount:.2f}",
"discount_percent": None,
"amount": f"{net / 100:.2f}",
}
)
total += net
return goods, total
async def update_order(
session: AsyncSession, order_id: str, data: OrderUpdateIn
) -> tuple[Order, list[str]] | None:
"""Сохраняет ручные правки заказа. Возвращает заказ и список изменённых полей.
Править можно только заказ без чека: у заказа с чеком (в т.ч. `pending`)
данные уже ушли или уходят в Checkbox. Строка блокируется, чтобы
параллельный запрос на создание чека не прочитал заказ посреди правки.
"""
order = await session.scalar(select(Order).where(Order.id == order_id).with_for_update())
if order is None or order.is_deleted:
return None
if order.receipt_created_at is not None:
raise OrderEditError("По заказу уже создан чек — редактирование недоступно")
goods, goods_total = _build_goods(data)
total = _to_kopecks(str(data.total_amount))
if total > goods_total:
raise OrderEditError(
f"Сумма заказа {total / 100:.2f} ₴ больше суммы товаров {goods_total / 100:.2f} ₴"
)
changed = [
field for field in _EDITABLE_FIELDS if getattr(order, field) != getattr(data, field)
]
goods_changed = [_good_key(good) for good in order.goods] != [_good_key(good) for good in goods]
if goods_changed:
changed.append("goods")
if order.total_amount_kopecks != total:
changed.append("total_amount")
if not changed:
return order, changed
if "waybill_number" in changed:
# Статус и касса старой ТТН к новой не относятся — worker опросит новую за минуту.
order.cash_register_id = None
order.np_status = None
order.np_status_code = None
order.np_cod_amount_kopecks = None
order.np_payment_status = None
for field in _EDITABLE_FIELDS:
setattr(order, field, getattr(data, field))
if goods_changed:
# Иначе оставляем снимок CRM как есть — форматы строк у CRM свои ("1" vs "1.000").
order.goods = goods
order.total_amount_kopecks = total
order.edited_at = datetime.now(UTC)
return order, changed
+49 -12
View File
@@ -38,6 +38,7 @@ from app.services.checkbox.client import (
CheckboxClient,
CheckboxCredentials,
CheckboxError,
CheckboxRateLimitedError,
CheckboxUnavailableError,
)
from app.services.crm.client import CrmClient, CrmError
@@ -67,6 +68,11 @@ _CHECKBOX_TO_STATUS = {
# или Checkbox был недоступен) и повторить его из cron'а.
_PENDING_RETRY_AFTER = timedelta(minutes=1)
# Сколько `created`-чеков опрашивать за один прогон cron'а (раз в минуту):
# при паузе ~1 с между запросами к Checkbox прогон укладывается в минуту и
# не наслаивается на следующий.
_ETTN_POLL_BATCH = 30
# Статус заказа в CRM, когда Checkbox принял ЕТТН-чек: заказ можно собирать.
CRM_STATUS_AFTER_RECEIPT = "PACKED"
# Чеки, при которых заказу нужен этот статус в CRM (Checkbox чек принял).
@@ -255,13 +261,11 @@ def credentials(register: CashRegister) -> CheckboxCredentials:
)
async def get_default_register(session: AsyncSession) -> CashRegister | None:
return await session.scalar(
select(CashRegister)
.where(CashRegister.is_active.is_(True))
.order_by(CashRegister.is_default.desc(), CashRegister.created_at)
.limit(1)
async def _active_registers(session: AsyncSession) -> dict[uuid.UUID, CashRegister]:
registers = await session.scalars(
select(CashRegister).where(CashRegister.is_active.is_(True))
)
return {register.id: register for register in registers}
# --- Создание ----------------------------------------------------------------
@@ -294,10 +298,13 @@ async def request_receipts(
Без commit и без обращения к Checkbox: вызывающий код коммитит и ставит
задачи worker'у (`create_ettn_for_receipt`) по `result.created`.
Ошибки по отдельным заказам не мешают остальным — массовое действие.
Чек создаётся от кассы, чей ключ НП видит ТТН заказа как свою
(`orders.cash_register_id`, проставляет `orders.sync_np_statuses`).
"""
result = RequestResult(created=[], errors={})
register = await get_default_register(session)
if register is None:
registers = await _active_registers(session)
if not registers:
for order_id, _ in items:
result.errors[order_id] = "Не настроена касса Checkbox"
return result
@@ -317,6 +324,13 @@ async def request_receipts(
if order_id in busy:
result.errors[order_id] = "По заказу уже есть чек"
continue
register = registers.get(order.cash_register_id) if order.cash_register_id else None
if register is None:
result.errors[order_id] = (
"Касса не определена: ТТН не найдена ни одним ключом Новой Почты "
"активных касс"
)
continue
try:
amounts = resolve_amounts(order, prepayment)
body = build_ettn_body(order, register, amounts)
@@ -353,6 +367,7 @@ async def request_receipts(
payload={
"order_id": order.id,
"waybill_number": order.waybill_number,
"cash_register_id": str(register.id),
"cod_kopecks": amounts.cod_kopecks,
"prepayment_kopecks": amounts.prepayment_kopecks,
},
@@ -387,7 +402,10 @@ def _apply_ettn(receipt: Receipt, ettn: EttnOut) -> None:
async def create_ettn_for_receipt(
session: AsyncSession, client: CheckboxClient, receipt_id: uuid.UUID
) -> Receipt | None:
"""Отправляет `pending`-чек в Checkbox. Идемпотентна — безопасно вызывать повторно."""
"""Отправляет `pending`-чек в Checkbox. Идемпотентна — безопасно вызывать повторно.
На лимит частоты пробрасывает `CheckboxRateLimitedError`, чек остаётся `pending`.
"""
receipt = await session.get(Receipt, receipt_id, with_for_update=True)
if receipt is None or receipt.status != ReceiptStatus.PENDING:
return receipt
@@ -403,6 +421,12 @@ async def create_ettn_for_receipt(
await client.find_ettn(creds, receipt.waybill_number) if receipt.error else None
)
ettn = existing or await client.create_ettn(creds, receipt.request_body)
except CheckboxRateLimitedError as exc:
# Запрос отклонён, чек точно не создан: остаётся `pending` без пометки
# «исход неизвестен». Повтор с задержкой — забота вызывающего (worker).
await session.commit() # снять блокировку строки
log.info("ettn_rate_limited", receipt_id=str(receipt_id), retry_after=exc.retry_after)
raise
except CheckboxUnavailableError as exc:
receipt.error = str(exc)
await session.commit()
@@ -435,7 +459,10 @@ async def retry_pending_receipts(session: AsyncSession, client: CheckboxClient)
)
)
for receipt_id in receipt_ids:
try:
await create_ettn_for_receipt(session, client, receipt_id)
except CheckboxRateLimitedError:
break # остальные — в следующем проходе cron'а
# --- Отмена и опрос ----------------------------------------------------------
@@ -482,9 +509,20 @@ async def cancel_receipt(
async def sync_ettn_statuses(session: AsyncSession, client: CheckboxClient) -> None:
"""Опрашивает Checkbox по чекам, ожидающим оплаты посылки (`created`)."""
"""Опрашивает Checkbox по чекам, ожидающим оплаты посылки (`created`).
За прогон — не больше `_ETTN_POLL_BATCH` давно не проверенных чеков, с
коммитом после каждого: запросы к Checkbox идут с паузой, и опрос всех
чеков разом не укладывался в таймаут cron-задачи, а откат по таймауту
терял весь прогон — статусы не обновлялись вовсе.
"""
receipts = list(
await session.scalars(select(Receipt).where(Receipt.status == ReceiptStatus.CREATED))
await session.scalars(
select(Receipt)
.where(Receipt.status == ReceiptStatus.CREATED)
.order_by(Receipt.last_checked_at.asc().nulls_first())
.limit(_ETTN_POLL_BATCH)
)
)
registers: dict[uuid.UUID, CheckboxCredentials] = {}
for receipt in receipts:
@@ -508,7 +546,6 @@ async def sync_ettn_statuses(session: AsyncSession, client: CheckboxClient) -> N
_apply_ettn(receipt, ettn)
if receipt.status in CLOSED_STATUSES:
await _release_order(session, receipt.order_id)
await session.commit()
+14 -5
View File
@@ -2,7 +2,7 @@
Запускается отдельным процессом: `arq app.worker.WorkerSettings`.
- `create_ettn_receipt` — задача, которую ставит API после запроса кассира;
- `poll_np_statuses` — раз в минуту статусы ТТН по заказам без чека;
- `poll_np_statuses` — раз в минуту статусы ТТН по ключам НП касс и привязка заказов к кассам;
- `poll_receipts` — раз в минуту повтор зависших `pending`, статусы `created`-чеков
и повтор смены статуса заказа в CRM (PACKED), если CRM была недоступна.
"""
@@ -12,24 +12,28 @@ from __future__ import annotations
import uuid
from typing import Any
from arq import cron
from arq import Retry, cron, func
from arq.connections import RedisSettings
from app.core.config import settings
from app.core.logging import configure_logging, get_logger
from app.db.session import SessionFactory
from app.services import receipts as receipts_service
from app.services.checkbox.client import get_checkbox_client
from app.services.checkbox.client import CheckboxRateLimitedError, get_checkbox_client
from app.services.crm.exo_client import ExoCrmClient
from app.services.nova_poshta.np_client import NpTrackingClient
from app.services.orders import sync_np_statuses
log = get_logger(__name__)
# Повторы `create_ettn_receipt` при лимите частоты: пауза retry_after + 5 с × номер попытки.
_RATE_LIMIT_BACKOFF = 5
_CREATE_ETTN_MAX_TRIES = 6
async def startup(ctx: dict[str, Any]) -> None:
configure_logging()
ctx["np_client"] = NpTrackingClient(settings)
ctx["np_client"] = NpTrackingClient()
ctx["checkbox_client"] = get_checkbox_client()
ctx["crm_client"] = ExoCrmClient(settings)
log.info("worker_starting", environment=settings.environment)
@@ -43,9 +47,14 @@ async def poll_np_statuses(ctx: dict[str, Any]) -> None:
async def create_ettn_receipt(ctx: dict[str, Any], receipt_id: str) -> None:
async with SessionFactory() as session:
try:
await receipts_service.create_ettn_for_receipt(
session, ctx["checkbox_client"], uuid.UUID(receipt_id)
)
except CheckboxRateLimitedError as exc:
# Лимит частоты Новой Почты: повтор с нарастающей паузой. Когда попытки
# кончатся, `pending`-чек подберёт cron `poll_receipts`.
raise Retry(defer=exc.retry_after + _RATE_LIMIT_BACKOFF * ctx["job_try"]) from exc
# Чек принят Checkbox — сразу переводим заказ в CRM в PACKED.
await receipts_service.sync_crm_statuses(session, ctx["crm_client"])
@@ -61,7 +70,7 @@ async def poll_receipts(ctx: dict[str, Any]) -> None:
class WorkerSettings:
redis_settings = RedisSettings.from_dsn(settings.redis_url)
on_startup = startup
functions = [create_ettn_receipt]
functions = [func(create_ettn_receipt, max_tries=_CREATE_ETTN_MAX_TRIES)]
cron_jobs = [
cron(poll_np_statuses, minute=set(range(60)), run_at_startup=True),
cron(poll_receipts, minute=set(range(60)), second=30),
+79 -2
View File
@@ -2,7 +2,9 @@
from __future__ import annotations
import asyncio
import json
import time
import uuid
import httpx
@@ -14,6 +16,7 @@ from app.core.config import Settings
from app.services.checkbox.client import (
CheckboxCredentials,
CheckboxError,
CheckboxRateLimitedError,
CheckboxUnavailableError,
)
from app.services.checkbox.http_client import HttpCheckboxClient
@@ -35,11 +38,12 @@ ETTN = {
}
def _client() -> HttpCheckboxClient:
def _client(interval_ms: int = 0) -> HttpCheckboxClient:
settings = Settings(
secret_key="test-secret-key",
encryption_key="dGVzdC1lbmNyeXB0aW9uLWtleS0zMi1ieXRlcyEh",
checkbox_base_url=BASE,
checkbox_min_request_interval_ms=interval_ms,
) # type: ignore[arg-type]
return HttpCheckboxClient(settings)
@@ -178,5 +182,78 @@ async def test_rate_limit_is_retryable() -> None:
respx.post(f"{BASE}/api/v1/ettn").mock(
return_value=Response(429, json={"message": "Занадто часто виконуються запити"})
)
with pytest.raises(CheckboxUnavailableError):
with pytest.raises(CheckboxRateLimitedError):
await _client().create_ettn(CREDS, {})
def _np_error(message: str, info: list[str], codes: list[str]) -> Response:
"""Ошибка Новой Почты в том виде, в каком её отдаёт боевой Checkbox."""
inner = {
"type": "ettn",
"message": message,
"code": 0,
"context": {"success": False, "errors": [message], "info": info, "errorCodes": codes},
}
return Response(400, json={"code": "third_party.generic", "message": json.dumps(inner)})
@respx.mock
async def test_np_too_many_requests_is_rate_limited() -> None:
_signin()
respx.post(f"{BASE}/api/v1/ettn").mock(
return_value=_np_error("To many requests", ["Try again after 3 seconds"], ["20000401501"])
)
with pytest.raises(CheckboxRateLimitedError) as exc_info:
await _client().create_ettn(CREDS, {})
assert exc_info.value.retry_after == 3
@respx.mock
async def test_np_zero_retry_after_waits_at_least_a_second() -> None:
_signin()
respx.post(f"{BASE}/api/v1/ettn").mock(
return_value=_np_error("To many requests", ["Try again after 0 seconds"], ["20000401501"])
)
with pytest.raises(CheckboxRateLimitedError) as exc_info:
await _client().create_ettn(CREDS, {})
assert exc_info.value.retry_after == 1
@respx.mock
async def test_np_timeout_is_unavailable_not_rate_limited() -> None:
_signin()
respx.post(f"{BASE}/api/v1/ettn").mock(
return_value=_np_error("cURL error 28: SSL connection timeout", [], [])
)
with pytest.raises(CheckboxUnavailableError) as exc_info:
await _client().create_ettn(CREDS, {})
assert not isinstance(exc_info.value, CheckboxRateLimitedError)
@respx.mock
async def test_concurrent_calls_sign_in_once() -> None:
signin = _signin()
respx.get(f"{BASE}/api/v1/ettn/e-1").mock(return_value=Response(200, json=ETTN))
client = _client()
await asyncio.gather(*(client.get_ettn(CREDS, "e-1") for _ in range(5)))
assert signin.call_count == 1
@respx.mock
async def test_requests_are_spaced_by_min_interval() -> None:
_signin()
sent_at: list[float] = []
def record(_: httpx.Request) -> Response:
sent_at.append(time.monotonic())
return Response(200, json=ETTN)
respx.get(f"{BASE}/api/v1/ettn/e-1").mock(side_effect=record)
client = _client(interval_ms=50)
await asyncio.gather(*(client.get_ettn(CREDS, "e-1") for _ in range(3)))
gaps = [b - a for a, b in zip(sent_at, sent_at[1:], strict=False)]
assert len(gaps) == 2 and all(gap >= 0.045 for gap in gaps)
+10 -6
View File
@@ -24,14 +24,18 @@ MODEL_TABLES = set(Base.metadata.tables)
def _migration_block(table: str) -> str:
"""Тело вызова op.create_table для указанной таблицы.
"""Тело вызова op.create_table для указанной таблицы плюс её поздние op.add_column.
Границей служит следующий op.create_table; для последней таблицы — конец файла.
Границей create_table служит следующий op.create_table или конец файла миграции.
"""
start = COMBINED_SOURCE.index(f'op.create_table(\n "{table}",')
next_table = COMBINED_SOURCE.find("op.create_table(", start + 1)
end = next_table if next_table != -1 else len(COMBINED_SOURCE)
return COMBINED_SOURCE[start:end]
marker = f'op.create_table(\n "{table}",'
source = next(text for text in SOURCES.values() if marker in text)
start = source.index(marker)
next_table = source.find("op.create_table(", start + 1)
end = next_table if next_table != -1 else len(source)
# Колонки, добавленные позже через op.add_column, — в любой миграции.
added = re.findall(rf'op\.add_column\(\s*"{table}",\s*(sa\.Column\(.*?\n)', COMBINED_SOURCE)
return source[start:end] + "\n" + "\n".join(added)
def test_migrations_create_every_model_table() -> None:
+24 -20
View File
@@ -8,7 +8,7 @@ import pytest
import respx
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.np_client import _API_URL, NpTrackingClient
@@ -27,12 +27,7 @@ STATUS_PAYLOAD = {
}
def _settings() -> Settings:
return Settings(
secret_key="test-secret-key",
encryption_key="dGVzdC1lbmNyeXB0aW9uLWtleS0zMi1ieXRlcyEh",
nova_poshta_api_key="np-apikey-123",
) # type: ignore[arg-type]
API_KEY = "np-apikey-123"
class TestNpTrackingClientGetStatuses:
@@ -41,13 +36,13 @@ class TestNpTrackingClientGetStatuses:
route = respx.post(_API_URL).mock(
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
body = json.loads(sent.content)
assert body["apiKey"] == "np-apikey-123"
assert body["apiKey"] == API_KEY
assert body["modelName"] == "TrackingDocument"
assert body["calledMethod"] == "getStatusDocuments"
assert body["methodProperties"]["Documents"] == [
@@ -63,9 +58,9 @@ class TestNpTrackingClientGetStatuses:
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
status = statuses[0]
@@ -75,6 +70,13 @@ class TestNpTrackingClientGetStatuses:
# не "сколько заплатить сейчас" (`ExpressWaybillAmountToPay` = 827.48).
assert status.cod_amount == "699"
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
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(
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"
@@ -95,18 +97,20 @@ class TestNpTrackingClientGetStatuses:
200, json={"success": False, "data": [], "errors": ["Invalid apiKey"]}
)
)
client = NpTrackingClient(_settings())
client = NpTrackingClient()
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:
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:
client = NpTrackingClient(_settings())
client = NpTrackingClient()
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
+98 -5
View File
@@ -69,8 +69,8 @@ def _patch_orders_service(monkeypatch: pytest.MonkeyPatch) -> None:
async def fake_sync(session: object, crm: object) -> None:
return None
async def fake_list(session: object, *, has_receipt: bool) -> list[Order]:
return [] if has_receipt else [_order()]
async def fake_list(session: object, *, tab: str) -> list[Order]:
return [_order()] if tab == "no_receipt" else []
async def fake_delete(session: object, order_id: str) -> Order | None:
return _order(order_id) if order_id == "1" else None
@@ -79,6 +79,24 @@ def _patch_orders_service(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(orders_router.orders_service, "list_orders", fake_list)
monkeypatch.setattr(orders_router.orders_service, "delete_order", fake_delete)
async def fake_cod_in_transit(session: object) -> tuple[int, int]:
return 3, 245050
monkeypatch.setattr(orders_router.orders_service, "cod_in_transit", fake_cod_in_transit)
async def fake_update(
session: object, order_id: str, data: object
) -> tuple[Order, list[str]] | None:
if order_id == "locked":
raise orders_router.orders_service.OrderEditError("По заказу уже создан чек")
if order_id != "1":
return None
order = _order(order_id)
order.edited_at = datetime(2026, 9, 24, tzinfo=UTC)
return order, ["notes"]
monkeypatch.setattr(orders_router.orders_service, "update_order", fake_update)
async def fake_latest(session: object, order_ids: list[str]) -> dict:
return {}
@@ -119,7 +137,7 @@ class TestListOrders:
) -> None:
app.dependency_overrides[get_current_user] = lambda: _user(role)
response = client.get("/api/v1/orders", params={"has_receipt": "false"})
response = client.get("/api/v1/orders", params={"tab": "no_receipt"})
assert response.status_code == 200
body = response.json()
@@ -129,14 +147,22 @@ class TestListOrders:
assert body[0]["has_receipt"] is False
assert body[0]["goods"][0]["sku"] == "SKU-1"
def test_has_receipt_tab_is_empty_for_now(self, client: TestClient) -> None:
@pytest.mark.parametrize("tab", ["has_receipt", "refused"])
def test_other_tabs_are_passed_to_service(self, client: TestClient, tab: str) -> None:
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER)
response = client.get("/api/v1/orders", params={"has_receipt": "true"})
response = client.get("/api/v1/orders", params={"tab": tab})
assert response.status_code == 200
assert response.json() == []
def test_unknown_tab_is_rejected(self, client: TestClient) -> None:
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER)
response = client.get("/api/v1/orders", params={"tab": "bogus"})
assert response.status_code == 422
def test_requires_authentication(self, client: TestClient) -> None:
response = client.get("/api/v1/orders")
assert response.status_code == 401
@@ -164,3 +190,70 @@ class TestDeleteOrder:
response = client.delete("/api/v1/orders/does-not-exist")
assert response.status_code == 404
_UPDATE_BODY = {
"recipient_name": "Тест Тестов",
"recipient_phone": "+380501112233",
"waybill_number": "20450123456789",
"notes": "правка",
"total_amount": "1200.00",
"goods": [
{"id": "1", "sku": "SKU-1", "name": "Товар 1", "price": "1200.00", "quantity": "1"}
],
}
class TestUpdateOrder:
@pytest.mark.parametrize("role", [UserRole.ADMIN, UserRole.CASHIER])
def test_admin_and_cashier_can_update(self, client: TestClient, role: UserRole) -> None:
app.dependency_overrides[get_current_user] = lambda: _user(role)
response = client.patch("/api/v1/orders/1", json=_UPDATE_BODY)
assert response.status_code == 200
body = response.json()
assert body["edited"] is True
assert body["goods"][0]["sku"] == "SKU-1"
def test_viewer_cannot_update(self, client: TestClient) -> None:
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.VIEWER)
response = client.patch("/api/v1/orders/1", json=_UPDATE_BODY)
assert response.status_code == 403
def test_unknown_order_is_404(self, client: TestClient) -> None:
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER)
response = client.patch("/api/v1/orders/nope", json=_UPDATE_BODY)
assert response.status_code == 404
def test_order_with_receipt_is_409(self, client: TestClient) -> None:
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER)
response = client.patch("/api/v1/orders/locked", json=_UPDATE_BODY)
assert response.status_code == 409
def test_empty_goods_is_rejected(self, client: TestClient) -> None:
app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER)
response = client.patch("/api/v1/orders/1", json={**_UPDATE_BODY, "goods": []})
assert response.status_code == 422
class TestSummary:
@pytest.mark.parametrize("role", [UserRole.ADMIN, UserRole.CASHIER, UserRole.VIEWER])
def test_returns_cod_in_transit(self, client: TestClient, role: UserRole) -> None:
app.dependency_overrides[get_current_user] = lambda: _user(role)
response = client.get("/api/v1/orders/summary")
assert response.status_code == 200
assert response.json() == {
"cod_in_transit_count": 3,
"cod_in_transit_amount": "2450.50",
}
+210 -3
View File
@@ -1,10 +1,25 @@
"""Юнит-тесты чистых хелперов сервиса заказов. Без БД и без сети."""
"""Юнит-тесты сервиса заказов. Без БД и без сети — сессия подменяется заглушкой."""
from __future__ import annotations
from datetime import datetime
import asyncio
from datetime import UTC, datetime
from typing import Any
from app.services.orders import _parse_crm_datetime, _to_kopecks
import pytest
from sqlalchemy.dialects import postgresql
from app.db.models.order import Order
from app.schemas.orders import OrderUpdateIn
from app.services.orders import (
OrderEditError,
OrderTab,
_parse_crm_datetime,
_to_kopecks,
cod_in_transit,
list_orders,
update_order,
)
class TestToKopecks:
@@ -24,3 +39,195 @@ class TestToKopecks:
class TestParseCrmDatetime:
def test_parses_crm_format(self) -> None:
assert _parse_crm_datetime("2026-09-20 10:00:00") == datetime(2026, 9, 20, 10, 0, 0)
class _FakeSession:
def __init__(self, order: Order | None) -> None:
self.order = order
async def scalar(self, query: Any) -> Order | None:
return self.order
def _order(**overrides: Any) -> Order:
fields: dict[str, Any] = {
"id": "1",
"recipient_name": "Тест",
"recipient_phone": "0501112233",
"recipient_email": None,
"waybill_number": "20450000000001",
"notes": None,
"total_amount_kopecks": 120000,
"goods": [],
"is_deleted": False,
"receipt_created_at": None,
"edited_at": None,
"np_status": "Прибув",
"np_status_code": "7",
"np_cod_amount_kopecks": 120000,
"np_payment_status": "NeedPayment",
}
fields.update(overrides)
return Order(**fields)
_GOOD = {
"id": "1",
"sku": "A",
"name": "Товар",
"price": "600.00",
"quantity": "2",
"discount_amount": "50.00",
}
def _payload(**overrides: Any) -> OrderUpdateIn:
data: dict[str, Any] = {
"recipient_name": "Тест",
"recipient_phone": "0501112233",
"recipient_email": "",
"waybill_number": "20450000000001",
"notes": "",
"total_amount": "1100.00",
"goods": [_GOOD],
}
data.update(overrides)
return OrderUpdateIn.model_validate(data)
def _update(order: Order | None, payload: OrderUpdateIn) -> tuple[Order, list[str]]:
result = asyncio.run(update_order(_FakeSession(order), "1", payload)) # type: ignore[arg-type]
assert result is not None
return result
class TestUpdateOrder:
def test_recomputes_line_amount_and_marks_edited(self) -> None:
order, changed = _update(_order(), _payload())
assert order.goods[0]["amount"] == "1150.00"
assert order.goods[0]["quantity"] == "2.000"
assert order.total_amount_kopecks == 110000
assert order.edited_at is not None
assert set(changed) == {"goods", "total_amount"}
def test_blank_strings_become_null(self) -> None:
order, _ = _update(_order(), _payload(notes=" "))
assert order.notes is None
assert order.recipient_email is None
def test_new_line_gets_generated_id(self) -> None:
good = {"sku": "", "name": "Новый", "price": "10.00", "quantity": "1"}
order, _ = _update(_order(), _payload(goods=[good], total_amount="10.00"))
assert order.goods[0]["id"].startswith("new-")
def test_waybill_change_resets_np_tracking(self) -> None:
order, changed = _update(_order(), _payload(waybill_number="20450000000002"))
assert "waybill_number" in changed
assert order.np_status is None
assert order.np_cod_amount_kopecks is None
def test_no_changes_does_not_mark_edited(self) -> None:
# Снимок в формате CRM ("2", а не "2.000") — формат строк не считается правкой.
stored = {**_GOOD, "discount_percent": "4.17", "amount": "1150.00"}
order, changed = _update(_order(goods=[stored], total_amount_kopecks=110000), _payload())
assert changed == []
assert order.edited_at is None
assert order.goods == [stored]
def test_total_above_goods_sum_is_rejected(self) -> None:
with pytest.raises(OrderEditError, match="больше суммы товаров"):
_update(_order(), _payload(total_amount="1200.00"))
def test_discount_above_line_sum_is_rejected(self) -> None:
good = {"name": "Товар", "price": "10.00", "quantity": "1", "discount_amount": "11"}
with pytest.raises(OrderEditError, match="скидка больше"):
_update(_order(), _payload(goods=[good], total_amount="0"))
def test_order_with_receipt_is_locked(self) -> None:
with pytest.raises(OrderEditError, match="уже создан чек"):
_update(_order(receipt_created_at=datetime.now(UTC)), _payload())
def test_missing_or_deleted_order_returns_none(self) -> None:
payload = _payload()
assert asyncio.run(update_order(_FakeSession(None), "1", payload)) is None # type: ignore[arg-type]
deleted = _FakeSession(_order(is_deleted=True))
assert asyncio.run(update_order(deleted, "1", payload)) is None # type: ignore[arg-type]
class _CapturingSession:
def __init__(self) -> None:
self.statement: Any = None
async def scalars(self, statement: Any) -> list[Order]:
self.statement = statement
return []
def _list_where(tab: OrderTab) -> str:
"""WHERE-часть запроса вкладки (SELECT перечисляет все колонки, в т.ч. receipt_created_at)."""
session = _CapturingSession()
asyncio.run(list_orders(session, tab=tab)) # type: ignore[arg-type]
sql = str(
session.statement.compile(
dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True}
)
)
return sql.split("WHERE", 1)[1]
class TestListOrders:
def test_refused_tab_filters_by_refusal_codes_only(self) -> None:
sql = _list_where(OrderTab.REFUSED)
assert "orders.np_status_code IN ('102', '103', '105', '108')" in sql
assert "receipt_created_at" not in sql
def test_received_tab_filters_by_received_codes_only(self) -> None:
sql = _list_where(OrderTab.RECEIVED)
assert "orders.np_status_code IN ('9', '10', '11', '106')" in sql
assert "receipt_created_at" not in sql
@pytest.mark.parametrize("tab", [OrderTab.NO_RECEIPT, OrderTab.HAS_RECEIPT])
def test_other_tabs_exclude_received_and_refusals(self, tab: OrderTab) -> None:
sql = _list_where(tab)
assert "NOT IN ('9', '10', '11', '106', '102', '103', '105', '108')" in sql
assert "receipt_created_at" in sql
class _CapturingExecuteSession:
def __init__(self) -> None:
self.statement: Any = None
async def execute(self, statement: Any) -> Any:
self.statement = statement
class _Result:
def one(self) -> tuple[int, int]:
return 2, 150000
return _Result()
class TestCodInTransit:
def test_excludes_received_refused_and_paid(self) -> None:
session = _CapturingExecuteSession()
result = asyncio.run(cod_in_transit(session)) # type: ignore[arg-type]
sql = str(
session.statement.compile(
dialect=postgresql.dialect(), compile_kwargs={"literal_binds": True}
)
)
assert result == (2, 150000)
assert "sum(orders.np_cod_amount_kopecks)" in sql
assert "orders.is_deleted IS false" in sql
assert "orders.np_cod_amount_kopecks IS NOT NULL" in sql
assert (
"orders.np_status_code NOT IN "
"('9', '10', '11', '106', '102', '103', '105', '108', '2', '3')" in sql
)
assert "orders.np_payment_status IS DISTINCT FROM 'Payed'" in sql
+66 -1
View File
@@ -16,9 +16,10 @@ from app.core import crypto
from app.db.models.cash_register import CashRegister
from app.db.models.order import Order
from app.db.models.receipt import Receipt, ReceiptStatus
from app.db.models.user import User
from app.schemas.checkbox import EttnStatus
from app.services import receipts as svc
from app.services.checkbox.client import CheckboxUnavailableError
from app.services.checkbox.client import CheckboxRateLimitedError, CheckboxUnavailableError
from app.services.checkbox.stub_client import StubCheckboxClient
@@ -182,6 +183,52 @@ class TestBuildBody:
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:
def __init__(self, *objects: Any) -> None:
self.objects = {(type(o), o.id): o for o in objects}
@@ -266,6 +313,24 @@ class TestCreateEttn:
assert receipt.status == ReceiptStatus.CREATED
assert len(client.orders) == 1 # второго чека на ту же ТТН нет
async def test_rate_limit_keeps_pending_without_unknown_outcome(self) -> None:
order, register = _order(receipt_created_at=datetime.now(UTC)), _register()
receipt = _pending(order, register)
client = StubCheckboxClient()
session = FakeSession(order, register, receipt)
async def rate_limited(creds: Any, body: dict[str, Any]) -> Any:
raise CheckboxRateLimitedError("To many requests", retry_after=1)
client.create_ettn = rate_limited # type: ignore[method-assign]
with pytest.raises(CheckboxRateLimitedError):
await svc.create_ettn_for_receipt(session, client, receipt.id)
assert receipt.status == ReceiptStatus.PENDING
assert receipt.error is None # повтор не пойдёт через find_ettn
assert order.receipt_created_at is not None # заказ не вернулся в очередь
assert session.commits == 1 # блокировка строки снята
class TestApplyEttn:
@pytest.mark.parametrize(
+11 -3
View File
@@ -1,10 +1,18 @@
import { apiFetch } from '@/api/client'
import type { Order } from '@/features/orders/types'
import type { Order, OrdersSummary, OrderTab, OrderUpdate } from '@/features/orders/types'
export function getOrders(hasReceipt: boolean): Promise<Order[]> {
return apiFetch<Order[]>(`/orders?has_receipt=${hasReceipt}`)
export function getOrders(tab: OrderTab): Promise<Order[]> {
return apiFetch<Order[]>(`/orders?tab=${tab}`)
}
export function getOrdersSummary(): Promise<OrdersSummary> {
return apiFetch<OrdersSummary>('/orders/summary')
}
export function deleteOrder(orderId: string): Promise<void> {
return apiFetch<void>(`/orders/${orderId}`, { method: 'DELETE' })
}
export function updateOrder(orderId: string, data: OrderUpdate): Promise<Order> {
return apiFetch<Order>(`/orders/${orderId}`, { method: 'PATCH', body: data })
}
+4
View File
@@ -19,3 +19,7 @@
max-height: 90vh;
overflow-y: auto;
}
.modal-content--wide {
max-width: 920px;
}
+3 -2
View File
@@ -6,9 +6,10 @@ import '@/components/Modal.css'
interface ModalProps {
onClose: () => void
children: ReactNode
wide?: boolean
}
export function Modal({ onClose, children }: ModalProps) {
export function Modal({ onClose, children, wide = false }: ModalProps) {
useEffect(() => {
function onKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose()
@@ -19,7 +20,7 @@ export function Modal({ onClose, children }: ModalProps) {
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content" onClick={(event) => event.stopPropagation()}>
<div className={`modal-content${wide ? ' modal-content--wide' : ''}`} onClick={(event) => event.stopPropagation()}>
{children}
</div>
</div>
@@ -5,6 +5,8 @@ export interface CashRegister {
name: string
fiscal_number: string | null
license_key_masked: string
/** null — ключ Новой Почты не задан: касса не получает посылки. */
np_api_key_masked: string | null
tax_codes: (number | string)[]
is_active: boolean
is_default: boolean
@@ -15,6 +17,7 @@ export interface CashRegisterCreate {
fiscal_number: string | null
license_key: string
pin_code: string
np_api_key: string | null
tax_codes: (number | string)[]
is_default: boolean
}
@@ -37,7 +37,8 @@
font-size: 14px;
}
.order-modal-meta div {
.order-modal-meta div,
.order-modal-meta label {
display: flex;
flex-direction: column;
gap: 2px;
@@ -111,3 +112,154 @@
border-color: var(--color-primary);
color: var(--color-primary);
}
.order-modal-edited {
margin-left: 10px;
padding: 2px 8px;
border-radius: 999px;
background: var(--color-bg);
border: 1px solid var(--color-border);
color: var(--color-text-muted);
font-size: 11px;
font-weight: 500;
vertical-align: middle;
}
.order-modal-notes {
display: flex;
flex-direction: column;
gap: 2px;
}
.order-modal-input {
width: 100%;
box-sizing: border-box;
padding: 6px 8px;
border-radius: 6px;
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-text);
font: inherit;
font-size: 13px;
}
.order-modal-input:focus {
outline: none;
border-color: var(--color-primary);
}
textarea.order-modal-input {
resize: vertical;
}
.order-modal-input--sku {
min-width: 80px;
}
.order-modal-input--num {
width: 84px;
text-align: right;
}
.order-modal-goods td:has(.order-modal-input) {
padding: 4px 4px;
}
.order-modal-invalid {
color: var(--color-danger);
}
.order-modal-remove {
border: none;
background: none;
color: var(--color-text-muted);
font-size: 18px;
line-height: 1;
cursor: pointer;
padding: 2px 6px;
}
.order-modal-remove:hover {
color: var(--color-danger);
}
.order-modal-add {
align-self: flex-start;
border: 1px dashed var(--color-border);
background: none;
color: var(--color-primary);
border-radius: 8px;
padding: 6px 12px;
font-size: 13px;
cursor: pointer;
}
.order-modal-add:hover {
border-color: var(--color-primary);
}
.order-modal-total {
align-items: center;
}
.order-modal-message {
padding: 8px 12px;
border-radius: 8px;
font-size: 13px;
}
.order-modal-message--ok {
background: #dcfce7;
color: #166534;
}
.order-modal-message--error {
background: var(--color-danger-bg);
color: var(--color-danger);
border: 1px solid var(--color-danger-border);
}
.order-modal-actions {
gap: 8px;
}
.order-modal-save-btn {
border: 1px solid var(--color-primary);
background: var(--color-primary);
color: #fff;
border-radius: 8px;
padding: 8px 16px;
font-size: 14px;
cursor: pointer;
}
.order-modal-save-btn:hover:not(:disabled) {
background: var(--color-primary-hover);
}
.order-modal-save-btn:disabled {
opacity: 0.5;
cursor: default;
}
@media (prefers-color-scheme: dark) {
.order-modal-message--ok {
background: rgba(22, 163, 74, 0.18);
color: #86efac;
}
}
@media (max-width: 640px) {
.order-modal {
padding: 16px;
}
.order-modal-meta {
grid-template-columns: minmax(0, 1fr);
}
.order-modal-goods {
display: block;
overflow-x: auto;
}
}
+331 -31
View File
@@ -1,20 +1,226 @@
import { useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react'
import { updateOrder } from '@/api/orders'
import { Modal } from '@/components/Modal'
import '@/features/orders/OrderDetailModal.css'
import type { Order } from '@/features/orders/types'
import type { Order, OrderUpdate } from '@/features/orders/types'
import { formatKopecks, toKopecks } from '@/features/receipts/money'
import { RECEIPT_STATUS } from '@/features/receipts/types'
interface OrderDetailModalProps {
order: Order
/** Кассир/админ и по заказу ещё нет чека — карточку можно править. */
canEdit: boolean
onClose: () => void
}
export function OrderDetailModal({ order, onClose }: OrderDetailModalProps) {
interface GoodDraft {
key: string
id: string | null
sku: string
name: string
price: string
quantity: string
discount: string
}
interface Draft {
recipient_name: string
recipient_phone: string
recipient_email: string
waybill_number: string
notes: string
total: string
goods: GoodDraft[]
}
type TextField = Exclude<keyof Draft, 'goods' | 'total'>
/** Количество в тысячных (конвенция Checkbox: 1 шт = 1000). */
function toThousandths(value: string): number | null {
const normalized = value.trim().replace(',', '.')
if (!/^\d+(\.\d{1,3})?$/.test(normalized)) return null
return Math.round(Number(normalized) * 1000)
}
/** Сумма строки после скидки в копейках; null — в строке некорректные числа. */
function lineAmount(good: GoodDraft): number | null {
const price = toKopecks(good.price)
const quantity = toThousandths(good.quantity)
const discount = toKopecks(good.discount)
if (price === null || quantity === null || discount === null) return null
return Math.round((price * quantity) / 1000) - discount
}
function goodsSum(goods: GoodDraft[]): number | null {
let sum = 0
for (const good of goods) {
const amount = lineAmount(good)
if (amount === null) return null
sum += amount
}
return sum
}
let newGoodSeq = 0
function toDraft(order: Order): Draft {
return {
recipient_name: order.recipient_name ?? '',
recipient_phone: order.recipient_phone ?? '',
recipient_email: order.recipient_email ?? '',
waybill_number: order.waybill_number ?? '',
notes: order.notes ?? '',
total: order.total_amount,
goods: order.goods.map((good) => {
// Скидку строки выводим из amount, как бэкенд при сборке чека, — не из discount_amount/percent.
const gross = Math.round(((toKopecks(good.price) ?? 0) * (toThousandths(good.quantity) ?? 0)) / 1000)
const discount = Math.max(gross - (toKopecks(good.amount) ?? gross), 0)
return {
key: good.id,
id: good.id,
sku: good.sku,
name: good.name,
price: good.price,
quantity: good.quantity,
discount: formatKopecks(discount),
}
}),
}
}
function orderDiscountOf(draft: Draft): number {
return (goodsSum(draft.goods) ?? 0) - (toKopecks(draft.total) ?? 0)
}
/** Проверяет черновик и собирает тело PATCH; строка — текст ошибки для кассира. */
function toPayload(draft: Draft): OrderUpdate | string {
if (draft.goods.length === 0) return 'Добавьте хотя бы один товар'
const goods = []
for (const good of draft.goods) {
const label = good.name.trim() || 'без названия'
if (!good.name.trim()) return 'У товара не указано наименование'
const price = toKopecks(good.price)
const quantity = toThousandths(good.quantity)
const discount = toKopecks(good.discount)
if (price === null) return `Товар «${label}»: некорректная цена`
if (quantity === null || quantity <= 0) return `Товар «${label}»: некорректное количество`
if (discount === null) return `Товар «${label}»: некорректная скидка`
if ((lineAmount(good) ?? 0) < 0) return `Товар «${label}»: скидка больше суммы строки`
goods.push({
id: good.id,
sku: good.sku.trim(),
name: good.name.trim(),
price: formatKopecks(price),
quantity: (quantity / 1000).toFixed(3),
discount_amount: formatKopecks(discount),
})
}
const total = toKopecks(draft.total)
if (total === null) return 'Некорректная сумма заказа'
const sum = goodsSum(draft.goods) ?? 0
if (total > sum) return `Сумма заказа больше суммы товаров (${formatKopecks(sum)} ₴)`
return {
recipient_name: draft.recipient_name,
recipient_phone: draft.recipient_phone,
recipient_email: draft.recipient_email,
waybill_number: draft.waybill_number,
notes: draft.notes,
total_amount: formatKopecks(total),
goods,
}
}
const TEXT_FIELDS: { field: TextField; label: string; type?: string }[] = [
{ field: 'recipient_name', label: 'Клиент' },
{ field: 'recipient_phone', label: 'Телефон', type: 'tel' },
{ field: 'recipient_email', label: 'Email', type: 'email' },
{ field: 'waybill_number', label: 'Номер ТТН' },
]
export function OrderDetailModal({ order, canEdit, onClose }: OrderDetailModalProps) {
const queryClient = useQueryClient()
const [draft, setDraft] = useState<Draft>(() => toDraft(order))
const [saved, setSaved] = useState<Draft>(draft)
// Скидка на весь заказ (итог товаров − сумма заказа): держим её при правке товаров.
const [orderDiscount, setOrderDiscount] = useState(() => orderDiscountOf(draft))
const [saving, setSaving] = useState(false)
const [message, setMessage] = useState<{ tone: 'ok' | 'error'; text: string } | null>(null)
const dirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(saved), [draft, saved])
const editable = canEdit && !order.has_receipt
function requestClose() {
if (dirty && !window.confirm('Есть несохранённые изменения. Закрыть без сохранения?')) return
onClose()
}
function setField(field: TextField, value: string) {
setDraft((prev) => ({ ...prev, [field]: value }))
setMessage(null)
}
/** Меняет товары и пересчитывает итог с сохранением скидки на заказ. */
function setGoods(update: (goods: GoodDraft[]) => GoodDraft[]) {
const goods = update(draft.goods)
const sum = goodsSum(goods)
setDraft({ ...draft, goods, total: sum === null ? draft.total : formatKopecks(Math.max(sum - orderDiscount, 0)) })
setMessage(null)
}
function setTotal(value: string) {
setDraft({ ...draft, total: value })
const sum = goodsSum(draft.goods)
const total = toKopecks(value)
if (sum !== null && total !== null) setOrderDiscount(sum - total)
setMessage(null)
}
function setGood(key: string, field: keyof GoodDraft, value: string) {
setGoods((goods) => goods.map((good) => (good.key === key ? { ...good, [field]: value } : good)))
}
function addGood() {
newGoodSeq += 1
setGoods((goods) => [
...goods,
{ key: `new-${newGoodSeq}`, id: null, sku: '', name: '', price: '0.00', quantity: '1', discount: '0.00' },
])
}
async function handleSave() {
const payload = toPayload(draft)
if (typeof payload === 'string') {
setMessage({ tone: 'error', text: payload })
return
}
setSaving(true)
try {
const updated = await updateOrder(order.id, payload)
const next = toDraft(updated)
setDraft(next)
setSaved(next)
setOrderDiscount(orderDiscountOf(next))
setMessage({ tone: 'ok', text: 'Изменения сохранены' })
await queryClient.invalidateQueries({ queryKey: ['orders'] })
} catch (err) {
setMessage({ tone: 'error', text: err instanceof Error ? err.message : 'Не удалось сохранить заказ' })
} finally {
setSaving(false)
}
}
return (
<Modal onClose={onClose}>
<Modal onClose={requestClose} wide={editable}>
<div className="order-modal">
<div className="order-modal-header">
<h3>Просмотр заказа {order.id}</h3>
<button type="button" className="order-modal-close" onClick={onClose} aria-label="Закрыть">
<h3>
Заказ {order.id}
{order.edited && <span className="order-modal-edited">изменён вручную</span>}
</h3>
<button type="button" className="order-modal-close" onClick={requestClose} aria-label="Закрыть">
×
</button>
</div>
@@ -24,10 +230,6 @@ export function OrderDetailModal({ order, onClose }: OrderDetailModalProps) {
<span className="order-modal-label">Дата</span>
<span>{order.create_date_time}</span>
</div>
<div>
<span className="order-modal-label">Номер ТТН</span>
<span>{order.waybill_number || '—'}</span>
</div>
<div>
<span className="order-modal-label">Статус ТТН</span>
<span>{order.np_status || 'Нет данных'}</span>
@@ -46,27 +248,45 @@ export function OrderDetailModal({ order, onClose }: OrderDetailModalProps) {
: '—'}
</span>
</div>
<div>
<span className="order-modal-label">Клиент</span>
<span>{order.recipient_name || '—'}</span>
</div>
<div>
<span className="order-modal-label">Телефон</span>
<span>{order.recipient_phone || '—'}</span>
</div>
{order.recipient_email && (
<div>
<span className="order-modal-label">Email</span>
<span>{order.recipient_email}</span>
{TEXT_FIELDS.map(({ field, label, type }) =>
editable ? (
<label key={field}>
<span className="order-modal-label">{label}</span>
<input
type={type ?? 'text'}
className="order-modal-input"
value={draft[field]}
onChange={(e) => setField(field, e.target.value)}
/>
</label>
) : (
(field !== 'recipient_email' || draft[field]) && (
<div key={field}>
<span className="order-modal-label">{label}</span>
<span>{draft[field] || '—'}</span>
</div>
)
),
)}
</div>
{order.notes && (
{editable ? (
<label className="order-modal-notes">
<span className="order-modal-label">Заметки</span>
<textarea
className="order-modal-input"
rows={2}
value={draft.notes}
onChange={(e) => setField('notes', e.target.value)}
/>
</label>
) : (
draft.notes && (
<div className="order-modal-notes">
<span className="order-modal-label">Заметки</span>
<p>{order.notes}</p>
<p>{draft.notes}</p>
</div>
)
)}
<table className="order-modal-goods">
@@ -78,22 +298,71 @@ export function OrderDetailModal({ order, onClose }: OrderDetailModalProps) {
<th>Кол-во</th>
<th>Скидка</th>
<th>Сумма</th>
{editable && <th aria-label="Удалить" />}
</tr>
</thead>
<tbody>
{order.goods.map((good) => (
<tr key={good.id}>
{draft.goods.map((good) => {
const amount = lineAmount(good)
return (
<tr key={good.key}>
{editable ? (
<>
<td>
<input
className="order-modal-input"
value={good.name}
onChange={(e) => setGood(good.key, 'name', e.target.value)}
/>
</td>
<td>
<input
className="order-modal-input order-modal-input--sku"
value={good.sku}
onChange={(e) => setGood(good.key, 'sku', e.target.value)}
/>
</td>
{(['price', 'quantity', 'discount'] as const).map((field) => (
<td key={field}>
<input
className="order-modal-input order-modal-input--num"
inputMode="decimal"
value={good[field]}
onChange={(e) => setGood(good.key, field, e.target.value)}
/>
</td>
))}
</>
) : (
<>
<td>{good.name}</td>
<td>{good.sku}</td>
<td>{good.price}</td>
<td>{good.quantity}</td>
<td>{good.discount_amount && good.discount_amount !== '0.00' ? good.discount_amount : '—'}</td>
<td>{good.amount}</td>
<td>{good.discount !== '0.00' ? good.discount : '—'}</td>
</>
)}
<td className={amount === null || amount < 0 ? 'order-modal-invalid' : undefined}>
{amount === null ? '—' : formatKopecks(amount)}
</td>
{editable && (
<td>
<button
type="button"
className="order-modal-remove"
aria-label={`Удалить товар ${good.name}`}
onClick={() => setGoods((goods) => goods.filter((g) => g.key !== good.key))}
>
×
</button>
</td>
)}
</tr>
))}
{order.goods.length === 0 && (
)
})}
{draft.goods.length === 0 && (
<tr>
<td colSpan={6} className="order-modal-goods-empty">
<td colSpan={editable ? 7 : 6} className="order-modal-goods-empty">
Товары не указаны
</td>
</tr>
@@ -101,9 +370,28 @@ export function OrderDetailModal({ order, onClose }: OrderDetailModalProps) {
</tbody>
</table>
{editable && (
<button type="button" className="order-modal-add" onClick={addGood}>
+ Добавить товар
</button>
)}
<div className="order-modal-total">
<span>Итого</span>
{editable ? (
<span>
<input
className="order-modal-input order-modal-input--num"
inputMode="decimal"
value={draft.total}
title="Сумма заказа после всех скидок"
onChange={(e) => setTotal(e.target.value)}
/>{' '}
₴
</span>
) : (
<span>{order.total_amount} ₴</span>
)}
</div>
{order.receipt_status && (
@@ -127,8 +415,20 @@ export function OrderDetailModal({ order, onClose }: OrderDetailModalProps) {
</div>
)}
{message && <div className={`order-modal-message order-modal-message--${message.tone}`}>{message.text}</div>}
<div className="order-modal-actions">
<button type="button" className="order-modal-close-btn" onClick={onClose}>
{editable && (
<button
type="button"
className="order-modal-save-btn"
disabled={!dirty || saving}
onClick={() => void handleSave()}
>
{saving ? 'Сохранение…' : 'Сохранить'}
</button>
)}
<button type="button" className="order-modal-close-btn" onClick={requestClose}>
Закрыть
</button>
</div>
+31
View File
@@ -5,6 +5,9 @@
import type { ReceiptStatus } from '@/features/receipts/types'
/** Вкладка дашборда (`OrderTab` в backend/app/services/orders.py). */
export type OrderTab = 'no_receipt' | 'has_receipt' | 'received' | 'refused'
export interface OrderGood {
id: string
sku: string
@@ -36,4 +39,32 @@ export interface Order {
receipt_status: ReceiptStatus | null
receipt_error: string | null
receipt_prepayment: string | null
// Заказ правили вручную — синхронизация из CRM его больше не перезаписывает.
edited: boolean
}
/** Тело `PATCH /orders/{id}` (`OrderUpdateIn`). Деньги — строки "1200.00", `amount` считает сервер. */
export interface OrderGoodUpdate {
id: string | null
sku: string
name: string
price: string
quantity: string
discount_amount: string
}
export interface OrderUpdate {
recipient_name: string
recipient_phone: string
recipient_email: string
waybill_number: string
notes: string
total_amount: string
goods: OrderGoodUpdate[]
}
/** Ответ `GET /orders/summary` (`OrdersSummaryOut`). */
export interface OrdersSummary {
cod_in_transit_count: number
cod_in_transit_amount: string
}
+17 -5
View File
@@ -1,18 +1,30 @@
import { useQuery } from '@tanstack/react-query'
import { getOrders } from '@/api/orders'
import { getOrders, getOrdersSummary } from '@/api/orders'
import type { OrderTab } from '@/features/orders/types'
/** Пока чек отправляется в Checkbox — опрашиваем часто, иначе статусы чеков обновляет worker раз в минуту. */
const PENDING_POLL_MS = 3_000
const RECEIPTS_POLL_MS = 30_000
// Статусы NP обновляет worker раз в минуту — чаще сводку опрашивать незачем.
const SUMMARY_POLL_MS = 60_000
export function useOrders(hasReceipt: boolean) {
export function useOrders(tab: OrderTab) {
return useQuery({
queryKey: ['orders', hasReceipt],
queryFn: () => getOrders(hasReceipt),
queryKey: ['orders', tab],
queryFn: () => getOrders(tab),
refetchInterval: (query) => {
if (query.state.data?.some((order) => order.receipt_status === 'pending')) return PENDING_POLL_MS
return hasReceipt ? RECEIPTS_POLL_MS : false
return tab === 'has_receipt' ? RECEIPTS_POLL_MS : false
},
})
}
export function useOrdersSummary() {
return useQuery({
// Под ключом ['orders', ...] — чтобы invalidateQueries(['orders']) обновлял и сводку.
queryKey: ['orders', 'summary'],
queryFn: getOrdersSummary,
refetchInterval: SUMMARY_POLL_MS,
})
}
+4
View File
@@ -44,3 +44,7 @@
display: flex;
gap: 8px;
}
.cr-missing {
color: var(--color-danger);
}
+22 -5
View File
@@ -19,6 +19,7 @@ interface FormState {
fiscal_number: string
license_key: string
pin_code: string
np_api_key: string
tax_codes: string
is_default: boolean
}
@@ -28,6 +29,7 @@ const EMPTY_FORM: FormState = {
fiscal_number: '',
license_key: '',
pin_code: '',
np_api_key: '',
tax_codes: '',
is_default: true,
}
@@ -75,6 +77,7 @@ export function CashRegistersPage() {
fiscal_number: register.fiscal_number ?? '',
license_key: '',
pin_code: '',
np_api_key: '',
tax_codes: register.tax_codes.join(', '),
is_default: register.is_default,
})
@@ -88,7 +91,7 @@ export function CashRegistersPage() {
async function handleSubmit(event: FormEvent) {
event.preventDefault()
if (editingId) {
// Пустые ключ/PIN при редактировании — «не менять».
// Пустые ключи/PIN при редактировании — «не менять».
const payload: CashRegisterUpdate = {
name: form.name,
fiscal_number: form.fiscal_number || null,
@@ -97,6 +100,7 @@ export function CashRegistersPage() {
}
if (form.license_key) payload.license_key = form.license_key
if (form.pin_code) payload.pin_code = form.pin_code
if (form.np_api_key) payload.np_api_key = form.np_api_key
await run(() => updateCashRegister(editingId, payload), 'Касса обновлена')
} else {
await run(
@@ -106,6 +110,7 @@ export function CashRegistersPage() {
fiscal_number: form.fiscal_number || null,
license_key: form.license_key,
pin_code: form.pin_code,
np_api_key: form.np_api_key,
tax_codes: parseTaxCodes(form.tax_codes),
is_default: form.is_default,
}),
@@ -145,6 +150,7 @@ export function CashRegistersPage() {
<th>Название</th>
<th>Фиск. номер</th>
<th>Ключ лицензии</th>
<th>Ключ Новой Почты</th>
<th>Налоги</th>
<th>Статус</th>
<th>Действия</th>
@@ -153,21 +159,21 @@ export function CashRegistersPage() {
<tbody>
{isLoading && (
<tr>
<td colSpan={6} className="orders-empty">
<td colSpan={7} className="orders-empty">
Загрузка…
</td>
</tr>
)}
{isError && (
<tr>
<td colSpan={6} className="orders-empty">
<td colSpan={7} className="orders-empty">
Не удалось загрузить кассы
</td>
</tr>
)}
{registers?.length === 0 && (
<tr>
<td colSpan={6} className="orders-empty">
<td colSpan={7} className="orders-empty">
Кассы не настроены — чеки создавать нельзя
</td>
</tr>
@@ -180,6 +186,7 @@ export function CashRegistersPage() {
</td>
<td>{register.fiscal_number || '—'}</td>
<td>{register.license_key_masked}</td>
<td>{register.np_api_key_masked ?? <span className="cr-missing">не задан</span>}</td>
<td>{register.tax_codes.length ? register.tax_codes.join(', ') : 'без налога'}</td>
<td>
<span className={`orders-status orders-status--${register.is_active ? 'delivered' : 'new'}`}>
@@ -249,6 +256,16 @@ export function CashRegistersPage() {
onChange={(e) => setForm({ ...form, pin_code: e.target.value })}
/>
</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 })} />
@@ -259,7 +276,7 @@ export function CashRegistersPage() {
checked={form.is_default}
onChange={(e) => setForm({ ...form, is_default: e.target.checked })}
/>
Основная касса (по ней создаются чеки)
Основная касса (первой проверяет новые посылки)
</label>
<div className="cr-form-actions">
<button type="submit" className="orders-create-btn" disabled={busy}>
+33
View File
@@ -65,6 +65,39 @@
justify-content: space-between;
}
.orders-summary {
display: flex;
gap: 16px;
flex-wrap: wrap;
}
.orders-summary-card {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 220px;
padding: 14px 18px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 10px;
}
.orders-summary-label {
font-size: 13px;
color: var(--color-text-muted);
}
.orders-summary-value {
font-size: 22px;
font-weight: 700;
font-variant-numeric: tabular-nums;
}
.orders-summary-hint {
font-size: 12px;
color: var(--color-text-muted);
}
.orders-create-btn {
border: none;
background: var(--color-primary);
+37 -11
View File
@@ -7,8 +7,8 @@ import { cancelReceipt, createReceipts } from '@/api/receipts'
import '@/pages/DashboardPage.css'
import { useAuth } from '@/features/auth/useAuth'
import { OrderDetailModal } from '@/features/orders/OrderDetailModal'
import type { Order } from '@/features/orders/types'
import { useOrders } from '@/features/orders/useOrders'
import type { Order, OrderTab } from '@/features/orders/types'
import { useOrders, useOrdersSummary } from '@/features/orders/useOrders'
import { defaultPrepayment, prepaymentMatches, toKopecks } from '@/features/receipts/money'
import { CANCELLABLE, RECEIPT_STATUS } from '@/features/receipts/types'
import type { ReceiptRequestItem } from '@/features/receipts/types'
@@ -19,11 +19,11 @@ const ROLE_LABEL: Record<string, string> = {
viewer: 'Наблюдатель',
}
type Tab = 'no_receipt' | 'has_receipt'
const TABS: { key: Tab; label: string }[] = [
const TABS: { key: OrderTab; label: string }[] = [
{ key: 'no_receipt', label: 'Без чека' },
{ key: 'has_receipt', label: 'Выписаны чеки' },
{ key: 'received', label: 'Полученные' },
{ key: 'refused', label: 'Отказы' },
]
function npStatusTone(order: Order): 'delivered' | 'processing' | 'danger' | 'new' {
@@ -45,10 +45,17 @@ function paymentBadge(order: Order): { label: string; tone: 'delivered' | 'proce
return null
}
const MONEY_FORMAT = new Intl.NumberFormat('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
function formatMoney(amount: string): string {
return MONEY_FORMAT.format(Number(amount))
}
export function DashboardPage() {
const { user, logout } = useAuth()
const [tab, setTab] = useState<Tab>('no_receipt')
const { data: orders, isLoading, isError } = useOrders(tab === 'has_receipt')
const [tab, setTab] = useState<OrderTab>('no_receipt')
const { data: orders, isLoading, isError } = useOrders(tab)
const { data: summary } = useOrdersSummary()
const queryClient = useQueryClient()
const [selected, setSelected] = useState<Set<string>>(new Set())
const [search, setSearch] = useState('')
@@ -103,7 +110,13 @@ export function DashboardPage() {
async function handleCancel(order: Order) {
if (!order.receipt_id) return
if (!window.confirm(`Отменить ЕТТН-чек по заказу ${order.id}? Заказ вернётся в очередь.`)) return
const outcome = {
no_receipt: 'Заказ вернётся в очередь.',
has_receipt: 'Заказ вернётся в очередь.',
received: 'Заказ останется в полученных.',
refused: 'Заказ останется в отказах.',
}[tab]
if (!window.confirm(`Отменить ЕТТН-чек по заказу ${order.id}? ${outcome}`)) return
setCancellingId(order.id)
try {
await cancelReceipt(order.receipt_id)
@@ -193,6 +206,18 @@ export function DashboardPage() {
</div>
</div>
<div className="orders-summary">
<div className="orders-summary-card" title="Посылки с наложкой, которые ещё не забрали и по которым нет отказа">
<span className="orders-summary-label">Наложка в пути</span>
<span className="orders-summary-value">
{summary ? `${formatMoney(summary.cod_in_transit_amount)} ₴` : '—'}
</span>
<span className="orders-summary-hint">
{summary ? `${summary.cod_in_transit_count} посылок` : ' '}
</span>
</div>
</div>
{notice && (
<div className={`orders-notice orders-notice--${notice.tone}`}>
<ul>
@@ -336,7 +361,7 @@ export function DashboardPage() {
Чек
</button>
)}
{order.receipt_status && (tab === 'has_receipt' || !CANCELLABLE.has(order.receipt_status)) && (
{order.receipt_status && (tab !== 'no_receipt' || !CANCELLABLE.has(order.receipt_status)) && (
<span
className={`orders-status orders-status--${RECEIPT_STATUS[order.receipt_status].tone}`}
title={order.receipt_error ?? undefined}
@@ -344,7 +369,7 @@ export function DashboardPage() {
{RECEIPT_STATUS[order.receipt_status].label}
</span>
)}
{tab === 'has_receipt' && canFiscalize && order.receipt_status && CANCELLABLE.has(order.receipt_status) && (
{tab !== 'no_receipt' && canFiscalize && order.receipt_status && CANCELLABLE.has(order.receipt_status) && (
<button
type="button"
className="orders-delete-btn"
@@ -354,7 +379,7 @@ export function DashboardPage() {
Отменить
</button>
)}
{canDelete && tab === 'no_receipt' && (
{canDelete && (tab === 'no_receipt' || ((tab === 'refused' || tab === 'received') && !order.has_receipt)) && (
<button
type="button"
className="orders-delete-btn"
@@ -383,6 +408,7 @@ export function DashboardPage() {
{viewingOrder && (
<OrderDetailModal
order={orders?.find((order) => order.id === viewingOrder.id) ?? viewingOrder}
canEdit={canFiscalize && tab === 'no_receipt'}
onClose={() => setViewingOrder(null)}
/>
)}