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>
161 lines
6.6 KiB
Python
161 lines
6.6 KiB
Python
"""Схемы заказов CRM.
|
||
|
||
Поля валидируются напрямую из "сырых" PascalCase-ключей ответа CRM через
|
||
`Field(alias=...)`, а наружу (в JSON фронту) отдаются как чистые snake_case
|
||
имена. Денежные значения остаются строками "как есть" от CRM — конвенция
|
||
integer-kopecks из CLAUDE.md относится к будущим персистентным моделям
|
||
`Order`/`Receipt`, а не к этому read-only проксирующему эндпоинту.
|
||
"""
|
||
|
||
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
|
||
|
||
if TYPE_CHECKING:
|
||
from app.db.models.order import Order
|
||
from app.db.models.receipt import Receipt
|
||
|
||
|
||
class OrderGoodOut(BaseModel):
|
||
model_config = ConfigDict(populate_by_name=True)
|
||
|
||
id: str = Field(alias="ID")
|
||
sku: str = Field(alias="SKU")
|
||
name: str = Field(alias="Name")
|
||
price: str = Field(alias="Price")
|
||
quantity: str = Field(alias="Quantity")
|
||
discount_amount: str | None = Field(default=None, alias="DiscountAmount")
|
||
discount_percent: str | None = Field(default=None, alias="DiscountPercent")
|
||
amount: str = Field(alias="Amount")
|
||
|
||
|
||
class OrderTotalOut(BaseModel):
|
||
model_config = ConfigDict(populate_by_name=True)
|
||
|
||
cost: str = Field(alias="Cost")
|
||
quantity: str = Field(alias="Quantity")
|
||
weight: str = Field(alias="Weight")
|
||
discount_amount: str = Field(alias="DiscountAmount")
|
||
discount_percent: str = Field(alias="DiscountPercent")
|
||
amount: str = Field(alias="Amount")
|
||
|
||
# CRM возвращает Quantity/Weight то числом, то строкой в зависимости от
|
||
# заказа — приводим к строке единообразно для фронта.
|
||
@field_validator("quantity", "weight", mode="before")
|
||
@classmethod
|
||
def _stringify(cls, value: Any) -> Any:
|
||
return str(value) if value is not None else value
|
||
|
||
|
||
class OrderOut(BaseModel):
|
||
model_config = ConfigDict(populate_by_name=True)
|
||
|
||
id: str = Field(alias="ID")
|
||
create_date_time: str = Field(alias="CreateDateTime")
|
||
recipient_name: str | None = Field(default=None, alias="RecipientDName")
|
||
recipient_phone: str | None = Field(default=None, alias="RecipientPhone")
|
||
recipient_email: str | None = Field(default=None, alias="RecipientEmail")
|
||
waybill_number: str | None = Field(default=None, alias="Waybill_Number")
|
||
notes: str | None = Field(default=None, alias="Notes")
|
||
total: OrderTotalOut = Field(alias="Total")
|
||
goods: list[OrderGoodOut] = Field(default_factory=list, alias="Goods")
|
||
|
||
|
||
class OrderRowOut(BaseModel):
|
||
"""Ответ `GET /orders` — строится из локальной таблицы `orders`, не из CRM напрямую."""
|
||
|
||
model_config = ConfigDict(from_attributes=True)
|
||
|
||
id: str
|
||
create_date_time: datetime
|
||
recipient_name: str | None
|
||
recipient_phone: str | None
|
||
recipient_email: str | None
|
||
waybill_number: str | None
|
||
notes: str | None
|
||
total_amount: str
|
||
goods: list[OrderGoodOut]
|
||
has_receipt: bool
|
||
np_status: str | None
|
||
np_status_code: str | None
|
||
np_cod_amount: str | None
|
||
np_payment_status: str | None
|
||
# Последний ЕТТН-чек по заказу (в т.ч. отменённый/неудачный — для показа причины).
|
||
receipt_id: str | None = None
|
||
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:
|
||
return cls(
|
||
id=order.id,
|
||
create_date_time=order.create_date_time,
|
||
recipient_name=order.recipient_name,
|
||
recipient_phone=order.recipient_phone,
|
||
recipient_email=order.recipient_email,
|
||
waybill_number=order.waybill_number,
|
||
notes=order.notes,
|
||
total_amount=f"{order.total_amount_kopecks / 100:.2f}",
|
||
goods=[OrderGoodOut.model_validate(good) for good in order.goods],
|
||
has_receipt=order.receipt_created_at is not None,
|
||
np_status=order.np_status,
|
||
np_status_code=order.np_status_code,
|
||
np_cod_amount=(
|
||
f"{order.np_cod_amount_kopecks / 100:.2f}"
|
||
if order.np_cod_amount_kopecks is not None
|
||
else None
|
||
),
|
||
np_payment_status=order.np_payment_status,
|
||
receipt_id=str(receipt.id) if receipt else None,
|
||
receipt_status=receipt.status.value if receipt else None,
|
||
receipt_error=receipt.error if receipt else None,
|
||
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
|