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" ? ` -
+
+ ${item.status === "proposed_new_time" ? ` -
- ` : ""} + ` : ""} + ${!["converted_to_work_order", "completed"].includes(item.status) ? `` : ""} +
`).join("") : `
Записей пока нет
`; @@ -1394,6 +1395,16 @@ async function loadAppointments() { await loadAppointments(); })); }); + root.querySelectorAll("[data-delete-appointment]").forEach((button) => { + button.addEventListener("click", () => { + if (!window.confirm("Удалить запись в СТО?")) return; + runAction(button, "Удаляю запись...", async () => { + await api(`/appointments/${button.dataset.deleteAppointment}`, { method: "DELETE" }); + await loadAppointments(); + toast("Запись удалена"); + }); + }); + }); } catch (error) { root.innerHTML = `
Записи не загрузились
`; } @@ -1466,6 +1477,9 @@ async function loadStoCalendar() { ` : ""} + ${!["converted_to_work_order", "completed"].includes(item.status) ? ` + + ` : ""} `).join("") : `
Записей на ближайший период нет
`; @@ -1487,6 +1501,16 @@ async function loadStoCalendar() { await loadStoCalendar(); })); }); + list.querySelectorAll("[data-delete-sto-appointment]").forEach((button) => { + button.addEventListener("click", () => { + if (!window.confirm("Удалить бронь из календаря СТО?")) return; + runAction(button, "Удаляю бронь...", async () => { + await api(`/sto/appointments/${button.dataset.deleteStoAppointment}`, { method: "DELETE" }); + await loadStoCalendar(); + toast("Бронь удалена"); + }); + }); + }); } catch (error) { summary.innerHTML = ""; list.innerHTML = `
Календарь СТО не загрузился
`; @@ -1569,6 +1593,7 @@ function renderMechanicAppointment(item) { ${canManageAppointments && item.status === "requested" ? `` : ""} ${canCreateWorkOrder ? `` : ""} ${canManageAppointments ? `` : ""} + ${canManageAppointments && !["converted_to_work_order", "completed"].includes(item.status) ? `` : ""} `; @@ -1639,6 +1664,16 @@ function bindMechanicWorkplaceActions(root) { await loadMechanicWorkplace(); })); }); + root.querySelectorAll("[data-mechanic-delete-appointment]").forEach((button) => { + button.addEventListener("click", () => { + if (!window.confirm("Удалить бронь из рабочего места?")) return; + runAction(button, "Удаляю бронь...", async () => { + await api(`/sto/appointments/${button.dataset.mechanicDeleteAppointment}`, { method: "DELETE" }); + await loadMechanicWorkplace(); + toast("Бронь удалена"); + }); + }); + }); root.querySelectorAll("[data-create-work-order]").forEach((button) => { button.addEventListener("click", () => runAction(button, "Открываю заказ-наряд...", async () => { const odometerValue = window.prompt("Пробег на приемке, км") || ""; @@ -1849,25 +1884,31 @@ function renderStats(stats) { function recordsForPeriod() { return [ ...state.latestFuel.map((item) => ({ + id: item.id, date: item.entry_date, type: "fuel", title: `Заправка ${Number(item.liters).toFixed(1)} л`, meta: item.station || `${item.odometer} км`, cost: item.total_cost, + deleteEndpoint: `/fuel/${item.id}`, })), ...state.latestService.map((item) => ({ + id: item.id, date: item.entry_date, type: "service", title: item.title, meta: item.vendor || serviceLabel(item.service_type), cost: item.total_cost, + deleteEndpoint: `/service/${item.id}`, })), ...state.latestExpenses.map((item) => ({ + id: item.id, date: item.entry_date, type: "expense", title: item.title, meta: expenseLabel(item.category), cost: item.total_cost, + deleteEndpoint: `/expenses/${item.id}`, })), ].sort((a, b) => b.date.localeCompare(a.date)); } @@ -1950,7 +1991,7 @@ function renderAchievements() { root.innerHTML = achievements .map( (item) => ` -
+
${item.title} ${item.description}
@@ -2063,6 +2104,7 @@ function openReport(type = "summary") { }; body.innerHTML = blocks[type] || blocks.summary; + bindRecordDeleteActions(body, type); applyTranslations(body); sheet.classList.remove("hidden"); } @@ -2080,12 +2122,27 @@ function reportRecords(records) { ${item.date}
${item.title}
${item.meta || ""}
${money(item.cost)} + ${item.deleteEndpoint ? `` : ""}
`, ) .join("")}`; } +function bindRecordDeleteActions(root, reportType) { + root.querySelectorAll("[data-delete-record]").forEach((button) => { + button.addEventListener("click", () => { + if (!window.confirm("Удалить запись из истории?")) return; + runAction(button, "Удаляю запись...", async () => { + await api(button.dataset.deleteRecord, { method: "DELETE" }); + await loadSelectedCar(); + openReport(reportType); + toast("Запись удалена"); + }); + }); + }); +} + function serviceLabel(value) { return { maintenance: t("Обслуживание"), @@ -2471,6 +2528,23 @@ document.querySelector("#carProfileForm").addEventListener("submit", async (even }); }); +document.querySelector("#deleteCarBtn")?.addEventListener("click", (event) => { + const car = selectedCar(); + if (!car) { + toast("Выбери автомобиль", "error"); + return; + } + if (!window.confirm(`Удалить автомобиль «${car.name}» и все его записи?`)) return; + runAction(event.currentTarget, "Удаляю автомобиль...", async () => { + await api(`/cars/${car.id}`, { method: "DELETE" }); + state.selectedCarId = null; + document.querySelector("#userDrawer").classList.add("hidden"); + await loadCars(); + toast("Автомобиль удален"); + haptic("success"); + }); +}); + document.querySelector("#settingsForm").addEventListener("submit", async (event) => { event.preventDefault(); const form = event.currentTarget; diff --git a/web/static/sto.js b/web/static/sto.js index f191907..d1ffe49 100644 --- a/web/static/sto.js +++ b/web/static/sto.js @@ -261,6 +261,7 @@ function renderAppointments() { ${item.status === "requested" ? `` : ""} ${["confirmed", "confirmed_by_sto"].includes(item.status) ? `` : ""} + ${!["converted_to_work_order", "completed"].includes(item.status) ? `` : ""} ` : ""} `).join("") @@ -418,6 +419,12 @@ document.body.addEventListener("click", async (event) => { body: JSON.stringify({ comment: "Отклонено в панели СТО" }), })); } + if (button.dataset.deleteAppointment) { + if (!window.confirm("Удалить бронь из панели СТО?")) return; + await runAction(button, () => api(`/sto/appointments/${button.dataset.deleteAppointment}`, { + method: "DELETE", + })); + } if (button.dataset.createWorkOrder) { const odometer = window.prompt("Пробег на приемке, км") || ""; await runAction(button, () => api(`/sto/appointments/${button.dataset.createWorkOrder}/create-work-order`, { diff --git a/web/static/styles.css b/web/static/styles.css index 085e45b..784223c 100644 --- a/web/static/styles.css +++ b/web/static/styles.css @@ -31,11 +31,17 @@ body.auth-required .shell { box-shadow: none; } +.danger-btn { + background: #fff0ee; + color: var(--danger); + box-shadow: none; +} + .passport-panel { display: grid; - gap: 14px; - padding: 16px; - margin-bottom: 14px; + gap: 10px; + padding: 12px; + margin-bottom: 12px; color: #f4fbf8; border: 1px solid rgba(255, 255, 255, 0.12); border-radius: 8px; @@ -48,16 +54,21 @@ body.auth-required .shell { .passport-head { display: flex; justify-content: space-between; - gap: 16px; + gap: 12px; align-items: center; } .passport-head h2 { margin: 0; color: #fff; + font-size: 20px; letter-spacing: 0; } +.passport-head .eyebrow { + margin-bottom: 3px; +} + .passport-head small, .passport-metric span, .passport-action span, @@ -71,9 +82,9 @@ body.auth-required .shell { .score-ring { display: grid; place-items: center; - flex: 0 0 84px; - width: 84px; - height: 84px; + flex: 0 0 72px; + width: 72px; + height: 72px; border-radius: 50%; background: conic-gradient(#5ee0bd 0deg, rgba(255, 255, 255, 0.12) 0deg); position: relative; @@ -82,7 +93,7 @@ body.auth-required .shell { .score-ring::after { content: ""; position: absolute; - inset: 8px; + inset: 7px; border-radius: inherit; background: #121d20; } @@ -94,31 +105,28 @@ body.auth-required .shell { } .score-ring strong { - font-size: 24px; + font-size: 21px; } .score-ring span { - margin-top: 28px; + margin-top: 24px; font-size: 11px; color: rgba(244, 251, 248, 0.62); position: absolute; } -.passport-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 8px; -} - .passport-metric, .passport-action, .achievement-card { display: grid; gap: 4px; - padding: 10px; + min-width: min(42vw, 210px); + min-height: 68px; + padding: 9px; border: 1px solid rgba(255, 255, 255, 0.1); border-radius: 8px; background: rgba(255, 255, 255, 0.055); + scroll-snap-align: start; } .passport-metric strong { @@ -126,31 +134,44 @@ body.auth-required .shell { font-size: 14px; } +.passport-grid, .passport-actions, .achievement-strip { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); + display: flex; gap: 8px; + overflow-x: auto; + overscroll-behavior-x: contain; + padding-bottom: 3px; + scroll-snap-type: x proximity; } -.achievement-strip { - grid-template-columns: repeat(4, minmax(0, 1fr)); +.achievement-card { + position: relative; + padding-right: 38px; } -.achievement-card strong::before { +.achievement-card.is-earned::before { content: ""; - display: inline-block; - width: 7px; - height: 7px; - margin-right: 6px; + position: absolute; + top: 8px; + right: 8px; + width: 22px; + height: 22px; border-radius: 50%; - background: #5ee0bd; - box-shadow: 0 0 16px rgba(94, 224, 189, 0.7); + background: linear-gradient(145deg, #ffe8a3, #d79b24); + border: 1px solid rgba(255, 255, 255, 0.42); + box-shadow: 0 4px 12px rgba(215, 155, 36, 0.28); } -.achievement-card.muted strong::before { - background: #91a39d; - box-shadow: none; +.achievement-card.is-earned::after { + content: ""; + position: absolute; + top: 14px; + right: 14px; + width: 10px; + height: 10px; + border-radius: 50%; + background: rgba(255, 255, 255, 0.35); } .vehicle-timeline { @@ -933,7 +954,7 @@ select:disabled { .record { display: grid; - grid-template-columns: 110px 1fr auto; + grid-template-columns: 110px 1fr auto auto; gap: 12px; padding: 12px 0; border-bottom: 1px solid var(--line); @@ -941,6 +962,15 @@ select:disabled { animation: fade 220ms ease both; } +.record .icon-btn { + width: 32px; + height: 32px; + min-height: 32px; + color: var(--danger); + background: #fff0ee; + box-shadow: none; +} + .record small { color: var(--muted); }