This commit is contained in:
@@ -1,18 +1,22 @@
|
||||
from datetime import UTC, datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import select
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy import or_, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.api.deps import ensure_service_employee, get_current_telegram_user, log_audit
|
||||
from app.core.config import settings
|
||||
from app.db.session import get_session
|
||||
from app.models.car import (
|
||||
Car,
|
||||
CarServiceLink,
|
||||
ServiceAppointment,
|
||||
ServiceCenter,
|
||||
ServiceProductItem,
|
||||
ServiceVisit,
|
||||
ServiceWorkItem,
|
||||
WorkOrderCatalogItem,
|
||||
WorkOrderCorrection,
|
||||
WorkOrderStatusHistory,
|
||||
)
|
||||
@@ -23,9 +27,15 @@ from app.schemas.service_center import (
|
||||
ServiceVisitRead,
|
||||
ServiceWorkItemCreate,
|
||||
ServiceWorkItemRead,
|
||||
VehicleProfileRequest,
|
||||
WorkOrderCatalogItemCreate,
|
||||
WorkOrderCatalogItemRead,
|
||||
WorkOrderCatalogRead,
|
||||
WorkOrderCatalogSuggestion,
|
||||
WorkOrderCorrectionCreate,
|
||||
WorkOrderCorrectionRead,
|
||||
WorkOrderDecision,
|
||||
WorkOrderDetailRead,
|
||||
WorkOrderStatusHistoryRead,
|
||||
WorkOrderUpdate,
|
||||
)
|
||||
@@ -43,6 +53,18 @@ from app.services.work_orders import (
|
||||
router = APIRouter(prefix="/work-orders", tags=["work-orders"])
|
||||
|
||||
|
||||
def webapp_url(path: str) -> str:
|
||||
return f"{settings.effective_webapp_url.rstrip('/')}/{path.lstrip('/')}"
|
||||
|
||||
|
||||
def work_order_webapp_url(work_order_id: int) -> str:
|
||||
return webapp_url(f"work_order.html?id={work_order_id}")
|
||||
|
||||
|
||||
def vehicle_profile_webapp_url(vehicle_id: int) -> str:
|
||||
return webapp_url(f"?section=carProfile&car_id={vehicle_id}")
|
||||
|
||||
|
||||
async def get_work_order(session: AsyncSession, work_order_id: int) -> ServiceVisit:
|
||||
visit = await session.get(ServiceVisit, work_order_id)
|
||||
if visit is None:
|
||||
@@ -50,6 +72,22 @@ async def get_work_order(session: AsyncSession, work_order_id: int) -> ServiceVi
|
||||
return visit
|
||||
|
||||
|
||||
async def get_work_order_with_items(session: AsyncSession, work_order_id: int) -> ServiceVisit:
|
||||
visit = (
|
||||
await session.execute(
|
||||
select(ServiceVisit)
|
||||
.options(
|
||||
selectinload(ServiceVisit.work_items),
|
||||
selectinload(ServiceVisit.product_items),
|
||||
)
|
||||
.where(ServiceVisit.id == work_order_id)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if visit is None:
|
||||
raise HTTPException(status_code=404, detail="Work order not found")
|
||||
return visit
|
||||
|
||||
|
||||
async def ensure_work_order_sto_access(
|
||||
session: AsyncSession, visit: ServiceVisit, user: User, allowed_roles: set[str] | None = None
|
||||
) -> None:
|
||||
@@ -93,6 +131,156 @@ async def ensure_work_order_vehicle_scope(session: AsyncSession, visit: ServiceV
|
||||
raise HTTPException(status_code=403, detail="Vehicle access is not confirmed by owner")
|
||||
|
||||
|
||||
async def ensure_center_vehicle_scope(session: AsyncSession, service_center_id: int, vehicle_id: int) -> None:
|
||||
link = (
|
||||
await session.execute(
|
||||
select(CarServiceLink).where(
|
||||
CarServiceLink.car_id == vehicle_id,
|
||||
CarServiceLink.service_center_id == service_center_id,
|
||||
CarServiceLink.status == "approved",
|
||||
CarServiceLink.is_active.is_(True),
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if link is not None:
|
||||
return
|
||||
appointment = (
|
||||
await session.execute(
|
||||
select(ServiceAppointment).where(
|
||||
ServiceAppointment.service_center_id == service_center_id,
|
||||
ServiceAppointment.vehicle_id == vehicle_id,
|
||||
ServiceAppointment.status.in_(["confirmed", "confirmed_by_sto", "converted_to_work_order", "completed"]),
|
||||
)
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if appointment is None:
|
||||
raise HTTPException(status_code=403, detail="Vehicle access is not confirmed by owner")
|
||||
|
||||
|
||||
def vehicle_catalog_suggestions(vehicle: Car | None) -> tuple[list[WorkOrderCatalogSuggestion], list[str]]:
|
||||
if vehicle is None:
|
||||
return [], []
|
||||
suggestions: list[WorkOrderCatalogSuggestion] = []
|
||||
missing: list[str] = []
|
||||
if vehicle.engine_oil_type:
|
||||
suggestions.append(
|
||||
WorkOrderCatalogSuggestion(
|
||||
title=f"Моторное масло {vehicle.engine_oil_type}",
|
||||
category="engine_oil",
|
||||
product_type="fluid",
|
||||
unit="l",
|
||||
default_quantity=vehicle.engine_oil_volume_l or 1,
|
||||
volume=vehicle.engine_oil_volume_l,
|
||||
specification=vehicle.engine_oil_type,
|
||||
metadata_json={"source_field": "engine_oil_type", "vehicle_id": vehicle.id},
|
||||
)
|
||||
)
|
||||
else:
|
||||
missing.append("engine_oil")
|
||||
if vehicle.transmission_fluid_type:
|
||||
suggestions.append(
|
||||
WorkOrderCatalogSuggestion(
|
||||
title=f"Трансмиссионная жидкость {vehicle.transmission_fluid_type}",
|
||||
category="transmission_fluid",
|
||||
product_type="fluid",
|
||||
unit="l",
|
||||
default_quantity=vehicle.transmission_fluid_volume_l or 1,
|
||||
volume=vehicle.transmission_fluid_volume_l,
|
||||
specification=vehicle.transmission_fluid_type,
|
||||
metadata_json={"source_field": "transmission_fluid_type", "vehicle_id": vehicle.id},
|
||||
)
|
||||
)
|
||||
else:
|
||||
missing.append("transmission_fluid")
|
||||
if vehicle.coolant_type:
|
||||
suggestions.append(
|
||||
WorkOrderCatalogSuggestion(
|
||||
title=f"Антифриз {vehicle.coolant_type}",
|
||||
category="coolant",
|
||||
product_type="fluid",
|
||||
unit="l",
|
||||
specification=vehicle.coolant_type,
|
||||
metadata_json={"source_field": "coolant_type", "vehicle_id": vehicle.id},
|
||||
)
|
||||
)
|
||||
else:
|
||||
missing.append("coolant")
|
||||
if vehicle.brake_fluid_type:
|
||||
suggestions.append(
|
||||
WorkOrderCatalogSuggestion(
|
||||
title=f"Тормозная жидкость {vehicle.brake_fluid_type}",
|
||||
category="brake_fluid",
|
||||
product_type="fluid",
|
||||
unit="l",
|
||||
specification=vehicle.brake_fluid_type,
|
||||
metadata_json={"source_field": "brake_fluid_type", "vehicle_id": vehicle.id},
|
||||
)
|
||||
)
|
||||
else:
|
||||
missing.append("brake_fluid")
|
||||
return suggestions, missing
|
||||
|
||||
|
||||
async def load_catalog(
|
||||
session: AsyncSession,
|
||||
*,
|
||||
service_center_id: int | None = None,
|
||||
vehicle_id: int | None = None,
|
||||
item_type: str | None = None,
|
||||
) -> WorkOrderCatalogRead:
|
||||
stmt = select(WorkOrderCatalogItem).where(WorkOrderCatalogItem.is_active.is_(True))
|
||||
if service_center_id is not None:
|
||||
stmt = stmt.where(
|
||||
or_(
|
||||
WorkOrderCatalogItem.service_center_id.is_(None),
|
||||
WorkOrderCatalogItem.service_center_id == service_center_id,
|
||||
)
|
||||
)
|
||||
else:
|
||||
stmt = stmt.where(WorkOrderCatalogItem.service_center_id.is_(None))
|
||||
if item_type:
|
||||
stmt = stmt.where(WorkOrderCatalogItem.item_type == item_type)
|
||||
stmt = stmt.order_by(WorkOrderCatalogItem.service_center_id.is_(None), WorkOrderCatalogItem.title.asc())
|
||||
items = list((await session.execute(stmt)).scalars())
|
||||
vehicle = await session.get(Car, vehicle_id) if vehicle_id else None
|
||||
suggestions, missing = vehicle_catalog_suggestions(vehicle)
|
||||
if item_type:
|
||||
suggestions = [item for item in suggestions if item.item_type == item_type]
|
||||
return WorkOrderCatalogRead(items=items, vehicle_suggestions=suggestions, missing_vehicle_fields=missing)
|
||||
|
||||
|
||||
@router.get("/catalog", response_model=WorkOrderCatalogRead)
|
||||
async def list_work_order_catalog(
|
||||
service_center_id: int | None = None,
|
||||
vehicle_id: int | None = None,
|
||||
item_type: str | None = Query(default=None, pattern="^(work|product)$"),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> WorkOrderCatalogRead:
|
||||
if service_center_id is not None:
|
||||
await ensure_service_employee(session, service_center_id, current_user, {"owner", "manager", "receptionist", "mechanic"})
|
||||
if service_center_id is not None and vehicle_id is not None:
|
||||
await ensure_center_vehicle_scope(session, service_center_id, vehicle_id)
|
||||
return await load_catalog(session, service_center_id=service_center_id, vehicle_id=vehicle_id, item_type=item_type)
|
||||
|
||||
|
||||
@router.post("/catalog", response_model=WorkOrderCatalogItemRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_work_order_catalog_item(
|
||||
payload: WorkOrderCatalogItemCreate,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> WorkOrderCatalogItem:
|
||||
if payload.service_center_id is None:
|
||||
raise HTTPException(status_code=400, detail="Service center catalog item must have service_center_id")
|
||||
await ensure_service_employee(session, payload.service_center_id, current_user, {"owner", "manager"})
|
||||
item = WorkOrderCatalogItem(**payload.model_dump())
|
||||
session.add(item)
|
||||
await log_audit(session, actor=current_user, action="work_order_catalog.create", target_type="service_center", target_id=payload.service_center_id)
|
||||
await session.commit()
|
||||
await session.refresh(item)
|
||||
return item
|
||||
|
||||
|
||||
@router.get("/{work_order_id}", response_model=ServiceVisitRead)
|
||||
async def get_work_order_detail(
|
||||
work_order_id: int,
|
||||
@@ -109,6 +297,31 @@ async def get_work_order_detail(
|
||||
return visit
|
||||
|
||||
|
||||
@router.get("/{work_order_id}/detail", response_model=WorkOrderDetailRead)
|
||||
async def get_work_order_rich_detail(
|
||||
work_order_id: int,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> WorkOrderDetailRead:
|
||||
visit = await get_work_order_with_items(session, work_order_id)
|
||||
vehicle = await session.get(Car, visit.vehicle_id)
|
||||
if vehicle is None:
|
||||
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||
if vehicle.owner_id != current_user.id:
|
||||
await ensure_work_order_sto_access(session, visit, current_user)
|
||||
center = await session.get(ServiceCenter, visit.service_center_id)
|
||||
if center is None:
|
||||
raise HTTPException(status_code=404, detail="Service center not found")
|
||||
return WorkOrderDetailRead(
|
||||
visit=visit,
|
||||
vehicle=vehicle,
|
||||
service_center=center,
|
||||
work_items=list(visit.work_items),
|
||||
product_items=list(visit.product_items),
|
||||
catalog=await load_catalog(session, service_center_id=visit.service_center_id, vehicle_id=visit.vehicle_id),
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/{work_order_id}", response_model=ServiceVisitRead)
|
||||
async def update_work_order(
|
||||
work_order_id: int,
|
||||
@@ -183,8 +396,10 @@ async def submit_work_order_for_approval(
|
||||
service_center_id=visit.service_center_id,
|
||||
notification_type="work_order.waiting_owner_approval",
|
||||
title="Заказ-наряд ожидает согласования",
|
||||
body=f"{visit.work_order_number}: {visit.final_total} {visit.currency}",
|
||||
body=f"{visit.work_order_number}: {visit.final_total} {visit.currency}. Откройте детали, чтобы согласовать или отклонить смету.",
|
||||
idempotency_key=f"work_order:{visit.id}:waiting_owner_approval:{visit.final_total}",
|
||||
web_app_url=work_order_webapp_url(visit.id),
|
||||
button_text="Согласовать заказ-наряд",
|
||||
)
|
||||
await log_audit(session, actor=current_user, action="work_order.submit_approval", target_type="service_visit", target_id=visit.id)
|
||||
await session.commit()
|
||||
@@ -284,6 +499,44 @@ async def complete_work_order(
|
||||
return visit
|
||||
|
||||
|
||||
@router.post("/{work_order_id}/request-vehicle-profile", response_model=ServiceVisitRead)
|
||||
async def request_vehicle_profile_details(
|
||||
work_order_id: int,
|
||||
payload: VehicleProfileRequest,
|
||||
session: AsyncSession = Depends(get_session),
|
||||
current_user: User = Depends(get_current_telegram_user),
|
||||
) -> ServiceVisit:
|
||||
visit = await get_work_order(session, work_order_id)
|
||||
await ensure_work_order_sto_access(session, visit, current_user, {"owner", "manager", "receptionist", "mechanic"})
|
||||
vehicle = await session.get(Car, visit.vehicle_id)
|
||||
if vehicle is None:
|
||||
raise HTTPException(status_code=404, detail="Vehicle not found")
|
||||
missing = payload.missing_fields or vehicle_catalog_suggestions(vehicle)[1]
|
||||
missing_text = ", ".join(missing) if missing else "масло и технические жидкости"
|
||||
await create_service_notification(
|
||||
session,
|
||||
recipient_user_id=vehicle.owner_id,
|
||||
service_center_id=visit.service_center_id,
|
||||
notification_type="vehicle_profile.requested_by_sto",
|
||||
title="СТО просит заполнить карточку авто",
|
||||
body=payload.comment or f"Для точного подбора материалов укажите данные: {missing_text}.",
|
||||
idempotency_key=f"work_order:{visit.id}:vehicle_profile_request:{','.join(sorted(missing))}",
|
||||
web_app_url=vehicle_profile_webapp_url(vehicle.id),
|
||||
button_text="Заполнить карточку авто",
|
||||
)
|
||||
await log_audit(
|
||||
session,
|
||||
actor=current_user,
|
||||
action="work_order.vehicle_profile.request",
|
||||
target_type="service_visit",
|
||||
target_id=visit.id,
|
||||
metadata={"missing_fields": missing},
|
||||
)
|
||||
await session.commit()
|
||||
await session.refresh(visit)
|
||||
return visit
|
||||
|
||||
|
||||
@router.post("/{work_order_id}/corrections", response_model=WorkOrderCorrectionRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_work_order_correction(
|
||||
work_order_id: int,
|
||||
|
||||
@@ -172,6 +172,7 @@ class ServiceCenter(Base):
|
||||
)
|
||||
holidays = relationship("ServiceCenterHoliday", back_populates="service_center", cascade="all, delete-orphan")
|
||||
appointments = relationship("ServiceAppointment", back_populates="service_center", cascade="all, delete-orphan")
|
||||
catalog_items = relationship("WorkOrderCatalogItem", back_populates="service_center", cascade="all, delete-orphan")
|
||||
|
||||
|
||||
class CarServiceLink(Base):
|
||||
@@ -531,6 +532,35 @@ class InventoryTransaction(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
|
||||
|
||||
class WorkOrderCatalogItem(Base):
|
||||
__tablename__ = "work_order_catalog_items"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
service_center_id: Mapped[int | None] = mapped_column(ForeignKey("service_centers.id", ondelete="CASCADE"), index=True)
|
||||
item_type: Mapped[str] = mapped_column(String(24), index=True)
|
||||
title: Mapped[str] = mapped_column(String(180), index=True)
|
||||
category: Mapped[str | None] = mapped_column(String(80), index=True)
|
||||
description: Mapped[str | None] = mapped_column(Text)
|
||||
work_type: Mapped[str | None] = mapped_column(String(40), index=True)
|
||||
product_type: Mapped[str | None] = mapped_column(String(40), index=True)
|
||||
brand: Mapped[str | None] = mapped_column(String(80))
|
||||
sku: Mapped[str | None] = mapped_column(String(120), index=True)
|
||||
unit: Mapped[str] = mapped_column(String(24), default="pcs", server_default="pcs")
|
||||
default_quantity: Mapped[Decimal] = mapped_column(Numeric(10, 3), default=1, server_default="1")
|
||||
default_unit_price: Mapped[Decimal] = mapped_column(Numeric(12, 2), default=0, server_default="0")
|
||||
volume: Mapped[Decimal | None] = mapped_column(Numeric(8, 3))
|
||||
viscosity: Mapped[str | None] = mapped_column(String(40))
|
||||
specification: Mapped[str | None] = mapped_column(String(120))
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON)
|
||||
is_active: Mapped[bool] = mapped_column(Boolean, default=True, server_default="true", index=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), index=True)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
||||
)
|
||||
|
||||
service_center = relationship("ServiceCenter", back_populates="catalog_items")
|
||||
|
||||
|
||||
class ServiceCenterReview(Base):
|
||||
__tablename__ = "service_center_reviews"
|
||||
__table_args__ = (UniqueConstraint("service_center_id", "user_id", name="uq_service_review_user"),)
|
||||
|
||||
@@ -328,6 +328,68 @@ class ServiceVisitRead(ServiceVisitCreate):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class WorkOrderCatalogItemCreate(BaseModel):
|
||||
service_center_id: int | None = None
|
||||
item_type: str = Field(pattern="^(work|product)$")
|
||||
title: str = Field(min_length=2, max_length=180)
|
||||
category: str | None = None
|
||||
description: str | None = None
|
||||
work_type: str | None = None
|
||||
product_type: str | None = None
|
||||
brand: str | None = None
|
||||
sku: str | None = None
|
||||
unit: str = "pcs"
|
||||
default_quantity: Decimal = Decimal("1")
|
||||
default_unit_price: Decimal = Decimal("0")
|
||||
volume: Decimal | None = None
|
||||
viscosity: str | None = None
|
||||
specification: str | None = None
|
||||
metadata_json: dict | None = None
|
||||
is_active: bool = True
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_catalog_item(self) -> "WorkOrderCatalogItemCreate":
|
||||
if self.default_quantity <= 0:
|
||||
raise ValueError("default_quantity must be positive")
|
||||
if self.default_unit_price < 0:
|
||||
raise ValueError("default_unit_price must be non-negative")
|
||||
return self
|
||||
|
||||
|
||||
class WorkOrderCatalogItemRead(WorkOrderCatalogItemCreate):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class WorkOrderCatalogSuggestion(BaseModel):
|
||||
source: str = "vehicle_profile"
|
||||
item_type: str = "product"
|
||||
title: str
|
||||
category: str | None = None
|
||||
product_type: str | None = None
|
||||
unit: str = "pcs"
|
||||
default_quantity: Decimal = Decimal("1")
|
||||
default_unit_price: Decimal = Decimal("0")
|
||||
volume: Decimal | None = None
|
||||
viscosity: str | None = None
|
||||
specification: str | None = None
|
||||
metadata_json: dict | None = None
|
||||
|
||||
|
||||
class WorkOrderCatalogRead(BaseModel):
|
||||
items: list[WorkOrderCatalogItemRead]
|
||||
vehicle_suggestions: list[WorkOrderCatalogSuggestion] = []
|
||||
missing_vehicle_fields: list[str] = []
|
||||
|
||||
|
||||
class VehicleProfileRequest(BaseModel):
|
||||
missing_fields: list[str] | None = None
|
||||
comment: str | None = None
|
||||
|
||||
|
||||
class ServiceWorkItemCreate(BaseModel):
|
||||
work_type: str = "other"
|
||||
title: str
|
||||
@@ -402,6 +464,15 @@ class ServiceProductItemRead(ServiceProductItemCreate):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
|
||||
class WorkOrderDetailRead(BaseModel):
|
||||
visit: ServiceVisitRead
|
||||
vehicle: VehicleRead
|
||||
service_center: ServiceCenterPublicRead
|
||||
work_items: list[ServiceWorkItemRead] = []
|
||||
product_items: list[ServiceProductItemRead] = []
|
||||
catalog: WorkOrderCatalogRead
|
||||
|
||||
|
||||
class WorkOrderUpdate(BaseModel):
|
||||
odometer: int | None = None
|
||||
assigned_employee_id: int | None = None
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import json
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import httpx
|
||||
@@ -11,14 +12,26 @@ from app.models.user import User
|
||||
MODERATOR_ROLES = {"admin", "verifier", "moderator"}
|
||||
|
||||
|
||||
async def notify_user(user: User, text: str) -> bool:
|
||||
async def notify_user(
|
||||
user: User,
|
||||
text: str,
|
||||
*,
|
||||
web_app_url: str | None = None,
|
||||
button_text: str = "Открыть",
|
||||
) -> bool:
|
||||
if not settings.bot_token or settings.app_env == "test":
|
||||
return False
|
||||
data: dict[str, str] = {"chat_id": str(user.telegram_id), "text": text}
|
||||
if web_app_url:
|
||||
data["reply_markup"] = json.dumps(
|
||||
{"inline_keyboard": [[{"text": button_text, "web_app": {"url": web_app_url}}]]},
|
||||
ensure_ascii=False,
|
||||
)
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=5) as client:
|
||||
response = await client.post(
|
||||
f"https://api.telegram.org/bot{settings.bot_token}/sendMessage",
|
||||
data={"chat_id": str(user.telegram_id), "text": text},
|
||||
data=data,
|
||||
)
|
||||
return response.status_code < 400
|
||||
except Exception:
|
||||
|
||||
@@ -191,6 +191,8 @@ async def create_service_notification(
|
||||
appointment_id: int | None = None,
|
||||
send_telegram: bool = True,
|
||||
idempotency_key: str | None = None,
|
||||
web_app_url: str | None = None,
|
||||
button_text: str = "Открыть",
|
||||
) -> ServiceNotification:
|
||||
if idempotency_key:
|
||||
existing = (
|
||||
@@ -214,7 +216,8 @@ async def create_service_notification(
|
||||
user = await session.get(User, recipient_user_id)
|
||||
if user is not None:
|
||||
notification.status = "processing"
|
||||
delivered = await notify_user(user, f"{title}\n{body}" if body else title)
|
||||
message = f"{title}\n{body}" if body else title
|
||||
delivered = await notify_user(user, message, web_app_url=web_app_url, button_text=button_text)
|
||||
if delivered:
|
||||
notification.status = "sent"
|
||||
notification.sent_at = datetime.now(UTC)
|
||||
|
||||
@@ -7,6 +7,7 @@ from fastapi import HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.car import (
|
||||
Car,
|
||||
InventoryTransaction,
|
||||
@@ -37,6 +38,10 @@ WORK_ORDER_STATUSES = {
|
||||
LOCKED_WORK_ORDER_STATUSES = {"completed", "cancelled", "archived"}
|
||||
|
||||
|
||||
def work_order_webapp_url(work_order_id: int) -> str:
|
||||
return f"{settings.effective_webapp_url.rstrip('/')}/work_order.html?id={work_order_id}"
|
||||
|
||||
|
||||
def money(value: Decimal | int | float | None) -> Decimal:
|
||||
return Decimal(str(value or 0)).quantize(Decimal("0.01"))
|
||||
|
||||
@@ -311,5 +316,7 @@ async def close_work_order(
|
||||
title="Работа по заказ-наряду завершена",
|
||||
body=f"{visit.work_order_number or visit.id}: {visit.final_total} {visit.currency}. Можно оставить отзыв.",
|
||||
idempotency_key=f"work_order:{visit.id}:completed",
|
||||
web_app_url=work_order_webapp_url(visit.id),
|
||||
button_text="Открыть заказ-наряд",
|
||||
)
|
||||
return service, expense
|
||||
|
||||
Reference in New Issue
Block a user