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>
This commit is contained in:
@@ -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")
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Локальная очередь заказов (синхронизируется из CRM) — список, вкладки, удаление."""
|
"""Локальная очередь заказов (синхронизируется из CRM) — список, вкладки, правка, удаление."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -6,7 +6,7 @@ from fastapi import APIRouter, Depends, HTTPException, Request, status
|
|||||||
|
|
||||||
from app.api.deps import CashierUser, CrmClientDep, SessionDep, require_any
|
from app.api.deps import CashierUser, CrmClientDep, SessionDep, require_any
|
||||||
from app.db.models.audit import AuditAction
|
from app.db.models.audit import AuditAction
|
||||||
from app.schemas.orders import OrderRowOut
|
from app.schemas.orders import OrderRowOut, OrderUpdateIn
|
||||||
from app.services import audit
|
from app.services import audit
|
||||||
from app.services import orders as orders_service
|
from app.services import orders as orders_service
|
||||||
from app.services import receipts as receipts_service
|
from app.services import receipts as receipts_service
|
||||||
@@ -28,6 +28,38 @@ async def list_orders(
|
|||||||
return [OrderRowOut.from_order(order, receipts.get(order.id)) for order in orders]
|
return [OrderRowOut.from_order(order, receipts.get(order.id)) for order in orders]
|
||||||
|
|
||||||
|
|
||||||
|
@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)
|
@router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_order(
|
async def delete_order(
|
||||||
order_id: str, request: Request, session: SessionDep, actor: CashierUser
|
order_id: str, request: Request, session: SessionDep, actor: CashierUser
|
||||||
|
|||||||
@@ -32,6 +32,7 @@ class AuditAction(str):
|
|||||||
USER_UPDATED = "user.updated"
|
USER_UPDATED = "user.updated"
|
||||||
USER_DEACTIVATED = "user.deactivated"
|
USER_DEACTIVATED = "user.deactivated"
|
||||||
ORDER_DELETED = "order.deleted"
|
ORDER_DELETED = "order.deleted"
|
||||||
|
ORDER_UPDATED = "order.updated"
|
||||||
CASH_REGISTER_CREATED = "cash_register.created"
|
CASH_REGISTER_CREATED = "cash_register.created"
|
||||||
CASH_REGISTER_UPDATED = "cash_register.updated"
|
CASH_REGISTER_UPDATED = "cash_register.updated"
|
||||||
RECEIPT_CREATE_REQUESTED = "receipt.create_requested"
|
RECEIPT_CREATE_REQUESTED = "receipt.create_requested"
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ class Order(TimestampMixin, Base):
|
|||||||
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True)
|
||||||
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
# Заказ правили вручную в карточке — синхронизация из CRM его больше не
|
||||||
|
# перезаписывает, иначе правки кассира затирались бы при следующем открытии.
|
||||||
|
edited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
|
|
||||||
# Заполняется будущей интеграцией с Checkbox — сейчас всегда NULL.
|
# Заполняется будущей интеграцией с Checkbox — сейчас всегда NULL.
|
||||||
receipt_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
receipt_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True)
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ integer-kopecks из CLAUDE.md относится к будущим персис
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
@@ -88,6 +89,7 @@ class OrderRowOut(BaseModel):
|
|||||||
receipt_status: str | None = None
|
receipt_status: str | None = None
|
||||||
receipt_error: str | None = None
|
receipt_error: str | None = None
|
||||||
receipt_prepayment: str | None = None
|
receipt_prepayment: str | None = None
|
||||||
|
edited: bool = False
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_order(cls, order: Order, receipt: Receipt | None = None) -> OrderRowOut:
|
def from_order(cls, order: Order, receipt: Receipt | None = None) -> OrderRowOut:
|
||||||
@@ -116,4 +118,43 @@ class OrderRowOut(BaseModel):
|
|||||||
receipt_prepayment=(
|
receipt_prepayment=(
|
||||||
f"{receipt.prepayment_kopecks / 100:.2f}" if receipt else None
|
f"{receipt.prepayment_kopecks / 100:.2f}" if receipt else None
|
||||||
),
|
),
|
||||||
|
edited=order.edited_at is not None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
@@ -2,13 +2,16 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from decimal import Decimal
|
from decimal import ROUND_HALF_UP, Decimal
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.db.models.order import Order
|
from app.db.models.order import Order
|
||||||
|
from app.schemas.orders import OrderUpdateIn
|
||||||
from app.services.crm.client import CrmClient
|
from app.services.crm.client import CrmClient
|
||||||
from app.services.nova_poshta.client import NovaPoshtaClient
|
from app.services.nova_poshta.client import NovaPoshtaClient
|
||||||
|
|
||||||
@@ -17,6 +20,19 @@ _CRM_STATUS = "APPROVED"
|
|||||||
# NP отклоняет запросы с более чем 100 накладными за раз (см. np_client.py).
|
# NP отклоняет запросы с более чем 100 накладными за раз (см. np_client.py).
|
||||||
_NP_BATCH_SIZE = 100
|
_NP_BATCH_SIZE = 100
|
||||||
|
|
||||||
|
# Поля, которые кассир может править в карточке заказа (кроме goods/total).
|
||||||
|
_EDITABLE_FIELDS = (
|
||||||
|
"recipient_name",
|
||||||
|
"recipient_phone",
|
||||||
|
"recipient_email",
|
||||||
|
"waybill_number",
|
||||||
|
"notes",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OrderEditError(Exception):
|
||||||
|
"""Заказ нельзя сохранить — сообщение показывается кассиру."""
|
||||||
|
|
||||||
|
|
||||||
def _to_kopecks(amount: str) -> int:
|
def _to_kopecks(amount: str) -> int:
|
||||||
return int((Decimal(amount) * 100).to_integral_value())
|
return int((Decimal(amount) * 100).to_integral_value())
|
||||||
@@ -31,7 +47,8 @@ async def sync_orders_from_crm(session: AsyncSession, crm: CrmClient) -> None:
|
|||||||
|
|
||||||
Уже скрытые (`is_deleted`) заказы не восстанавливаются и не перезаписываются
|
Уже скрытые (`is_deleted`) заказы не восстанавливаются и не перезаписываются
|
||||||
— иначе кнопка «Удалить» переставала бы работать при следующем открытии
|
— иначе кнопка «Удалить» переставала бы работать при следующем открытии
|
||||||
дашборда, т.к. CRM продолжает возвращать эти заказы как есть.
|
дашборда, т.к. CRM продолжает возвращать эти заказы как есть. По той же
|
||||||
|
причине не трогаются заказы, отредактированные вручную (`edited_at`).
|
||||||
"""
|
"""
|
||||||
crm_orders = await crm.get_orders(status=_CRM_STATUS)
|
crm_orders = await crm.get_orders(status=_CRM_STATUS)
|
||||||
if not crm_orders:
|
if not crm_orders:
|
||||||
@@ -45,7 +62,7 @@ async def sync_orders_from_crm(session: AsyncSession, crm: CrmClient) -> None:
|
|||||||
for crm_order in crm_orders:
|
for crm_order in crm_orders:
|
||||||
local = existing_by_id.get(crm_order.id)
|
local = existing_by_id.get(crm_order.id)
|
||||||
if local is not None:
|
if local is not None:
|
||||||
if local.is_deleted:
|
if local.is_deleted or local.edited_at is not None:
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
local = Order(id=crm_order.id)
|
local = Order(id=crm_order.id)
|
||||||
@@ -119,3 +136,92 @@ async def delete_order(session: AsyncSession, order_id: str) -> Order | None:
|
|||||||
order.is_deleted = True
|
order.is_deleted = True
|
||||||
order.deleted_at = datetime.now(UTC)
|
order.deleted_at = datetime.now(UTC)
|
||||||
return order
|
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.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
|
||||||
|
|||||||
@@ -24,14 +24,18 @@ MODEL_TABLES = set(Base.metadata.tables)
|
|||||||
|
|
||||||
|
|
||||||
def _migration_block(table: str) -> str:
|
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}",')
|
marker = f'op.create_table(\n "{table}",'
|
||||||
next_table = COMBINED_SOURCE.find("op.create_table(", start + 1)
|
source = next(text for text in SOURCES.values() if marker in text)
|
||||||
end = next_table if next_table != -1 else len(COMBINED_SOURCE)
|
start = source.index(marker)
|
||||||
return COMBINED_SOURCE[start:end]
|
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:
|
def test_migrations_create_every_model_table() -> None:
|
||||||
|
|||||||
@@ -79,6 +79,19 @@ def _patch_orders_service(monkeypatch: pytest.MonkeyPatch) -> None:
|
|||||||
monkeypatch.setattr(orders_router.orders_service, "list_orders", fake_list)
|
monkeypatch.setattr(orders_router.orders_service, "list_orders", fake_list)
|
||||||
monkeypatch.setattr(orders_router.orders_service, "delete_order", fake_delete)
|
monkeypatch.setattr(orders_router.orders_service, "delete_order", fake_delete)
|
||||||
|
|
||||||
|
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:
|
async def fake_latest(session: object, order_ids: list[str]) -> dict:
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
@@ -164,3 +177,56 @@ class TestDeleteOrder:
|
|||||||
response = client.delete("/api/v1/orders/does-not-exist")
|
response = client.delete("/api/v1/orders/does-not-exist")
|
||||||
|
|
||||||
assert response.status_code == 404
|
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
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
"""Юнит-тесты чистых хелперов сервиса заказов. Без БД и без сети."""
|
"""Юнит-тесты сервиса заказов. Без БД и без сети — сессия подменяется заглушкой."""
|
||||||
|
|
||||||
from __future__ import annotations
|
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 app.db.models.order import Order
|
||||||
|
from app.schemas.orders import OrderUpdateIn
|
||||||
|
from app.services.orders import OrderEditError, _parse_crm_datetime, _to_kopecks, update_order
|
||||||
|
|
||||||
|
|
||||||
class TestToKopecks:
|
class TestToKopecks:
|
||||||
@@ -24,3 +30,121 @@ class TestToKopecks:
|
|||||||
class TestParseCrmDatetime:
|
class TestParseCrmDatetime:
|
||||||
def test_parses_crm_format(self) -> None:
|
def test_parses_crm_format(self) -> None:
|
||||||
assert _parse_crm_datetime("2026-09-20 10:00:00") == datetime(2026, 9, 20, 10, 0, 0)
|
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]
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { apiFetch } from '@/api/client'
|
import { apiFetch } from '@/api/client'
|
||||||
import type { Order } from '@/features/orders/types'
|
import type { Order, OrderUpdate } from '@/features/orders/types'
|
||||||
|
|
||||||
export function getOrders(hasReceipt: boolean): Promise<Order[]> {
|
export function getOrders(hasReceipt: boolean): Promise<Order[]> {
|
||||||
return apiFetch<Order[]>(`/orders?has_receipt=${hasReceipt}`)
|
return apiFetch<Order[]>(`/orders?has_receipt=${hasReceipt}`)
|
||||||
@@ -8,3 +8,7 @@ export function getOrders(hasReceipt: boolean): Promise<Order[]> {
|
|||||||
export function deleteOrder(orderId: string): Promise<void> {
|
export function deleteOrder(orderId: string): Promise<void> {
|
||||||
return apiFetch<void>(`/orders/${orderId}`, { method: 'DELETE' })
|
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 })
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,3 +19,7 @@
|
|||||||
max-height: 90vh;
|
max-height: 90vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.modal-content--wide {
|
||||||
|
max-width: 920px;
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import '@/components/Modal.css'
|
|||||||
interface ModalProps {
|
interface ModalProps {
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
|
wide?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Modal({ onClose, children }: ModalProps) {
|
export function Modal({ onClose, children, wide = false }: ModalProps) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function onKeyDown(event: KeyboardEvent) {
|
function onKeyDown(event: KeyboardEvent) {
|
||||||
if (event.key === 'Escape') onClose()
|
if (event.key === 'Escape') onClose()
|
||||||
@@ -19,7 +20,7 @@ export function Modal({ onClose, children }: ModalProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay" onClick={onClose}>
|
<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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,7 +37,8 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.order-modal-meta div {
|
.order-modal-meta div,
|
||||||
|
.order-modal-meta label {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
@@ -111,3 +112,154 @@
|
|||||||
border-color: var(--color-primary);
|
border-color: var(--color-primary);
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 { Modal } from '@/components/Modal'
|
||||||
import '@/features/orders/OrderDetailModal.css'
|
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'
|
import { RECEIPT_STATUS } from '@/features/receipts/types'
|
||||||
|
|
||||||
interface OrderDetailModalProps {
|
interface OrderDetailModalProps {
|
||||||
order: Order
|
order: Order
|
||||||
|
/** Кассир/админ и по заказу ещё нет чека — карточку можно править. */
|
||||||
|
canEdit: boolean
|
||||||
onClose: () => void
|
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 (
|
return (
|
||||||
<Modal onClose={onClose}>
|
<Modal onClose={requestClose} wide={editable}>
|
||||||
<div className="order-modal">
|
<div className="order-modal">
|
||||||
<div className="order-modal-header">
|
<div className="order-modal-header">
|
||||||
<h3>Просмотр заказа {order.id}</h3>
|
<h3>
|
||||||
<button type="button" className="order-modal-close" onClick={onClose} aria-label="Закрыть">
|
Заказ {order.id}
|
||||||
|
{order.edited && <span className="order-modal-edited">изменён вручную</span>}
|
||||||
|
</h3>
|
||||||
|
<button type="button" className="order-modal-close" onClick={requestClose} aria-label="Закрыть">
|
||||||
×
|
×
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -24,10 +230,6 @@ export function OrderDetailModal({ order, onClose }: OrderDetailModalProps) {
|
|||||||
<span className="order-modal-label">Дата</span>
|
<span className="order-modal-label">Дата</span>
|
||||||
<span>{order.create_date_time}</span>
|
<span>{order.create_date_time}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
|
||||||
<span className="order-modal-label">Номер ТТН</span>
|
|
||||||
<span>{order.waybill_number || '—'}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
<div>
|
||||||
<span className="order-modal-label">Статус ТТН</span>
|
<span className="order-modal-label">Статус ТТН</span>
|
||||||
<span>{order.np_status || 'Нет данных'}</span>
|
<span>{order.np_status || 'Нет данных'}</span>
|
||||||
@@ -46,27 +248,45 @@ export function OrderDetailModal({ order, onClose }: OrderDetailModalProps) {
|
|||||||
: '—'}
|
: '—'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
{TEXT_FIELDS.map(({ field, label, type }) =>
|
||||||
<span className="order-modal-label">Клиент</span>
|
editable ? (
|
||||||
<span>{order.recipient_name || '—'}</span>
|
<label key={field}>
|
||||||
</div>
|
<span className="order-modal-label">{label}</span>
|
||||||
<div>
|
<input
|
||||||
<span className="order-modal-label">Телефон</span>
|
type={type ?? 'text'}
|
||||||
<span>{order.recipient_phone || '—'}</span>
|
className="order-modal-input"
|
||||||
</div>
|
value={draft[field]}
|
||||||
{order.recipient_email && (
|
onChange={(e) => setField(field, e.target.value)}
|
||||||
<div>
|
/>
|
||||||
<span className="order-modal-label">Email</span>
|
</label>
|
||||||
<span>{order.recipient_email}</span>
|
) : (
|
||||||
</div>
|
(field !== 'recipient_email' || draft[field]) && (
|
||||||
|
<div key={field}>
|
||||||
|
<span className="order-modal-label">{label}</span>
|
||||||
|
<span>{draft[field] || '—'}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{order.notes && (
|
{editable ? (
|
||||||
<div className="order-modal-notes">
|
<label className="order-modal-notes">
|
||||||
<span className="order-modal-label">Заметки</span>
|
<span className="order-modal-label">Заметки</span>
|
||||||
<p>{order.notes}</p>
|
<textarea
|
||||||
</div>
|
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>{draft.notes}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<table className="order-modal-goods">
|
<table className="order-modal-goods">
|
||||||
@@ -78,22 +298,71 @@ export function OrderDetailModal({ order, onClose }: OrderDetailModalProps) {
|
|||||||
<th>Кол-во</th>
|
<th>Кол-во</th>
|
||||||
<th>Скидка</th>
|
<th>Скидка</th>
|
||||||
<th>Сумма</th>
|
<th>Сумма</th>
|
||||||
|
{editable && <th aria-label="Удалить" />}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{order.goods.map((good) => (
|
{draft.goods.map((good) => {
|
||||||
<tr key={good.id}>
|
const amount = lineAmount(good)
|
||||||
<td>{good.name}</td>
|
return (
|
||||||
<td>{good.sku}</td>
|
<tr key={good.key}>
|
||||||
<td>{good.price}</td>
|
{editable ? (
|
||||||
<td>{good.quantity}</td>
|
<>
|
||||||
<td>{good.discount_amount && good.discount_amount !== '0.00' ? good.discount_amount : '—'}</td>
|
<td>
|
||||||
<td>{good.amount}</td>
|
<input
|
||||||
</tr>
|
className="order-modal-input"
|
||||||
))}
|
value={good.name}
|
||||||
{order.goods.length === 0 && (
|
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 !== '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>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{draft.goods.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="order-modal-goods-empty">
|
<td colSpan={editable ? 7 : 6} className="order-modal-goods-empty">
|
||||||
Товары не указаны
|
Товары не указаны
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -101,9 +370,28 @@ export function OrderDetailModal({ order, onClose }: OrderDetailModalProps) {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
|
{editable && (
|
||||||
|
<button type="button" className="order-modal-add" onClick={addGood}>
|
||||||
|
+ Добавить товар
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="order-modal-total">
|
<div className="order-modal-total">
|
||||||
<span>Итого</span>
|
<span>Итого</span>
|
||||||
<span>{order.total_amount} ₴</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>
|
</div>
|
||||||
|
|
||||||
{order.receipt_status && (
|
{order.receipt_status && (
|
||||||
@@ -127,8 +415,20 @@ export function OrderDetailModal({ order, onClose }: OrderDetailModalProps) {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{message && <div className={`order-modal-message order-modal-message--${message.tone}`}>{message.text}</div>}
|
||||||
|
|
||||||
<div className="order-modal-actions">
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -36,4 +36,26 @@ export interface Order {
|
|||||||
receipt_status: ReceiptStatus | null
|
receipt_status: ReceiptStatus | null
|
||||||
receipt_error: string | null
|
receipt_error: string | null
|
||||||
receipt_prepayment: 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[]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -383,6 +383,7 @@ export function DashboardPage() {
|
|||||||
{viewingOrder && (
|
{viewingOrder && (
|
||||||
<OrderDetailModal
|
<OrderDetailModal
|
||||||
order={orders?.find((order) => order.id === viewingOrder.id) ?? viewingOrder}
|
order={orders?.find((order) => order.id === viewingOrder.id) ?? viewingOrder}
|
||||||
|
canEdit={canFiscalize && tab === 'no_receipt'}
|
||||||
onClose={() => setViewingOrder(null)}
|
onClose={() => setViewingOrder(null)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user