diff --git a/backend/alembic/versions/0007_order_edited_at.py b/backend/alembic/versions/0007_order_edited_at.py new file mode 100644 index 0000000..9bdd427 --- /dev/null +++ b/backend/alembic/versions/0007_order_edited_at.py @@ -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") diff --git a/backend/app/api/v1/orders.py b/backend/app/api/v1/orders.py index 0744734..4ea3057 100644 --- a/backend/app/api/v1/orders.py +++ b/backend/app/api/v1/orders.py @@ -1,4 +1,4 @@ -"""Локальная очередь заказов (синхронизируется из CRM) — список, вкладки, удаление.""" +"""Локальная очередь заказов (синхронизируется из CRM) — список, вкладки, правка, удаление.""" 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.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 orders as orders_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] +@router.patch("/{order_id}", response_model=OrderRowOut, response_model_by_alias=False) +async def update_order( + order_id: str, + payload: OrderUpdateIn, + request: Request, + session: SessionDep, + actor: CashierUser, +) -> OrderRowOut: + try: + result = await orders_service.update_order(session, order_id, payload) + except orders_service.OrderEditError as exc: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(exc)) from exc + if result is None: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Заказ не найден") + + order, changed = result + if changed: + await audit.record( + session, + action=AuditAction.ORDER_UPDATED, + user=actor, + entity_type="order", + entity_id=order.id, + payload={"fields": changed}, + request=request, + ) + await session.commit() + + receipts = await receipts_service.latest_receipts_by_order(session, [order.id]) + return OrderRowOut.from_order(order, receipts.get(order.id)) + + @router.delete("/{order_id}", status_code=status.HTTP_204_NO_CONTENT) async def delete_order( order_id: str, request: Request, session: SessionDep, actor: CashierUser diff --git a/backend/app/db/models/audit.py b/backend/app/db/models/audit.py index 41164f9..12fe074 100644 --- a/backend/app/db/models/audit.py +++ b/backend/app/db/models/audit.py @@ -32,6 +32,7 @@ class AuditAction(str): USER_UPDATED = "user.updated" USER_DEACTIVATED = "user.deactivated" ORDER_DELETED = "order.deleted" + ORDER_UPDATED = "order.updated" CASH_REGISTER_CREATED = "cash_register.created" CASH_REGISTER_UPDATED = "cash_register.updated" RECEIPT_CREATE_REQUESTED = "receipt.create_requested" diff --git a/backend/app/db/models/order.py b/backend/app/db/models/order.py index 2c07efe..b394017 100644 --- a/backend/app/db/models/order.py +++ b/backend/app/db/models/order.py @@ -41,6 +41,10 @@ class Order(TimestampMixin, Base): is_deleted: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, index=True) deleted_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + # Заказ правили вручную в карточке — синхронизация из CRM его больше не + # перезаписывает, иначе правки кассира затирались бы при следующем открытии. + edited_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) + # Заполняется будущей интеграцией с Checkbox — сейчас всегда NULL. receipt_created_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), index=True) diff --git a/backend/app/schemas/orders.py b/backend/app/schemas/orders.py index 3eb51af..0083537 100644 --- a/backend/app/schemas/orders.py +++ b/backend/app/schemas/orders.py @@ -10,6 +10,7 @@ integer-kopecks из CLAUDE.md относится к будущим персис from __future__ import annotations from datetime import datetime +from decimal import Decimal from typing import TYPE_CHECKING, Any from pydantic import BaseModel, ConfigDict, Field, field_validator @@ -88,6 +89,7 @@ class OrderRowOut(BaseModel): receipt_status: str | None = None receipt_error: str | None = None receipt_prepayment: str | None = None + edited: bool = False @classmethod def from_order(cls, order: Order, receipt: Receipt | None = None) -> OrderRowOut: @@ -116,4 +118,43 @@ class OrderRowOut(BaseModel): receipt_prepayment=( 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 diff --git a/backend/app/services/orders.py b/backend/app/services/orders.py index 7f049b3..b86dee4 100644 --- a/backend/app/services/orders.py +++ b/backend/app/services/orders.py @@ -2,13 +2,16 @@ from __future__ import annotations +import uuid 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.ext.asyncio import AsyncSession from app.db.models.order import Order +from app.schemas.orders import OrderUpdateIn from app.services.crm.client import CrmClient from app.services.nova_poshta.client import NovaPoshtaClient @@ -17,6 +20,19 @@ _CRM_STATUS = "APPROVED" # NP отклоняет запросы с более чем 100 накладными за раз (см. np_client.py). _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: 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`) заказы не восстанавливаются и не перезаписываются — иначе кнопка «Удалить» переставала бы работать при следующем открытии - дашборда, т.к. CRM продолжает возвращать эти заказы как есть. + дашборда, т.к. CRM продолжает возвращать эти заказы как есть. По той же + причине не трогаются заказы, отредактированные вручную (`edited_at`). """ crm_orders = await crm.get_orders(status=_CRM_STATUS) 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: local = existing_by_id.get(crm_order.id) if local is not None: - if local.is_deleted: + if local.is_deleted or local.edited_at is not None: continue else: local = Order(id=crm_order.id) @@ -119,3 +136,92 @@ async def delete_order(session: AsyncSession, order_id: str) -> Order | None: order.is_deleted = True order.deleted_at = datetime.now(UTC) return order + + +def _good_key(good: dict[str, Any]) -> tuple[Any, ...]: + """Позиция без учёта формата строк: CRM пишет "1", форма — "1.000".""" + return ( + good["id"], + good.get("sku") or "", + good["name"], + _to_kopecks(good["price"]), + Decimal(good["quantity"]), + _to_kopecks(good["amount"]) if good.get("amount") else None, + ) + + +def _build_goods(data: OrderUpdateIn) -> tuple[list[dict[str, Any]], int]: + """Позиции формы → снимок `orders.goods` в формате CRM и их сумма в копейках.""" + goods: list[dict[str, Any]] = [] + total = 0 + for good in data.goods: + price = int(good.price * 100) + discount = int(good.discount_amount * 100) + gross = int((price * good.quantity).to_integral_value(ROUND_HALF_UP)) + net = gross - discount + if net < 0: + raise OrderEditError(f"Товар «{good.name}»: скидка больше суммы строки") + goods.append( + { + "id": good.id or f"new-{uuid.uuid4().hex[:8]}", + "sku": good.sku, + "name": good.name, + "price": f"{good.price:.2f}", + "quantity": f"{good.quantity:.3f}", + "discount_amount": f"{good.discount_amount:.2f}", + "discount_percent": None, + "amount": f"{net / 100:.2f}", + } + ) + total += net + return goods, total + + +async def update_order( + session: AsyncSession, order_id: str, data: OrderUpdateIn +) -> tuple[Order, list[str]] | None: + """Сохраняет ручные правки заказа. Возвращает заказ и список изменённых полей. + + Править можно только заказ без чека: у заказа с чеком (в т.ч. `pending`) + данные уже ушли или уходят в Checkbox. Строка блокируется, чтобы + параллельный запрос на создание чека не прочитал заказ посреди правки. + """ + order = await session.scalar(select(Order).where(Order.id == order_id).with_for_update()) + if order is None or order.is_deleted: + return None + if order.receipt_created_at is not None: + raise OrderEditError("По заказу уже создан чек — редактирование недоступно") + + goods, goods_total = _build_goods(data) + total = _to_kopecks(str(data.total_amount)) + if total > goods_total: + raise OrderEditError( + f"Сумма заказа {total / 100:.2f} ₴ больше суммы товаров {goods_total / 100:.2f} ₴" + ) + + changed = [ + field for field in _EDITABLE_FIELDS if getattr(order, field) != getattr(data, field) + ] + goods_changed = [_good_key(good) for good in order.goods] != [_good_key(good) for good in goods] + if goods_changed: + changed.append("goods") + if order.total_amount_kopecks != total: + changed.append("total_amount") + if not changed: + return order, changed + + if "waybill_number" in changed: + # Статус старой ТТН к новой не относится — worker опросит новую за минуту. + order.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 diff --git a/backend/tests/test_migration_matches_models.py b/backend/tests/test_migration_matches_models.py index f8b396c..c815eeb 100644 --- a/backend/tests/test_migration_matches_models.py +++ b/backend/tests/test_migration_matches_models.py @@ -24,14 +24,18 @@ MODEL_TABLES = set(Base.metadata.tables) def _migration_block(table: str) -> str: - """Тело вызова op.create_table для указанной таблицы. + """Тело вызова op.create_table для указанной таблицы плюс её поздние op.add_column. - Границей служит следующий op.create_table; для последней таблицы — конец файла. + Границей create_table служит следующий op.create_table или конец файла миграции. """ - start = COMBINED_SOURCE.index(f'op.create_table(\n "{table}",') - next_table = COMBINED_SOURCE.find("op.create_table(", start + 1) - end = next_table if next_table != -1 else len(COMBINED_SOURCE) - return COMBINED_SOURCE[start:end] + marker = f'op.create_table(\n "{table}",' + source = next(text for text in SOURCES.values() if marker in text) + start = source.index(marker) + next_table = source.find("op.create_table(", start + 1) + end = next_table if next_table != -1 else len(source) + # Колонки, добавленные позже через op.add_column, — в любой миграции. + added = re.findall(rf'op\.add_column\(\s*"{table}",\s*(sa\.Column\(.*?\n)', COMBINED_SOURCE) + return source[start:end] + "\n" + "\n".join(added) def test_migrations_create_every_model_table() -> None: diff --git a/backend/tests/test_orders_router.py b/backend/tests/test_orders_router.py index f42b454..3d3a5d8 100644 --- a/backend/tests/test_orders_router.py +++ b/backend/tests/test_orders_router.py @@ -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, "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: return {} @@ -164,3 +177,56 @@ class TestDeleteOrder: response = client.delete("/api/v1/orders/does-not-exist") assert response.status_code == 404 + + +_UPDATE_BODY = { + "recipient_name": "Тест Тестов", + "recipient_phone": "+380501112233", + "waybill_number": "20450123456789", + "notes": "правка", + "total_amount": "1200.00", + "goods": [ + {"id": "1", "sku": "SKU-1", "name": "Товар 1", "price": "1200.00", "quantity": "1"} + ], +} + + +class TestUpdateOrder: + @pytest.mark.parametrize("role", [UserRole.ADMIN, UserRole.CASHIER]) + def test_admin_and_cashier_can_update(self, client: TestClient, role: UserRole) -> None: + app.dependency_overrides[get_current_user] = lambda: _user(role) + + response = client.patch("/api/v1/orders/1", json=_UPDATE_BODY) + + assert response.status_code == 200 + body = response.json() + assert body["edited"] is True + assert body["goods"][0]["sku"] == "SKU-1" + + def test_viewer_cannot_update(self, client: TestClient) -> None: + app.dependency_overrides[get_current_user] = lambda: _user(UserRole.VIEWER) + + response = client.patch("/api/v1/orders/1", json=_UPDATE_BODY) + + assert response.status_code == 403 + + def test_unknown_order_is_404(self, client: TestClient) -> None: + app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER) + + response = client.patch("/api/v1/orders/nope", json=_UPDATE_BODY) + + assert response.status_code == 404 + + def test_order_with_receipt_is_409(self, client: TestClient) -> None: + app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER) + + response = client.patch("/api/v1/orders/locked", json=_UPDATE_BODY) + + assert response.status_code == 409 + + def test_empty_goods_is_rejected(self, client: TestClient) -> None: + app.dependency_overrides[get_current_user] = lambda: _user(UserRole.CASHIER) + + response = client.patch("/api/v1/orders/1", json={**_UPDATE_BODY, "goods": []}) + + assert response.status_code == 422 diff --git a/backend/tests/test_orders_service.py b/backend/tests/test_orders_service.py index 6856e43..b7285aa 100644 --- a/backend/tests/test_orders_service.py +++ b/backend/tests/test_orders_service.py @@ -1,10 +1,16 @@ -"""Юнит-тесты чистых хелперов сервиса заказов. Без БД и без сети.""" +"""Юнит-тесты сервиса заказов. Без БД и без сети — сессия подменяется заглушкой.""" 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: @@ -24,3 +30,121 @@ class TestToKopecks: class TestParseCrmDatetime: def test_parses_crm_format(self) -> None: assert _parse_crm_datetime("2026-09-20 10:00:00") == datetime(2026, 9, 20, 10, 0, 0) + + +class _FakeSession: + def __init__(self, order: Order | None) -> None: + self.order = order + + async def scalar(self, query: Any) -> Order | None: + return self.order + + +def _order(**overrides: Any) -> Order: + fields: dict[str, Any] = { + "id": "1", + "recipient_name": "Тест", + "recipient_phone": "0501112233", + "recipient_email": None, + "waybill_number": "20450000000001", + "notes": None, + "total_amount_kopecks": 120000, + "goods": [], + "is_deleted": False, + "receipt_created_at": None, + "edited_at": None, + "np_status": "Прибув", + "np_status_code": "7", + "np_cod_amount_kopecks": 120000, + "np_payment_status": "NeedPayment", + } + fields.update(overrides) + return Order(**fields) + + +_GOOD = { + "id": "1", + "sku": "A", + "name": "Товар", + "price": "600.00", + "quantity": "2", + "discount_amount": "50.00", +} + + +def _payload(**overrides: Any) -> OrderUpdateIn: + data: dict[str, Any] = { + "recipient_name": "Тест", + "recipient_phone": "0501112233", + "recipient_email": "", + "waybill_number": "20450000000001", + "notes": "", + "total_amount": "1100.00", + "goods": [_GOOD], + } + data.update(overrides) + return OrderUpdateIn.model_validate(data) + + +def _update(order: Order | None, payload: OrderUpdateIn) -> tuple[Order, list[str]]: + result = asyncio.run(update_order(_FakeSession(order), "1", payload)) # type: ignore[arg-type] + assert result is not None + return result + + +class TestUpdateOrder: + def test_recomputes_line_amount_and_marks_edited(self) -> None: + order, changed = _update(_order(), _payload()) + + assert order.goods[0]["amount"] == "1150.00" + assert order.goods[0]["quantity"] == "2.000" + assert order.total_amount_kopecks == 110000 + assert order.edited_at is not None + assert set(changed) == {"goods", "total_amount"} + + def test_blank_strings_become_null(self) -> None: + order, _ = _update(_order(), _payload(notes=" ")) + + assert order.notes is None + assert order.recipient_email is None + + def test_new_line_gets_generated_id(self) -> None: + good = {"sku": "", "name": "Новый", "price": "10.00", "quantity": "1"} + order, _ = _update(_order(), _payload(goods=[good], total_amount="10.00")) + + assert order.goods[0]["id"].startswith("new-") + + def test_waybill_change_resets_np_tracking(self) -> None: + order, changed = _update(_order(), _payload(waybill_number="20450000000002")) + + assert "waybill_number" in changed + assert order.np_status is None + assert order.np_cod_amount_kopecks is None + + def test_no_changes_does_not_mark_edited(self) -> None: + # Снимок в формате CRM ("2", а не "2.000") — формат строк не считается правкой. + stored = {**_GOOD, "discount_percent": "4.17", "amount": "1150.00"} + order, changed = _update(_order(goods=[stored], total_amount_kopecks=110000), _payload()) + + assert changed == [] + assert order.edited_at is None + assert order.goods == [stored] + + def test_total_above_goods_sum_is_rejected(self) -> None: + with pytest.raises(OrderEditError, match="больше суммы товаров"): + _update(_order(), _payload(total_amount="1200.00")) + + def test_discount_above_line_sum_is_rejected(self) -> None: + good = {"name": "Товар", "price": "10.00", "quantity": "1", "discount_amount": "11"} + with pytest.raises(OrderEditError, match="скидка больше"): + _update(_order(), _payload(goods=[good], total_amount="0")) + + def test_order_with_receipt_is_locked(self) -> None: + with pytest.raises(OrderEditError, match="уже создан чек"): + _update(_order(receipt_created_at=datetime.now(UTC)), _payload()) + + def test_missing_or_deleted_order_returns_none(self) -> None: + payload = _payload() + assert asyncio.run(update_order(_FakeSession(None), "1", payload)) is None # type: ignore[arg-type] + deleted = _FakeSession(_order(is_deleted=True)) + assert asyncio.run(update_order(deleted, "1", payload)) is None # type: ignore[arg-type] diff --git a/frontend/src/api/orders.ts b/frontend/src/api/orders.ts index 39fa290..3e19e3c 100644 --- a/frontend/src/api/orders.ts +++ b/frontend/src/api/orders.ts @@ -1,5 +1,5 @@ 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 { return apiFetch(`/orders?has_receipt=${hasReceipt}`) @@ -8,3 +8,7 @@ export function getOrders(hasReceipt: boolean): Promise { export function deleteOrder(orderId: string): Promise { return apiFetch(`/orders/${orderId}`, { method: 'DELETE' }) } + +export function updateOrder(orderId: string, data: OrderUpdate): Promise { + return apiFetch(`/orders/${orderId}`, { method: 'PATCH', body: data }) +} diff --git a/frontend/src/components/Modal.css b/frontend/src/components/Modal.css index 3223351..c1b00a4 100644 --- a/frontend/src/components/Modal.css +++ b/frontend/src/components/Modal.css @@ -19,3 +19,7 @@ max-height: 90vh; overflow-y: auto; } + +.modal-content--wide { + max-width: 920px; +} diff --git a/frontend/src/components/Modal.tsx b/frontend/src/components/Modal.tsx index 725ca67..071f129 100644 --- a/frontend/src/components/Modal.tsx +++ b/frontend/src/components/Modal.tsx @@ -6,9 +6,10 @@ import '@/components/Modal.css' interface ModalProps { onClose: () => void children: ReactNode + wide?: boolean } -export function Modal({ onClose, children }: ModalProps) { +export function Modal({ onClose, children, wide = false }: ModalProps) { useEffect(() => { function onKeyDown(event: KeyboardEvent) { if (event.key === 'Escape') onClose() @@ -19,7 +20,7 @@ export function Modal({ onClose, children }: ModalProps) { return (
-
event.stopPropagation()}> +
event.stopPropagation()}> {children}
diff --git a/frontend/src/features/orders/OrderDetailModal.css b/frontend/src/features/orders/OrderDetailModal.css index c74a55c..319f0e3 100644 --- a/frontend/src/features/orders/OrderDetailModal.css +++ b/frontend/src/features/orders/OrderDetailModal.css @@ -37,7 +37,8 @@ font-size: 14px; } -.order-modal-meta div { +.order-modal-meta div, +.order-modal-meta label { display: flex; flex-direction: column; gap: 2px; @@ -111,3 +112,154 @@ border-color: var(--color-primary); color: var(--color-primary); } + +.order-modal-edited { + margin-left: 10px; + padding: 2px 8px; + border-radius: 999px; + background: var(--color-bg); + border: 1px solid var(--color-border); + color: var(--color-text-muted); + font-size: 11px; + font-weight: 500; + vertical-align: middle; +} + +.order-modal-notes { + display: flex; + flex-direction: column; + gap: 2px; +} + +.order-modal-input { + width: 100%; + box-sizing: border-box; + padding: 6px 8px; + border-radius: 6px; + border: 1px solid var(--color-border); + background: var(--color-surface); + color: var(--color-text); + font: inherit; + font-size: 13px; +} + +.order-modal-input:focus { + outline: none; + border-color: var(--color-primary); +} + +textarea.order-modal-input { + resize: vertical; +} + +.order-modal-input--sku { + min-width: 80px; +} + +.order-modal-input--num { + width: 84px; + text-align: right; +} + +.order-modal-goods td:has(.order-modal-input) { + padding: 4px 4px; +} + +.order-modal-invalid { + color: var(--color-danger); +} + +.order-modal-remove { + border: none; + background: none; + color: var(--color-text-muted); + font-size: 18px; + line-height: 1; + cursor: pointer; + padding: 2px 6px; +} + +.order-modal-remove:hover { + color: var(--color-danger); +} + +.order-modal-add { + align-self: flex-start; + border: 1px dashed var(--color-border); + background: none; + color: var(--color-primary); + border-radius: 8px; + padding: 6px 12px; + font-size: 13px; + cursor: pointer; +} + +.order-modal-add:hover { + border-color: var(--color-primary); +} + +.order-modal-total { + align-items: center; +} + +.order-modal-message { + padding: 8px 12px; + border-radius: 8px; + font-size: 13px; +} + +.order-modal-message--ok { + background: #dcfce7; + color: #166534; +} + +.order-modal-message--error { + background: var(--color-danger-bg); + color: var(--color-danger); + border: 1px solid var(--color-danger-border); +} + +.order-modal-actions { + gap: 8px; +} + +.order-modal-save-btn { + border: 1px solid var(--color-primary); + background: var(--color-primary); + color: #fff; + border-radius: 8px; + padding: 8px 16px; + font-size: 14px; + cursor: pointer; +} + +.order-modal-save-btn:hover:not(:disabled) { + background: var(--color-primary-hover); +} + +.order-modal-save-btn:disabled { + opacity: 0.5; + cursor: default; +} + +@media (prefers-color-scheme: dark) { + .order-modal-message--ok { + background: rgba(22, 163, 74, 0.18); + color: #86efac; + } +} + +@media (max-width: 640px) { + .order-modal { + padding: 16px; + } + + .order-modal-meta { + grid-template-columns: minmax(0, 1fr); + } + + .order-modal-goods { + display: block; + overflow-x: auto; + } +} diff --git a/frontend/src/features/orders/OrderDetailModal.tsx b/frontend/src/features/orders/OrderDetailModal.tsx index 58d18c9..67a257e 100644 --- a/frontend/src/features/orders/OrderDetailModal.tsx +++ b/frontend/src/features/orders/OrderDetailModal.tsx @@ -1,20 +1,226 @@ +import { useQueryClient } from '@tanstack/react-query' +import { useMemo, useState } from 'react' + +import { updateOrder } from '@/api/orders' import { Modal } from '@/components/Modal' import '@/features/orders/OrderDetailModal.css' -import type { Order } from '@/features/orders/types' +import type { Order, OrderUpdate } from '@/features/orders/types' +import { formatKopecks, toKopecks } from '@/features/receipts/money' import { RECEIPT_STATUS } from '@/features/receipts/types' interface OrderDetailModalProps { order: Order + /** Кассир/админ и по заказу ещё нет чека — карточку можно править. */ + canEdit: boolean onClose: () => void } -export function OrderDetailModal({ order, onClose }: OrderDetailModalProps) { +interface GoodDraft { + key: string + id: string | null + sku: string + name: string + price: string + quantity: string + discount: string +} + +interface Draft { + recipient_name: string + recipient_phone: string + recipient_email: string + waybill_number: string + notes: string + total: string + goods: GoodDraft[] +} + +type TextField = Exclude + +/** Количество в тысячных (конвенция 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(() => toDraft(order)) + const [saved, setSaved] = useState(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 ( - +
-

Просмотр заказа {order.id}

-
@@ -24,10 +230,6 @@ export function OrderDetailModal({ order, onClose }: OrderDetailModalProps) { Дата {order.create_date_time}
-
- Номер ТТН - {order.waybill_number || '—'} -
Статус ТТН {order.np_status || 'Нет данных'} @@ -46,27 +248,45 @@ export function OrderDetailModal({ order, onClose }: OrderDetailModalProps) { : '—'}
-
- Клиент - {order.recipient_name || '—'} -
-
- Телефон - {order.recipient_phone || '—'} -
- {order.recipient_email && ( -
- Email - {order.recipient_email} -
+ {TEXT_FIELDS.map(({ field, label, type }) => + editable ? ( + + ) : ( + (field !== 'recipient_email' || draft[field]) && ( +
+ {label} + {draft[field] || '—'} +
+ ) + ), )}
- {order.notes && ( -
+ {editable ? ( +
+