This commit is contained in:
128
alembic/versions/202605160001_work_order_catalog.py
Normal file
128
alembic/versions/202605160001_work_order_catalog.py
Normal file
@@ -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")
|
||||
@@ -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
|
||||
|
||||
@@ -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,6 +2857,7 @@ Promise.all([loadAuthConfig()])
|
||||
initCarCatalog();
|
||||
return Promise.all([loadMyServiceCenters().catch(() => []), loadCars()]);
|
||||
})
|
||||
.then(() => applyInitialRoute())
|
||||
.catch((error) => {
|
||||
if (error.message === "Требуется вход через Telegram") return;
|
||||
document.body.insertAdjacentHTML("afterbegin", `<div class="error">${error.message}</div>`);
|
||||
|
||||
@@ -13,6 +13,8 @@ const state = {
|
||||
appointments: [],
|
||||
workOrders: [],
|
||||
employees: [],
|
||||
catalogsByVehicleId: {},
|
||||
catalogLookup: {},
|
||||
};
|
||||
|
||||
function authHeaders(extra = {}) {
|
||||
@@ -184,12 +186,57 @@ async function loadWorkplace() {
|
||||
state.appointments = appointments.filter((item) => ["requested", "confirmed", "confirmed_by_sto", "proposed_new_time"].includes(item.status));
|
||||
state.workOrders = visits.filter((item) => !["completed", "cancelled", "archived", "confirmed", "disputed"].includes(item.status));
|
||||
state.employees = employees;
|
||||
await loadWorkOrderCatalogs(center.id);
|
||||
renderDashboard(dashboard);
|
||||
renderAppointments();
|
||||
renderWorkOrders();
|
||||
renderStaff();
|
||||
}
|
||||
|
||||
async function loadWorkOrderCatalogs(serviceCenterId) {
|
||||
state.catalogsByVehicleId = {};
|
||||
state.catalogLookup = {};
|
||||
const vehicleIds = [...new Set(state.workOrders.map((item) => item.vehicle_id).filter(Boolean))];
|
||||
await Promise.all(vehicleIds.map(async (vehicleId) => {
|
||||
try {
|
||||
state.catalogsByVehicleId[vehicleId] = await api(`/work-orders/catalog?service_center_id=${serviceCenterId}&vehicle_id=${vehicleId}`);
|
||||
} catch (_) {
|
||||
state.catalogsByVehicleId[vehicleId] = { items: [], vehicle_suggestions: [], missing_vehicle_fields: [] };
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function catalogForWorkOrder(workOrder) {
|
||||
return state.catalogsByVehicleId[workOrder.vehicle_id] || { items: [], vehicle_suggestions: [], missing_vehicle_fields: [] };
|
||||
}
|
||||
|
||||
function registerCatalogOption(item) {
|
||||
const key = `${item.source || "catalog"}:${item.id || Object.keys(state.catalogLookup).length}:${item.item_type}:${item.title}`;
|
||||
state.catalogLookup[key] = item;
|
||||
return key;
|
||||
}
|
||||
|
||||
function catalogOptions(workOrder, itemType) {
|
||||
const catalog = catalogForWorkOrder(workOrder);
|
||||
const catalogItems = (catalog.items || []).filter((item) => item.item_type === itemType);
|
||||
const suggestions = itemType === "product" ? (catalog.vehicle_suggestions || []) : [];
|
||||
return [...catalogItems, ...suggestions].map((item) => {
|
||||
const key = registerCatalogOption(item);
|
||||
const meta = [item.category, item.specification || item.sku].filter(Boolean).join(" · ");
|
||||
return `<option value="${escapeHtml(key)}">${escapeHtml(item.title)}${meta ? ` · ${escapeHtml(meta)}` : ""}</option>`;
|
||||
}).join("");
|
||||
}
|
||||
|
||||
function missingVehicleFieldsText(fields) {
|
||||
const labels = {
|
||||
engine_oil: "моторное масло",
|
||||
transmission_fluid: "трансмиссионная жидкость",
|
||||
coolant: "антифриз",
|
||||
brake_fluid: "тормозная жидкость",
|
||||
};
|
||||
return fields.map((field) => labels[field] || field).join(", ");
|
||||
}
|
||||
|
||||
function renderDashboard(dashboard) {
|
||||
document.querySelector("#dashboardStats").innerHTML = dashboard
|
||||
? `
|
||||
@@ -223,8 +270,12 @@ function renderAppointments() {
|
||||
function renderWorkOrders() {
|
||||
const role = activeCenter()?.employee_role || "owner";
|
||||
const canComplete = role === "owner";
|
||||
state.catalogLookup = {};
|
||||
document.querySelector("#workOrdersList").innerHTML = state.workOrders.length
|
||||
? state.workOrders.map((item) => `
|
||||
? state.workOrders.map((item) => {
|
||||
const catalog = catalogForWorkOrder(item);
|
||||
const missingFields = catalog.missing_vehicle_fields || [];
|
||||
return `
|
||||
<div class="stack-item work-order-card">
|
||||
<div class="work-order-head">
|
||||
<div>
|
||||
@@ -240,16 +291,39 @@ function renderWorkOrders() {
|
||||
<span>Запчасти: <strong>${money(item.product_total || 0)}</strong></span>
|
||||
<span>Итого: <strong>${money(item.final_total || item.total_cost || 0)}</strong></span>
|
||||
</div>
|
||||
<form class="inline-work-form" data-labor-form="${item.id}">
|
||||
${missingFields.length ? `<div class="tip-card compact-tip">
|
||||
Для точного подбора не хватает: ${escapeHtml(missingVehicleFieldsText(missingFields))}.
|
||||
<button type="button" class="ghost-btn" data-request-vehicle-profile="${item.id}" data-missing-fields="${escapeHtml(missingFields.join(","))}">Попросить заполнить</button>
|
||||
</div>` : ""}
|
||||
<form class="inline-work-form catalog-work-form" data-labor-form="${item.id}">
|
||||
<select name="catalog_item" data-catalog-select>
|
||||
<option value="">Работа из каталога</option>
|
||||
${catalogOptions(item, "work")}
|
||||
</select>
|
||||
<input name="title" placeholder="Работа" required />
|
||||
<input name="quantity" type="number" min="0.001" step="0.001" value="1" aria-label="Количество" />
|
||||
<input name="unit_price" type="number" min="0" step="0.01" placeholder="Цена" required />
|
||||
<input name="unit" type="hidden" value="job" />
|
||||
<input name="work_type" type="hidden" value="repair" />
|
||||
<input name="category" type="hidden" />
|
||||
<button type="submit">+ Работа</button>
|
||||
</form>
|
||||
<form class="inline-work-form" data-product-form="${item.id}">
|
||||
<form class="inline-work-form catalog-work-form" data-product-form="${item.id}">
|
||||
<select name="catalog_item" data-catalog-select>
|
||||
<option value="">Материал из каталога</option>
|
||||
${catalogOptions(item, "product")}
|
||||
</select>
|
||||
<input name="title" placeholder="Запчасть / материал" required />
|
||||
<input name="quantity" type="number" min="0.001" step="0.001" value="1" aria-label="Количество" />
|
||||
<input name="unit_price" type="number" min="0" step="0.01" placeholder="Цена" required />
|
||||
<input name="unit" type="hidden" value="pcs" />
|
||||
<input name="product_type" type="hidden" value="part" />
|
||||
<input name="category" type="hidden" />
|
||||
<input name="brand" type="hidden" />
|
||||
<input name="sku" type="hidden" />
|
||||
<input name="viscosity" type="hidden" />
|
||||
<input name="specification" type="hidden" />
|
||||
<input name="volume" type="hidden" />
|
||||
<button type="submit">+ Материал</button>
|
||||
</form>
|
||||
<div class="row-actions">
|
||||
@@ -258,7 +332,8 @@ function renderWorkOrders() {
|
||||
${canComplete ? `<button type="button" class="ghost-btn" data-complete-work-order="${item.id}">Завершить</button>` : ""}
|
||||
</div>
|
||||
</div>
|
||||
`).join("")
|
||||
`;
|
||||
}).join("")
|
||||
: `<div class="empty">Активных заказ-нарядов нет</div>`;
|
||||
}
|
||||
|
||||
@@ -331,7 +406,6 @@ document.querySelector("#inviteForm").addEventListener("submit", async (event) =
|
||||
document.body.addEventListener("click", async (event) => {
|
||||
const button = event.target.closest("button");
|
||||
if (!button) return;
|
||||
const center = activeCenter();
|
||||
if (button.dataset.confirmAppointment) {
|
||||
await runAction(button, () => api(`/sto/appointments/${button.dataset.confirmAppointment}/confirm`, {
|
||||
method: "POST",
|
||||
@@ -369,6 +443,13 @@ document.body.addEventListener("click", async (event) => {
|
||||
body: JSON.stringify({ comment: "Работы завершены" }),
|
||||
}));
|
||||
}
|
||||
if (button.dataset.requestVehicleProfile) {
|
||||
const missingFields = (button.dataset.missingFields || "").split(",").filter(Boolean);
|
||||
await runAction(button, () => api(`/work-orders/${button.dataset.requestVehicleProfile}/request-vehicle-profile`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ missing_fields: missingFields }),
|
||||
}));
|
||||
}
|
||||
if (button.dataset.roleEmployee) {
|
||||
await runAction(button, () => api(`/service-centers/employees/${button.dataset.roleEmployee}`, {
|
||||
method: "PATCH",
|
||||
@@ -383,6 +464,26 @@ document.body.addEventListener("click", async (event) => {
|
||||
}
|
||||
});
|
||||
|
||||
document.body.addEventListener("change", (event) => {
|
||||
const select = event.target.closest("[data-catalog-select]");
|
||||
if (!select || !select.value) return;
|
||||
const item = state.catalogLookup[select.value];
|
||||
if (!item) return;
|
||||
const form = select.closest("form");
|
||||
form.title.value = item.title || "";
|
||||
form.quantity.value = item.default_quantity || 1;
|
||||
form.unit_price.value = item.default_unit_price || 0;
|
||||
if (form.unit) form.unit.value = item.unit || form.unit.value;
|
||||
if (form.category) form.category.value = item.category || "";
|
||||
if (form.work_type) form.work_type.value = item.work_type || "repair";
|
||||
if (form.product_type) form.product_type.value = item.product_type || "part";
|
||||
if (form.brand) form.brand.value = item.brand || "";
|
||||
if (form.sku) form.sku.value = item.sku || "";
|
||||
if (form.viscosity) form.viscosity.value = item.viscosity || "";
|
||||
if (form.specification) form.specification.value = item.specification || "";
|
||||
if (form.volume) form.volume.value = item.volume || "";
|
||||
});
|
||||
|
||||
document.body.addEventListener("submit", async (event) => {
|
||||
const form = event.target;
|
||||
if (form.matches("[data-labor-form]")) {
|
||||
@@ -392,10 +493,11 @@ document.body.addEventListener("submit", async (event) => {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
title: data.title,
|
||||
category: data.category || null,
|
||||
quantity: Number(data.quantity || 1),
|
||||
unit: "job",
|
||||
unit: data.unit || "job",
|
||||
unit_price: Number(data.unit_price || 0),
|
||||
work_type: "repair",
|
||||
work_type: data.work_type || "repair",
|
||||
}),
|
||||
}));
|
||||
}
|
||||
@@ -406,10 +508,16 @@ document.body.addEventListener("submit", async (event) => {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
title: data.title,
|
||||
category: data.category || null,
|
||||
quantity: Number(data.quantity || 1),
|
||||
unit: "pcs",
|
||||
unit: data.unit || "pcs",
|
||||
unit_price: Number(data.unit_price || 0),
|
||||
product_type: "part",
|
||||
product_type: data.product_type || "part",
|
||||
brand: data.brand || null,
|
||||
sku: data.sku || null,
|
||||
viscosity: data.viscosity || null,
|
||||
specification: data.specification || null,
|
||||
volume: numberOrNull(data.volume),
|
||||
}),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -524,7 +524,8 @@ body {
|
||||
|
||||
button,
|
||||
input,
|
||||
select {
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
@@ -697,7 +698,8 @@ label {
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
border: 1px solid var(--line);
|
||||
@@ -712,12 +714,19 @@ select {
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 4px rgba(22, 128, 106, 0.12);
|
||||
}
|
||||
|
||||
textarea {
|
||||
min-height: 86px;
|
||||
padding: 10px 11px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
select:disabled {
|
||||
background: #f1f5f3;
|
||||
color: #9aa4a0;
|
||||
@@ -1725,6 +1734,74 @@ select {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.work-order-page {
|
||||
background:
|
||||
linear-gradient(180deg, #ffffff 0, #f3f7f5 240px),
|
||||
var(--bg);
|
||||
}
|
||||
|
||||
.work-order-shell {
|
||||
width: min(1120px, 100%);
|
||||
}
|
||||
|
||||
.work-order-hero {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.work-order-total {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
.work-order-total span {
|
||||
color: rgba(244, 251, 248, 0.68);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.work-order-total strong {
|
||||
color: #fff;
|
||||
font-size: clamp(24px, 4vw, 34px);
|
||||
}
|
||||
|
||||
.work-order-layout {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.work-order-decision {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.work-order-decision p {
|
||||
margin: 8px 0 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.single-total {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.catalog-work-form {
|
||||
grid-template-columns: minmax(160px, 1.1fr) minmax(0, 1.3fr) minmax(74px, 0.5fr) minmax(86px, 0.7fr) auto;
|
||||
}
|
||||
|
||||
.compact-tip {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.compact-tip button {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
@keyframes toastIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -1807,6 +1884,7 @@ select {
|
||||
}
|
||||
|
||||
.sto-grid,
|
||||
.work-order-layout,
|
||||
.staff-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -1822,6 +1900,10 @@ select {
|
||||
min-width: 70vw;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
.catalog-work-form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@@ -1926,6 +2008,10 @@ select {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.work-order-hero {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.sto-page .top-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 44px;
|
||||
|
||||
210
web/static/work_order.js
Normal file
210
web/static/work_order.js
Normal file
@@ -0,0 +1,210 @@
|
||||
const tg = window.Telegram?.WebApp;
|
||||
tg?.ready();
|
||||
tg?.expand();
|
||||
|
||||
const state = {
|
||||
user: null,
|
||||
authConfig: null,
|
||||
detail: null,
|
||||
};
|
||||
|
||||
function orderId() {
|
||||
return Number(new URLSearchParams(window.location.search).get("id") || 0);
|
||||
}
|
||||
|
||||
function authHeaders(extra = {}) {
|
||||
const headers = { ...extra };
|
||||
if (tg?.initData) headers["X-Telegram-Init-Data"] = tg.initData;
|
||||
if (!tg?.initData && state.authConfig?.allow_dev_auth) {
|
||||
headers["X-Dev-Telegram-Id"] = localStorage.getItem("driversDevTelegramId") || "1";
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
async function api(path, options = {}) {
|
||||
const { headers: optionHeaders = {}, ...fetchOptions } = options;
|
||||
const headers = { "Content-Type": "application/json", ...authHeaders(optionHeaders) };
|
||||
const response = await fetch(`/api${path}`, { ...fetchOptions, headers });
|
||||
if (!response.ok) throw new Error(await response.text() || response.statusText);
|
||||
if (response.status === 204) return null;
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function loadAuthConfig() {
|
||||
state.authConfig = await api("/users/auth/config");
|
||||
}
|
||||
|
||||
async function ensureUser() {
|
||||
if (tg?.initData) {
|
||||
state.user = await api("/users/webapp-auth", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ init_data: tg.initData }),
|
||||
});
|
||||
} else if (state.authConfig?.allow_dev_auth) {
|
||||
state.user = await api("/users/me");
|
||||
} else {
|
||||
showAuthOverlay();
|
||||
throw new Error("Требуется вход через Telegram");
|
||||
}
|
||||
document.body.classList.remove("auth-required");
|
||||
document.querySelector("#authOverlay")?.classList.add("hidden");
|
||||
}
|
||||
|
||||
function showAuthOverlay() {
|
||||
document.body.classList.add("auth-required");
|
||||
const botUsername = state.authConfig?.bot_username;
|
||||
const link = document.querySelector("#telegramLoginLink");
|
||||
if (botUsername && link) {
|
||||
link.href = `https://t.me/${botUsername}`;
|
||||
link.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function toast(message, tone = "success") {
|
||||
const node = document.querySelector("#toast");
|
||||
if (!node) return;
|
||||
node.textContent = message;
|
||||
node.className = `toast ${tone}`;
|
||||
window.clearTimeout(toast.timer);
|
||||
toast.timer = window.setTimeout(() => node.classList.add("hidden"), 2600);
|
||||
}
|
||||
|
||||
function money(value, currency = "RUB") {
|
||||
return Number(value || 0).toLocaleString("ru-RU", {
|
||||
style: "currency",
|
||||
currency,
|
||||
maximumFractionDigits: currency === "KRW" ? 0 : 2,
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function statusLabel(status) {
|
||||
return {
|
||||
draft: "Черновик",
|
||||
diagnosis: "Диагностика",
|
||||
waiting_owner_approval: "Ждет согласования",
|
||||
approved_by_owner: "Согласован",
|
||||
rejected_by_owner: "Отклонен",
|
||||
in_progress: "В работе",
|
||||
completed: "Завершен",
|
||||
cancelled: "Отменен",
|
||||
}[status] || status || "Без статуса";
|
||||
}
|
||||
|
||||
function itemTotal(item) {
|
||||
return item.total ?? item.price ?? Number(item.quantity || 0) * Number(item.unit_price || 0);
|
||||
}
|
||||
|
||||
function renderItems(rootSelector, items, emptyText) {
|
||||
const root = document.querySelector(rootSelector);
|
||||
root.innerHTML = items.length
|
||||
? items.map((item) => `
|
||||
<div class="stack-item work-order-card">
|
||||
<strong>${escapeHtml(item.title)}</strong>
|
||||
${item.description ? `<small>${escapeHtml(item.description)}</small>` : ""}
|
||||
<small>${Number(item.quantity || 1)} ${escapeHtml(item.unit || "шт")} × ${money(item.unit_price || item.price || 0, state.detail.visit.currency)}</small>
|
||||
<div class="work-order-totals single-total">
|
||||
<span>Сумма: <strong>${money(itemTotal(item), state.detail.visit.currency)}</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
`).join("")
|
||||
: `<div class="empty">${emptyText}</div>`;
|
||||
}
|
||||
|
||||
function renderDecision(detail) {
|
||||
const status = detail.visit.status;
|
||||
const isWaiting = status === "waiting_owner_approval";
|
||||
document.querySelector("#approveBtn").classList.toggle("hidden", !isWaiting);
|
||||
document.querySelector("#rejectBtn").classList.toggle("hidden", !isWaiting);
|
||||
document.querySelector("#ownerComment").disabled = !isWaiting;
|
||||
if (isWaiting) {
|
||||
document.querySelector("#decisionTitle").textContent = "Нужно ваше решение";
|
||||
document.querySelector("#decisionText").textContent = "Проверьте работы, материалы и итоговую сумму. Решение попадет в историю заказ-наряда.";
|
||||
return;
|
||||
}
|
||||
if (status === "approved_by_owner") {
|
||||
document.querySelector("#decisionTitle").textContent = "Заказ-наряд согласован";
|
||||
document.querySelector("#decisionText").textContent = "СТО может выполнять и закрывать работы по согласованной смете.";
|
||||
return;
|
||||
}
|
||||
if (status === "completed") {
|
||||
document.querySelector("#decisionTitle").textContent = "Работы завершены";
|
||||
document.querySelector("#decisionText").textContent = "Заказ-наряд сохранен в истории автомобиля.";
|
||||
return;
|
||||
}
|
||||
document.querySelector("#decisionTitle").textContent = "Решение сейчас не требуется";
|
||||
document.querySelector("#decisionText").textContent = "Когда СТО отправит смету на согласование, здесь появятся кнопки решения.";
|
||||
}
|
||||
|
||||
function renderProfileLink(detail) {
|
||||
const link = document.querySelector("#fillProfileLink");
|
||||
const missing = detail.catalog?.missing_vehicle_fields || [];
|
||||
if (!missing.length || detail.visit.status === "completed") {
|
||||
link.classList.add("hidden");
|
||||
return;
|
||||
}
|
||||
link.href = `/?section=carProfile&car_id=${detail.vehicle.id}`;
|
||||
link.classList.remove("hidden");
|
||||
}
|
||||
|
||||
async function loadDetail() {
|
||||
const id = orderId();
|
||||
if (!id) throw new Error("Не указан заказ-наряд");
|
||||
state.detail = await api(`/work-orders/${id}/detail`);
|
||||
const detail = state.detail;
|
||||
document.querySelector("#centerName").textContent = detail.service_center.display_name || detail.service_center.name;
|
||||
document.querySelector("#orderTitle").textContent = detail.visit.work_order_number || `Заказ-наряд #${detail.visit.id}`;
|
||||
document.querySelector("#vehicleMeta").textContent = [
|
||||
detail.vehicle.name,
|
||||
detail.vehicle.license_plate_display,
|
||||
detail.visit.odometer ? `${detail.visit.odometer} км` : "",
|
||||
].filter(Boolean).join(" · ");
|
||||
document.querySelector("#statusBadge").textContent = statusLabel(detail.visit.status);
|
||||
document.querySelector("#orderTotal").textContent = money(detail.visit.final_total || detail.visit.total_cost || 0, detail.visit.currency);
|
||||
document.querySelector("#ownerComment").value = detail.visit.owner_comment || "";
|
||||
renderItems("#laborList", detail.work_items || [], "Работы пока не добавлены");
|
||||
renderItems("#productList", detail.product_items || [], "Материалы пока не добавлены");
|
||||
renderDecision(detail);
|
||||
renderProfileLink(detail);
|
||||
}
|
||||
|
||||
async function decide(action) {
|
||||
const id = orderId();
|
||||
const comment = document.querySelector("#ownerComment").value.trim() || null;
|
||||
const button = action === "approve" ? document.querySelector("#approveBtn") : document.querySelector("#rejectBtn");
|
||||
button.disabled = true;
|
||||
try {
|
||||
await api(`/work-orders/${id}/${action}`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ comment }),
|
||||
});
|
||||
toast(action === "approve" ? "Заказ-наряд согласован" : "Заказ-наряд отклонен");
|
||||
await loadDetail();
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast(error.message || "Ошибка", "error");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
document.querySelector("#refreshBtn").addEventListener("click", () => loadDetail());
|
||||
document.querySelector("#approveBtn").addEventListener("click", () => decide("approve"));
|
||||
document.querySelector("#rejectBtn").addEventListener("click", () => decide("reject"));
|
||||
document.querySelector("#telegramRetryBtn").addEventListener("click", () => window.location.reload());
|
||||
|
||||
Promise.all([loadAuthConfig()])
|
||||
.then(() => ensureUser())
|
||||
.then(() => loadDetail())
|
||||
.catch((error) => {
|
||||
if (error.message === "Требуется вход через Telegram") return;
|
||||
console.error(error);
|
||||
toast(error.message || "Ошибка", "error");
|
||||
});
|
||||
92
web/work_order.html
Normal file
92
web/work_order.html
Normal file
@@ -0,0 +1,92 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#16806a" />
|
||||
<title>Заказ-наряд</title>
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||||
</head>
|
||||
<body class="auth-required work-order-page">
|
||||
<div class="auth-overlay" id="authOverlay">
|
||||
<div class="auth-panel">
|
||||
<p class="eyebrow">CarPass</p>
|
||||
<h1>Заказ-наряд</h1>
|
||||
<p id="authMessage">Откройте страницу через Telegram-бота, чтобы подтвердить доступ к заказ-наряду.</p>
|
||||
<div class="auth-actions">
|
||||
<a id="telegramLoginLink" class="telegram-login-link hidden" href="#" rel="noreferrer">Открыть в Telegram</a>
|
||||
<button id="telegramRetryBtn" class="telegram-secondary-btn" type="button">Проверить вход</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<main class="shell work-order-shell">
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<p class="eyebrow">Согласование</p>
|
||||
<h1>Заказ-наряд</h1>
|
||||
</div>
|
||||
<button class="icon-btn" id="refreshBtn" title="Обновить" aria-label="Обновить">↻</button>
|
||||
</header>
|
||||
|
||||
<section class="passport-panel work-order-hero">
|
||||
<div class="passport-head">
|
||||
<div>
|
||||
<p class="eyebrow" id="centerName">СТО</p>
|
||||
<h2 id="orderTitle">Загружаю...</h2>
|
||||
<small id="vehicleMeta">Проверяю доступ</small>
|
||||
</div>
|
||||
<span class="trust-badge" id="statusBadge">Статус</span>
|
||||
</div>
|
||||
<div class="work-order-total">
|
||||
<span>Итого к согласованию</span>
|
||||
<strong id="orderTotal">-</strong>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="work-order-layout">
|
||||
<section class="workspace">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Состав</p>
|
||||
<h2>Работы</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div id="laborList" class="stack-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="workspace">
|
||||
<div class="section-head">
|
||||
<div>
|
||||
<p class="eyebrow">Материалы</p>
|
||||
<h2>Запчасти и жидкости</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div id="productList" class="stack-list"></div>
|
||||
</section>
|
||||
|
||||
<section class="workspace work-order-decision">
|
||||
<div>
|
||||
<p class="eyebrow">Решение владельца</p>
|
||||
<h2 id="decisionTitle">Проверьте смету</h2>
|
||||
<p id="decisionText">Если все понятно, согласуйте заказ-наряд. После согласования СТО сможет завершить работы.</p>
|
||||
</div>
|
||||
<label>
|
||||
Комментарий
|
||||
<textarea id="ownerComment" rows="3" placeholder="Например: согласен, но старые детали прошу оставить в багажнике"></textarea>
|
||||
</label>
|
||||
<div class="row-actions">
|
||||
<button type="button" id="approveBtn">Согласовать</button>
|
||||
<button type="button" class="ghost-btn" id="rejectBtn">Отклонить</button>
|
||||
</div>
|
||||
<a class="telegram-login-link hidden" id="fillProfileLink" href="/">Заполнить карточку авто</a>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<div class="toast hidden" id="toast" role="status" aria-live="polite"></div>
|
||||
<script src="/static/work_order.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user