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:
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user