Add owner work order approval page
Some checks failed
ci / test (push) Has been cancelled

This commit is contained in:
VPN SaaS Dev
2026-05-16 10:51:05 +09:00
parent ac5845d5a0
commit 545f4d088d
12 changed files with 1066 additions and 48 deletions

View 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")

View File

@@ -1,18 +1,22 @@
from datetime import UTC, datetime from datetime import UTC, datetime
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy import select from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession 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.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.db.session import get_session
from app.models.car import ( from app.models.car import (
Car, Car,
CarServiceLink, CarServiceLink,
ServiceAppointment, ServiceAppointment,
ServiceCenter,
ServiceProductItem, ServiceProductItem,
ServiceVisit, ServiceVisit,
ServiceWorkItem, ServiceWorkItem,
WorkOrderCatalogItem,
WorkOrderCorrection, WorkOrderCorrection,
WorkOrderStatusHistory, WorkOrderStatusHistory,
) )
@@ -23,9 +27,15 @@ from app.schemas.service_center import (
ServiceVisitRead, ServiceVisitRead,
ServiceWorkItemCreate, ServiceWorkItemCreate,
ServiceWorkItemRead, ServiceWorkItemRead,
VehicleProfileRequest,
WorkOrderCatalogItemCreate,
WorkOrderCatalogItemRead,
WorkOrderCatalogRead,
WorkOrderCatalogSuggestion,
WorkOrderCorrectionCreate, WorkOrderCorrectionCreate,
WorkOrderCorrectionRead, WorkOrderCorrectionRead,
WorkOrderDecision, WorkOrderDecision,
WorkOrderDetailRead,
WorkOrderStatusHistoryRead, WorkOrderStatusHistoryRead,
WorkOrderUpdate, WorkOrderUpdate,
) )
@@ -43,6 +53,18 @@ from app.services.work_orders import (
router = APIRouter(prefix="/work-orders", tags=["work-orders"]) 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: async def get_work_order(session: AsyncSession, work_order_id: int) -> ServiceVisit:
visit = await session.get(ServiceVisit, work_order_id) visit = await session.get(ServiceVisit, work_order_id)
if visit is None: if visit is None:
@@ -50,6 +72,22 @@ async def get_work_order(session: AsyncSession, work_order_id: int) -> ServiceVi
return visit 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( async def ensure_work_order_sto_access(
session: AsyncSession, visit: ServiceVisit, user: User, allowed_roles: set[str] | None = None session: AsyncSession, visit: ServiceVisit, user: User, allowed_roles: set[str] | None = 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") 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) @router.get("/{work_order_id}", response_model=ServiceVisitRead)
async def get_work_order_detail( async def get_work_order_detail(
work_order_id: int, work_order_id: int,
@@ -109,6 +297,31 @@ async def get_work_order_detail(
return visit 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) @router.patch("/{work_order_id}", response_model=ServiceVisitRead)
async def update_work_order( async def update_work_order(
work_order_id: int, work_order_id: int,
@@ -183,8 +396,10 @@ async def submit_work_order_for_approval(
service_center_id=visit.service_center_id, service_center_id=visit.service_center_id,
notification_type="work_order.waiting_owner_approval", notification_type="work_order.waiting_owner_approval",
title="Заказ-наряд ожидает согласования", 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}", 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 log_audit(session, actor=current_user, action="work_order.submit_approval", target_type="service_visit", target_id=visit.id)
await session.commit() await session.commit()
@@ -284,6 +499,44 @@ async def complete_work_order(
return visit 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) @router.post("/{work_order_id}/corrections", response_model=WorkOrderCorrectionRead, status_code=status.HTTP_201_CREATED)
async def create_work_order_correction( async def create_work_order_correction(
work_order_id: int, work_order_id: int,

View File

@@ -172,6 +172,7 @@ class ServiceCenter(Base):
) )
holidays = relationship("ServiceCenterHoliday", back_populates="service_center", cascade="all, delete-orphan") holidays = relationship("ServiceCenterHoliday", back_populates="service_center", cascade="all, delete-orphan")
appointments = relationship("ServiceAppointment", 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): 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) 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): class ServiceCenterReview(Base):
__tablename__ = "service_center_reviews" __tablename__ = "service_center_reviews"
__table_args__ = (UniqueConstraint("service_center_id", "user_id", name="uq_service_review_user"),) __table_args__ = (UniqueConstraint("service_center_id", "user_id", name="uq_service_review_user"),)

View File

@@ -328,6 +328,68 @@ class ServiceVisitRead(ServiceVisitCreate):
model_config = ConfigDict(from_attributes=True) 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): class ServiceWorkItemCreate(BaseModel):
work_type: str = "other" work_type: str = "other"
title: str title: str
@@ -402,6 +464,15 @@ class ServiceProductItemRead(ServiceProductItemCreate):
model_config = ConfigDict(from_attributes=True) 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): class WorkOrderUpdate(BaseModel):
odometer: int | None = None odometer: int | None = None
assigned_employee_id: int | None = None assigned_employee_id: int | None = None

View File

@@ -1,3 +1,4 @@
import json
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
import httpx import httpx
@@ -11,14 +12,26 @@ from app.models.user import User
MODERATOR_ROLES = {"admin", "verifier", "moderator"} 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": if not settings.bot_token or settings.app_env == "test":
return False 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: try:
async with httpx.AsyncClient(timeout=5) as client: async with httpx.AsyncClient(timeout=5) as client:
response = await client.post( response = await client.post(
f"https://api.telegram.org/bot{settings.bot_token}/sendMessage", 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 return response.status_code < 400
except Exception: except Exception:

View File

@@ -191,6 +191,8 @@ async def create_service_notification(
appointment_id: int | None = None, appointment_id: int | None = None,
send_telegram: bool = True, send_telegram: bool = True,
idempotency_key: str | None = None, idempotency_key: str | None = None,
web_app_url: str | None = None,
button_text: str = "Открыть",
) -> ServiceNotification: ) -> ServiceNotification:
if idempotency_key: if idempotency_key:
existing = ( existing = (
@@ -214,7 +216,8 @@ async def create_service_notification(
user = await session.get(User, recipient_user_id) user = await session.get(User, recipient_user_id)
if user is not None: if user is not None:
notification.status = "processing" 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: if delivered:
notification.status = "sent" notification.status = "sent"
notification.sent_at = datetime.now(UTC) notification.sent_at = datetime.now(UTC)

View File

@@ -7,6 +7,7 @@ from fastapi import HTTPException
from sqlalchemy import select from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.core.config import settings
from app.models.car import ( from app.models.car import (
Car, Car,
InventoryTransaction, InventoryTransaction,
@@ -37,6 +38,10 @@ WORK_ORDER_STATUSES = {
LOCKED_WORK_ORDER_STATUSES = {"completed", "cancelled", "archived"} 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: def money(value: Decimal | int | float | None) -> Decimal:
return Decimal(str(value or 0)).quantize(Decimal("0.01")) return Decimal(str(value or 0)).quantize(Decimal("0.01"))
@@ -311,5 +316,7 @@ async def close_work_order(
title="Работа по заказ-наряду завершена", title="Работа по заказ-наряду завершена",
body=f"{visit.work_order_number or visit.id}: {visit.final_total} {visit.currency}. Можно оставить отзыв.", body=f"{visit.work_order_number or visit.id}: {visit.final_total} {visit.currency}. Можно оставить отзыв.",
idempotency_key=f"work_order:{visit.id}:completed", idempotency_key=f"work_order:{visit.id}:completed",
web_app_url=work_order_webapp_url(visit.id),
button_text="Открыть заказ-наряд",
) )
return service, expense return service, expense

View File

@@ -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) { async function selectCar(carId) {
state.selectedCarId = carId; state.selectedCarId = carId;
renderCars(); renderCars();
@@ -2841,6 +2857,7 @@ Promise.all([loadAuthConfig()])
initCarCatalog(); initCarCatalog();
return Promise.all([loadMyServiceCenters().catch(() => []), loadCars()]); return Promise.all([loadMyServiceCenters().catch(() => []), loadCars()]);
}) })
.then(() => applyInitialRoute())
.catch((error) => { .catch((error) => {
if (error.message === "Требуется вход через Telegram") return; if (error.message === "Требуется вход через Telegram") return;
document.body.insertAdjacentHTML("afterbegin", `<div class="error">${error.message}</div>`); document.body.insertAdjacentHTML("afterbegin", `<div class="error">${error.message}</div>`);

View File

@@ -13,6 +13,8 @@ const state = {
appointments: [], appointments: [],
workOrders: [], workOrders: [],
employees: [], employees: [],
catalogsByVehicleId: {},
catalogLookup: {},
}; };
function authHeaders(extra = {}) { 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.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.workOrders = visits.filter((item) => !["completed", "cancelled", "archived", "confirmed", "disputed"].includes(item.status));
state.employees = employees; state.employees = employees;
await loadWorkOrderCatalogs(center.id);
renderDashboard(dashboard); renderDashboard(dashboard);
renderAppointments(); renderAppointments();
renderWorkOrders(); renderWorkOrders();
renderStaff(); 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) { function renderDashboard(dashboard) {
document.querySelector("#dashboardStats").innerHTML = dashboard document.querySelector("#dashboardStats").innerHTML = dashboard
? ` ? `
@@ -223,8 +270,12 @@ function renderAppointments() {
function renderWorkOrders() { function renderWorkOrders() {
const role = activeCenter()?.employee_role || "owner"; const role = activeCenter()?.employee_role || "owner";
const canComplete = role === "owner"; const canComplete = role === "owner";
state.catalogLookup = {};
document.querySelector("#workOrdersList").innerHTML = state.workOrders.length 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="stack-item work-order-card">
<div class="work-order-head"> <div class="work-order-head">
<div> <div>
@@ -240,16 +291,39 @@ function renderWorkOrders() {
<span>Запчасти: <strong>${money(item.product_total || 0)}</strong></span> <span>Запчасти: <strong>${money(item.product_total || 0)}</strong></span>
<span>Итого: <strong>${money(item.final_total || item.total_cost || 0)}</strong></span> <span>Итого: <strong>${money(item.final_total || item.total_cost || 0)}</strong></span>
</div> </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="title" placeholder="Работа" required />
<input name="quantity" type="number" min="0.001" step="0.001" value="1" aria-label="Количество" /> <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_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> <button type="submit">+ Работа</button>
</form> </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="title" placeholder="Запчасть / материал" required />
<input name="quantity" type="number" min="0.001" step="0.001" value="1" aria-label="Количество" /> <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_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> <button type="submit">+ Материал</button>
</form> </form>
<div class="row-actions"> <div class="row-actions">
@@ -258,7 +332,8 @@ function renderWorkOrders() {
${canComplete ? `<button type="button" class="ghost-btn" data-complete-work-order="${item.id}">Завершить</button>` : ""} ${canComplete ? `<button type="button" class="ghost-btn" data-complete-work-order="${item.id}">Завершить</button>` : ""}
</div> </div>
</div> </div>
`).join("") `;
}).join("")
: `<div class="empty">Активных заказ-нарядов нет</div>`; : `<div class="empty">Активных заказ-нарядов нет</div>`;
} }
@@ -331,7 +406,6 @@ document.querySelector("#inviteForm").addEventListener("submit", async (event) =
document.body.addEventListener("click", async (event) => { document.body.addEventListener("click", async (event) => {
const button = event.target.closest("button"); const button = event.target.closest("button");
if (!button) return; if (!button) return;
const center = activeCenter();
if (button.dataset.confirmAppointment) { if (button.dataset.confirmAppointment) {
await runAction(button, () => api(`/sto/appointments/${button.dataset.confirmAppointment}/confirm`, { await runAction(button, () => api(`/sto/appointments/${button.dataset.confirmAppointment}/confirm`, {
method: "POST", method: "POST",
@@ -369,6 +443,13 @@ document.body.addEventListener("click", async (event) => {
body: JSON.stringify({ comment: "Работы завершены" }), 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) { if (button.dataset.roleEmployee) {
await runAction(button, () => api(`/service-centers/employees/${button.dataset.roleEmployee}`, { await runAction(button, () => api(`/service-centers/employees/${button.dataset.roleEmployee}`, {
method: "PATCH", 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) => { document.body.addEventListener("submit", async (event) => {
const form = event.target; const form = event.target;
if (form.matches("[data-labor-form]")) { if (form.matches("[data-labor-form]")) {
@@ -392,10 +493,11 @@ document.body.addEventListener("submit", async (event) => {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
title: data.title, title: data.title,
category: data.category || null,
quantity: Number(data.quantity || 1), quantity: Number(data.quantity || 1),
unit: "job", unit: data.unit || "job",
unit_price: Number(data.unit_price || 0), 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", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
title: data.title, title: data.title,
category: data.category || null,
quantity: Number(data.quantity || 1), quantity: Number(data.quantity || 1),
unit: "pcs", unit: data.unit || "pcs",
unit_price: Number(data.unit_price || 0), 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),
}), }),
})); }));
} }

View File

@@ -524,7 +524,8 @@ body {
button, button,
input, input,
select { select,
textarea {
font: inherit; font: inherit;
} }
@@ -697,7 +698,8 @@ label {
} }
input, input,
select { select,
textarea {
width: 100%; width: 100%;
min-height: 42px; min-height: 42px;
border: 1px solid var(--line); border: 1px solid var(--line);
@@ -712,12 +714,19 @@ select {
} }
input:focus, input:focus,
select:focus { select:focus,
textarea:focus {
outline: none; outline: none;
border-color: var(--accent); border-color: var(--accent);
box-shadow: 0 0 0 4px rgba(22, 128, 106, 0.12); box-shadow: 0 0 0 4px rgba(22, 128, 106, 0.12);
} }
textarea {
min-height: 86px;
padding: 10px 11px;
resize: vertical;
}
select:disabled { select:disabled {
background: #f1f5f3; background: #f1f5f3;
color: #9aa4a0; color: #9aa4a0;
@@ -1725,6 +1734,74 @@ select {
overflow-wrap: anywhere; 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 { @keyframes toastIn {
from { from {
opacity: 0; opacity: 0;
@@ -1807,6 +1884,7 @@ select {
} }
.sto-grid, .sto-grid,
.work-order-layout,
.staff-form { .staff-form {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@@ -1822,6 +1900,10 @@ select {
min-width: 70vw; min-width: 70vw;
scroll-snap-align: start; scroll-snap-align: start;
} }
.catalog-work-form {
grid-template-columns: 1fr;
}
} }
@media (max-width: 640px) { @media (max-width: 640px) {
@@ -1926,6 +2008,10 @@ select {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.work-order-hero {
grid-template-columns: 1fr;
}
.sto-page .top-actions { .sto-page .top-actions {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 44px; grid-template-columns: minmax(0, 1fr) 44px;

210
web/static/work_order.js Normal file
View 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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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
View 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>