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:
2026-09-24 17:23:41 +03:00
co-authored by Claude Opus 5.5
parent cbf9832e5b
commit 56ac0fc370
16 changed files with 946 additions and 58 deletions
@@ -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")
+34 -2
View File
@@ -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
+1
View File
@@ -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"
+4
View File
@@ -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)
+41
View File
@@ -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
+109 -3
View File
@@ -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
+10 -6
View File
@@ -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:
+66
View File
@@ -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
+127 -3
View File
@@ -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]
+5 -1
View File
@@ -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 })
}
+4
View File
@@ -19,3 +19,7 @@
max-height: 90vh; max-height: 90vh;
overflow-y: auto; overflow-y: auto;
} }
.modal-content--wide {
max-width: 920px;
}
+3 -2
View File
@@ -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;
}
}
+340 -40
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 { 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>
+22
View File
@@ -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[]
} }
+1
View File
@@ -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)}
/> />
)} )}