Согласование
+Заказ-наряд
+СТО
+Загружаю...
+ Проверяю доступ +Состав
+Работы
+Материалы
+Запчасти и жидкости
+Решение владельца
+Проверьте смету
+Если все понятно, согласуйте заказ-наряд. После согласования СТО сможет завершить работы.
+diff --git a/alembic/versions/202605160001_work_order_catalog.py b/alembic/versions/202605160001_work_order_catalog.py new file mode 100644 index 0000000..4e29986 --- /dev/null +++ b/alembic/versions/202605160001_work_order_catalog.py @@ -0,0 +1,128 @@ +"""work order catalog items + +Revision ID: 202605160001 +Revises: 202605150004 +Create Date: 2026-05-16 12:00:00.000000 +""" + +from collections.abc import Sequence + +import sqlalchemy as sa + +from alembic import op + +revision: str = "202605160001" +down_revision: str | None = "202605150004" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + catalog = op.create_table( + "work_order_catalog_items", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("service_center_id", sa.Integer(), nullable=True), + sa.Column("item_type", sa.String(length=24), nullable=False), + sa.Column("title", sa.String(length=180), nullable=False), + sa.Column("category", sa.String(length=80), nullable=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("work_type", sa.String(length=40), nullable=True), + sa.Column("product_type", sa.String(length=40), nullable=True), + sa.Column("brand", sa.String(length=80), nullable=True), + sa.Column("sku", sa.String(length=120), nullable=True), + sa.Column("unit", sa.String(length=24), server_default="pcs", nullable=False), + sa.Column("default_quantity", sa.Numeric(10, 3), server_default="1", nullable=False), + sa.Column("default_unit_price", sa.Numeric(12, 2), server_default="0", nullable=False), + sa.Column("volume", sa.Numeric(8, 3), nullable=True), + sa.Column("viscosity", sa.String(length=40), nullable=True), + sa.Column("specification", sa.String(length=120), nullable=True), + sa.Column("metadata_json", sa.JSON(), nullable=True), + sa.Column("is_active", sa.Boolean(), server_default=sa.text("true"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False), + sa.ForeignKeyConstraint(["service_center_id"], ["service_centers.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_work_order_catalog_items_category", "work_order_catalog_items", ["category"]) + op.create_index("ix_work_order_catalog_items_created_at", "work_order_catalog_items", ["created_at"]) + op.create_index("ix_work_order_catalog_items_is_active", "work_order_catalog_items", ["is_active"]) + op.create_index("ix_work_order_catalog_items_item_type", "work_order_catalog_items", ["item_type"]) + op.create_index("ix_work_order_catalog_items_product_type", "work_order_catalog_items", ["product_type"]) + op.create_index("ix_work_order_catalog_items_service_center_id", "work_order_catalog_items", ["service_center_id"]) + op.create_index("ix_work_order_catalog_items_sku", "work_order_catalog_items", ["sku"]) + op.create_index("ix_work_order_catalog_items_title", "work_order_catalog_items", ["title"]) + op.create_index("ix_work_order_catalog_items_work_type", "work_order_catalog_items", ["work_type"]) + + op.bulk_insert( + catalog, + [ + { + "service_center_id": None, + "item_type": "work", + "title": "Замена моторного масла", + "category": "maintenance", + "work_type": "maintenance", + "unit": "job", + "default_quantity": 1, + "default_unit_price": 0, + "metadata_json": {"source": "system_seed"}, + }, + { + "service_center_id": None, + "item_type": "work", + "title": "Компьютерная диагностика", + "category": "diagnostics", + "work_type": "diagnostics", + "unit": "job", + "default_quantity": 1, + "default_unit_price": 0, + "metadata_json": {"source": "system_seed"}, + }, + { + "service_center_id": None, + "item_type": "work", + "title": "Замена тормозных колодок", + "category": "brakes", + "work_type": "repair", + "unit": "job", + "default_quantity": 1, + "default_unit_price": 0, + "metadata_json": {"source": "system_seed"}, + }, + { + "service_center_id": None, + "item_type": "product", + "title": "Масляный фильтр", + "category": "filter", + "product_type": "part", + "unit": "pcs", + "default_quantity": 1, + "default_unit_price": 0, + "metadata_json": {"source": "system_seed"}, + }, + { + "service_center_id": None, + "item_type": "product", + "title": "Тормозная жидкость", + "category": "brake_fluid", + "product_type": "fluid", + "unit": "l", + "default_quantity": 1, + "default_unit_price": 0, + "metadata_json": {"source": "system_seed"}, + }, + ], + ) + + +def downgrade() -> None: + op.drop_index("ix_work_order_catalog_items_work_type", table_name="work_order_catalog_items") + op.drop_index("ix_work_order_catalog_items_title", table_name="work_order_catalog_items") + op.drop_index("ix_work_order_catalog_items_sku", table_name="work_order_catalog_items") + op.drop_index("ix_work_order_catalog_items_service_center_id", table_name="work_order_catalog_items") + op.drop_index("ix_work_order_catalog_items_product_type", table_name="work_order_catalog_items") + op.drop_index("ix_work_order_catalog_items_item_type", table_name="work_order_catalog_items") + op.drop_index("ix_work_order_catalog_items_is_active", table_name="work_order_catalog_items") + op.drop_index("ix_work_order_catalog_items_created_at", table_name="work_order_catalog_items") + op.drop_index("ix_work_order_catalog_items_category", table_name="work_order_catalog_items") + op.drop_table("work_order_catalog_items") diff --git a/app/api/work_orders.py b/app/api/work_orders.py index afd9984..d62146f 100644 --- a/app/api/work_orders.py +++ b/app/api/work_orders.py @@ -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, diff --git a/app/models/car.py b/app/models/car.py index 10d5bf0..b4098d2 100644 --- a/app/models/car.py +++ b/app/models/car.py @@ -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"),) diff --git a/app/schemas/service_center.py b/app/schemas/service_center.py index f36afdb..cd411bf 100644 --- a/app/schemas/service_center.py +++ b/app/schemas/service_center.py @@ -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 diff --git a/app/services/notifications.py b/app/services/notifications.py index f483fa3..51e3180 100644 --- a/app/services/notifications.py +++ b/app/services/notifications.py @@ -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: diff --git a/app/services/sto_booking.py b/app/services/sto_booking.py index bdd9941..0397156 100644 --- a/app/services/sto_booking.py +++ b/app/services/sto_booking.py @@ -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) diff --git a/app/services/work_orders.py b/app/services/work_orders.py index 962bce7..074dae5 100644 --- a/app/services/work_orders.py +++ b/app/services/work_orders.py @@ -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 diff --git a/web/static/app.js b/web/static/app.js index 2706c8f..7b67ba1 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -2280,6 +2280,22 @@ async function loadCars() { } } +async function applyInitialRoute() { + const params = new URLSearchParams(window.location.search); + const section = params.get("section"); + const carId = Number(params.get("car_id") || 0); + if (carId && state.cars.some((car) => car.id === carId)) { + state.selectedCarId = carId; + renderCars(); + fillCarProfileForm(); + await loadSelectedCar(); + } + if (section === "carProfile") { + await openDrawerSection("carProfileSection"); + window.history.replaceState({}, "", window.location.pathname); + } +} + async function selectCar(carId) { state.selectedCarId = carId; renderCars(); @@ -2841,7 +2857,8 @@ Promise.all([loadAuthConfig()]) initCarCatalog(); return Promise.all([loadMyServiceCenters().catch(() => []), loadCars()]); }) + .then(() => applyInitialRoute()) .catch((error) => { if (error.message === "Требуется вход через Telegram") return; document.body.insertAdjacentHTML("afterbegin", `
Согласование
+СТО
+Состав
+Материалы
+Решение владельца
+Если все понятно, согласуйте заказ-наряд. После согласования СТО сможет завершить работы.
+