diff --git a/app/api/sto_booking.py b/app/api/sto_booking.py index d611bc0..2e3f37e 100644 --- a/app/api/sto_booking.py +++ b/app/api/sto_booking.py @@ -93,6 +93,23 @@ async def _appointment_for_sto(session: AsyncSession, appointment_id: int, user: return appointment +def _ensure_appointment_deletable(appointment: ServiceAppointment) -> None: + if appointment.linked_work_order_id or appointment.status in {"converted_to_work_order", "completed"}: + raise HTTPException(status_code=409, detail="Appointment already has a work order and cannot be deleted") + + +async def _restore_recommendation_after_appointment_delete( + session: AsyncSession, + appointment: ServiceAppointment, +) -> None: + if not appointment.source_recommendation_id: + return + recommendation = await session.get(MaintenanceRecommendation, appointment.source_recommendation_id) + if recommendation is not None and recommendation.status == "booked": + recommendation.status = "active" + recommendation.source_appointment_id = None + + @router.get("/sto/catalog", response_model=list[ServiceCatalogItem]) async def get_sto_catalog( city: str | None = None, @@ -276,6 +293,34 @@ async def cancel_appointment( return appointment +@router.delete("/appointments/{appointment_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_appointment_by_owner( + appointment_id: int, + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_telegram_user), +) -> None: + appointment = await _appointment_for_owner(session, appointment_id, current_user) + _ensure_appointment_deletable(appointment) + await _restore_recommendation_after_appointment_delete(session, appointment) + await notify_service_staff( + session, + service_center_id=appointment.service_center_id, + notification_type="appointment.deleted_by_owner", + title="Клиент удалил запись", + body=f"{appointment.service_name}: {appointment.requested_start_at:%Y-%m-%d %H:%M}", + appointment_id=None, + ) + await log_audit( + session, + actor=current_user, + action="appointment.delete_by_owner", + target_type="service_appointment", + target_id=appointment_id, + ) + await session.delete(appointment) + await session.commit() + + @router.post("/appointments/{appointment_id}/accept-proposed-time", response_model=AppointmentRead) async def accept_proposed_time( appointment_id: int, @@ -515,6 +560,36 @@ async def reject_appointment( return appointment +@router.delete("/sto/appointments/{appointment_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_appointment_by_sto( + appointment_id: int, + session: AsyncSession = Depends(get_session), + current_user: User = Depends(get_current_telegram_user), +) -> None: + appointment = await _appointment_for_sto(session, appointment_id, current_user) + _ensure_appointment_deletable(appointment) + await _restore_recommendation_after_appointment_delete(session, appointment) + await create_service_notification( + session, + recipient_user_id=appointment.owner_id, + service_center_id=appointment.service_center_id, + appointment_id=None, + notification_type="appointment.deleted_by_sto", + title="СТО удалило запись", + body=f"{appointment.service_name}: {appointment.requested_start_at:%Y-%m-%d %H:%M}", + idempotency_key=f"appointment:{appointment.id}:deleted_by_sto", + ) + await log_audit( + session, + actor=current_user, + action="appointment.delete_by_sto", + target_type="service_appointment", + target_id=appointment_id, + ) + await session.delete(appointment) + await session.commit() + + @router.post("/sto/appointments/{appointment_id}/propose-time", response_model=AppointmentRead) async def propose_appointment_time( appointment_id: int, @@ -570,6 +645,9 @@ async def create_work_order_from_appointment( if visit is None: raise HTTPException(status_code=404, detail="Linked work order not found") return visit + vehicle = await session.get(Car, appointment.vehicle_id) + if vehicle is None: + raise HTTPException(status_code=404, detail="Vehicle not found") visit = ServiceVisit( service_center_id=appointment.service_center_id, vehicle_id=appointment.vehicle_id, @@ -577,7 +655,7 @@ async def create_work_order_from_appointment( created_by_employee_id=employee.id, assigned_employee_id=employee.id, visit_date=(appointment.confirmed_start_at or appointment.requested_start_at).date(), - odometer=payload.odometer, + odometer=payload.odometer if payload.odometer is not None else vehicle.current_odometer, status="draft", customer_complaint=appointment.customer_comment, notes=payload.notes or appointment.customer_comment, diff --git a/app/services/work_orders.py b/app/services/work_orders.py index 074dae5..1053b8c 100644 --- a/app/services/work_orders.py +++ b/app/services/work_orders.py @@ -50,6 +50,51 @@ def line_total(quantity: Decimal, unit_price: Decimal | None, discount: Decimal) return max(Decimal("0"), Decimal(quantity) * money(unit_price) - money(discount)).quantize(Decimal("0.01")) +def _product_profile_label(product: ServiceProductItem) -> str | None: + details = [product.viscosity, product.specification] + label = " ".join(str(item).strip() for item in details if item).strip() + return label or product.specification or product.title or None + + +def _product_volume(product: ServiceProductItem) -> Decimal | None: + if product.used_volume is not None: + return product.used_volume + if product.volume is not None: + return product.volume + if product.unit == "l": + return product.quantity + return None + + +def sync_vehicle_profile_from_products(vehicle: Car, product_items: list[ServiceProductItem]) -> dict[str, str]: + updates: dict[str, str] = {} + for product in product_items: + category = product.category + label = _product_profile_label(product) + volume = _product_volume(product) + if category == "engine_oil": + if label and not vehicle.engine_oil_type: + vehicle.engine_oil_type = label + updates["engine_oil_type"] = label + if volume is not None and vehicle.engine_oil_volume_l is None: + vehicle.engine_oil_volume_l = volume + updates["engine_oil_volume_l"] = str(volume) + elif category == "transmission_fluid": + if label and not vehicle.transmission_fluid_type: + vehicle.transmission_fluid_type = label + updates["transmission_fluid_type"] = label + if volume is not None and vehicle.transmission_fluid_volume_l is None: + vehicle.transmission_fluid_volume_l = volume + updates["transmission_fluid_volume_l"] = str(volume) + elif category == "coolant" and label and not vehicle.coolant_type: + vehicle.coolant_type = label + updates["coolant_type"] = label + elif category == "brake_fluid" and label and not vehicle.brake_fluid_type: + vehicle.brake_fluid_type = label + updates["brake_fluid_type"] = label + return updates + + async def add_status_history( session: AsyncSession, visit: ServiceVisit, @@ -294,6 +339,7 @@ async def close_work_order( source_appointment_id=appointment.id if appointment else None, ) ) + vehicle_profile_updates = sync_vehicle_profile_from_products(vehicle, product_items) visit.version = (visit.version or 1) + 1 visit.completed_snapshot = { "work_order_number": visit.work_order_number, @@ -306,6 +352,7 @@ async def close_work_order( "final_total": str(visit.final_total), "currency": visit.currency, "completed_at": visit.completed_at.isoformat() if visit.completed_at else None, + "vehicle_profile_updates": vehicle_profile_updates, } await create_service_notification( session, diff --git a/tests/test_production_flows.py b/tests/test_production_flows.py index 1221fa7..c8ba2b0 100644 --- a/tests/test_production_flows.py +++ b/tests/test_production_flows.py @@ -138,6 +138,7 @@ async def test_work_order_completion_creates_vehicle_records_and_updates_costs( headers=auth_headers, json={ "title": "Engine oil", + "category": "engine_oil", "product_type": "engine_oil", "quantity": 4, "unit": "l", @@ -210,6 +211,8 @@ async def test_work_order_completion_creates_vehicle_records_and_updates_costs( assert len(expenses.json()) == 1 assert expenses.json()[0]["total_cost"] == "130.00" assert refreshed.json()["current_odometer"] == 10150 + assert refreshed.json()["engine_oil_type"] == "5W-30" + assert refreshed.json()["engine_oil_volume_l"] == "4.00" assert stats.json()["total_cost"] == "130.00" cannot_edit = await client.patch( diff --git a/tests/test_sto_booking.py b/tests/test_sto_booking.py index b6f1fc5..cc0918d 100644 --- a/tests/test_sto_booking.py +++ b/tests/test_sto_booking.py @@ -203,6 +203,75 @@ async def test_customer_booking_lifecycle_capacity_calendar_work_order_and_notif my_appointments = await client.get("/api/appointments/my", headers=other_auth_headers) assert my_appointments.json()[0]["linked_work_order_id"] == work_order.json()["id"] + owner_delete_converted = await client.delete(f"/api/appointments/{appointment['id']}", headers=other_auth_headers) + sto_delete_converted = await client.delete(f"/api/sto/appointments/{appointment['id']}", headers=auth_headers) + assert owner_delete_converted.status_code == 409 + assert sto_delete_converted.status_code == 409 + + +@pytest.mark.asyncio +async def test_appointments_can_be_deleted_by_owner_or_sto_before_work_order( + client, auth_headers, other_auth_headers, admin_auth_headers, internal_headers +) -> None: + center = await create_verified_center(client, auth_headers, admin_auth_headers, internal_headers, city="Delete Booking") + workday = next_weekday(2) + await client.post( + "/api/sto/settings/booking", + headers=auth_headers, + json={ + "service_center_id": center["id"], + "working_days": [0, 1, 2, 3, 4], + "open_time": "09:00:00", + "close_time": "18:00:00", + "slot_duration_minutes": 60, + "max_parallel_bookings": 1, + "accepts_online_booking": True, + }, + ) + vehicle = ( + await client.post( + "/api/my/vehicles", + headers=other_auth_headers, + json={"name": "Delete booking car", "current_odometer": 12000}, + ) + ).json() + + owner_deleted = ( + await client.post( + "/api/appointments", + headers=other_auth_headers, + json={ + "service_center_id": center["id"], + "vehicle_id": vehicle["id"], + "service_type": "diagnostics", + "service_name": "Diagnostics", + "requested_start_at": datetime.combine(workday, time(10, 0), tzinfo=UTC).isoformat(), + "estimated_duration_minutes": 60, + }, + ) + ).json() + deleted_by_owner = await client.delete(f"/api/appointments/{owner_deleted['id']}", headers=other_auth_headers) + assert deleted_by_owner.status_code == 204 + + sto_deleted = ( + await client.post( + "/api/appointments", + headers=other_auth_headers, + json={ + "service_center_id": center["id"], + "vehicle_id": vehicle["id"], + "service_type": "diagnostics", + "service_name": "Diagnostics", + "requested_start_at": datetime.combine(workday, time(11, 0), tzinfo=UTC).isoformat(), + "estimated_duration_minutes": 60, + }, + ) + ).json() + deleted_by_sto = await client.delete(f"/api/sto/appointments/{sto_deleted['id']}", headers=auth_headers) + assert deleted_by_sto.status_code == 204 + + my_appointments = await client.get("/api/appointments/my", headers=other_auth_headers) + assert {item["id"] for item in my_appointments.json()}.isdisjoint({owner_deleted["id"], sto_deleted["id"]}) @pytest.mark.asyncio diff --git a/web/index.html b/web/index.html index 75b6318..215a215 100644 --- a/web/index.html +++ b/web/index.html @@ -805,6 +805,7 @@ + diff --git a/web/static/app.js b/web/static/app.js index 7b67ba1..8380c51 100644 --- a/web/static/app.js +++ b/web/static/app.js @@ -1370,12 +1370,13 @@ async function loadAppointments() { ${item.service_name} ${formatDateTime(item.confirmed_start_at || item.proposed_start_at || item.requested_start_at)} ${item.status} - ${item.status === "proposed_new_time" ? ` -