- Add a single shared, pure vehicle-status evaluator (app/services/vehicle_status.py)
used identically by the data-quality scanner, a new non-mutating status-recommendation
preview endpoint, and a transactional apply endpoint with optimistic-concurrency token
revalidation -- eliminates the old opaque "calculate and apply" action and the unsafe
"maintenance + active booking -> auto rented" shortcut. Frontend
DataQualityIssueDetail.tsx now shows a review/decide/confirm panel with localized
why/evidence/consequence text in nl-BE/en-GB/fr-BE, with an exact "Change status to
<status>" confirm action per the brief.
- Fix MO-016 issue-order dependency: resolving the booking-overlap issue before vs.
after the status-conflict issue now converges on the same final vehicle status,
proven by test_mo_016_status_conflict_recommendation_is_order_independent.
- Make "Fleet Ops" a non-localizable brand constant (frontend/src/product.ts,
backend PRODUCT_NAME) via {{productName}} interpolation everywhere the brand name
appeared in locale prose; add a permanent test guarding against a translation file
ever defining the brand name or an "appName" key again.
- Convert dynamic backend prose to stable message codes + params: return status
reasons, audit field/actor-type labels, automation last_error, and search
section/vehicle/booking/issue results all now carry codes the frontend localizes,
with raw technical text demoted to a "Technical details" disclosure.
- docs/fleet-ops-correction/: gap audit, i18n inventory, and the vehicle-status
decision table documenting the evaluator's rules and safe-status principles.
148 backend tests + Ruff + mypy green; Alembic migration verified upgrade/downgrade;
frontend tsc/build and the i18n-coverage Playwright suite green.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
176 lines
5.5 KiB
Python
176 lines
5.5 KiB
Python
from __future__ import annotations
|
|
|
|
from fastapi import APIRouter, Depends, Query
|
|
from sqlalchemy import or_, select
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.api.deps import get_current_user, get_db
|
|
from app.models.booking import Booking
|
|
from app.models.data_quality import DataQualityIssue
|
|
from app.models.vehicle import Vehicle
|
|
from app.schemas import CurrentUser, SearchResponse, SearchResultItem
|
|
|
|
router = APIRouter(prefix="/api/v1/search", tags=["search"])
|
|
|
|
# Static application sections. `id` is a stable code matching navigation.json's
|
|
# `items.*` keys -- the frontend localizes both the section label and its one-line
|
|
# detail from `id`, so no English prose is sent over the wire (search.sections.<id> in
|
|
# every locale; see docs/fleet-ops-correction/i18n-inventory.md). Manager-only sections
|
|
# are filtered by role, mirroring the same nav visibility rule Layout.tsx applies --
|
|
# search must never surface a destination the current role can't actually reach.
|
|
_SECTIONS: list[dict] = [
|
|
{
|
|
"id": "overview",
|
|
"link": "/dashboard",
|
|
# Search terms deliberately span all three supported UI languages (not just
|
|
# English) so a query never depends on the operator's selected locale.
|
|
"terms": ["overview", "dashboard", "readiness", "overzicht", "aperçu", "tableau de bord"],
|
|
},
|
|
{
|
|
"id": "fleet",
|
|
"link": "/vehicles",
|
|
"terms": ["fleet", "vehicle", "vehicles", "wagenpark", "voertuig", "flotte", "véhicule"],
|
|
},
|
|
{
|
|
"id": "bookings",
|
|
"link": "/bookings",
|
|
"terms": [
|
|
"booking",
|
|
"bookings",
|
|
"rental",
|
|
"boeking",
|
|
"boekingen",
|
|
"verhuur",
|
|
"réservation",
|
|
"réservations",
|
|
"location",
|
|
],
|
|
},
|
|
{
|
|
"id": "quality",
|
|
"link": "/data-quality",
|
|
"terms": [
|
|
"quality",
|
|
"data quality",
|
|
"issues",
|
|
"kwaliteit",
|
|
"datakwaliteit",
|
|
"problemen",
|
|
"qualité",
|
|
"problèmes",
|
|
],
|
|
"role": "operations_manager",
|
|
},
|
|
{
|
|
"id": "knowledge",
|
|
"link": "/knowledge",
|
|
"terms": ["knowledge", "procedures", "kennis", "procedures", "connaissances", "procédures"],
|
|
},
|
|
{
|
|
"id": "integrations",
|
|
"link": "/automation",
|
|
"terms": [
|
|
"automation",
|
|
"integrations",
|
|
"systems",
|
|
"n8n",
|
|
"automatisering",
|
|
"integraties",
|
|
"systemen",
|
|
"automatisation",
|
|
"intégrations",
|
|
"systèmes",
|
|
],
|
|
"role": "operations_manager",
|
|
},
|
|
{
|
|
"id": "audit",
|
|
"link": "/audit",
|
|
"terms": ["audit", "history", "geschiedenis", "historique"],
|
|
"role": "operations_manager",
|
|
},
|
|
]
|
|
|
|
|
|
@router.get("", response_model=SearchResponse)
|
|
def search(
|
|
q: str = Query(min_length=1, max_length=100),
|
|
db: Session = Depends(get_db),
|
|
user: CurrentUser = Depends(get_current_user),
|
|
) -> SearchResponse:
|
|
query = q.strip()
|
|
normalized = query.lower()
|
|
results: list[SearchResultItem] = []
|
|
|
|
for section in _SECTIONS:
|
|
role = section.get("role")
|
|
if role and user.role != role:
|
|
continue
|
|
terms: list[str] = section["terms"]
|
|
if any(term in normalized or normalized in term for term in terms):
|
|
results.append(
|
|
SearchResultItem(
|
|
type="section",
|
|
label=section["id"],
|
|
detail_code=section["id"],
|
|
link=section["link"],
|
|
)
|
|
)
|
|
|
|
like = f"%{query}%"
|
|
for v in db.scalars(
|
|
select(Vehicle)
|
|
.where(
|
|
or_(
|
|
Vehicle.public_ref.ilike(like),
|
|
Vehicle.make.ilike(like),
|
|
Vehicle.model.ilike(like),
|
|
Vehicle.registration_number.ilike(like),
|
|
Vehicle.location.ilike(like),
|
|
)
|
|
)
|
|
.order_by(Vehicle.public_ref)
|
|
.limit(5)
|
|
).all():
|
|
results.append(
|
|
SearchResultItem(
|
|
type="vehicle",
|
|
label=v.public_ref,
|
|
detail_code="vehicleSummary",
|
|
detail_params={"make": v.make, "model": v.model, "location": v.location},
|
|
link=f"/vehicles/{v.public_ref}",
|
|
)
|
|
)
|
|
|
|
for b in db.scalars(
|
|
select(Booking).where(Booking.public_ref.ilike(like)).order_by(Booking.starts_at.desc()).limit(5)
|
|
).all():
|
|
results.append(
|
|
SearchResultItem(
|
|
type="booking",
|
|
label=b.public_ref,
|
|
detail_code=b.status,
|
|
link=f"/bookings/{b.public_ref}",
|
|
)
|
|
)
|
|
|
|
# No customer detail route exists in this proof of concept, so customers are
|
|
# deliberately never returned here -- there is nowhere useful to send the user.
|
|
if user.role == "operations_manager":
|
|
for i in db.scalars(
|
|
select(DataQualityIssue)
|
|
.where(DataQualityIssue.public_ref.ilike(like))
|
|
.order_by(DataQualityIssue.detected_at.desc())
|
|
.limit(5)
|
|
).all():
|
|
results.append(
|
|
SearchResultItem(
|
|
type="data_quality_issue",
|
|
label=i.public_ref,
|
|
detail_code=i.rule_type,
|
|
link=f"/data-quality/{i.public_ref}",
|
|
)
|
|
)
|
|
|
|
return SearchResponse(query=query, results=results[:10])
|