Improve passport layout and deletion flows
Some checks failed
ci / test (push) Has been cancelled

This commit is contained in:
VPN SaaS Dev
2026-05-16 11:38:29 +09:00
parent 8aa6640308
commit 01a69fc42d
8 changed files with 347 additions and 38 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -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(

View File

@@ -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

View File

@@ -805,6 +805,7 @@
<input name="notes" placeholder="Особенности авто" />
</label>
<button type="submit">Сохранить параметры</button>
<button type="button" class="danger-btn" id="deleteCarBtn">Удалить автомобиль</button>
</form>
</section>
</div>

View File

@@ -1370,12 +1370,13 @@ async function loadAppointments() {
<strong>${item.service_name}</strong>
<small>${formatDateTime(item.confirmed_start_at || item.proposed_start_at || item.requested_start_at)}</small>
<span class="trust-badge">${item.status}</span>
${item.status === "proposed_new_time" ? `
<div class="service-actions">
<div class="service-actions">
${item.status === "proposed_new_time" ? `
<button type="button" data-accept-appointment="${item.id}">Принять время</button>
<button type="button" class="ghost-btn" data-reject-appointment="${item.id}">Отклонить</button>
</div>
` : ""}
` : ""}
${!["converted_to_work_order", "completed"].includes(item.status) ? `<button type="button" class="danger-btn" data-delete-appointment="${item.id}">Удалить запись</button>` : ""}
</div>
</div>
`).join("")
: `<div class="empty">Записей пока нет</div>`;
@@ -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 = `<div class="empty">Записи не загрузились</div>`;
}
@@ -1466,6 +1477,9 @@ async function loadStoCalendar() {
<button type="button" class="ghost-btn" data-reject-sto-appointment="${item.id}">Отклонить</button>
</div>
` : ""}
${!["converted_to_work_order", "completed"].includes(item.status) ? `
<button type="button" class="danger-btn" data-delete-sto-appointment="${item.id}">Удалить бронь</button>
` : ""}
</div>
`).join("")
: `<div class="empty">Записей на ближайший период нет</div>`;
@@ -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 = `<div class="empty">Календарь СТО не загрузился</div>`;
@@ -1569,6 +1593,7 @@ function renderMechanicAppointment(item) {
${canManageAppointments && item.status === "requested" ? `<button type="button" data-mechanic-confirm-appointment="${item.id}">Подтвердить</button>` : ""}
${canCreateWorkOrder ? `<button type="button" data-create-work-order="${item.id}">Открыть заказ-наряд</button>` : ""}
${canManageAppointments ? `<button type="button" class="ghost-btn" data-mechanic-reject-appointment="${item.id}">Отклонить</button>` : ""}
${canManageAppointments && !["converted_to_work_order", "completed"].includes(item.status) ? `<button type="button" class="danger-btn" data-mechanic-delete-appointment="${item.id}">Удалить</button>` : ""}
</div>
</div>
`;
@@ -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) => `
<div class="achievement-card">
<div class="achievement-card is-earned">
<strong>${item.title}</strong>
<span>${item.description}</span>
</div>
@@ -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) {
<small>${item.date}</small>
<div><strong>${item.title}</strong><br><small>${item.meta || ""}</small></div>
<strong class="${item.type}">${money(item.cost)}</strong>
${item.deleteEndpoint ? `<button type="button" class="icon-btn" data-delete-record="${item.deleteEndpoint}" aria-label="Удалить">×</button>` : ""}
</div>
`,
)
.join("")}</div>`;
}
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;

View File

@@ -261,6 +261,7 @@ function renderAppointments() {
${item.status === "requested" ? `<button type="button" data-confirm-appointment="${item.id}">Подтвердить</button>` : ""}
${["confirmed", "confirmed_by_sto"].includes(item.status) ? `<button type="button" data-create-work-order="${item.id}">Открыть заказ-наряд</button>` : ""}
<button type="button" class="ghost-btn" data-reject-appointment="${item.id}">Отклонить</button>
${!["converted_to_work_order", "completed"].includes(item.status) ? `<button type="button" class="danger-btn" data-delete-appointment="${item.id}">Удалить</button>` : ""}
</div>` : ""}
</div>
`).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`, {

View File

@@ -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);
}