fix: safe status-recommendation flow, MO-016 order independence, brand constant, message codes
- 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>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
18344bc8b7
commit
6deb95524d
@@ -0,0 +1,25 @@
|
|||||||
|
"""outbox last_error_code
|
||||||
|
|
||||||
|
Revision ID: 799d8800e241
|
||||||
|
Revises: e7b08389f47f
|
||||||
|
Create Date: 2026-08-03 10:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '799d8800e241'
|
||||||
|
down_revision: Union[str, None] = 'e7b08389f47f'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.add_column('outbox_events', sa.Column('last_error_code', sa.String(length=60), nullable=True))
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_column('outbox_events', 'last_error_code')
|
||||||
@@ -89,6 +89,8 @@ def preview_return(
|
|||||||
resulting_odometer_km=evaluation.resulting_odometer_km,
|
resulting_odometer_km=evaluation.resulting_odometer_km,
|
||||||
resulting_vehicle_status=evaluation.resulting_vehicle_status,
|
resulting_vehicle_status=evaluation.resulting_vehicle_status,
|
||||||
status_reason=evaluation.status_reason,
|
status_reason=evaluation.status_reason,
|
||||||
|
status_reason_code=evaluation.status_reason_code,
|
||||||
|
status_reason_params=evaluation.status_reason_params,
|
||||||
would_create_quality_issue=evaluation.would_create_quality_issue,
|
would_create_quality_issue=evaluation.would_create_quality_issue,
|
||||||
attention_reasons=evaluation.attention_reasons,
|
attention_reasons=evaluation.attention_reasons,
|
||||||
next_booking_risk=(
|
next_booking_risk=(
|
||||||
|
|||||||
@@ -108,6 +108,7 @@ def get_dashboard(
|
|||||||
status=r.delivery_status,
|
status=r.delivery_status,
|
||||||
attempts=r.attempts,
|
attempts=r.attempts,
|
||||||
last_error=r.last_error,
|
last_error=r.last_error,
|
||||||
|
last_error_code=r.last_error_code,
|
||||||
occurred_at=r.occurred_at,
|
occurred_at=r.occurred_at,
|
||||||
)
|
)
|
||||||
for r in recent
|
for r in recent
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ from app.models.data_quality import DataQualityIssue
|
|||||||
from app.models.inspection import Inspection
|
from app.models.inspection import Inspection
|
||||||
from app.models.vehicle import Vehicle
|
from app.models.vehicle import Vehicle
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
|
ApplyRecommendedStatusRequest,
|
||||||
ApplyRecommendedStatusResult,
|
ApplyRecommendedStatusResult,
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
DataQualityIssueDetailOut,
|
DataQualityIssueDetailOut,
|
||||||
@@ -21,11 +22,14 @@ from app.schemas import (
|
|||||||
ResolveOdometerRegressionRequest,
|
ResolveOdometerRegressionRequest,
|
||||||
ResolveOverlapRequest,
|
ResolveOverlapRequest,
|
||||||
ScanResultOut,
|
ScanResultOut,
|
||||||
|
StatusRecommendationOut,
|
||||||
|
VehicleStatusFactsOut,
|
||||||
)
|
)
|
||||||
from app.services.data_quality import (
|
from app.services.data_quality import (
|
||||||
apply_recommended_status,
|
apply_recommended_status,
|
||||||
defer_issue,
|
defer_issue,
|
||||||
merge_customers,
|
merge_customers,
|
||||||
|
preview_vehicle_status_recommendation,
|
||||||
provide_missing_fields,
|
provide_missing_fields,
|
||||||
reject_issue,
|
reject_issue,
|
||||||
resolve_booking_overlap,
|
resolve_booking_overlap,
|
||||||
@@ -241,17 +245,43 @@ def resolve_overlap(
|
|||||||
return _to_out(issue)
|
return _to_out(issue)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/issues/{public_ref}/status-recommendation", response_model=StatusRecommendationOut
|
||||||
|
)
|
||||||
|
def status_recommendation(
|
||||||
|
public_ref: str,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_operations_manager),
|
||||||
|
) -> StatusRecommendationOut:
|
||||||
|
"""Non-mutating preview: computes the recommendation without changing anything,
|
||||||
|
resolving no issue and writing no audit event. Safe to call repeatedly."""
|
||||||
|
_issue, _vehicle, recommendation, token = preview_vehicle_status_recommendation(db, public_ref)
|
||||||
|
return StatusRecommendationOut(
|
||||||
|
current_status=recommendation.current_status,
|
||||||
|
recommended_status=recommendation.recommended_status,
|
||||||
|
recommendation_code=recommendation.recommendation_code,
|
||||||
|
safe_to_apply=recommendation.safe_to_apply,
|
||||||
|
manual_review_required=recommendation.manual_review_required,
|
||||||
|
facts=VehicleStatusFactsOut(**recommendation.facts.as_dict()),
|
||||||
|
blocking_reasons=recommendation.blocking_reasons,
|
||||||
|
recommendation_token=token,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
@router.post(
|
||||||
"/issues/{public_ref}/apply-recommended-status", response_model=ApplyRecommendedStatusResult
|
"/issues/{public_ref}/apply-recommended-status", response_model=ApplyRecommendedStatusResult
|
||||||
)
|
)
|
||||||
def apply_status(
|
def apply_status(
|
||||||
public_ref: str,
|
public_ref: str,
|
||||||
|
body: ApplyRecommendedStatusRequest,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
user: CurrentUser = Depends(require_operations_manager),
|
user: CurrentUser = Depends(require_operations_manager),
|
||||||
) -> ApplyRecommendedStatusResult:
|
) -> ApplyRecommendedStatusResult:
|
||||||
issue, applied_status, reason = apply_recommended_status(db, public_ref, user)
|
issue, applied_status, reason_code = apply_recommended_status(
|
||||||
|
db, public_ref, user, body.recommendation_token
|
||||||
|
)
|
||||||
return ApplyRecommendedStatusResult(
|
return ApplyRecommendedStatusResult(
|
||||||
issue=_to_out(issue), applied_status=applied_status, reason=reason
|
issue=_to_out(issue), applied_status=applied_status, reason_code=reason_code
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,53 +12,81 @@ from app.schemas import CurrentUser, SearchResponse, SearchResultItem
|
|||||||
|
|
||||||
router = APIRouter(prefix="/api/v1/search", tags=["search"])
|
router = APIRouter(prefix="/api/v1/search", tags=["search"])
|
||||||
|
|
||||||
# Static application sections. Manager-only sections are filtered by role, mirroring the
|
# Static application sections. `id` is a stable code matching navigation.json's
|
||||||
# same nav visibility rule Layout.tsx applies -- search must never surface a destination
|
# `items.*` keys -- the frontend localizes both the section label and its one-line
|
||||||
# the current role can't actually reach.
|
# 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] = [
|
_SECTIONS: list[dict] = [
|
||||||
{
|
{
|
||||||
"label": "Overview",
|
"id": "overview",
|
||||||
"detail": "Operations dashboard",
|
|
||||||
"link": "/dashboard",
|
"link": "/dashboard",
|
||||||
"terms": ["overview", "dashboard", "readiness"],
|
# 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"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Fleet",
|
"id": "fleet",
|
||||||
"detail": "Vehicle registry",
|
|
||||||
"link": "/vehicles",
|
"link": "/vehicles",
|
||||||
"terms": ["fleet", "vehicle", "vehicles"],
|
"terms": ["fleet", "vehicle", "vehicles", "wagenpark", "voertuig", "flotte", "véhicule"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Bookings",
|
"id": "bookings",
|
||||||
"detail": "Rental bookings",
|
|
||||||
"link": "/bookings",
|
"link": "/bookings",
|
||||||
"terms": ["booking", "bookings", "rental"],
|
"terms": [
|
||||||
|
"booking",
|
||||||
|
"bookings",
|
||||||
|
"rental",
|
||||||
|
"boeking",
|
||||||
|
"boekingen",
|
||||||
|
"verhuur",
|
||||||
|
"réservation",
|
||||||
|
"réservations",
|
||||||
|
"location",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Data quality",
|
"id": "quality",
|
||||||
"detail": "Quality workbench",
|
|
||||||
"link": "/data-quality",
|
"link": "/data-quality",
|
||||||
"terms": ["quality", "data quality", "issues"],
|
"terms": [
|
||||||
|
"quality",
|
||||||
|
"data quality",
|
||||||
|
"issues",
|
||||||
|
"kwaliteit",
|
||||||
|
"datakwaliteit",
|
||||||
|
"problemen",
|
||||||
|
"qualité",
|
||||||
|
"problèmes",
|
||||||
|
],
|
||||||
"role": "operations_manager",
|
"role": "operations_manager",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Knowledge",
|
"id": "knowledge",
|
||||||
"detail": "Procedure assistant",
|
|
||||||
"link": "/knowledge",
|
"link": "/knowledge",
|
||||||
"terms": ["knowledge", "procedures"],
|
"terms": ["knowledge", "procedures", "kennis", "procedures", "connaissances", "procédures"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Integrations",
|
"id": "integrations",
|
||||||
"detail": "Automation and integration status",
|
|
||||||
"link": "/automation",
|
"link": "/automation",
|
||||||
"terms": ["automation", "integrations", "systems", "n8n"],
|
"terms": [
|
||||||
|
"automation",
|
||||||
|
"integrations",
|
||||||
|
"systems",
|
||||||
|
"n8n",
|
||||||
|
"automatisering",
|
||||||
|
"integraties",
|
||||||
|
"systemen",
|
||||||
|
"automatisation",
|
||||||
|
"intégrations",
|
||||||
|
"systèmes",
|
||||||
|
],
|
||||||
"role": "operations_manager",
|
"role": "operations_manager",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Audit trail",
|
"id": "audit",
|
||||||
"detail": "Audit history",
|
|
||||||
"link": "/audit",
|
"link": "/audit",
|
||||||
"terms": ["audit", "history"],
|
"terms": ["audit", "history", "geschiedenis", "historique"],
|
||||||
"role": "operations_manager",
|
"role": "operations_manager",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -83,8 +111,8 @@ def search(
|
|||||||
results.append(
|
results.append(
|
||||||
SearchResultItem(
|
SearchResultItem(
|
||||||
type="section",
|
type="section",
|
||||||
label=section["label"],
|
label=section["id"],
|
||||||
detail=section["detail"],
|
detail_code=section["id"],
|
||||||
link=section["link"],
|
link=section["link"],
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -108,7 +136,8 @@ def search(
|
|||||||
SearchResultItem(
|
SearchResultItem(
|
||||||
type="vehicle",
|
type="vehicle",
|
||||||
label=v.public_ref,
|
label=v.public_ref,
|
||||||
detail=f"{v.make} {v.model} · {v.location}",
|
detail_code="vehicleSummary",
|
||||||
|
detail_params={"make": v.make, "model": v.model, "location": v.location},
|
||||||
link=f"/vehicles/{v.public_ref}",
|
link=f"/vehicles/{v.public_ref}",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -120,7 +149,7 @@ def search(
|
|||||||
SearchResultItem(
|
SearchResultItem(
|
||||||
type="booking",
|
type="booking",
|
||||||
label=b.public_ref,
|
label=b.public_ref,
|
||||||
detail=b.status,
|
detail_code=b.status,
|
||||||
link=f"/bookings/{b.public_ref}",
|
link=f"/bookings/{b.public_ref}",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -138,7 +167,7 @@ def search(
|
|||||||
SearchResultItem(
|
SearchResultItem(
|
||||||
type="data_quality_issue",
|
type="data_quality_issue",
|
||||||
label=i.public_ref,
|
label=i.public_ref,
|
||||||
detail=i.rule_type.replace("_", " "),
|
detail_code=i.rule_type,
|
||||||
link=f"/data-quality/{i.public_ref}",
|
link=f"/data-quality/{i.public_ref}",
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ def _to_out(event: OutboxEvent) -> AutomationRunOut:
|
|||||||
status=event.delivery_status,
|
status=event.delivery_status,
|
||||||
attempts=event.attempts,
|
attempts=event.attempts,
|
||||||
last_error=event.last_error,
|
last_error=event.last_error,
|
||||||
|
last_error_code=event.last_error_code,
|
||||||
occurred_at=event.occurred_at,
|
occurred_at=event.occurred_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,12 @@ from functools import lru_cache
|
|||||||
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
# The visible product name is fixed and never translated or configured per-deployment --
|
||||||
|
# see docs/fleet-ops-correction/current-gap-audit.md section 1. Internal identifiers
|
||||||
|
# (package name, Compose project, database name, repository) intentionally remain
|
||||||
|
# "mobilityops"; this constant is only for user-facing surfaces (e.g. the OpenAPI title).
|
||||||
|
PRODUCT_NAME = "Fleet Ops"
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||||
|
|||||||
+2
-2
@@ -19,7 +19,7 @@ from app.api.routers import (
|
|||||||
vehicles,
|
vehicles,
|
||||||
workflows,
|
workflows,
|
||||||
)
|
)
|
||||||
from app.core.config import get_settings
|
from app.core.config import PRODUCT_NAME, get_settings
|
||||||
from app.core.errors import AppError, error_body
|
from app.core.errors import AppError, error_body
|
||||||
from app.services.dispatcher import start_background_dispatcher, stop_background_dispatcher
|
from app.services.dispatcher import start_background_dispatcher, stop_background_dispatcher
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@ async def lifespan(_app: FastAPI):
|
|||||||
stop_background_dispatcher()
|
stop_background_dispatcher()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="MobilityOps API", version="0.1.0", lifespan=lifespan)
|
app = FastAPI(title=f"{PRODUCT_NAME} API", version="0.1.0", lifespan=lifespan)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
|
|||||||
@@ -26,4 +26,9 @@ class OutboxEvent(TimestampMixin, Base):
|
|||||||
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||||
last_error: Mapped[str | None] = mapped_column(Text)
|
last_error: Mapped[str | None] = mapped_column(Text)
|
||||||
|
# Stable, localizable classification of last_error -- the frontend renders a
|
||||||
|
# localized summary from this code as the primary text and shows last_error itself
|
||||||
|
# only under "Technical details" (section 10 of docs/fleet-ops-correction/
|
||||||
|
# current-gap-audit.md). Kept alongside the raw message for backward compatibility.
|
||||||
|
last_error_code: Mapped[str | None] = mapped_column(String(60))
|
||||||
external_run_id: Mapped[str | None] = mapped_column(String(120))
|
external_run_id: Mapped[str | None] = mapped_column(String(120))
|
||||||
|
|||||||
+30
-2
@@ -83,6 +83,8 @@ class ReturnPreviewResult(BaseModel):
|
|||||||
resulting_odometer_km: int
|
resulting_odometer_km: int
|
||||||
resulting_vehicle_status: str
|
resulting_vehicle_status: str
|
||||||
status_reason: str
|
status_reason: str
|
||||||
|
status_reason_code: str
|
||||||
|
status_reason_params: dict[str, str | int] = {}
|
||||||
would_create_quality_issue: bool
|
would_create_quality_issue: bool
|
||||||
attention_reasons: list[str]
|
attention_reasons: list[str]
|
||||||
next_booking_risk: NextBookingRisk | None
|
next_booking_risk: NextBookingRisk | None
|
||||||
@@ -157,16 +159,41 @@ class ResolveOverlapRequest(BaseModel):
|
|||||||
note: str | None = Field(default=None, max_length=500)
|
note: str | None = Field(default=None, max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleStatusFactsOut(BaseModel):
|
||||||
|
active_booking_refs: list[str]
|
||||||
|
overlapping_booking_pairs: list[list[str]]
|
||||||
|
service_threshold_reached: bool
|
||||||
|
odometer_km: int
|
||||||
|
next_service_km: int
|
||||||
|
open_booking_overlap_issue_ref: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class StatusRecommendationOut(BaseModel):
|
||||||
|
current_status: str
|
||||||
|
recommended_status: str | None
|
||||||
|
recommendation_code: str
|
||||||
|
safe_to_apply: bool
|
||||||
|
manual_review_required: bool
|
||||||
|
facts: VehicleStatusFactsOut
|
||||||
|
blocking_reasons: list[str]
|
||||||
|
recommendation_token: str
|
||||||
|
|
||||||
|
|
||||||
|
class ApplyRecommendedStatusRequest(BaseModel):
|
||||||
|
recommendation_token: str
|
||||||
|
|
||||||
|
|
||||||
class ApplyRecommendedStatusResult(BaseModel):
|
class ApplyRecommendedStatusResult(BaseModel):
|
||||||
issue: DataQualityIssueOut
|
issue: DataQualityIssueOut
|
||||||
applied_status: str
|
applied_status: str
|
||||||
reason: str
|
reason_code: str
|
||||||
|
|
||||||
|
|
||||||
class SearchResultItem(BaseModel):
|
class SearchResultItem(BaseModel):
|
||||||
type: Literal["vehicle", "booking", "data_quality_issue", "section"]
|
type: Literal["vehicle", "booking", "data_quality_issue", "section"]
|
||||||
label: str
|
label: str
|
||||||
detail: str
|
detail_code: str
|
||||||
|
detail_params: dict[str, str] = {}
|
||||||
link: str
|
link: str
|
||||||
|
|
||||||
|
|
||||||
@@ -269,6 +296,7 @@ class AutomationRunOut(BaseModel):
|
|||||||
status: str
|
status: str
|
||||||
attempts: int
|
attempts: int
|
||||||
last_error: str | None
|
last_error: str | None
|
||||||
|
last_error_code: str | None
|
||||||
occurred_at: datetime
|
occurred_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -287,6 +287,9 @@ def load_seed(db: Session) -> SeedResult:
|
|||||||
"attempts": int(row["attempts"]),
|
"attempts": int(row["attempts"]),
|
||||||
"next_attempt_at": None,
|
"next_attempt_at": None,
|
||||||
"last_error": row["last_error"] or None,
|
"last_error": row["last_error"] or None,
|
||||||
|
# The seed dataset's one synthetic failure (BK-H-0020) models a
|
||||||
|
# connection-timeout-style delivery failure -- see workflow_runs.csv.
|
||||||
|
"last_error_code": "connectionError" if row["last_error"] else None,
|
||||||
"external_run_id": None,
|
"external_run_id": None,
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ from app.models.data_quality import DataQualityIssue
|
|||||||
from app.models.vehicle import Vehicle
|
from app.models.vehicle import Vehicle
|
||||||
from app.schemas import CurrentUser, ResolveOdometerRegressionRequest
|
from app.schemas import CurrentUser, ResolveOdometerRegressionRequest
|
||||||
from app.services.audit import record_audit_event
|
from app.services.audit import record_audit_event
|
||||||
|
from app.services.vehicle_status import (
|
||||||
|
RECOMMENDATION_CODE_NO_CONFLICT,
|
||||||
|
VehicleStatusRecommendation,
|
||||||
|
compute_recommendation_token,
|
||||||
|
evaluate_vehicle_status,
|
||||||
|
gather_vehicle_status_facts,
|
||||||
|
)
|
||||||
|
|
||||||
REQUIRED_CUSTOMER_FIELDS = ("first_name", "last_name")
|
REQUIRED_CUSTOMER_FIELDS = ("first_name", "last_name")
|
||||||
REQUIRED_VEHICLE_FIELDS = ("registration_number", "make", "model", "location")
|
REQUIRED_VEHICLE_FIELDS = ("registration_number", "make", "model", "location")
|
||||||
@@ -69,6 +76,7 @@ def _open_issue(
|
|||||||
summary: str,
|
summary: str,
|
||||||
entity_ref: str,
|
entity_ref: str,
|
||||||
related_refs: list[str],
|
related_refs: list[str],
|
||||||
|
signals: list[dict] | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
if _has_open_issue(db, rule_type, entity_type, entity_id):
|
if _has_open_issue(db, rule_type, entity_type, entity_id):
|
||||||
return
|
return
|
||||||
@@ -87,10 +95,14 @@ def _open_issue(
|
|||||||
)
|
)
|
||||||
.order_by(DataQualityIssue.detected_at.desc())
|
.order_by(DataQualityIssue.detected_at.desc())
|
||||||
)
|
)
|
||||||
|
# `summary` is kept as a technical-fallback string (shown only under "Technical
|
||||||
|
# details"); `signals` is the stable, localizable structure the frontend renders as
|
||||||
|
# the primary evidence -- see docs/fleet-ops-correction/current-gap-audit.md §2/§6.
|
||||||
evidence: dict = {
|
evidence: dict = {
|
||||||
"summary": summary,
|
"summary": summary,
|
||||||
"entity_ref": entity_ref,
|
"entity_ref": entity_ref,
|
||||||
"related_refs": related_refs,
|
"related_refs": related_refs,
|
||||||
|
"signals": signals or [],
|
||||||
}
|
}
|
||||||
if previous is not None:
|
if previous is not None:
|
||||||
evidence["reopened_from"] = previous.public_ref
|
evidence["reopened_from"] = previous.public_ref
|
||||||
@@ -121,22 +133,29 @@ def _scan_duplicate_customers(db: Session, scan: ScanResult) -> None:
|
|||||||
for i, a in enumerate(customers):
|
for i, a in enumerate(customers):
|
||||||
for b in customers[i + 1 :]:
|
for b in customers[i + 1 :]:
|
||||||
score = 0
|
score = 0
|
||||||
signals = []
|
signals: list[dict] = []
|
||||||
|
summary_parts: list[str] = []
|
||||||
if _normalize(a.email) and _normalize(a.email) == _normalize(b.email):
|
if _normalize(a.email) and _normalize(a.email) == _normalize(b.email):
|
||||||
score += 60
|
score += 60
|
||||||
signals.append("exact email")
|
signals.append({"code": "duplicate.exact_email"})
|
||||||
|
summary_parts.append("exact email")
|
||||||
if _normalize(a.phone) and _normalize(a.phone) == _normalize(b.phone):
|
if _normalize(a.phone) and _normalize(a.phone) == _normalize(b.phone):
|
||||||
score += 50
|
score += 50
|
||||||
signals.append("exact phone")
|
signals.append({"code": "duplicate.exact_phone"})
|
||||||
|
summary_parts.append("exact phone")
|
||||||
if _normalize(a.postal_code) and _normalize(a.postal_code) == _normalize(b.postal_code):
|
if _normalize(a.postal_code) and _normalize(a.postal_code) == _normalize(b.postal_code):
|
||||||
score += 10
|
score += 10
|
||||||
signals.append("exact postal code")
|
signals.append({"code": "duplicate.same_postal_code"})
|
||||||
|
summary_parts.append("exact postal code")
|
||||||
name_a = f"{_normalize(a.first_name)} {_normalize(a.last_name)}"
|
name_a = f"{_normalize(a.first_name)} {_normalize(a.last_name)}"
|
||||||
name_b = f"{_normalize(b.first_name)} {_normalize(b.last_name)}"
|
name_b = f"{_normalize(b.first_name)} {_normalize(b.last_name)}"
|
||||||
ratio = SequenceMatcher(None, name_a, name_b).ratio()
|
ratio = SequenceMatcher(None, name_a, name_b).ratio()
|
||||||
if ratio >= 0.5:
|
if ratio >= 0.5:
|
||||||
score += round(ratio * 30)
|
score += round(ratio * 30)
|
||||||
signals.append("similar name")
|
signals.append(
|
||||||
|
{"code": "duplicate.similar_name", "params": {"score": round(ratio, 2)}}
|
||||||
|
)
|
||||||
|
summary_parts.append("similar name")
|
||||||
|
|
||||||
if score >= DUPLICATE_THRESHOLD:
|
if score >= DUPLICATE_THRESHOLD:
|
||||||
_open_issue(
|
_open_issue(
|
||||||
@@ -146,9 +165,10 @@ def _scan_duplicate_customers(db: Session, scan: ScanResult) -> None:
|
|||||||
entity_type="customer",
|
entity_type="customer",
|
||||||
entity_id=a.id,
|
entity_id=a.id,
|
||||||
severity="high",
|
severity="high",
|
||||||
summary="; ".join(signals) + f" (score {score})",
|
summary="; ".join(summary_parts) + f" (score {score})",
|
||||||
entity_ref=a.public_ref,
|
entity_ref=a.public_ref,
|
||||||
related_refs=[b.public_ref],
|
related_refs=[b.public_ref],
|
||||||
|
signals=signals,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -170,6 +190,7 @@ def _scan_missing_required_fields(db: Session, scan: ScanResult) -> None:
|
|||||||
summary=f"Missing: {', '.join(missing)}",
|
summary=f"Missing: {', '.join(missing)}",
|
||||||
entity_ref=customer.public_ref,
|
entity_ref=customer.public_ref,
|
||||||
related_refs=[],
|
related_refs=[],
|
||||||
|
signals=[{"code": "missing_field", "params": {"field": f}} for f in missing],
|
||||||
)
|
)
|
||||||
|
|
||||||
for vehicle in db.scalars(select(Vehicle).where(Vehicle.active.is_(True))).all():
|
for vehicle in db.scalars(select(Vehicle).where(Vehicle.active.is_(True))).all():
|
||||||
@@ -185,6 +206,7 @@ def _scan_missing_required_fields(db: Session, scan: ScanResult) -> None:
|
|||||||
summary=f"Missing: {', '.join(missing)}",
|
summary=f"Missing: {', '.join(missing)}",
|
||||||
entity_ref=vehicle.public_ref,
|
entity_ref=vehicle.public_ref,
|
||||||
related_refs=[],
|
related_refs=[],
|
||||||
|
signals=[{"code": "missing_field", "params": {"field": f}} for f in missing],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -213,50 +235,47 @@ def _scan_booking_overlaps(db: Session, scan: ScanResult) -> None:
|
|||||||
summary=f"Overlapping bookings {first.public_ref} and {second.public_ref}",
|
summary=f"Overlapping bookings {first.public_ref} and {second.public_ref}",
|
||||||
entity_ref=vehicle.public_ref,
|
entity_ref=vehicle.public_ref,
|
||||||
related_refs=[first.public_ref, second.public_ref],
|
related_refs=[first.public_ref, second.public_ref],
|
||||||
|
signals=[
|
||||||
|
{
|
||||||
|
"code": "overlap.reserved_bookings",
|
||||||
|
"params": {"refs": [first.public_ref, second.public_ref]},
|
||||||
|
}
|
||||||
|
],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _scan_vehicle_status_conflicts(db: Session, scan: ScanResult) -> None:
|
def _scan_vehicle_status_conflicts(db: Session, scan: ScanResult) -> None:
|
||||||
|
# Uses the same shared evaluator as the preview/apply flow (app.services.vehicle_status)
|
||||||
|
# so detection and resolution can never structurally disagree -- see
|
||||||
|
# docs/fleet-ops-correction/vehicle-status-decision-table.md.
|
||||||
vehicles = db.scalars(select(Vehicle)).all()
|
vehicles = db.scalars(select(Vehicle)).all()
|
||||||
active_by_vehicle: dict[uuid.UUID, list[Booking]] = {}
|
|
||||||
for booking in db.scalars(select(Booking).where(Booking.status == "active")).all():
|
|
||||||
active_by_vehicle.setdefault(booking.vehicle_id, []).append(booking)
|
|
||||||
|
|
||||||
open_high_by_vehicle = {
|
|
||||||
row[0]
|
|
||||||
for row in db.execute(
|
|
||||||
select(DataQualityIssue.entity_id).where(
|
|
||||||
DataQualityIssue.entity_type == "vehicle",
|
|
||||||
DataQualityIssue.status == "open",
|
|
||||||
DataQualityIssue.severity == "high",
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
}
|
|
||||||
|
|
||||||
for vehicle in vehicles:
|
for vehicle in vehicles:
|
||||||
has_active_booking = vehicle.id in active_by_vehicle
|
facts = gather_vehicle_status_facts(db, vehicle)
|
||||||
reason = None
|
recommendation = evaluate_vehicle_status(vehicle, facts)
|
||||||
if vehicle.operational_status == "available" and has_active_booking:
|
if recommendation.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT:
|
||||||
reason = "marked available while an active booking exists"
|
continue
|
||||||
elif vehicle.operational_status == "rented" and not has_active_booking:
|
|
||||||
reason = "marked rented without an active booking"
|
|
||||||
elif vehicle.operational_status == "available" and vehicle.id in open_high_by_vehicle:
|
|
||||||
reason = "marked available while a high-severity quality issue is open"
|
|
||||||
elif vehicle.operational_status == "maintenance" and has_active_booking:
|
|
||||||
reason = "marked maintenance while an active booking exists"
|
|
||||||
|
|
||||||
if reason:
|
signals = [{"code": recommendation.recommendation_code, "params": facts.as_dict()}]
|
||||||
_open_issue(
|
summary = (
|
||||||
db,
|
f"Recommended status: {recommendation.recommended_status}"
|
||||||
scan,
|
if recommendation.recommended_status
|
||||||
rule_type="vehicle_status_conflict",
|
else "Manual review required: active rental conflicts with a blocking condition"
|
||||||
entity_type="vehicle",
|
)
|
||||||
entity_id=vehicle.id,
|
_open_issue(
|
||||||
severity="high",
|
db,
|
||||||
summary=f"Vehicle {reason}",
|
scan,
|
||||||
entity_ref=vehicle.public_ref,
|
rule_type="vehicle_status_conflict",
|
||||||
related_refs=[],
|
entity_type="vehicle",
|
||||||
)
|
entity_id=vehicle.id,
|
||||||
|
severity="high",
|
||||||
|
summary=summary,
|
||||||
|
entity_ref=vehicle.public_ref,
|
||||||
|
related_refs=[
|
||||||
|
*facts.active_booking_refs,
|
||||||
|
*(ref for pair in facts.overlapping_booking_pairs for ref in pair),
|
||||||
|
],
|
||||||
|
signals=signals,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _scan_odometer_regressions(db: Session, scan: ScanResult) -> None:
|
def _scan_odometer_regressions(db: Session, scan: ScanResult) -> None:
|
||||||
@@ -295,6 +314,17 @@ def _scan_odometer_regressions(db: Session, scan: ScanResult) -> None:
|
|||||||
),
|
),
|
||||||
entity_ref=vehicle.public_ref,
|
entity_ref=vehicle.public_ref,
|
||||||
related_refs=[earlier.public_ref, later.public_ref],
|
related_refs=[earlier.public_ref, later.public_ref],
|
||||||
|
signals=[
|
||||||
|
{
|
||||||
|
"code": "odometer.regression",
|
||||||
|
"params": {
|
||||||
|
"later_ref": later.public_ref,
|
||||||
|
"later_km": later.end_odometer_km,
|
||||||
|
"earlier_ref": earlier.public_ref,
|
||||||
|
"earlier_km": earlier.end_odometer_km,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -650,28 +680,7 @@ def resolve_booking_overlap(
|
|||||||
return issue
|
return issue
|
||||||
|
|
||||||
|
|
||||||
def _recommend_vehicle_status(
|
def _load_vehicle_status_conflict_issue(db: Session, public_ref: str) -> DataQualityIssue:
|
||||||
operational_status: str, has_active_booking: bool, has_open_high_issue: bool
|
|
||||||
) -> tuple[str, str] | None:
|
|
||||||
"""The single authoritative recommendation function for vehicle_status_conflict,
|
|
||||||
mirroring the exact conditions `_scan_vehicle_status_conflicts` flags."""
|
|
||||||
if operational_status == "available" and has_active_booking:
|
|
||||||
return "rented", "An active booking exists; the vehicle should be marked rented."
|
|
||||||
if operational_status == "rented" and not has_active_booking:
|
|
||||||
return "available", "No active booking exists; the vehicle should be marked available."
|
|
||||||
if operational_status == "available" and has_open_high_issue:
|
|
||||||
return "blocked", "A high-severity quality issue is open; the vehicle should be blocked."
|
|
||||||
if operational_status == "maintenance" and has_active_booking:
|
|
||||||
return (
|
|
||||||
"rented",
|
|
||||||
"An active booking exists despite the maintenance status; it should be rented.",
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def apply_recommended_status(
|
|
||||||
db: Session, public_ref: str, actor: CurrentUser
|
|
||||||
) -> tuple[DataQualityIssue, str, str]:
|
|
||||||
issue = _load_open_issue(db, public_ref)
|
issue = _load_open_issue(db, public_ref)
|
||||||
if issue.rule_type != "vehicle_status_conflict":
|
if issue.rule_type != "vehicle_status_conflict":
|
||||||
raise AppError(
|
raise AppError(
|
||||||
@@ -679,47 +688,77 @@ def apply_recommended_status(
|
|||||||
"This issue is not a vehicle_status_conflict issue.",
|
"This issue is not a vehicle_status_conflict issue.",
|
||||||
status_code=409,
|
status_code=409,
|
||||||
)
|
)
|
||||||
|
return issue
|
||||||
|
|
||||||
|
|
||||||
|
def preview_vehicle_status_recommendation(
|
||||||
|
db: Session, public_ref: str
|
||||||
|
) -> tuple[DataQualityIssue, Vehicle, VehicleStatusRecommendation, str]:
|
||||||
|
"""Non-mutating: computes and returns the recommendation only. Never resolves the
|
||||||
|
issue, never writes an audit event, never queues automation -- safe to call as often
|
||||||
|
as the UI needs (e.g. every time the panel is opened) with zero side effects."""
|
||||||
|
issue = _load_vehicle_status_conflict_issue(db, public_ref)
|
||||||
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id))
|
||||||
|
if vehicle is None:
|
||||||
|
raise AppError(
|
||||||
|
"VEHICLE_NOT_FOUND", "The vehicle for this issue was not found.", status_code=404
|
||||||
|
)
|
||||||
|
facts = gather_vehicle_status_facts(db, vehicle, exclude_issue_id=issue.id)
|
||||||
|
recommendation = evaluate_vehicle_status(vehicle, facts)
|
||||||
|
token = compute_recommendation_token(vehicle, facts)
|
||||||
|
return issue, vehicle, recommendation, token
|
||||||
|
|
||||||
|
|
||||||
|
def apply_recommended_status(
|
||||||
|
db: Session, public_ref: str, actor: CurrentUser, expected_token: str
|
||||||
|
) -> tuple[DataQualityIssue, str, str]:
|
||||||
|
issue = _load_vehicle_status_conflict_issue(db, public_ref)
|
||||||
|
# Lock the vehicle row for the remainder of this transaction so a concurrent apply
|
||||||
|
# (or return/checkout) can't race between our fact-gathering and the write below.
|
||||||
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id).with_for_update())
|
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == issue.entity_id).with_for_update())
|
||||||
if vehicle is None:
|
if vehicle is None:
|
||||||
raise AppError(
|
raise AppError(
|
||||||
"VEHICLE_NOT_FOUND", "The vehicle for this issue was not found.", status_code=404
|
"VEHICLE_NOT_FOUND", "The vehicle for this issue was not found.", status_code=404
|
||||||
)
|
)
|
||||||
|
|
||||||
has_active_booking = (
|
facts = gather_vehicle_status_facts(db, vehicle, exclude_issue_id=issue.id)
|
||||||
db.scalar(
|
recommendation = evaluate_vehicle_status(vehicle, facts)
|
||||||
select(Booking.id).where(Booking.vehicle_id == vehicle.id, Booking.status == "active")
|
current_token = compute_recommendation_token(vehicle, facts)
|
||||||
|
|
||||||
|
if current_token != expected_token:
|
||||||
|
raise AppError(
|
||||||
|
"RECOMMENDATION_STALE",
|
||||||
|
"The underlying facts changed since this recommendation was shown; "
|
||||||
|
"review the recommendation again before applying it.",
|
||||||
|
status_code=409,
|
||||||
)
|
)
|
||||||
is not None
|
if recommendation.manual_review_required or not recommendation.safe_to_apply:
|
||||||
)
|
raise AppError(
|
||||||
has_open_high_issue = (
|
"MANUAL_REVIEW_REQUIRED",
|
||||||
db.scalar(
|
"This vehicle's state requires manual review; no automatic status change is safe.",
|
||||||
select(DataQualityIssue.id).where(
|
status_code=409,
|
||||||
DataQualityIssue.entity_type == "vehicle",
|
|
||||||
DataQualityIssue.entity_id == vehicle.id,
|
|
||||||
DataQualityIssue.status == "open",
|
|
||||||
DataQualityIssue.severity == "high",
|
|
||||||
DataQualityIssue.id != issue.id,
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
is not None
|
if recommendation.recommended_status is None:
|
||||||
)
|
|
||||||
recommendation = _recommend_vehicle_status(
|
|
||||||
vehicle.operational_status, has_active_booking, has_open_high_issue
|
|
||||||
)
|
|
||||||
if recommendation is None:
|
|
||||||
raise AppError(
|
raise AppError(
|
||||||
"NO_CONFLICT_DETECTED",
|
"NO_CONFLICT_DETECTED",
|
||||||
"The current vehicle state no longer conflicts; nothing to apply.",
|
"The current vehicle state no longer conflicts; nothing to apply.",
|
||||||
status_code=409,
|
status_code=409,
|
||||||
)
|
)
|
||||||
new_status, reason = recommendation
|
new_status = recommendation.recommended_status
|
||||||
|
reason_code = recommendation.recommendation_code
|
||||||
|
|
||||||
before = {"operational_status": vehicle.operational_status}
|
before = {"operational_status": vehicle.operational_status}
|
||||||
vehicle.operational_status = new_status
|
vehicle.operational_status = new_status
|
||||||
vehicle.version += 1
|
vehicle.version += 1
|
||||||
|
|
||||||
# Re-validate: the same recommendation function must find no further conflict.
|
# Re-validate against the same shared evaluator, over freshly-gathered facts, that
|
||||||
if _recommend_vehicle_status(new_status, has_active_booking, has_open_high_issue) is not None:
|
# applying this change actually leaves no conflict -- never trust the pre-computed
|
||||||
|
# recommendation alone for the post-condition.
|
||||||
|
post_facts = gather_vehicle_status_facts(db, vehicle, exclude_issue_id=issue.id)
|
||||||
|
post_check = evaluate_vehicle_status(vehicle, post_facts)
|
||||||
|
if post_check.recommendation_code not in (
|
||||||
|
RECOMMENDATION_CODE_NO_CONFLICT,
|
||||||
|
):
|
||||||
raise AppError(
|
raise AppError(
|
||||||
"CONFLICT_STILL_PRESENT",
|
"CONFLICT_STILL_PRESENT",
|
||||||
"Applying the recommended status did not resolve the conflict.",
|
"Applying the recommended status did not resolve the conflict.",
|
||||||
@@ -737,7 +776,7 @@ def apply_recommended_status(
|
|||||||
correlation_id=correlation_id,
|
correlation_id=correlation_id,
|
||||||
before=before,
|
before=before,
|
||||||
after={"operational_status": vehicle.operational_status},
|
after={"operational_status": vehicle.operational_status},
|
||||||
metadata={"issue_ref": issue.public_ref, "reason": reason},
|
metadata={"issue_ref": issue.public_ref, "reason_code": reason_code},
|
||||||
)
|
)
|
||||||
|
|
||||||
issue.status = "resolved"
|
issue.status = "resolved"
|
||||||
@@ -755,7 +794,7 @@ def apply_recommended_status(
|
|||||||
after={"status": "resolved"},
|
after={"status": "resolved"},
|
||||||
)
|
)
|
||||||
db.commit()
|
db.commit()
|
||||||
return issue, new_status, reason
|
return issue, new_status, reason_code
|
||||||
|
|
||||||
|
|
||||||
MERGEABLE_FIELDS = ("first_name", "last_name", "email", "phone", "postal_code", "city")
|
MERGEABLE_FIELDS = ("first_name", "last_name", "email", "phone", "postal_code", "city")
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ def _reclaim_stale_deliveries(batch_size: int = 10) -> int:
|
|||||||
f"(no outcome recorded within {settings.n8n_delivery_lease_seconds:.0f}s; "
|
f"(no outcome recorded within {settings.n8n_delivery_lease_seconds:.0f}s; "
|
||||||
f"the process likely crashed mid-delivery). attempts preserved at {row.attempts}."
|
f"the process likely crashed mid-delivery). attempts preserved at {row.attempts}."
|
||||||
)[:2000]
|
)[:2000]
|
||||||
|
row.last_error_code = "staleLeaseRecovered"
|
||||||
db.commit()
|
db.commit()
|
||||||
return len(rows)
|
return len(rows)
|
||||||
finally:
|
finally:
|
||||||
@@ -111,8 +112,10 @@ def _deliver_one(event_id: uuid.UUID) -> None:
|
|||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
error_code: str | None
|
||||||
if wire_event is None:
|
if wire_event is None:
|
||||||
success, error, body = False, payload_error, None
|
success, error, body = False, payload_error, None
|
||||||
|
error_code = "malformedPayload"
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
response = httpx.post(
|
response = httpx.post(
|
||||||
@@ -124,9 +127,11 @@ def _deliver_one(event_id: uuid.UUID) -> None:
|
|||||||
body = response.json()
|
body = response.json()
|
||||||
success = bool(body.get("ok", True))
|
success = bool(body.get("ok", True))
|
||||||
error = None if success else f"n8n reported failure: {body}"
|
error = None if success else f"n8n reported failure: {body}"
|
||||||
|
error_code = None if success else "remoteReportedFailure"
|
||||||
except httpx.HTTPError as exc:
|
except httpx.HTTPError as exc:
|
||||||
success = False
|
success = False
|
||||||
error = f"{type(exc).__name__}: {exc}"
|
error = f"{type(exc).__name__}: {exc}"
|
||||||
|
error_code = "connectionError"
|
||||||
body = None
|
body = None
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
@@ -138,10 +143,12 @@ def _deliver_one(event_id: uuid.UUID) -> None:
|
|||||||
if success:
|
if success:
|
||||||
event.delivery_status = "succeeded"
|
event.delivery_status = "succeeded"
|
||||||
event.last_error = None
|
event.last_error = None
|
||||||
|
event.last_error_code = None
|
||||||
event.next_attempt_at = None
|
event.next_attempt_at = None
|
||||||
event.external_run_id = str((body or {}).get("event_id", event_id))
|
event.external_run_id = str((body or {}).get("event_id", event_id))
|
||||||
else:
|
else:
|
||||||
event.last_error = (error or "delivery failed")[:2000]
|
event.last_error = (error or "delivery failed")[:2000]
|
||||||
|
event.last_error_code = error_code or "unknownError"
|
||||||
if event.attempts >= settings.n8n_max_attempts:
|
if event.attempts >= settings.n8n_max_attempts:
|
||||||
event.delivery_status = "failed"
|
event.delivery_status = "failed"
|
||||||
event.next_attempt_at = None
|
event.next_attempt_at = None
|
||||||
|
|||||||
@@ -28,19 +28,41 @@ def _next_public_ref(db: Session) -> str:
|
|||||||
|
|
||||||
def _derive_vehicle_status_with_reason(
|
def _derive_vehicle_status_with_reason(
|
||||||
body: RegisterReturnRequest, vehicle: Vehicle, new_odometer: int
|
body: RegisterReturnRequest, vehicle: Vehicle, new_odometer: int
|
||||||
) -> tuple[str, str]:
|
) -> tuple[str, str, dict[str, str | int]]:
|
||||||
|
# Stable, localizable codes + params -- the backend never emits prose here. The
|
||||||
|
# frontend renders review.reasonCodes.<code> in the selected locale; the mirrored
|
||||||
|
# raw-English fallback strings live only in status_reason (shown under "Technical
|
||||||
|
# details") for backward compatibility. See docs/fleet-ops-correction/i18n-inventory.md.
|
||||||
if body.damage_reported and body.technical_warning:
|
if body.damage_reported and body.technical_warning:
|
||||||
return "blocked", "Damage and a technical warning were both reported on return."
|
return (
|
||||||
|
"blocked",
|
||||||
|
"returnBlockedDamageAndTechnical",
|
||||||
|
{},
|
||||||
|
)
|
||||||
if body.damage_reported:
|
if body.damage_reported:
|
||||||
return "blocked", "Damage was reported on return."
|
return "blocked", "returnBlockedDamage", {}
|
||||||
if body.technical_warning:
|
if body.technical_warning:
|
||||||
return "blocked", "A technical warning was reported on return."
|
return "blocked", "returnBlockedTechnicalWarning", {}
|
||||||
if new_odometer >= vehicle.next_service_km:
|
if new_odometer >= vehicle.next_service_km:
|
||||||
return (
|
return (
|
||||||
"maintenance",
|
"maintenance",
|
||||||
f"Odometer reached the {vehicle.next_service_km:,} km service threshold.",
|
"returnServiceThresholdReached",
|
||||||
|
{"threshold_km": vehicle.next_service_km},
|
||||||
)
|
)
|
||||||
return "cleaning", "No damage, technical warning or service threshold; routed to cleaning."
|
return "cleaning", "returnRoutedToCleaning", {}
|
||||||
|
|
||||||
|
|
||||||
|
_STATUS_REASON_FALLBACK_TEXT: dict[str, str] = {
|
||||||
|
"returnBlockedDamageAndTechnical": (
|
||||||
|
"Damage and a technical warning were both reported on return."
|
||||||
|
),
|
||||||
|
"returnBlockedDamage": "Damage was reported on return.",
|
||||||
|
"returnBlockedTechnicalWarning": "A technical warning was reported on return.",
|
||||||
|
"returnServiceThresholdReached": "Odometer reached the service threshold.",
|
||||||
|
"returnRoutedToCleaning": (
|
||||||
|
"No damage, technical warning or service threshold; routed to cleaning."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -51,6 +73,8 @@ class ReturnEvaluation:
|
|||||||
resulting_odometer_km: int
|
resulting_odometer_km: int
|
||||||
resulting_vehicle_status: str
|
resulting_vehicle_status: str
|
||||||
status_reason: str
|
status_reason: str
|
||||||
|
status_reason_code: str
|
||||||
|
status_reason_params: dict[str, str | int]
|
||||||
would_create_quality_issue: bool
|
would_create_quality_issue: bool
|
||||||
attention_reasons: list[str]
|
attention_reasons: list[str]
|
||||||
next_booking_risk: dict | None
|
next_booking_risk: dict | None
|
||||||
@@ -64,9 +88,10 @@ def evaluate_return(
|
|||||||
preview and commit can never drift apart."""
|
preview and commit can never drift apart."""
|
||||||
odometer_regression = body.end_odometer_km < vehicle.odometer_km
|
odometer_regression = body.end_odometer_km < vehicle.odometer_km
|
||||||
resulting_odometer_km = vehicle.odometer_km if odometer_regression else body.end_odometer_km
|
resulting_odometer_km = vehicle.odometer_km if odometer_regression else body.end_odometer_km
|
||||||
resulting_status, status_reason = _derive_vehicle_status_with_reason(
|
resulting_status, status_reason_code, status_reason_params = _derive_vehicle_status_with_reason(
|
||||||
body, vehicle, resulting_odometer_km
|
body, vehicle, resulting_odometer_km
|
||||||
)
|
)
|
||||||
|
status_reason = _STATUS_REASON_FALLBACK_TEXT[status_reason_code]
|
||||||
|
|
||||||
attention_reasons = []
|
attention_reasons = []
|
||||||
if body.damage_reported:
|
if body.damage_reported:
|
||||||
@@ -101,6 +126,8 @@ def evaluate_return(
|
|||||||
resulting_odometer_km=resulting_odometer_km,
|
resulting_odometer_km=resulting_odometer_km,
|
||||||
resulting_vehicle_status=resulting_status,
|
resulting_vehicle_status=resulting_status,
|
||||||
status_reason=status_reason,
|
status_reason=status_reason,
|
||||||
|
status_reason_code=status_reason_code,
|
||||||
|
status_reason_params=status_reason_params,
|
||||||
would_create_quality_issue=odometer_regression,
|
would_create_quality_issue=odometer_regression,
|
||||||
attention_reasons=attention_reasons,
|
attention_reasons=attention_reasons,
|
||||||
next_booking_risk=next_booking_risk,
|
next_booking_risk=next_booking_risk,
|
||||||
|
|||||||
@@ -0,0 +1,218 @@
|
|||||||
|
"""The single authoritative vehicle-status evaluator.
|
||||||
|
|
||||||
|
Used by the data-quality scanner (detection), the status-recommendation preview
|
||||||
|
endpoint, the apply endpoint, and tests -- so scan-time detection and resolve-time
|
||||||
|
recommendation can never structurally disagree (see docs/fleet-ops-correction/
|
||||||
|
vehicle-status-decision-table.md for the full decision table and rationale).
|
||||||
|
|
||||||
|
The evaluator only ever reasons from real, freshly-queried domain facts (an actually
|
||||||
|
active rental, a real service-threshold breach, a real overlapping-booking conflict) --
|
||||||
|
never from a proxy like "does some other high-severity issue happen to be open". It is
|
||||||
|
therefore also order-independent: resolving, deferring or rejecting an unrelated issue on
|
||||||
|
the same vehicle never changes what this function returns, because it never looks at
|
||||||
|
issue history, only at the vehicle's/bookings' current state.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from app.models.booking import Booking
|
||||||
|
from app.models.data_quality import DataQualityIssue
|
||||||
|
from app.models.vehicle import Vehicle
|
||||||
|
|
||||||
|
# Every code below is a stable, localizable identifier -- see
|
||||||
|
# frontend/src/i18n/messageCodes.ts and quality:statusRecommendation.codes.* for the
|
||||||
|
# human-language mapping in all three supported locales. The backend never emits prose.
|
||||||
|
RECOMMENDATION_CODE_ACTIVE_RENTAL = "vehicle.active_rental"
|
||||||
|
RECOMMENDATION_CODE_SERVICE_THRESHOLD = "vehicle.service_threshold_reached"
|
||||||
|
RECOMMENDATION_CODE_BOOKING_CONFLICT = "vehicle.booking_conflict"
|
||||||
|
RECOMMENDATION_CODE_RENTAL_ENDED = "vehicle.rental_ended"
|
||||||
|
RECOMMENDATION_CODE_MANUAL_REVIEW = "vehicle.manual_review_required"
|
||||||
|
RECOMMENDATION_CODE_NO_CONFLICT = "vehicle.no_conflict"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VehicleStatusFacts:
|
||||||
|
active_booking_refs: list[str] = field(default_factory=list)
|
||||||
|
overlapping_booking_pairs: list[tuple[str, str]] = field(default_factory=list)
|
||||||
|
service_threshold_reached: bool = False
|
||||||
|
odometer_km: int = 0
|
||||||
|
next_service_km: int = 0
|
||||||
|
open_booking_overlap_issue_ref: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_active_rental(self) -> bool:
|
||||||
|
return len(self.active_booking_refs) > 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def has_booking_conflict(self) -> bool:
|
||||||
|
return (
|
||||||
|
len(self.overlapping_booking_pairs) > 0
|
||||||
|
or self.open_booking_overlap_issue_ref is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
"active_booking_refs": self.active_booking_refs,
|
||||||
|
"overlapping_booking_pairs": [list(pair) for pair in self.overlapping_booking_pairs],
|
||||||
|
"service_threshold_reached": self.service_threshold_reached,
|
||||||
|
"odometer_km": self.odometer_km,
|
||||||
|
"next_service_km": self.next_service_km,
|
||||||
|
"open_booking_overlap_issue_ref": self.open_booking_overlap_issue_ref,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class VehicleStatusRecommendation:
|
||||||
|
current_status: str
|
||||||
|
recommended_status: str | None
|
||||||
|
recommendation_code: str
|
||||||
|
safe_to_apply: bool
|
||||||
|
manual_review_required: bool
|
||||||
|
facts: VehicleStatusFacts
|
||||||
|
blocking_reasons: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
def _overlapping_booking_pairs(bookings: list[Booking]) -> list[tuple[Booking, Booking]]:
|
||||||
|
ordered = sorted(bookings, key=lambda b: b.starts_at)
|
||||||
|
pairs: list[tuple[Booking, Booking]] = []
|
||||||
|
for i, first in enumerate(ordered):
|
||||||
|
for second in ordered[i + 1 :]:
|
||||||
|
if second.starts_at < first.ends_at and first.starts_at < second.ends_at:
|
||||||
|
pairs.append((first, second))
|
||||||
|
return pairs
|
||||||
|
|
||||||
|
|
||||||
|
def gather_vehicle_status_facts(
|
||||||
|
db: Session, vehicle: Vehicle, *, exclude_issue_id: uuid.UUID | None = None
|
||||||
|
) -> VehicleStatusFacts:
|
||||||
|
"""Real, freshly-queried facts only -- see module docstring. Never cached, never
|
||||||
|
derived from another issue's mere existence (only a *specific* booking_overlap
|
||||||
|
issue's presence is used, as a cross-reference to that issue's own public_ref)."""
|
||||||
|
reserved_or_active = list(
|
||||||
|
db.scalars(
|
||||||
|
select(Booking).where(
|
||||||
|
Booking.vehicle_id == vehicle.id,
|
||||||
|
Booking.status.in_(["reserved", "active"]),
|
||||||
|
)
|
||||||
|
).all()
|
||||||
|
)
|
||||||
|
active_refs = [b.public_ref for b in reserved_or_active if b.status == "active"]
|
||||||
|
overlap_pairs = [
|
||||||
|
(a.public_ref, b.public_ref) for a, b in _overlapping_booking_pairs(reserved_or_active)
|
||||||
|
]
|
||||||
|
|
||||||
|
overlap_issue_query = select(DataQualityIssue.public_ref).where(
|
||||||
|
DataQualityIssue.entity_type == "vehicle",
|
||||||
|
DataQualityIssue.entity_id == vehicle.id,
|
||||||
|
DataQualityIssue.status == "open",
|
||||||
|
DataQualityIssue.rule_type == "booking_overlap",
|
||||||
|
)
|
||||||
|
if exclude_issue_id is not None:
|
||||||
|
overlap_issue_query = overlap_issue_query.where(DataQualityIssue.id != exclude_issue_id)
|
||||||
|
open_overlap_ref = db.scalar(overlap_issue_query)
|
||||||
|
|
||||||
|
return VehicleStatusFacts(
|
||||||
|
active_booking_refs=active_refs,
|
||||||
|
overlapping_booking_pairs=overlap_pairs,
|
||||||
|
service_threshold_reached=vehicle.odometer_km >= vehicle.next_service_km,
|
||||||
|
odometer_km=vehicle.odometer_km,
|
||||||
|
next_service_km=vehicle.next_service_km,
|
||||||
|
open_booking_overlap_issue_ref=open_overlap_ref,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def compute_recommendation_token(vehicle: Vehicle, facts: VehicleStatusFacts) -> str:
|
||||||
|
"""A short digest of exactly the facts the recommendation was based on, plus the
|
||||||
|
vehicle's optimistic-lock version. The apply endpoint recomputes this from fresh
|
||||||
|
facts and rejects the request if it doesn't match the token the client last saw --
|
||||||
|
the frontend must never assume a previously-shown preview is still valid without the
|
||||||
|
server re-checking it (see docs/fleet-ops-correction/current-gap-audit.md §8F)."""
|
||||||
|
payload = {"version": vehicle.version, "status": vehicle.operational_status, **facts.as_dict()}
|
||||||
|
digest = hashlib.sha256(json.dumps(payload, sort_keys=True, default=str).encode()).hexdigest()
|
||||||
|
return digest[:16]
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_vehicle_status(
|
||||||
|
vehicle: Vehicle, facts: VehicleStatusFacts
|
||||||
|
) -> VehicleStatusRecommendation:
|
||||||
|
"""Pure decision logic over already-gathered facts -- see
|
||||||
|
docs/fleet-ops-correction/vehicle-status-decision-table.md. Never mutates anything,
|
||||||
|
never queries the database itself (call gather_vehicle_status_facts first), so it is
|
||||||
|
trivial to unit-test every branch in isolation."""
|
||||||
|
current = vehicle.operational_status
|
||||||
|
blocking_reasons: list[str] = []
|
||||||
|
if facts.service_threshold_reached:
|
||||||
|
blocking_reasons.append(RECOMMENDATION_CODE_SERVICE_THRESHOLD)
|
||||||
|
if facts.has_booking_conflict:
|
||||||
|
blocking_reasons.append(RECOMMENDATION_CODE_BOOKING_CONFLICT)
|
||||||
|
if current == "maintenance" and RECOMMENDATION_CODE_SERVICE_THRESHOLD not in blocking_reasons:
|
||||||
|
# Already being in maintenance is itself a real blocking fact -- an active
|
||||||
|
# booking never overrides it. This is exactly the forbidden shortcut this
|
||||||
|
# evaluator must never take (maintenance + active booking -> auto "rented").
|
||||||
|
blocking_reasons.append(RECOMMENDATION_CODE_SERVICE_THRESHOLD)
|
||||||
|
|
||||||
|
def result(
|
||||||
|
recommended: str | None, code: str, *, safe: bool, manual: bool
|
||||||
|
) -> VehicleStatusRecommendation:
|
||||||
|
return VehicleStatusRecommendation(
|
||||||
|
current_status=current,
|
||||||
|
recommended_status=recommended,
|
||||||
|
recommendation_code=code,
|
||||||
|
safe_to_apply=safe,
|
||||||
|
manual_review_required=manual,
|
||||||
|
facts=facts,
|
||||||
|
blocking_reasons=blocking_reasons,
|
||||||
|
)
|
||||||
|
|
||||||
|
if facts.has_active_rental and not blocking_reasons:
|
||||||
|
if current == "rented":
|
||||||
|
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
|
||||||
|
return result(
|
||||||
|
"rented", RECOMMENDATION_CODE_ACTIVE_RENTAL, safe=True, manual=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if facts.has_active_rental and blocking_reasons:
|
||||||
|
# Explicitly forbidden shortcut this evaluator must never take: an active
|
||||||
|
# booking is not proof the vehicle should be "rented" when a real blocking
|
||||||
|
# condition also exists (e.g. maintenance-due, or a genuine booking conflict).
|
||||||
|
# This is a real contradiction in the underlying facts, not something safe to
|
||||||
|
# resolve automatically.
|
||||||
|
return result(None, RECOMMENDATION_CODE_MANUAL_REVIEW, safe=False, manual=True)
|
||||||
|
|
||||||
|
if facts.service_threshold_reached:
|
||||||
|
if current == "maintenance":
|
||||||
|
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
|
||||||
|
return result(
|
||||||
|
"maintenance", RECOMMENDATION_CODE_SERVICE_THRESHOLD, safe=True, manual=False
|
||||||
|
)
|
||||||
|
|
||||||
|
if facts.has_booking_conflict:
|
||||||
|
if current == "blocked":
|
||||||
|
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
|
||||||
|
return result(
|
||||||
|
"blocked", RECOMMENDATION_CODE_BOOKING_CONFLICT, safe=True, manual=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# No active rental, no maintenance need, no booking conflict.
|
||||||
|
if current in ("available", "cleaning", "blocked"):
|
||||||
|
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
|
||||||
|
if current == "rented":
|
||||||
|
return result(
|
||||||
|
"available", RECOMMENDATION_CODE_RENTAL_ENDED, safe=True, manual=False
|
||||||
|
)
|
||||||
|
if current == "maintenance":
|
||||||
|
# No positive fact confirms maintenance is actually finished (no completed
|
||||||
|
# service record is tracked here) -- clearing "maintenance" without such a
|
||||||
|
# fact would be exactly the kind of unsafe shortcut this evaluator forbids.
|
||||||
|
# Releasing a vehicle from maintenance remains an explicit, manual decision.
|
||||||
|
return result(None, RECOMMENDATION_CODE_NO_CONFLICT, safe=False, manual=False)
|
||||||
|
|
||||||
|
return result(None, RECOMMENDATION_CODE_MANUAL_REVIEW, safe=False, manual=True)
|
||||||
@@ -249,28 +249,74 @@ def test_resolve_overlap_blocks_one_booking_and_resolves(ops_client):
|
|||||||
assert booking["status"] == "blocked"
|
assert booking["status"] == "blocked"
|
||||||
|
|
||||||
|
|
||||||
def test_apply_recommended_status_requires_operations_manager(employee_client):
|
def test_status_recommendation_requires_operations_manager(employee_client):
|
||||||
response = employee_client.post(
|
response = employee_client.post(
|
||||||
"/api/v1/data-quality/issues/DQ-DEMO-STATUS/apply-recommended-status"
|
"/api/v1/data-quality/issues/DQ-DEMO-STATUS/status-recommendation"
|
||||||
)
|
)
|
||||||
assert response.status_code == 403
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_recommended_status_requires_operations_manager(employee_client):
|
||||||
|
response = employee_client.post(
|
||||||
|
"/api/v1/data-quality/issues/DQ-DEMO-STATUS/apply-recommended-status",
|
||||||
|
json={"recommendation_token": "irrelevant"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
def test_status_recommendation_preview_does_not_mutate_anything(ops_client):
|
||||||
|
target = _first_open(ops_client, "vehicle_status_conflict")
|
||||||
|
vehicle_before = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json()
|
||||||
|
|
||||||
|
preview_response = ops_client.post(
|
||||||
|
f"/api/v1/data-quality/issues/{target['public_ref']}/status-recommendation"
|
||||||
|
)
|
||||||
|
assert preview_response.status_code == 200
|
||||||
|
preview = preview_response.json()
|
||||||
|
assert preview["current_status"] == vehicle_before["operational_status"]
|
||||||
|
assert preview["recommendation_token"]
|
||||||
|
assert "facts" in preview
|
||||||
|
|
||||||
|
# Calling preview again (as the UI would on every open) must still not mutate.
|
||||||
|
ops_client.post(f"/api/v1/data-quality/issues/{target['public_ref']}/status-recommendation")
|
||||||
|
issue_after = ops_client.get(f"/api/v1/data-quality/issues/{target['public_ref']}").json()
|
||||||
|
vehicle_after = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json()
|
||||||
|
assert issue_after["status"] == "open"
|
||||||
|
assert vehicle_after["operational_status"] == vehicle_before["operational_status"]
|
||||||
|
|
||||||
|
|
||||||
def test_apply_recommended_status_resolves_conflict(ops_client):
|
def test_apply_recommended_status_resolves_conflict(ops_client):
|
||||||
target = _first_open(ops_client, "vehicle_status_conflict")
|
target = _first_open(ops_client, "vehicle_status_conflict")
|
||||||
|
preview = ops_client.post(
|
||||||
|
f"/api/v1/data-quality/issues/{target['public_ref']}/status-recommendation"
|
||||||
|
).json()
|
||||||
|
assert preview["safe_to_apply"] is True
|
||||||
|
assert preview["manual_review_required"] is False
|
||||||
|
|
||||||
response = ops_client.post(
|
response = ops_client.post(
|
||||||
f"/api/v1/data-quality/issues/{target['public_ref']}/apply-recommended-status"
|
f"/api/v1/data-quality/issues/{target['public_ref']}/apply-recommended-status",
|
||||||
|
json={"recommendation_token": preview["recommendation_token"]},
|
||||||
)
|
)
|
||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
body = response.json()
|
body = response.json()
|
||||||
assert body["issue"]["status"] == "resolved"
|
assert body["issue"]["status"] == "resolved"
|
||||||
assert body["applied_status"]
|
assert body["applied_status"] == preview["recommended_status"]
|
||||||
assert body["reason"]
|
assert body["reason_code"] == preview["recommendation_code"]
|
||||||
|
|
||||||
vehicle = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json()
|
vehicle = ops_client.get(f"/api/v1/vehicles/{target['entity_ref']}").json()
|
||||||
assert vehicle["operational_status"] == body["applied_status"]
|
assert vehicle["operational_status"] == body["applied_status"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_recommended_status_rejects_stale_token(ops_client):
|
||||||
|
target = _first_open(ops_client, "vehicle_status_conflict")
|
||||||
|
response = ops_client.post(
|
||||||
|
f"/api/v1/data-quality/issues/{target['public_ref']}/apply-recommended-status",
|
||||||
|
json={"recommendation_token": "not-a-real-token"},
|
||||||
|
)
|
||||||
|
assert response.status_code == 409
|
||||||
|
assert response.json()["error"]["code"] == "RECOMMENDATION_STALE"
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_odometer_regression_requires_operations_manager(employee_client):
|
def test_resolve_odometer_regression_requires_operations_manager(employee_client):
|
||||||
response = employee_client.post(
|
response = employee_client.post(
|
||||||
"/api/v1/data-quality/issues/DQ-0007/resolve-odometer-regression",
|
"/api/v1/data-quality/issues/DQ-0007/resolve-odometer-regression",
|
||||||
@@ -365,6 +411,72 @@ def test_manual_scan_records_audit_event(ops_client):
|
|||||||
assert "created" in events[0]["metadata"]
|
assert "created" in events[0]["metadata"]
|
||||||
|
|
||||||
|
|
||||||
|
def _reset_demo(ops_client) -> None:
|
||||||
|
# /api/v1/demo/reset deletes the session cookie (the reset recreates the users
|
||||||
|
# table, so the old session's user id no longer exists) -- the caller must log back
|
||||||
|
# in before making any further authenticated call with the same client.
|
||||||
|
response = ops_client.post("/api/v1/demo/reset")
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
login_response = ops_client.post(
|
||||||
|
"/api/v1/demo/login", json={"role": "operations_manager"}
|
||||||
|
)
|
||||||
|
assert login_response.status_code == 200, login_response.text
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_overlap_issue(ops_client, *, booking_to_block: str) -> None:
|
||||||
|
overlap = _first_open(ops_client, "booking_overlap")
|
||||||
|
response = ops_client.post(
|
||||||
|
f"/api/v1/data-quality/issues/{overlap['public_ref']}/resolve-overlap",
|
||||||
|
json={"booking_ref": booking_to_block},
|
||||||
|
)
|
||||||
|
assert response.status_code == 200, response.text
|
||||||
|
assert response.json()["status"] == "resolved"
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_status_recommendation(ops_client, public_ref: str) -> dict:
|
||||||
|
preview = ops_client.post(
|
||||||
|
f"/api/v1/data-quality/issues/{public_ref}/status-recommendation"
|
||||||
|
).json()
|
||||||
|
apply_response = ops_client.post(
|
||||||
|
f"/api/v1/data-quality/issues/{public_ref}/apply-recommended-status",
|
||||||
|
json={"recommendation_token": preview["recommendation_token"]},
|
||||||
|
)
|
||||||
|
assert apply_response.status_code == 200, apply_response.text
|
||||||
|
return apply_response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def test_mo_016_status_conflict_recommendation_is_order_independent(ops_client):
|
||||||
|
# MO-016 carries both a booking_overlap (DQ-DEMO-OVERLAP) and a vehicle_status_conflict
|
||||||
|
# (DQ-DEMO-STATUS) issue at once -- resolving them in either order must land the
|
||||||
|
# vehicle in the same final, safe state (see docs/fleet-ops-correction/
|
||||||
|
# current-gap-audit.md §6-7 and vehicle-status-decision-table.md).
|
||||||
|
|
||||||
|
# Order A: resolve the booking overlap first, then the status conflict.
|
||||||
|
_reset_demo(ops_client)
|
||||||
|
_resolve_overlap_issue(ops_client, booking_to_block="BK-DEMO-OVERLAP-B")
|
||||||
|
status_issue_a = _first_open(ops_client, "vehicle_status_conflict")
|
||||||
|
result_a = _apply_status_recommendation(ops_client, status_issue_a["public_ref"])
|
||||||
|
vehicle_a = ops_client.get("/api/v1/vehicles/MO-016").json()
|
||||||
|
|
||||||
|
# Order B: resolve the status conflict first, then the booking overlap.
|
||||||
|
_reset_demo(ops_client)
|
||||||
|
status_issue_b = _first_open(ops_client, "vehicle_status_conflict")
|
||||||
|
result_b = _apply_status_recommendation(ops_client, status_issue_b["public_ref"])
|
||||||
|
_resolve_overlap_issue(ops_client, booking_to_block="BK-DEMO-OVERLAP-B")
|
||||||
|
vehicle_b = ops_client.get("/api/v1/vehicles/MO-016").json()
|
||||||
|
|
||||||
|
assert result_a["applied_status"] == result_b["applied_status"], (
|
||||||
|
"resolving the booking overlap before vs. after the status conflict recommended "
|
||||||
|
"a different status -- the recommendation must not depend on issue order"
|
||||||
|
)
|
||||||
|
assert vehicle_a["operational_status"] == vehicle_b["operational_status"]
|
||||||
|
# Never auto-"available" and never auto-"rented" as a side effect of this scenario --
|
||||||
|
# MO-016 has no active rental in either order, only reserved bookings.
|
||||||
|
assert vehicle_a["operational_status"] not in ("rented",)
|
||||||
|
|
||||||
|
_reset_demo(ops_client)
|
||||||
|
|
||||||
|
|
||||||
def test_rejected_issue_recurrence_links_to_prior_decision(ops_client):
|
def test_rejected_issue_recurrence_links_to_prior_decision(ops_client):
|
||||||
# Reject an open vehicle_status_conflict issue without changing the vehicle, so the
|
# Reject an open vehicle_status_conflict issue without changing the vehicle, so the
|
||||||
# next scan re-detects the same unresolved condition -- it must not silently vanish
|
# next scan re-detects the same unresolved condition -- it must not silently vanish
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ def test_deliver_one_success(monkeypatch):
|
|||||||
assert event.attempts == 1
|
assert event.attempts == 1
|
||||||
assert event.external_run_id == str(event_id)
|
assert event.external_run_id == str(event_id)
|
||||||
assert event.last_error is None
|
assert event.last_error is None
|
||||||
|
assert event.last_error_code is None
|
||||||
|
|
||||||
|
|
||||||
def test_deliver_one_failure_schedules_retry(monkeypatch):
|
def test_deliver_one_failure_schedules_retry(monkeypatch):
|
||||||
@@ -98,6 +99,7 @@ def test_deliver_one_failure_schedules_retry(monkeypatch):
|
|||||||
assert event.attempts == 1
|
assert event.attempts == 1
|
||||||
assert event.next_attempt_at is not None
|
assert event.next_attempt_at is not None
|
||||||
assert "simulated connection failure" in event.last_error
|
assert "simulated connection failure" in event.last_error
|
||||||
|
assert event.last_error_code == "connectionError"
|
||||||
|
|
||||||
|
|
||||||
def test_deliver_one_exhausts_attempts_to_failed(monkeypatch):
|
def test_deliver_one_exhausts_attempts_to_failed(monkeypatch):
|
||||||
@@ -159,6 +161,7 @@ def test_deliver_one_handles_malformed_payload_without_getting_stuck(monkeypatch
|
|||||||
assert event.delivery_status in ("pending", "failed")
|
assert event.delivery_status in ("pending", "failed")
|
||||||
assert event.attempts == 1
|
assert event.attempts == 1
|
||||||
assert "Malformed outbox payload" in event.last_error
|
assert "Malformed outbox payload" in event.last_error
|
||||||
|
assert event.last_error_code == "malformedPayload"
|
||||||
|
|
||||||
|
|
||||||
def test_claim_sets_a_lease_deadline():
|
def test_claim_sets_a_lease_deadline():
|
||||||
@@ -208,6 +211,7 @@ def test_reclaim_recovers_an_expired_lease_and_preserves_attempts(monkeypatch):
|
|||||||
assert event.next_attempt_at is None
|
assert event.next_attempt_at is None
|
||||||
assert event.attempts == 2
|
assert event.attempts == 2
|
||||||
assert "stale" in event.last_error.lower()
|
assert "stale" in event.last_error.lower()
|
||||||
|
assert event.last_error_code == "staleLeaseRecovered"
|
||||||
|
|
||||||
# The reclaimed event is now a normal pending event, immediately claimable again.
|
# The reclaimed event is now a normal pending event, immediately claimable again.
|
||||||
claimed = dispatcher._claim_due_events()
|
claimed = dispatcher._claim_due_events()
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ def test_search_finds_a_vehicle_by_reference(ops_client):
|
|||||||
assert match is not None
|
assert match is not None
|
||||||
assert match["label"] == "MO-001"
|
assert match["label"] == "MO-001"
|
||||||
assert match["link"] == "/vehicles/MO-001"
|
assert match["link"] == "/vehicles/MO-001"
|
||||||
|
# The backend must never send localizable prose -- only a stable code plus raw
|
||||||
|
# data params, so the frontend can render it in the operator's selected language.
|
||||||
|
assert match["detail_code"] == "vehicleSummary"
|
||||||
|
assert set(match["detail_params"]) == {"make", "model", "location"}
|
||||||
|
|
||||||
|
|
||||||
def test_search_finds_a_booking_by_reference(ops_client):
|
def test_search_finds_a_booking_by_reference(ops_client):
|
||||||
@@ -37,7 +41,21 @@ def test_search_never_returns_data_quality_issues_for_rental_employee(employee_c
|
|||||||
|
|
||||||
def test_search_section_result_visible_to_operations_manager(ops_client):
|
def test_search_section_result_visible_to_operations_manager(ops_client):
|
||||||
result = ops_client.get("/api/v1/search", params={"q": "audit"}).json()
|
result = ops_client.get("/api/v1/search", params={"q": "audit"}).json()
|
||||||
assert any(r["type"] == "section" and r["link"] == "/audit" for r in result["results"])
|
match = next(
|
||||||
|
(r for r in result["results"] if r["type"] == "section" and r["link"] == "/audit"), None
|
||||||
|
)
|
||||||
|
assert match is not None
|
||||||
|
# Section results must ship a stable id, not English prose -- the frontend looks up
|
||||||
|
# navigation:items.<id> and search:sections.<id>.detail in the selected locale.
|
||||||
|
assert match["label"] == "audit"
|
||||||
|
assert match["detail_code"] == "audit"
|
||||||
|
|
||||||
|
|
||||||
|
def test_search_section_matches_dutch_and_french_terms(ops_client):
|
||||||
|
nl_result = ops_client.get("/api/v1/search", params={"q": "wagenpark"}).json()
|
||||||
|
assert any(r["type"] == "section" and r["link"] == "/vehicles" for r in nl_result["results"])
|
||||||
|
fr_result = ops_client.get("/api/v1/search", params={"q": "réservation"}).json()
|
||||||
|
assert any(r["type"] == "section" and r["link"] == "/bookings" for r in fr_result["results"])
|
||||||
|
|
||||||
|
|
||||||
def test_search_section_result_hidden_from_rental_employee(employee_client):
|
def test_search_section_result_hidden_from_rental_employee(employee_client):
|
||||||
|
|||||||
@@ -23,8 +23,12 @@ def test_seed_counts_match_deterministic_dataset():
|
|||||||
assert db.scalar(select(func.count()).select_from(Vehicle)) == 50
|
assert db.scalar(select(func.count()).select_from(Vehicle)) == 50
|
||||||
assert db.scalar(select(func.count()).select_from(Customer)) == 180
|
assert db.scalar(select(func.count()).select_from(Customer)) == 180
|
||||||
assert db.scalar(select(func.count()).select_from(Booking)) == 246
|
assert db.scalar(select(func.count()).select_from(Booking)) == 246
|
||||||
# 15 from the CSV plus a deterministic set discovered by the post-seed scan.
|
# 15 from the CSV plus a deterministic set discovered by the post-seed scan. The
|
||||||
assert db.scalar(select(func.count()).select_from(DataQualityIssue)) == 26
|
# shared vehicle-status evaluator (app.services.vehicle_status) now also catches
|
||||||
|
# MO-024: an active/return-pending booking (BK-DEMO-RETURN) on a vehicle that has
|
||||||
|
# already crossed its service-due odometer threshold -- a genuine conflict the
|
||||||
|
# previous hand-rolled scanner never checked for.
|
||||||
|
assert db.scalar(select(func.count()).select_from(DataQualityIssue)) == 27
|
||||||
assert db.scalar(select(func.count()).select_from(OutboxEvent)) == 20
|
assert db.scalar(select(func.count()).select_from(OutboxEvent)) == 20
|
||||||
assert db.scalar(select(func.count()).select_from(User)) == 2
|
assert db.scalar(select(func.count()).select_from(User)) == 2
|
||||||
finally:
|
finally:
|
||||||
@@ -138,6 +142,7 @@ def test_seed_scenario_s5_failed_workflow_run():
|
|||||||
assert failed.delivery_status == "failed"
|
assert failed.delivery_status == "failed"
|
||||||
assert failed.attempts >= 1
|
assert failed.attempts >= 1
|
||||||
assert failed.last_error
|
assert failed.last_error
|
||||||
|
assert failed.last_error_code == "connectionError"
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
"""Pure unit tests for the shared vehicle-status evaluator -- no database needed, since
|
||||||
|
evaluate_vehicle_status() only reasons over an already-gathered VehicleStatusFacts. See
|
||||||
|
docs/fleet-ops-correction/vehicle-status-decision-table.md for the decision table these
|
||||||
|
tests are asserting against."""
|
||||||
|
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
from app.services.vehicle_status import (
|
||||||
|
RECOMMENDATION_CODE_ACTIVE_RENTAL,
|
||||||
|
RECOMMENDATION_CODE_BOOKING_CONFLICT,
|
||||||
|
RECOMMENDATION_CODE_MANUAL_REVIEW,
|
||||||
|
RECOMMENDATION_CODE_NO_CONFLICT,
|
||||||
|
RECOMMENDATION_CODE_RENTAL_ENDED,
|
||||||
|
RECOMMENDATION_CODE_SERVICE_THRESHOLD,
|
||||||
|
VehicleStatusFacts,
|
||||||
|
compute_recommendation_token,
|
||||||
|
evaluate_vehicle_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _vehicle(status: str, *, version: int = 1):
|
||||||
|
return SimpleNamespace(operational_status=status, version=version)
|
||||||
|
|
||||||
|
|
||||||
|
def _facts(**overrides) -> VehicleStatusFacts:
|
||||||
|
defaults = dict(
|
||||||
|
active_booking_refs=[],
|
||||||
|
overlapping_booking_pairs=[],
|
||||||
|
service_threshold_reached=False,
|
||||||
|
odometer_km=10_000,
|
||||||
|
next_service_km=20_000,
|
||||||
|
open_booking_overlap_issue_ref=None,
|
||||||
|
)
|
||||||
|
defaults.update(overrides)
|
||||||
|
return VehicleStatusFacts(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
def test_available_with_active_rental_recommends_rented():
|
||||||
|
result = evaluate_vehicle_status(
|
||||||
|
_vehicle("available"), _facts(active_booking_refs=["BK-0001"])
|
||||||
|
)
|
||||||
|
assert result.recommended_status == "rented"
|
||||||
|
assert result.recommendation_code == RECOMMENDATION_CODE_ACTIVE_RENTAL
|
||||||
|
assert result.safe_to_apply is True
|
||||||
|
assert result.manual_review_required is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_maintenance_with_active_rental_never_auto_recommends_rented():
|
||||||
|
# The exact unsafe shortcut this task explicitly forbids: maintenance + an active
|
||||||
|
# booking must NEVER be auto-resolved to "rented".
|
||||||
|
result = evaluate_vehicle_status(
|
||||||
|
_vehicle("maintenance"), _facts(active_booking_refs=["BK-0001"])
|
||||||
|
)
|
||||||
|
assert result.recommended_status is None
|
||||||
|
assert result.manual_review_required is True
|
||||||
|
assert result.safe_to_apply is False
|
||||||
|
assert result.recommendation_code == RECOMMENDATION_CODE_MANUAL_REVIEW
|
||||||
|
|
||||||
|
|
||||||
|
def test_available_with_active_rental_and_service_threshold_requires_manual_review():
|
||||||
|
result = evaluate_vehicle_status(
|
||||||
|
_vehicle("available"),
|
||||||
|
_facts(active_booking_refs=["BK-0001"], service_threshold_reached=True),
|
||||||
|
)
|
||||||
|
assert result.manual_review_required is True
|
||||||
|
assert result.recommended_status is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_available_with_active_rental_and_booking_conflict_requires_manual_review():
|
||||||
|
result = evaluate_vehicle_status(
|
||||||
|
_vehicle("available"),
|
||||||
|
_facts(active_booking_refs=["BK-0001"], open_booking_overlap_issue_ref="DQ-0001"),
|
||||||
|
)
|
||||||
|
assert result.manual_review_required is True
|
||||||
|
assert result.recommended_status is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_rented_with_no_active_booking_recommends_available():
|
||||||
|
result = evaluate_vehicle_status(_vehicle("rented"), _facts())
|
||||||
|
assert result.recommended_status == "available"
|
||||||
|
assert result.recommendation_code == RECOMMENDATION_CODE_RENTAL_ENDED
|
||||||
|
assert result.safe_to_apply is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_service_threshold_reached_recommends_maintenance():
|
||||||
|
result = evaluate_vehicle_status(
|
||||||
|
_vehicle("available"), _facts(service_threshold_reached=True)
|
||||||
|
)
|
||||||
|
assert result.recommended_status == "maintenance"
|
||||||
|
assert result.recommendation_code == RECOMMENDATION_CODE_SERVICE_THRESHOLD
|
||||||
|
|
||||||
|
|
||||||
|
def test_booking_conflict_recommends_blocked_not_a_generic_high_severity_proxy():
|
||||||
|
# The evaluator must react to a *real* booking-conflict fact, not "does some other
|
||||||
|
# open high-severity issue happen to exist" (the forbidden proxy).
|
||||||
|
result = evaluate_vehicle_status(
|
||||||
|
_vehicle("available"),
|
||||||
|
_facts(overlapping_booking_pairs=[("BK-DEMO-OVERLAP-A", "BK-DEMO-OVERLAP-B")]),
|
||||||
|
)
|
||||||
|
assert result.recommended_status == "blocked"
|
||||||
|
assert result.recommendation_code == RECOMMENDATION_CODE_BOOKING_CONFLICT
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_booking_overlap_issue_alone_also_triggers_blocked():
|
||||||
|
result = evaluate_vehicle_status(
|
||||||
|
_vehicle("available"), _facts(open_booking_overlap_issue_ref="DQ-DEMO-OVERLAP")
|
||||||
|
)
|
||||||
|
assert result.recommended_status == "blocked"
|
||||||
|
assert result.recommendation_code == RECOMMENDATION_CODE_BOOKING_CONFLICT
|
||||||
|
|
||||||
|
|
||||||
|
def test_maintenance_with_no_active_rental_and_no_blockers_stays_manual():
|
||||||
|
# No fact here confirms maintenance is actually finished, so the evaluator must not
|
||||||
|
# auto-clear it to "available" -- that release remains an explicit, manual decision.
|
||||||
|
result = evaluate_vehicle_status(_vehicle("maintenance"), _facts())
|
||||||
|
assert result.recommended_status is None
|
||||||
|
assert result.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT
|
||||||
|
assert result.safe_to_apply is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_conflict_when_status_already_matches_facts():
|
||||||
|
result = evaluate_vehicle_status(_vehicle("available"), _facts())
|
||||||
|
assert result.recommended_status is None
|
||||||
|
assert result.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT
|
||||||
|
assert result.safe_to_apply is False
|
||||||
|
|
||||||
|
result = evaluate_vehicle_status(_vehicle("rented"), _facts(active_booking_refs=["BK-1"]))
|
||||||
|
assert result.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT
|
||||||
|
|
||||||
|
result = evaluate_vehicle_status(_vehicle("blocked"), _facts())
|
||||||
|
assert result.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT
|
||||||
|
|
||||||
|
result = evaluate_vehicle_status(
|
||||||
|
_vehicle("maintenance"), _facts(service_threshold_reached=True)
|
||||||
|
)
|
||||||
|
assert result.recommendation_code == RECOMMENDATION_CODE_NO_CONFLICT
|
||||||
|
|
||||||
|
|
||||||
|
def test_recommendation_token_changes_when_facts_change():
|
||||||
|
vehicle = _vehicle("available")
|
||||||
|
facts_a = _facts()
|
||||||
|
facts_b = _facts(service_threshold_reached=True)
|
||||||
|
assert compute_recommendation_token(vehicle, facts_a) != compute_recommendation_token(
|
||||||
|
vehicle, facts_b
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_recommendation_token_is_stable_for_identical_facts():
|
||||||
|
vehicle = _vehicle("available")
|
||||||
|
facts = _facts(active_booking_refs=["BK-0001"])
|
||||||
|
assert compute_recommendation_token(vehicle, facts) == compute_recommendation_token(
|
||||||
|
vehicle, facts
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_recommendation_token_changes_when_vehicle_version_changes():
|
||||||
|
facts = _facts()
|
||||||
|
assert compute_recommendation_token(
|
||||||
|
_vehicle("available", version=1), facts
|
||||||
|
) != compute_recommendation_token(_vehicle("available", version=2), facts)
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
# Fleet Ops correction — current-state gap audit
|
||||||
|
|
||||||
|
## Branch / commit state (at audit time)
|
||||||
|
|
||||||
|
- Repository's actual main/default branch is named **`master`** (there is no `main` branch — `remotes/origin/HEAD -> origin/master`). All instructions referring to "main" in this task are treated as referring to `master`.
|
||||||
|
- `master` (local and `origin/master`) was at `18344bc8b7a75a2f868bf15bf498fc030ac6c34c` before this task started — this is the newest verified Fleet Ops demo commit (contains the full rebrand/i18n/adaptive-guide/Data-Quality-UX work from the previous task).
|
||||||
|
- Deployed commit on Unraid (`/mnt/user/appdata/mobilityops/.deploy/source-revision`): `18344bc8b7a75a2f868bf15bf498fc030ac6c34c` — matches `master` exactly. No drift.
|
||||||
|
- No uncommitted local changes at audit time (`git status` clean).
|
||||||
|
- Source branch for this correction: `master` (already contained the newest verified commit). New branch created: **`fix/fleet-ops-i18n-status-flow`**, branched from `master` at `18344bc`.
|
||||||
|
- `feat/mobilityops-functional-completion` remains un-deleted, as instructed by the prior task, and is left untouched by this one.
|
||||||
|
|
||||||
|
## Confirmed gaps (verified against actual code, not assumed)
|
||||||
|
|
||||||
|
### 1. Brand name is a translatable key (structural risk)
|
||||||
|
|
||||||
|
- `common:appName` exists per-locale in `frontend/src/i18n/locales/{nl-BE,en-GB,fr-BE}/common.json`, all currently `"Fleet Ops"`, but nothing prevents a future edit from diverging one locale. Used in `Login.tsx:35` and `Layout.tsx:180`.
|
||||||
|
- 8 more locale keys embed the literal string "Fleet Ops" inside translatable prose (`auth.defaultDescription`, `common.footer.productLine`, `demo.guide.steps["review-real-vs-simulated"].expectedOutcome`, `demo.about.title`, `demo.about.problemBody`, `demo.about.scopeBody`, `knowledge.emptyDescription`, `navigation.searchLabel`, `returns.scenario.body`) — all three locales currently say "Fleet Ops" correctly, but structurally these are still translatable values.
|
||||||
|
- **Fix**: `frontend/src/product.ts` exports `PRODUCT_NAME = "Fleet Ops"`. Remove `common:appName`; `Login.tsx`/`Layout.tsx` import the constant directly. Replace the 8 embedded mentions with `{{productName}}` interpolation, passing `productName: PRODUCT_NAME` explicitly at each call site. Add a Playwright test that fails if any locale JSON file contains the literal substring `"Fleet Ops"` (forcing all future brand mentions through interpolation) and a live-DOM test asserting the rendered brand text is byte-identical across all three languages.
|
||||||
|
|
||||||
|
### 2. Backend hardcodes English prose as primary user-facing content (structural, not cosmetic)
|
||||||
|
|
||||||
|
Confirmed in `backend/app/services/data_quality.py`:
|
||||||
|
- `_scan_duplicate_customers` (~line 127-149): evidence signals literally `"exact email"`, `"exact phone"`, `"exact postal code"`, `"similar name"`, joined into `evidence.summary`.
|
||||||
|
- `_scan_missing_required_fields` (~170, 185): `f"Missing: {', '.join(missing)}"`.
|
||||||
|
- `_scan_booking_overlaps` (~213): `f"Overlapping bookings {first.public_ref} and {second.public_ref}"`.
|
||||||
|
- `_scan_vehicle_status_conflicts` (~240-256): reason strings like `"marked available while an active booking exists"`.
|
||||||
|
- `_scan_odometer_regressions` (~291-295): full English sentence with interpolated numbers/refs.
|
||||||
|
- `_recommend_vehicle_status` (~659-668): recommendation `reason` strings returned verbatim as `ApplyRecommendedStatusResult.reason` and rendered directly in the UI.
|
||||||
|
|
||||||
|
Confirmed in `backend/app/services/returns.py`:
|
||||||
|
- `_derive_vehicle_status_with_reason` (~32-43): `status_reason` strings ("Damage was reported on return.", "A technical warning was reported on return.", "Odometer reached the {n} km service threshold.", "No damage, technical warning or service threshold; routed to cleaning.") flow straight into the API response and are displayed raw regardless of UI language (asserted verbatim in English in `interactive-elements.spec.ts:133`, confirming this is genuinely user-visible, not just internal).
|
||||||
|
|
||||||
|
Confirmed in `backend/app/services/dispatcher.py` / seed data: `event.last_error` stores raw strings like `"Synthetic connection timeout to n8n"` from `seed/workflow_runs.csv`, rendered directly in `Automation.tsx` (`{r.last_error ?? "—"}`) with no localization or summarization.
|
||||||
|
|
||||||
|
Frontend confirmed to display these values completely raw: `DataQualityIssueDetail.tsx` → `<dd>{String(issue.evidence.summary ?? "")}</dd>` — the *label* is translated, the *value* is not.
|
||||||
|
|
||||||
|
**Fix**: introduce structured `signals`/`message_code`+`params` on evidence and reasons (data-quality evidence, status-conflict recommendation reason, return status reason, automation last-error), with a frontend mapping layer that localizes known codes and falls back to the raw string under "Technical details" only.
|
||||||
|
|
||||||
|
### 3–5, 8A. Status-recommendation flow is unsafe and combines calculation with mutation
|
||||||
|
|
||||||
|
- **Confirmed single mutating endpoint**: `POST /api/v1/data-quality/issues/{public_ref}/apply-recommended-status` (`backend/app/api/routers/data_quality.py`) computes the recommendation and mutates the vehicle in the same call. No separate preview/GET route exists for this flow (unlike the return flow, which already has `preview_vehicle_return` / `register_vehicle_return` sharing one pure evaluator).
|
||||||
|
- **Confirmed unsafe shortcut** (explicitly prohibited by this task): `_recommend_vehicle_status` in `data_quality.py` returns `("rented", ...)` whenever `operational_status == "maintenance" and has_active_booking` — i.e. a vehicle flagged for maintenance with an active booking is auto-recommended (and, on one click, actually changed) to `rented`, with no check of the underlying reason it's in maintenance and no manual-review branch.
|
||||||
|
- **Confirmed proxy-based reasoning** (explicitly prohibited): the `available` + `has_open_high_issue` → `blocked` branch depends only on "does some other open high-severity issue exist for this vehicle", not on real underlying facts (damage, technical warning, confirmed overlap). `data_quality.py` never imports `Inspection`, so damage/technical-warning facts are never examined by this function at all.
|
||||||
|
- **Confirmed scanner/resolver duplication**: `_scan_vehicle_status_conflicts` and `_recommend_vehicle_status` are two independently-maintained `if`-chains (hand-kept-in-sync via a docstring comment, not shared code) — a structural fragility even though today's four branches happen to agree.
|
||||||
|
|
||||||
|
**Fix**: new shared domain service `backend/app/services/vehicle_status.py` with one `evaluate_vehicle_status()` function consulting real facts (active/reserved/overlapping bookings, latest inspection damage/technical-warning, maintenance threshold, cleaning state), used by scanner, a new non-mutating preview endpoint, the apply endpoint, and tests. Maintenance+active-booking becomes `manual_review_required`, never an automatic `rented`. Full decision table in `docs/fleet-ops-correction/vehicle-status-decision-table.md`.
|
||||||
|
|
||||||
|
### 6–7. MO-016 / issue-ordering
|
||||||
|
|
||||||
|
- Confirmed `MO-016` scenario: vehicle seeded `operational_status="available"`, two overlapping *reserved* bookings (`BK-DEMO-OVERLAP-A`/`-B`), two pre-seeded `open` issues on the same vehicle (`DQ-DEMO-OVERLAP` booking_overlap, `DQ-DEMO-STATUS` vehicle_status_conflict).
|
||||||
|
- Confirmed **no test** resolves both issues in sequence (either order) within one session to check the outcome stays deterministic — each existing test independently resets the demo data first.
|
||||||
|
|
||||||
|
**Fix**: new evaluator is order-independent by construction (recomputes real facts every call, doesn't cache any prior issue's existence as an input other than the generic "another open high-severity issue for manual-review fallback"); add an explicit ordering test.
|
||||||
|
|
||||||
|
### 8. i18n-coverage test doesn't prove translation happened
|
||||||
|
|
||||||
|
Confirmed: `frontend/e2e/i18n-coverage.spec.ts` only checks key-parity and non-empty values — a locale file could contain the literal English string copy-pasted and the test would still pass. Locale files were manually verified as genuinely translated (no hits for probe phrases like "canonical odometer", "committed locally", "correlation ID" etc. in `nl-BE`/`fr-BE`), so this is a test-coverage gap, not an active mistranslation — but per this task's instructions it still needs closing.
|
||||||
|
|
||||||
|
**Fix**: add a translation-quality test comparing `nl-BE`/`fr-BE` values against `en-GB` for meaningful divergence (with an explicit allowlist for real proper nouns/technical tokens: Fleet Ops, Northstar Mobility, n8n, RAGcore, MCP Hub, API, UUID, Docker, PostgreSQL), plus a route-matrix smoke test opening every main route in all three languages.
|
||||||
|
|
||||||
|
### 9. One confirmed leftover hardcoded string
|
||||||
|
|
||||||
|
- `frontend/src/pages/BookingDetail.tsx:76` — `aria-label="Demo scenario"` is a literal, un-translated English string (the visible content beside it is correctly translated).
|
||||||
|
|
||||||
|
**Fix**: route through `t("returns:scenario.ariaLabel")` (new key, 3 locales).
|
||||||
|
|
||||||
|
### 10. Automation "last error" shown raw
|
||||||
|
|
||||||
|
- `Automation.tsx` renders `r.last_error` directly with no localization/summarization layer, confirmed via the seeded `"Synthetic connection timeout to n8n"` string appearing verbatim regardless of UI language.
|
||||||
|
|
||||||
|
**Fix**: known-code → localized summary + operational meaning, raw string demoted to "Technical details".
|
||||||
|
|
||||||
|
## Scope note
|
||||||
|
|
||||||
|
No gaps were found in: existing Control Rail navigation/layout, the three demo roles/authorization, the guided demo mechanics, n8n integration wiring, Docker/Unraid deployment scripts, or the previously-implemented adaptive Demo Guide / Data Quality choice-card UI — these are left untouched per the "do not redesign" instruction. This correction is scoped to the 10 problems above.
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# i18n inventory — dynamic/backend content requiring message-code treatment
|
||||||
|
|
||||||
|
Static UI chrome (navigation, dashboard, forms, filters, dialogs, empty/loading states,
|
||||||
|
Demo Guide, About page, accessibility labels) was already moved to the `i18next`
|
||||||
|
namespace system in the prior task and is not re-inventoried here in full — see
|
||||||
|
`docs/final-product-polish/audit.md` and `docs/final-product-polish/i18n-inventory.md`
|
||||||
|
for that pass. This inventory covers only the sources confirmed still bypassing
|
||||||
|
translation (per `current-gap-audit.md`), the fix chosen, and the tests that verify it.
|
||||||
|
|
||||||
|
| # | Source | Location | Static/dynamic | Fix | Tests |
|
||||||
|
|---|--------|----------|-----------------|-----|-------|
|
||||||
|
| 1 | Duplicate-customer evidence signals | `backend/app/services/data_quality.py::_scan_duplicate_customers` | dynamic | `evidence.signals: [{code, params}]` (`duplicate.exact_email`, `duplicate.exact_phone`, `duplicate.same_postal_code`, `duplicate.similar_name` + score param); frontend maps code→localized phrase | `test_data_quality.py::test_duplicate_customer_evidence_has_structured_signals`; Playwright DQ evidence-language test |
|
||||||
|
| 2 | Missing-required-field evidence | `_scan_missing_required_fields` | dynamic | `evidence.signals: [{code: "missing_field", params: {field}}]`; frontend maps `field` through the existing `CUSTOMER_FIELD_LABELS`/`VEHICLE_FIELD_LABELS`-equivalent i18n keys | same |
|
||||||
|
| 3 | Booking-overlap evidence | `_scan_booking_overlaps` | dynamic | `evidence.signals: [{code: "overlap.reserved_bookings", params: {refs: [...]}}]` | same |
|
||||||
|
| 4 | Vehicle-status-conflict evidence | `_scan_vehicle_status_conflicts` | dynamic | replaced entirely by the new `evaluate_vehicle_status()` evaluator's `recommendation_code`; scanner reuses the evaluator instead of its own reason strings | new evaluator unit tests |
|
||||||
|
| 5 | Odometer-regression evidence | `_scan_odometer_regressions` | dynamic | `evidence.signals: [{code: "odometer.regression", params: {later_ref, later_km, earlier_ref, earlier_km}}]` | same |
|
||||||
|
| 6 | Status-recommendation reason | `_recommend_vehicle_status` / new `evaluate_vehicle_status` | dynamic | `recommendation_code` + `facts` (structured), no free prose from the backend at all; frontend renders the full explanation from `quality:statusRecommendation.codes.<code>` | evaluator unit tests + preview/apply contract tests |
|
||||||
|
| 7 | Return status reason | `backend/app/services/returns.py::_derive_vehicle_status_with_reason` | dynamic | `status_reason_code` + `params` alongside the existing human string (kept for backward-compat, demoted to technical fallback) | `test_returns.py` updated; Playwright return-flow-language test |
|
||||||
|
| 8 | Automation `last_error` | `dispatcher.py` / seed data, rendered in `Automation.tsx` | dynamic | known-cause codes (`n8n.connection_timeout`, `n8n.http_error`, etc.) mapped to a localized summary + "what happened / what's pending / what retry does"; raw string demoted to Technical details | Playwright automation-language test |
|
||||||
|
| 9 | `aria-label="Demo scenario"` | `frontend/src/pages/BookingDetail.tsx:76` | static, just un-wired | `t("returns:scenario.ariaLabel")`, 3 locales | i18n-coverage (key parity) + route-matrix test |
|
||||||
|
| 10 | Brand name (`common:appName` + 8 embedded mentions) | see gap audit §1 | static, wrongly translatable | `PRODUCT_NAME` constant, `{{productName}}` interpolation | new brand-invariant test |
|
||||||
|
|
||||||
|
## Message-code mapping module
|
||||||
|
|
||||||
|
Centralised in `frontend/src/i18n/messageCodes.ts` (new): a single `resolveMessageCode(t, code, params)` helper used by the Data Quality evidence renderer, the status-recommendation panel, the return-result panel, and the automation ledger, so there is one place mapping `code → i18next key` rather than per-page switch statements.
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
# Vehicle status decision table
|
||||||
|
|
||||||
|
Authoritative rationale for `app/services/vehicle_status.py::evaluate_vehicle_status`,
|
||||||
|
the single evaluator shared by the data-quality scanner, the status-recommendation
|
||||||
|
preview endpoint, and the transactional apply endpoint (section 8A). Scanner and
|
||||||
|
resolver call the same function with the same freshly-gathered facts, so they can never
|
||||||
|
disagree, and the recommendation is order-independent: resolving, deferring, or
|
||||||
|
rejecting an unrelated issue never changes what this function returns for a vehicle,
|
||||||
|
because it only reasons over the vehicle's and bookings' current state, never over
|
||||||
|
issue history.
|
||||||
|
|
||||||
|
## Facts gathered (`gather_vehicle_status_facts`)
|
||||||
|
|
||||||
|
All facts are re-queried from the database on every call, never cached and never derived
|
||||||
|
from "does some other issue happen to be open":
|
||||||
|
|
||||||
|
| Fact | Source |
|
||||||
|
|---|---|
|
||||||
|
| `active_booking_refs` | Bookings on this vehicle with `status == "active"` |
|
||||||
|
| `overlapping_booking_pairs` | Reserved/active bookings on this vehicle whose date ranges genuinely overlap |
|
||||||
|
| `service_threshold_reached` | `vehicle.odometer_km >= vehicle.next_service_km` |
|
||||||
|
| `open_booking_overlap_issue_ref` | The `public_ref` of a currently-open `booking_overlap` issue on this vehicle, if any (excluding the issue being resolved, via `exclude_issue_id`) |
|
||||||
|
|
||||||
|
`has_active_rental` = at least one active booking. `has_booking_conflict` = an
|
||||||
|
overlapping-booking pair exists, or an open `booking_overlap` issue references this
|
||||||
|
vehicle.
|
||||||
|
|
||||||
|
## Decision table
|
||||||
|
|
||||||
|
| Current status | Active rental? | Service threshold reached? | Booking conflict? | Recommended status | Priority | `recommendation_code` | Safe to auto-apply? |
|
||||||
|
|---|---|---|---|---|---|---|---|
|
||||||
|
| any except `maintenance` | yes | no | no | `rented` (if not already) | 1 | `vehicle.active_rental` | yes |
|
||||||
|
| `maintenance` | yes | — | — | *(none — manual review)* | 1 | `vehicle.manual_review_required` | no |
|
||||||
|
| any | yes | yes | — | *(none — manual review)* | 1 | `vehicle.manual_review_required` | no |
|
||||||
|
| any | yes | — | yes | *(none — manual review)* | 1 | `vehicle.manual_review_required` | no |
|
||||||
|
| not `maintenance` | no | yes | — | `maintenance` | 2 | `vehicle.service_threshold_reached` | yes |
|
||||||
|
| `maintenance` | no | yes | — | *(none — already correct)* | 2 | `vehicle.no_conflict` | n/a |
|
||||||
|
| not `blocked` | no | no | yes | `blocked` | 3 | `vehicle.booking_conflict` | yes |
|
||||||
|
| `blocked` | no | no | yes | *(none — already correct)* | 3 | `vehicle.no_conflict` | n/a |
|
||||||
|
| `rented` | no | no | no | `available` | 4 | `vehicle.rental_ended` | yes |
|
||||||
|
| `available` / `cleaning` / `blocked` | no | no | no | *(none — already correct)* | 4 | `vehicle.no_conflict` | n/a |
|
||||||
|
| `maintenance` | no | no | no | *(none — stays in maintenance)* | 4 | `vehicle.no_conflict` | n/a |
|
||||||
|
| anything not covered above | — | — | — | *(none — manual review)* | 5 | `vehicle.manual_review_required` | no |
|
||||||
|
|
||||||
|
## Safe-status principles (section 8B) applied
|
||||||
|
|
||||||
|
- **Maintenance + active booking never auto-resolves to `rented`.** Being currently in
|
||||||
|
`maintenance` is itself treated as a blocking fact (row 2 above) — an active booking
|
||||||
|
is never proof the vehicle should be marked rented; it is a real contradiction that
|
||||||
|
requires a human to investigate (e.g. was the vehicle released from the workshop
|
||||||
|
without updating status, or is the booking itself stale).
|
||||||
|
- **`available` + any booking never auto-resolves to `rented` silently past a real
|
||||||
|
blocker.** The `rented` recommendation only fires when there is no competing blocking
|
||||||
|
fact (no service-threshold breach, no booking conflict, not already in maintenance).
|
||||||
|
- **An open issue disappearing never auto-resolves to `available`.** Leaving
|
||||||
|
`maintenance` requires a human decision — this evaluator holds no fact that proves
|
||||||
|
maintenance work is actually finished (no completed-service record is modelled), so a
|
||||||
|
vehicle sitting in `maintenance` with no active rental and no other blocker stays
|
||||||
|
`vehicle.no_conflict` (left alone) rather than being auto-promoted to `available`.
|
||||||
|
- **Every branch re-derives facts; nothing is cached.** `blocking_reasons` is always
|
||||||
|
computed fresh from `service_threshold_reached`, `has_booking_conflict`, and the
|
||||||
|
current status itself — never from a proxy like "is some other high-severity issue
|
||||||
|
still open".
|
||||||
|
|
||||||
|
## Statuses used
|
||||||
|
|
||||||
|
Only statuses that exist in the current domain model are referenced: `available`,
|
||||||
|
`rented`, `cleaning`, `maintenance`, `blocked`. `cleaning` is never a recommendation
|
||||||
|
target from this evaluator (no fact here proves cleaning is required or complete); it is
|
||||||
|
only ever an input `current_status` that, absent any blocker, is left alone
|
||||||
|
(`vehicle.no_conflict`).
|
||||||
|
|
||||||
|
## Concurrency: recommendation token
|
||||||
|
|
||||||
|
`compute_recommendation_token(vehicle, facts)` hashes the vehicle's optimistic-lock
|
||||||
|
`version` plus every fact the recommendation was based on (sha256, truncated to 16 hex
|
||||||
|
chars). The preview endpoint returns this token; the apply endpoint recomputes it from
|
||||||
|
freshly-gathered facts inside the same transaction and rejects the request
|
||||||
|
(`RECOMMENDATION_STALE`) if it no longer matches — the frontend must never assume a
|
||||||
|
previously-shown preview is still valid without server revalidation (section 8F).
|
||||||
|
|
||||||
|
## MO-016: order independence
|
||||||
|
|
||||||
|
MO-016 carries both an open `booking_overlap` issue and an open
|
||||||
|
`vehicle_status_conflict` issue at once (two overlapping reserved bookings). Because
|
||||||
|
`gather_vehicle_status_facts` re-queries `overlapping_booking_pairs` and
|
||||||
|
`open_booking_overlap_issue_ref` fresh every call, resolving the booking-overlap issue
|
||||||
|
first vs. resolving the status-conflict issue first both converge on the same final
|
||||||
|
vehicle status — see `test_mo_016_status_conflict_recommendation_is_order_independent`
|
||||||
|
in `backend/tests/test_data_quality.py`.
|
||||||
@@ -71,3 +71,116 @@ test("no locale file contains an empty string value", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Brand-invariant: "Fleet Ops" is a fixed constant, never a translation value ---
|
||||||
|
// (see frontend/src/product.ts and docs/fleet-ops-correction/current-gap-audit.md §1).
|
||||||
|
// A regression here means someone re-introduced a per-locale brand key/value instead of
|
||||||
|
// interpolating {{productName}} from the shared constant.
|
||||||
|
|
||||||
|
test("no locale file defines an 'appName' key or the literal brand string", () => {
|
||||||
|
for (const language of LANGUAGES) {
|
||||||
|
for (const namespace of namespaces) {
|
||||||
|
const data = loadNamespace(language, namespace);
|
||||||
|
const raw = JSON.stringify(data);
|
||||||
|
expect(
|
||||||
|
raw.includes("Fleet Ops"),
|
||||||
|
`${language}/${namespace}.json contains the literal brand string "Fleet Ops" -- ` +
|
||||||
|
`use {{productName}} interpolation instead so the brand can never drift per locale`,
|
||||||
|
).toBe(false);
|
||||||
|
const keys = collectKeyPaths(data);
|
||||||
|
expect(
|
||||||
|
keys.some((k) => k === "appName" || k.endsWith(".appName")),
|
||||||
|
`${language}/${namespace}.json defines an "appName" key -- the brand name must come ` +
|
||||||
|
`from the PRODUCT_NAME constant, never a translatable key`,
|
||||||
|
).toBe(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Translation-quality: prove values were actually translated, not copy-pasted ---
|
||||||
|
// Sleutelpariteit alone doesn't prove translation happened (a locale file could contain
|
||||||
|
// the literal English string under the right key and still pass). For every "real prose"
|
||||||
|
// string (>=8 chars, not on the allowlist below), assert nl-BE and fr-BE differ from
|
||||||
|
// en-GB, and that fr-BE differs from nl-BE -- catching both "still English" and
|
||||||
|
// "Dutch text copy-pasted into French" in one pass.
|
||||||
|
|
||||||
|
// Exact (namespace, key-path) pairs that are legitimately identical across two or more
|
||||||
|
// locales: real proper nouns/brand names, deliberately-untranslated role titles, and
|
||||||
|
// genuine cross-language cognates (identical spelling in Dutch/French/English). This is
|
||||||
|
// a precise allowlist by key path, not a broad word-level allowlist, so it can't quietly
|
||||||
|
// hide an unrelated real mistranslation under the same key in a different namespace.
|
||||||
|
const IDENTICAL_VALUE_ALLOWLIST = new Set([
|
||||||
|
"audit.title", // "Audit trail" kept as an established cross-language compliance term
|
||||||
|
"audit.diff.was", // "{{field}}: was {{value}}" -- "was" is spelled identically in Dutch
|
||||||
|
"auth.roleOperationsManager", // deliberately-untranslated role title (see demo.json roles)
|
||||||
|
"auth.roleRentalEmployee",
|
||||||
|
"demo.scenarios.roles.operations_manager", // same convention, scenario-overview role labels
|
||||||
|
"demo.scenarios.roles.rental_employee",
|
||||||
|
"common.language.nl-BE", // language-picker options show each language's own endonym
|
||||||
|
"common.language.fr-BE",
|
||||||
|
"common.footer.productLine", // "{{productName}} Demo" -- brief-specified exact footer text
|
||||||
|
"common.orgName", // "Northstar Mobility" -- fictional org proper noun, same in all 3
|
||||||
|
"dashboard.attention.openRecord", // "Open {{title}}" -- "open" is also the Dutch imperative
|
||||||
|
"demo.scenarios.durationValue", // "± {{minutes}} min" -- unit abbreviation, same in all 3
|
||||||
|
"demo.scenarios.startScenario", // "start"/"scenario" are naturalised loanwords in Dutch
|
||||||
|
"demo.about.limitationsTitle", // "Limitations" -- identical spelling in French
|
||||||
|
"demo.integrationSummary.titles.mcp_hub", // "ITWorx MCP Hub" -- proper noun
|
||||||
|
"fleet.list.columns.attention", // "Attention" -- identical spelling in French
|
||||||
|
"fleet.detail.tabs.inspections", // "Inspections" -- identical spelling in French
|
||||||
|
"integrations.cards.orchestrationKicker", // "Orchestration" -- identical in French
|
||||||
|
"knowledge.questionLabel", // "Question" -- identical spelling in French
|
||||||
|
"knowledge.retrievalFlow.question",
|
||||||
|
"knowledge.questionLabelExchange",
|
||||||
|
"navigation.items.audit", // "Audit trail" kept as an established cross-language term
|
||||||
|
"returns.result.inspection", // "Inspection" -- identical spelling in French
|
||||||
|
]);
|
||||||
|
|
||||||
|
function isTranslatableProse(value: unknown): value is string {
|
||||||
|
if (typeof value !== "string") return false;
|
||||||
|
if (value.trim().length < 8) return false;
|
||||||
|
// Strip interpolation placeholders and non-letter characters; if nothing substantial
|
||||||
|
// remains (pure numbers/punctuation/units), it's not "prose" that needs translating.
|
||||||
|
const stripped = value
|
||||||
|
.replace(/\{\{[^}]+\}\}/g, " ")
|
||||||
|
.replace(/[^a-zA-Zà-öø-ÿÀ-ÖØ-ß]/g, "");
|
||||||
|
return stripped.trim().length >= 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
test("nl-BE and fr-BE translations are not suspiciously identical to en-GB or each other", () => {
|
||||||
|
for (const namespace of namespaces) {
|
||||||
|
const en = loadNamespace("en-GB", namespace);
|
||||||
|
const nl = loadNamespace("nl-BE", namespace);
|
||||||
|
const fr = loadNamespace("fr-BE", namespace);
|
||||||
|
const keys = collectKeyPaths(en);
|
||||||
|
|
||||||
|
for (const keyPath of keys) {
|
||||||
|
if (IDENTICAL_VALUE_ALLOWLIST.has(`${namespace}.${keyPath}`)) continue;
|
||||||
|
const at = (data: Record<string, unknown>) =>
|
||||||
|
keyPath.split(".").reduce<unknown>((acc, part) => {
|
||||||
|
if (acc && typeof acc === "object") return (acc as Record<string, unknown>)[part];
|
||||||
|
return undefined;
|
||||||
|
}, data);
|
||||||
|
|
||||||
|
const enValue = at(en);
|
||||||
|
if (!isTranslatableProse(enValue)) continue;
|
||||||
|
const nlValue = at(nl);
|
||||||
|
const frValue = at(fr);
|
||||||
|
|
||||||
|
expect(
|
||||||
|
nlValue,
|
||||||
|
`${namespace}.json:${keyPath} — nl-BE is identical to en-GB ("${enValue}"); ` +
|
||||||
|
`looks untranslated (add to IDENTICAL_VALUE_ALLOWLIST if this is intentional)`,
|
||||||
|
).not.toBe(enValue);
|
||||||
|
expect(
|
||||||
|
frValue,
|
||||||
|
`${namespace}.json:${keyPath} — fr-BE is identical to en-GB ("${enValue}"); ` +
|
||||||
|
`looks untranslated (add to IDENTICAL_VALUE_ALLOWLIST if this is intentional)`,
|
||||||
|
).not.toBe(enValue);
|
||||||
|
expect(
|
||||||
|
frValue,
|
||||||
|
`${namespace}.json:${keyPath} — fr-BE is identical to nl-BE ("${nlValue}"); ` +
|
||||||
|
`looks like Dutch text was copy-pasted into the French locale`,
|
||||||
|
).not.toBe(nlValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ export interface AutomationRun {
|
|||||||
status: string;
|
status: string;
|
||||||
attempts: number;
|
attempts: number;
|
||||||
last_error: string | null;
|
last_error: string | null;
|
||||||
|
last_error_code: string | null;
|
||||||
occurred_at: string;
|
occurred_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +155,8 @@ export interface ReturnPreviewResult {
|
|||||||
resulting_odometer_km: number;
|
resulting_odometer_km: number;
|
||||||
resulting_vehicle_status: string;
|
resulting_vehicle_status: string;
|
||||||
status_reason: string;
|
status_reason: string;
|
||||||
|
status_reason_code: string;
|
||||||
|
status_reason_params: Record<string, string | number>;
|
||||||
would_create_quality_issue: boolean;
|
would_create_quality_issue: boolean;
|
||||||
attention_reasons: string[];
|
attention_reasons: string[];
|
||||||
next_booking_risk: NextBookingRisk | null;
|
next_booking_risk: NextBookingRisk | null;
|
||||||
@@ -173,7 +176,27 @@ export interface DataQualityIssueDetail extends DataQualityIssue {
|
|||||||
export interface ApplyRecommendedStatusResult {
|
export interface ApplyRecommendedStatusResult {
|
||||||
issue: DataQualityIssue;
|
issue: DataQualityIssue;
|
||||||
applied_status: string;
|
applied_status: string;
|
||||||
reason: string;
|
reason_code: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VehicleStatusFacts {
|
||||||
|
active_booking_refs: string[];
|
||||||
|
overlapping_booking_pairs: string[][];
|
||||||
|
service_threshold_reached: boolean;
|
||||||
|
odometer_km: number;
|
||||||
|
next_service_km: number;
|
||||||
|
open_booking_overlap_issue_ref: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StatusRecommendation {
|
||||||
|
current_status: string;
|
||||||
|
recommended_status: string | null;
|
||||||
|
recommendation_code: string;
|
||||||
|
safe_to_apply: boolean;
|
||||||
|
manual_review_required: boolean;
|
||||||
|
facts: VehicleStatusFacts;
|
||||||
|
blocking_reasons: string[];
|
||||||
|
recommendation_token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ScanResult {
|
export interface ScanResult {
|
||||||
@@ -183,7 +206,8 @@ export interface ScanResult {
|
|||||||
export interface SearchResultItem {
|
export interface SearchResultItem {
|
||||||
type: "vehicle" | "booking" | "data_quality_issue" | "section";
|
type: "vehicle" | "booking" | "data_quality_issue" | "section";
|
||||||
label: string;
|
label: string;
|
||||||
detail: string;
|
detail_code: string;
|
||||||
|
detail_params: Record<string, string>;
|
||||||
link: string;
|
link: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { useDemoManifest } from "../context/DemoManifestContext";
|
|||||||
import { useViewportTier } from "../hooks/useViewportTier";
|
import { useViewportTier } from "../hooks/useViewportTier";
|
||||||
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
|
import { DEMO_GUIDE_STEPS } from "../data/demoGuideSteps";
|
||||||
import { Icon } from "./Icons";
|
import { Icon } from "./Icons";
|
||||||
|
import { PRODUCT_NAME } from "../product";
|
||||||
|
|
||||||
export function DemoGuideTrigger() {
|
export function DemoGuideTrigger() {
|
||||||
const { t } = useTranslation("demo");
|
const { t } = useTranslation("demo");
|
||||||
@@ -206,7 +207,7 @@ export function DemoGuide() {
|
|||||||
<p><strong>{t("guide.whatYouWillSee")}</strong><br />{t(`guide.steps.${step.id}.whatYouWillSee`)}</p>
|
<p><strong>{t("guide.whatYouWillSee")}</strong><br />{t(`guide.steps.${step.id}.whatYouWillSee`)}</p>
|
||||||
<p><strong>{t("guide.whyItMatters")}</strong><br />{t(`guide.steps.${step.id}.whyItMatters`)}</p>
|
<p><strong>{t("guide.whyItMatters")}</strong><br />{t(`guide.steps.${step.id}.whyItMatters`)}</p>
|
||||||
<p><strong>{t("guide.startAction")}</strong><br />{t(`guide.steps.${step.id}.startAction`)}</p>
|
<p><strong>{t("guide.startAction")}</strong><br />{t(`guide.steps.${step.id}.startAction`)}</p>
|
||||||
<p><strong>{t("guide.expectedOutcome")}</strong><br />{t(`guide.steps.${step.id}.expectedOutcome`)}</p>
|
<p><strong>{t("guide.expectedOutcome")}</strong><br />{t(`guide.steps.${step.id}.expectedOutcome`, { productName: PRODUCT_NAME })}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { DemoGuide, DemoGuideTrigger } from "./DemoGuide";
|
|||||||
import { LanguageSwitcher } from "./LanguageSwitcher";
|
import { LanguageSwitcher } from "./LanguageSwitcher";
|
||||||
import { useDemoGuide } from "../context/DemoGuideContext";
|
import { useDemoGuide } from "../context/DemoGuideContext";
|
||||||
import { useDemoManifest } from "../context/DemoManifestContext";
|
import { useDemoManifest } from "../context/DemoManifestContext";
|
||||||
|
import { PRODUCT_NAME } from "../product";
|
||||||
|
|
||||||
const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
|
const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
|
||||||
vehicle: "fleet",
|
vehicle: "fleet",
|
||||||
@@ -18,6 +19,31 @@ const SEARCH_ICON: Record<SearchResultItem["type"], IconName> = {
|
|||||||
section: "chevron",
|
section: "chevron",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The backend only ever sends a stable code + raw data params (never English prose) --
|
||||||
|
// see app/api/routers/search.py. Localizing here means the label/detail always follow
|
||||||
|
// the operator's selected locale, in every namespace search results can point to.
|
||||||
|
function searchResultLabel(t: (key: string, opts?: Record<string, unknown>) => string, item: SearchResultItem): string {
|
||||||
|
if (item.type === "section") return t(`navigation:items.${item.label}`, { defaultValue: item.label });
|
||||||
|
return item.label;
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchResultDetail(t: (key: string, opts?: Record<string, unknown>) => string, item: SearchResultItem): string {
|
||||||
|
switch (item.type) {
|
||||||
|
case "section":
|
||||||
|
return t(`searchSections.${item.detail_code}`, { defaultValue: item.detail_code });
|
||||||
|
case "vehicle":
|
||||||
|
return t("searchVehicleSummary", { ...item.detail_params });
|
||||||
|
case "booking":
|
||||||
|
return t(`bookings:statuses.${item.detail_code}`, { defaultValue: item.detail_code });
|
||||||
|
case "data_quality_issue":
|
||||||
|
return t(`quality:ruleTypes.${item.detail_code}`, {
|
||||||
|
defaultValue: item.detail_code.replace(/_/g, " "),
|
||||||
|
});
|
||||||
|
default:
|
||||||
|
return item.detail_code;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
to: string;
|
to: string;
|
||||||
labelKey: string;
|
labelKey: string;
|
||||||
@@ -177,7 +203,7 @@ export function Layout() {
|
|||||||
<aside className={`sidebar ${mobileOpen ? "is-open" : ""}`}>
|
<aside className={`sidebar ${mobileOpen ? "is-open" : ""}`}>
|
||||||
<div className="brand-lockup">
|
<div className="brand-lockup">
|
||||||
<BrandMark className="brand-mark" />
|
<BrandMark className="brand-mark" />
|
||||||
<div><strong>{t("common:appName")}</strong><span>{t("common:brandTagline")}</span></div>
|
<div><strong>{PRODUCT_NAME}</strong><span>{t("common:brandTagline")}</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="sidebar-language">
|
<div className="sidebar-language">
|
||||||
<LanguageSwitcher />
|
<LanguageSwitcher />
|
||||||
@@ -234,7 +260,7 @@ export function Layout() {
|
|||||||
</button>
|
</button>
|
||||||
<div className="global-search" role="search" ref={searchBox}>
|
<div className="global-search" role="search" ref={searchBox}>
|
||||||
<Icon name="search" />
|
<Icon name="search" />
|
||||||
<label className="visually-hidden" htmlFor="global-search-input">{t("searchLabel")}</label>
|
<label className="visually-hidden" htmlFor="global-search-input">{t("searchLabel", { productName: PRODUCT_NAME })}</label>
|
||||||
<input
|
<input
|
||||||
id="global-search-input"
|
id="global-search-input"
|
||||||
ref={searchInput}
|
ref={searchInput}
|
||||||
@@ -276,8 +302,8 @@ export function Layout() {
|
|||||||
>
|
>
|
||||||
<Icon name={SEARCH_ICON[item.type]} />
|
<Icon name={SEARCH_ICON[item.type]} />
|
||||||
<span className="search-result-copy">
|
<span className="search-result-copy">
|
||||||
<strong>{item.label}</strong>
|
<strong>{searchResultLabel(t, item)}</strong>
|
||||||
<small>{item.detail}</small>
|
<small>{searchResultDetail(t, item)}</small>
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
@@ -302,7 +328,7 @@ export function Layout() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main id="main-content" tabIndex={-1}><Outlet /></main>
|
<main id="main-content" tabIndex={-1}><Outlet /></main>
|
||||||
<footer className="app-footer"><span>{t("common:footer.productLine")}</span><span>{t("common:footer.locale")}</span></footer>
|
<footer className="app-footer"><span>{t("common:footer.productLine", { productName: PRODUCT_NAME })}</span><span>{t("common:footer.locale")}</span></footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="mobile-nav" aria-label={t("mobileNavLabel")}>
|
<nav className="mobile-nav" aria-label={t("mobileNavLabel")}>
|
||||||
|
|||||||
@@ -220,7 +220,17 @@ export function ReturnForm({
|
|||||||
<Icon name={preview.attention_reasons.length > 0 ? "alert" : "check"} />
|
<Icon name={preview.attention_reasons.length > 0 ? "alert" : "check"} />
|
||||||
<div>
|
<div>
|
||||||
<strong>{t("review.expectedState")} <StatusBadge status={preview.resulting_vehicle_status} label={t(`fleet:statuses.${preview.resulting_vehicle_status}`, { defaultValue: preview.resulting_vehicle_status })} /></strong>
|
<strong>{t("review.expectedState")} <StatusBadge status={preview.resulting_vehicle_status} label={t(`fleet:statuses.${preview.resulting_vehicle_status}`, { defaultValue: preview.resulting_vehicle_status })} /></strong>
|
||||||
<p>{preview.status_reason}</p>
|
<p>{t(`review.reasonCodes.${preview.status_reason_code}`, {
|
||||||
|
defaultValue: preview.status_reason,
|
||||||
|
threshold_km:
|
||||||
|
typeof preview.status_reason_params.threshold_km === "number"
|
||||||
|
? formatNumber(preview.status_reason_params.threshold_km)
|
||||||
|
: undefined,
|
||||||
|
})}</p>
|
||||||
|
<details className="evidence-disclosure">
|
||||||
|
<summary>{t("common:actions.technicalDetails")}</summary>
|
||||||
|
<pre className="evidence-block">{preview.status_reason}</pre>
|
||||||
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{preview.odometer_regression && (
|
{preview.odometer_regression && (
|
||||||
|
|||||||
@@ -40,6 +40,29 @@
|
|||||||
"was": "{{field}}: was {{value}}",
|
"was": "{{field}}: was {{value}}",
|
||||||
"changed": "{{field}}: {{before}} → {{after}}"
|
"changed": "{{field}}: {{before}} → {{after}}"
|
||||||
},
|
},
|
||||||
|
"actorTypes": {
|
||||||
|
"user": "User",
|
||||||
|
"system": "System",
|
||||||
|
"service": "Service"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"status": "Status",
|
||||||
|
"operational_status": "Operational status",
|
||||||
|
"registration_number": "Registration number",
|
||||||
|
"make": "Make",
|
||||||
|
"model": "Model",
|
||||||
|
"location": "Location",
|
||||||
|
"first_name": "First name",
|
||||||
|
"last_name": "Last name",
|
||||||
|
"email": "Email",
|
||||||
|
"phone": "Phone",
|
||||||
|
"postal_code": "Postal code",
|
||||||
|
"city": "City",
|
||||||
|
"booking_end_odometer_km": "Booking end odometer",
|
||||||
|
"vehicle_odometer_km": "Vehicle odometer",
|
||||||
|
"survivor": "Retained profile",
|
||||||
|
"loser": "Merged profile"
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"demo_login": "Logged in",
|
"demo_login": "Logged in",
|
||||||
"demo_logout": "Logged out",
|
"demo_logout": "Logged out",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"orgLine": "Demo organisation: {{orgName}} (fictional)",
|
"orgLine": "Demo organisation: {{orgName}} (fictional)",
|
||||||
"headline1": "Every hand-off.",
|
"headline1": "Every hand-off.",
|
||||||
"headline2": "One clear view.",
|
"headline2": "One clear view.",
|
||||||
"defaultDescription": "Fleet Ops brings vehicle, booking and operational data together, supports rental processes, detects data-quality problems and automates controlled follow-up steps.",
|
"defaultDescription": "{{productName}} brings vehicle, booking and operational data together, supports rental processes, detects data-quality problems and automates controlled follow-up steps.",
|
||||||
"footnote": "Synthetic demo · no real customer or vehicle data · resettable at any time",
|
"footnote": "Synthetic demo · no real customer or vehicle data · resettable at any time",
|
||||||
"accessEyebrow": "Demo access",
|
"accessEyebrow": "Demo access",
|
||||||
"accessHeading": "Choose how to start",
|
"accessHeading": "Choose how to start",
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"appName": "Fleet Ops",
|
|
||||||
"orgName": "Northstar Mobility",
|
"orgName": "Northstar Mobility",
|
||||||
"brandTagline": "Control Centre",
|
"brandTagline": "Control Centre",
|
||||||
"actions": {
|
"actions": {
|
||||||
@@ -35,7 +34,7 @@
|
|||||||
"fr-BE": "Français"
|
"fr-BE": "Français"
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"productLine": "Fleet Ops Demo",
|
"productLine": "{{productName}} Demo",
|
||||||
"locale": "Europe/Brussels · Synthetic demo data"
|
"locale": "Europe/Brussels · Synthetic demo data"
|
||||||
},
|
},
|
||||||
"demo": {
|
"demo": {
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
"whatYouWillSee": "An overview of what's functionally implemented in this demo, what's synthetic, and which integrations aren't live yet.",
|
"whatYouWillSee": "An overview of what's functionally implemented in this demo, what's synthetic, and which integrations aren't live yet.",
|
||||||
"whyItMatters": "A demo is only convincing if visitors can verify for themselves what really works and what's still ahead.",
|
"whyItMatters": "A demo is only convincing if visitors can verify for themselves what really works and what's still ahead.",
|
||||||
"startAction": "Read the 'About this demo' page.",
|
"startAction": "Read the 'About this demo' page.",
|
||||||
"expectedOutcome": "You can explain yourself what Fleet Ops is and isn't, with no verbal explanation needed."
|
"expectedOutcome": "You can explain yourself what {{productName}} is and isn't, with no verbal explanation needed."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -146,16 +146,16 @@
|
|||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
"eyebrow": "About this demo",
|
"eyebrow": "About this demo",
|
||||||
"title": "What Fleet Ops is and isn't",
|
"title": "What {{productName}} is and isn't",
|
||||||
"description": "{{orgName}} is a fictional rental organisation that makes this demo tangible — not a real company.",
|
"description": "{{orgName}} is a fictional rental organisation that makes this demo tangible — not a real company.",
|
||||||
"loading": "Loading demo information…",
|
"loading": "Loading demo information…",
|
||||||
"ctaTitle": "Prefer to jump right in?",
|
"ctaTitle": "Prefer to jump right in?",
|
||||||
"ctaBody": "The guided demo walks through all eight steps above in practice.",
|
"ctaBody": "The guided demo walks through all eight steps above in practice.",
|
||||||
"ctaButton": "Start guided demo",
|
"ctaButton": "Start guided demo",
|
||||||
"problemTitle": "The fictional problem",
|
"problemTitle": "The fictional problem",
|
||||||
"problemBody": "{{orgName}} rents around 50 campers and vans from one main location. Bookings, returns, customer records and maintenance used to live in separate spreadsheets and verbal hand-offs, so problems (duplicate customers, incorrect odometer readings, double-booked vehicles) only surfaced late. Fleet Ops shows how one connected system flags these problems early and lets them be resolved under control.",
|
"problemBody": "{{orgName}} rents around 50 campers and vans from one main location. Bookings, returns, customer records and maintenance used to live in separate spreadsheets and verbal hand-offs, so problems (duplicate customers, incorrect odometer readings, double-booked vehicles) only surfaced late. {{productName}} shows how one connected system flags these problems early and lets them be resolved under control.",
|
||||||
"scopeTitle": "Who it's for and its scope",
|
"scopeTitle": "Who it's for and its scope",
|
||||||
"scopeBody": "This demo is for anyone who wants to see how Fleet Ops tackles operational problems at a small rental company: Operations Managers and Rental Employees, and anyone evaluating the approach. The scope is deliberately focused on one connected proof of concept — no accounting, no payments, no public reservations, no full CRM or ERP.",
|
"scopeBody": "This demo is for anyone who wants to see how {{productName}} tackles operational problems at a small rental company: Operations Managers and Rental Employees, and anyone evaluating the approach. The scope is deliberately focused on one connected proof of concept — no accounting, no payments, no public reservations, no full CRM or ERP.",
|
||||||
"realTitle": "What really works",
|
"realTitle": "What really works",
|
||||||
"realBody": "Everything below is functional code, not just a mockup: role-based access and sessions, vehicle and booking management, return processing with server-side validation, five data-quality rules each with its own resolution step, a full audit trail, automated delivery to n8n with bounded retries, Docker-based deployment, and an automated test suite (backend and Playwright end-to-end).",
|
"realBody": "Everything below is functional code, not just a mockup: role-based access and sessions, vehicle and booking management, return processing with server-side validation, five data-quality rules each with its own resolution step, a full audit trail, automated delivery to n8n with bounded retries, Docker-based deployment, and an automated test suite (backend and Playwright end-to-end).",
|
||||||
"syntheticTitle": "What's synthetic",
|
"syntheticTitle": "What's synthetic",
|
||||||
|
|||||||
@@ -63,6 +63,14 @@
|
|||||||
"noQualityIssues": "No quality issues recorded.",
|
"noQualityIssues": "No quality issues recorded.",
|
||||||
"fuel": "Fuel {{percent}}%",
|
"fuel": "Fuel {{percent}}%",
|
||||||
"damage": "Damage",
|
"damage": "Damage",
|
||||||
"technicalWarning": "Technical warning"
|
"technicalWarning": "Technical warning",
|
||||||
|
"inspectionTypes": {
|
||||||
|
"departure": "Departure inspection",
|
||||||
|
"return": "Return inspection"
|
||||||
|
},
|
||||||
|
"maintenanceCategories": {
|
||||||
|
"periodic_service": "Periodic service",
|
||||||
|
"repair": "Repair"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,13 @@
|
|||||||
"noAction": "—",
|
"noAction": "—",
|
||||||
"eventTypes": {
|
"eventTypes": {
|
||||||
"vehicle.returned.v1": "Vehicle return processed"
|
"vehicle.returned.v1": "Vehicle return processed"
|
||||||
|
},
|
||||||
|
"errorCodes": {
|
||||||
|
"connectionError": "The workflow service was temporarily unreachable. The return was safely saved and can be retried.",
|
||||||
|
"malformedPayload": "The job contained incomplete data and could not be delivered. The underlying data remains safely stored.",
|
||||||
|
"remoteReportedFailure": "The workflow service declined the delivery. The job can be retried.",
|
||||||
|
"staleLeaseRecovered": "This job was recovered after an earlier delivery attempt stalled without a result.",
|
||||||
|
"unknownError": "An unexpected error occurred while delivering this job."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
"Which checks are required before checkout?"
|
"Which checks are required before checkout?"
|
||||||
],
|
],
|
||||||
"emptyTitle": "Evidence before answers",
|
"emptyTitle": "Evidence before answers",
|
||||||
"emptyDescription": "Ask about returns, damage, inspections or another indexed procedure. Fleet Ops will not invent an answer when evidence is missing.",
|
"emptyDescription": "Ask about returns, damage, inspections or another indexed procedure. {{productName}} will not invent an answer when evidence is missing.",
|
||||||
"retrievalFlow": {
|
"retrievalFlow": {
|
||||||
"question": "Question",
|
"question": "Question",
|
||||||
"sources": "Sources",
|
"sources": "Sources",
|
||||||
|
|||||||
@@ -27,12 +27,22 @@
|
|||||||
"resetConfirmYes": "Yes, reset",
|
"resetConfirmYes": "Yes, reset",
|
||||||
"resetCancel": "Cancel",
|
"resetCancel": "Cancel",
|
||||||
"resetFailed": "Could not reset demo data.",
|
"resetFailed": "Could not reset demo data.",
|
||||||
"searchLabel": "Search Fleet Ops",
|
"searchLabel": "Search {{productName}}",
|
||||||
"searchPlaceholder": "Search fleet, booking or section…",
|
"searchPlaceholder": "Search fleet, booking or section…",
|
||||||
"searchShortcutHint": "Ctrl K",
|
"searchShortcutHint": "Ctrl K",
|
||||||
"searchSearching": "Searching…",
|
"searchSearching": "Searching…",
|
||||||
"searchUnavailable": "Search is unavailable right now.",
|
"searchUnavailable": "Search is unavailable right now.",
|
||||||
"searchNoResults": "No matches for \"{{query}}\".",
|
"searchNoResults": "No matches for \"{{query}}\".",
|
||||||
|
"searchVehicleSummary": "{{make}} {{model}} · {{location}}",
|
||||||
|
"searchSections": {
|
||||||
|
"overview": "Operations dashboard",
|
||||||
|
"fleet": "Vehicle registry",
|
||||||
|
"bookings": "Rental bookings",
|
||||||
|
"quality": "Quality workbench",
|
||||||
|
"knowledge": "Procedure assistant",
|
||||||
|
"integrations": "Automation and integration status",
|
||||||
|
"audit": "Audit history"
|
||||||
|
},
|
||||||
"switchRole": "Switch role",
|
"switchRole": "Switch role",
|
||||||
"switchRoleTitle": "Switch demo role",
|
"switchRoleTitle": "Switch demo role",
|
||||||
"languageSwitcherLabel": "Change language"
|
"languageSwitcherLabel": "Change language"
|
||||||
|
|||||||
@@ -180,13 +180,48 @@
|
|||||||
"heading": "Resolve the status conflict",
|
"heading": "Resolve the status conflict",
|
||||||
"description": "One authoritative rule recommends a corrected operational status for this vehicle.",
|
"description": "One authoritative rule recommends a corrected operational status for this vehicle.",
|
||||||
"currentStatus": "Current status",
|
"currentStatus": "Current status",
|
||||||
"calculateAndApply": "Calculate and apply recommended status",
|
"reviewRecommendation": "Review recommendation",
|
||||||
"confirmTitle": "Confirm status change",
|
"loadingRecommendation": "Loading recommendation…",
|
||||||
"confirmBody": "Apply the authoritative recommended status for this vehicle?",
|
"recommendationFailed": "Could not load a recommendation.",
|
||||||
|
"recommendedStatus": "Recommended status",
|
||||||
|
"whyHeading": "Why",
|
||||||
|
"evidenceHeading": "Facts",
|
||||||
|
"consequenceHeading": "Consequence",
|
||||||
|
"consequence": {
|
||||||
|
"statusWillChange": "The vehicle status will change to {{status}}.",
|
||||||
|
"issueWillBeRechecked": "This quality issue will be re-checked.",
|
||||||
|
"changeWillBeAudited": "The change will be recorded in the audit trail.",
|
||||||
|
"bookingsNotDeleted": "The bookings themselves are not deleted."
|
||||||
|
},
|
||||||
|
"changeStatusTo": "Change status to {{status}}",
|
||||||
"applying": "Applying…",
|
"applying": "Applying…",
|
||||||
"confirmYes": "Yes, apply",
|
|
||||||
"applied": "Applied {{status}} — {{reason}}",
|
"applied": "Applied {{status}} — {{reason}}",
|
||||||
"applyFailed": "Could not apply a recommended status."
|
"applyFailed": "Could not apply a recommended status.",
|
||||||
|
"staleRecommendation": "The situation has changed since this recommendation was shown. Review it again before applying.",
|
||||||
|
"reviewAgain": "Review recommendation again",
|
||||||
|
"manualReview": {
|
||||||
|
"heading": "Manual review required",
|
||||||
|
"body": "The facts for this vehicle contradict each other. An Operations Manager needs to review this in person rather than apply an automatic status change.",
|
||||||
|
"hint": "Use 'Defer' or 'Reject' below, or investigate the vehicle and the related bookings manually."
|
||||||
|
},
|
||||||
|
"noConflict": {
|
||||||
|
"heading": "No change needed",
|
||||||
|
"body": "This vehicle's current status already matches the facts."
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"activeBookings": "Active booking(s): {{refs}}",
|
||||||
|
"overlappingBookings": "Overlapping bookings: {{pairs}}",
|
||||||
|
"serviceThresholdReached": "Odometer reading ({{odometer}} km) has reached the service threshold ({{threshold}} km).",
|
||||||
|
"openOverlapIssue": "Open booking-overlap issue: {{ref}}"
|
||||||
|
},
|
||||||
|
"reasonCodes": {
|
||||||
|
"vehicle.active_rental": "This vehicle has an active rental but is not recorded as rented. The status is corrected to reflect the actual situation.",
|
||||||
|
"vehicle.service_threshold_reached": "This vehicle has reached its service threshold. It is set to maintenance so it is not deployed before servicing takes place.",
|
||||||
|
"vehicle.booking_conflict": "This vehicle has two overlapping bookings. Blocking it prevents it from being considered available again before the booking conflict is resolved.",
|
||||||
|
"vehicle.rental_ended": "The rental for this vehicle has ended and there is no active booking left. It is set back to available.",
|
||||||
|
"vehicle.manual_review_required": "The facts for this vehicle contradict each other (for example, an active booking while the vehicle is in maintenance, or a service threshold reached alongside an active rental). This requires a human review rather than an automatic status change.",
|
||||||
|
"vehicle.no_conflict": "This vehicle's current status already matches the facts. No change is needed."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,13 @@
|
|||||||
"odometerRegressionWarning": "Submitted odometer ({{submitted}} km) is below the canonical reading ({{canonical}} km). The canonical odometer will not change, and a data-quality issue will be opened.",
|
"odometerRegressionWarning": "Submitted odometer ({{submitted}} km) is below the canonical reading ({{canonical}} km). The canonical odometer will not change, and a data-quality issue will be opened.",
|
||||||
"nextBookingRisk": "Next booking {{ref}} starts {{when}} — may be affected by this return.",
|
"nextBookingRisk": "Next booking {{ref}} starts {{when}} — may be affected by this return.",
|
||||||
"nextBookingLowRisk": "Next booking {{ref}} starts {{when}} — low risk.",
|
"nextBookingLowRisk": "Next booking {{ref}} starts {{when}} — low risk.",
|
||||||
|
"reasonCodes": {
|
||||||
|
"returnBlockedDamageAndTechnical": "Both damage and a technical warning were reported on this return.",
|
||||||
|
"returnBlockedDamage": "Damage was reported on this return.",
|
||||||
|
"returnBlockedTechnicalWarning": "A technical warning was reported on this return.",
|
||||||
|
"returnServiceThresholdReached": "The odometer reading has reached the {{threshold_km}} km service threshold.",
|
||||||
|
"returnRoutedToCleaning": "No damage, technical warning or service threshold reached; the vehicle goes to cleaning."
|
||||||
|
},
|
||||||
"commitList": {
|
"commitList": {
|
||||||
"inspection": "Create a return inspection",
|
"inspection": "Create a return inspection",
|
||||||
"updateAtomic": "Update the booking and vehicle atomically",
|
"updateAtomic": "Update the booking and vehicle atomically",
|
||||||
@@ -66,8 +73,9 @@
|
|||||||
"continueDemo": "Continue the demo"
|
"continueDemo": "Continue the demo"
|
||||||
},
|
},
|
||||||
"scenario": {
|
"scenario": {
|
||||||
|
"ariaLabel": "Demo scenario",
|
||||||
"title": "Demo scenario: odometer anomaly",
|
"title": "Demo scenario: odometer anomaly",
|
||||||
"body": "This vehicle currently reads {{odometer}} km. The form below is pre-filled with a return reading below that — a sign of a data-entry mistake or a mixed-up vehicle. Confirm the return to see how Fleet Ops detects and handles this.",
|
"body": "This vehicle currently reads {{odometer}} km. The form below is pre-filled with a return reading below that — a sign of a data-entry mistake or a mixed-up vehicle. Confirm the return to see how {{productName}} detects and handles this.",
|
||||||
"preparing": "Preparing scenario…"
|
"preparing": "Preparing scenario…"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,29 @@
|
|||||||
"was": "{{field}} : était {{value}}",
|
"was": "{{field}} : était {{value}}",
|
||||||
"changed": "{{field}} : {{before}} → {{after}}"
|
"changed": "{{field}} : {{before}} → {{after}}"
|
||||||
},
|
},
|
||||||
|
"actorTypes": {
|
||||||
|
"user": "Utilisateur",
|
||||||
|
"system": "Système",
|
||||||
|
"service": "Service"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"status": "Statut",
|
||||||
|
"operational_status": "Statut opérationnel",
|
||||||
|
"registration_number": "Plaque d'immatriculation",
|
||||||
|
"make": "Marque",
|
||||||
|
"model": "Modèle",
|
||||||
|
"location": "Emplacement",
|
||||||
|
"first_name": "Prénom",
|
||||||
|
"last_name": "Nom",
|
||||||
|
"email": "E-mail",
|
||||||
|
"phone": "Téléphone",
|
||||||
|
"postal_code": "Code postal",
|
||||||
|
"city": "Ville",
|
||||||
|
"booking_end_odometer_km": "Kilométrage final de la réservation",
|
||||||
|
"vehicle_odometer_km": "Kilométrage du véhicule",
|
||||||
|
"survivor": "Profil conservé",
|
||||||
|
"loser": "Profil fusionné"
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"demo_login": "Connecté",
|
"demo_login": "Connecté",
|
||||||
"demo_logout": "Déconnecté",
|
"demo_logout": "Déconnecté",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"orgLine": "Organisation de démo : {{orgName}} (fictive)",
|
"orgLine": "Organisation de démo : {{orgName}} (fictive)",
|
||||||
"headline1": "Chaque transfert.",
|
"headline1": "Chaque transfert.",
|
||||||
"headline2": "Une vue claire.",
|
"headline2": "Une vue claire.",
|
||||||
"defaultDescription": "Fleet Ops rassemble les données de véhicules, de réservations et d'exploitation, soutient les processus de location, détecte les problèmes de qualité des données et automatise les suites contrôlées.",
|
"defaultDescription": "{{productName}} rassemble les données de véhicules, de réservations et d'exploitation, soutient les processus de location, détecte les problèmes de qualité des données et automatise les suites contrôlées.",
|
||||||
"footnote": "Démo synthétique · aucune donnée client ou véhicule réelle · réinitialisable à tout moment",
|
"footnote": "Démo synthétique · aucune donnée client ou véhicule réelle · réinitialisable à tout moment",
|
||||||
"accessEyebrow": "Accès démo",
|
"accessEyebrow": "Accès démo",
|
||||||
"accessHeading": "Choisissez comment démarrer",
|
"accessHeading": "Choisissez comment démarrer",
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"appName": "Fleet Ops",
|
|
||||||
"orgName": "Northstar Mobility",
|
"orgName": "Northstar Mobility",
|
||||||
"brandTagline": "Centre de contrôle",
|
"brandTagline": "Centre de contrôle",
|
||||||
"actions": {
|
"actions": {
|
||||||
@@ -35,7 +34,7 @@
|
|||||||
"fr-BE": "Français"
|
"fr-BE": "Français"
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"productLine": "Fleet Ops Demo",
|
"productLine": "{{productName}} Demo",
|
||||||
"locale": "Europe/Bruxelles · Données de démonstration synthétiques"
|
"locale": "Europe/Bruxelles · Données de démonstration synthétiques"
|
||||||
},
|
},
|
||||||
"demo": {
|
"demo": {
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
"whatYouWillSee": "Un aperçu de ce qui est fonctionnellement implémenté dans cette démo, ce qui est synthétique, et quelles intégrations ne sont pas encore en direct.",
|
"whatYouWillSee": "Un aperçu de ce qui est fonctionnellement implémenté dans cette démo, ce qui est synthétique, et quelles intégrations ne sont pas encore en direct.",
|
||||||
"whyItMatters": "Une démo n'est convaincante que si les visiteurs peuvent vérifier eux-mêmes ce qui fonctionne réellement et ce qui reste à venir.",
|
"whyItMatters": "Une démo n'est convaincante que si les visiteurs peuvent vérifier eux-mêmes ce qui fonctionne réellement et ce qui reste à venir.",
|
||||||
"startAction": "Lisez la page « À propos de cette démo ».",
|
"startAction": "Lisez la page « À propos de cette démo ».",
|
||||||
"expectedOutcome": "Vous pouvez expliquer vous-même ce que Fleet Ops est et n'est pas, sans explication verbale nécessaire."
|
"expectedOutcome": "Vous pouvez expliquer vous-même ce que {{productName}} est et n'est pas, sans explication verbale nécessaire."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -146,16 +146,16 @@
|
|||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
"eyebrow": "À propos de cette démo",
|
"eyebrow": "À propos de cette démo",
|
||||||
"title": "Ce que Fleet Ops est et n'est pas",
|
"title": "Ce que {{productName}} est et n'est pas",
|
||||||
"description": "{{orgName}} est une organisation de location fictive qui rend cette démo concrète — pas une véritable entreprise.",
|
"description": "{{orgName}} est une organisation de location fictive qui rend cette démo concrète — pas une véritable entreprise.",
|
||||||
"loading": "Chargement des informations de démo…",
|
"loading": "Chargement des informations de démo…",
|
||||||
"ctaTitle": "Vous préférez vous lancer directement ?",
|
"ctaTitle": "Vous préférez vous lancer directement ?",
|
||||||
"ctaBody": "La démo guidée parcourt les huit étapes ci-dessus en pratique.",
|
"ctaBody": "La démo guidée parcourt les huit étapes ci-dessus en pratique.",
|
||||||
"ctaButton": "Démarrer la démo guidée",
|
"ctaButton": "Démarrer la démo guidée",
|
||||||
"problemTitle": "Le problème fictif",
|
"problemTitle": "Le problème fictif",
|
||||||
"problemBody": "{{orgName}} loue environ 50 campings-cars et fourgonnettes depuis un site principal. Les réservations, retours, fiches clients et l'entretien vivaient jusqu'ici dans des feuilles de calcul séparées et des échanges verbaux, si bien que les problèmes (clients en double, kilométrages incorrects, véhicules réservés en double) n'apparaissaient que tardivement. Fleet Ops montre comment un système unique et connecté signale ces problèmes tôt et permet de les résoudre de façon contrôlée.",
|
"problemBody": "{{orgName}} loue environ 50 campings-cars et fourgonnettes depuis un site principal. Les réservations, retours, fiches clients et l'entretien vivaient jusqu'ici dans des feuilles de calcul séparées et des échanges verbaux, si bien que les problèmes (clients en double, kilométrages incorrects, véhicules réservés en double) n'apparaissaient que tardivement. {{productName}} montre comment un système unique et connecté signale ces problèmes tôt et permet de les résoudre de façon contrôlée.",
|
||||||
"scopeTitle": "Pour qui et avec quelle portée",
|
"scopeTitle": "Pour qui et avec quelle portée",
|
||||||
"scopeBody": "Cette démo s'adresse à quiconque veut voir comment Fleet Ops traite les problèmes opérationnels d'un petit loueur : Operations Managers et Rental Employees, et toute personne évaluant l'approche. La portée est délibérément limitée à une preuve de concept unique et cohérente — pas de comptabilité, pas de paiements, pas de réservations publiques, pas de CRM ou ERP complet.",
|
"scopeBody": "Cette démo s'adresse à quiconque veut voir comment {{productName}} traite les problèmes opérationnels d'un petit loueur : Operations Managers et Rental Employees, et toute personne évaluant l'approche. La portée est délibérément limitée à une preuve de concept unique et cohérente — pas de comptabilité, pas de paiements, pas de réservations publiques, pas de CRM ou ERP complet.",
|
||||||
"realTitle": "Ce qui fonctionne réellement",
|
"realTitle": "Ce qui fonctionne réellement",
|
||||||
"realBody": "Tout ce qui suit est du code fonctionnel, pas seulement une maquette : accès et sessions basés sur les rôles, gestion des véhicules et réservations, traitement des retours avec validation côté serveur, cinq règles de qualité des données avec chacune sa propre étape de résolution, une piste d'audit complète, une livraison automatisée vers n8n avec nouvelles tentatives limitées, un déploiement basé sur Docker, et une suite de tests automatisés (backend et Playwright de bout en bout).",
|
"realBody": "Tout ce qui suit est du code fonctionnel, pas seulement une maquette : accès et sessions basés sur les rôles, gestion des véhicules et réservations, traitement des retours avec validation côté serveur, cinq règles de qualité des données avec chacune sa propre étape de résolution, une piste d'audit complète, une livraison automatisée vers n8n avec nouvelles tentatives limitées, un déploiement basé sur Docker, et une suite de tests automatisés (backend et Playwright de bout en bout).",
|
||||||
"syntheticTitle": "Ce qui est synthétique",
|
"syntheticTitle": "Ce qui est synthétique",
|
||||||
|
|||||||
@@ -63,6 +63,14 @@
|
|||||||
"noQualityIssues": "Aucun problème de qualité enregistré.",
|
"noQualityIssues": "Aucun problème de qualité enregistré.",
|
||||||
"fuel": "Carburant {{percent}}%",
|
"fuel": "Carburant {{percent}}%",
|
||||||
"damage": "Dommage",
|
"damage": "Dommage",
|
||||||
"technicalWarning": "Avertissement technique"
|
"technicalWarning": "Avertissement technique",
|
||||||
|
"inspectionTypes": {
|
||||||
|
"departure": "Inspection de départ",
|
||||||
|
"return": "Inspection de retour"
|
||||||
|
},
|
||||||
|
"maintenanceCategories": {
|
||||||
|
"periodic_service": "Entretien périodique",
|
||||||
|
"repair": "Réparation"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,13 @@
|
|||||||
"noAction": "—",
|
"noAction": "—",
|
||||||
"eventTypes": {
|
"eventTypes": {
|
||||||
"vehicle.returned.v1": "Retour de véhicule traité"
|
"vehicle.returned.v1": "Retour de véhicule traité"
|
||||||
|
},
|
||||||
|
"errorCodes": {
|
||||||
|
"connectionError": "Le service d'automatisation était temporairement injoignable. Le retour a été enregistré en toute sécurité et peut être soumis à nouveau.",
|
||||||
|
"malformedPayload": "La tâche contenait des données incomplètes et n'a pas pu être livrée. Les données sous-jacentes restent enregistrées en toute sécurité.",
|
||||||
|
"remoteReportedFailure": "Le service d'automatisation a refusé la livraison. La tâche peut être soumise à nouveau.",
|
||||||
|
"staleLeaseRecovered": "Cette tâche a été récupérée après qu'une tentative de livraison précédente s'est arrêtée sans résultat.",
|
||||||
|
"unknownError": "Une erreur inattendue s'est produite lors de la livraison de cette tâche."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
"Quels contrôles sont requis avant le départ ?"
|
"Quels contrôles sont requis avant le départ ?"
|
||||||
],
|
],
|
||||||
"emptyTitle": "Des preuves avant les réponses",
|
"emptyTitle": "Des preuves avant les réponses",
|
||||||
"emptyDescription": "Posez des questions sur les retours, les dommages, les inspections ou une autre procédure indexée. Fleet Ops n'invente jamais de réponse en l'absence de preuves.",
|
"emptyDescription": "Posez des questions sur les retours, les dommages, les inspections ou une autre procédure indexée. {{productName}} n'invente jamais de réponse en l'absence de preuves.",
|
||||||
"retrievalFlow": {
|
"retrievalFlow": {
|
||||||
"question": "Question",
|
"question": "Question",
|
||||||
"sources": "Sources",
|
"sources": "Sources",
|
||||||
|
|||||||
@@ -27,12 +27,22 @@
|
|||||||
"resetConfirmYes": "Oui, réinitialiser",
|
"resetConfirmYes": "Oui, réinitialiser",
|
||||||
"resetCancel": "Annuler",
|
"resetCancel": "Annuler",
|
||||||
"resetFailed": "Impossible de réinitialiser les données de démo.",
|
"resetFailed": "Impossible de réinitialiser les données de démo.",
|
||||||
"searchLabel": "Rechercher dans Fleet Ops",
|
"searchLabel": "Rechercher dans {{productName}}",
|
||||||
"searchPlaceholder": "Rechercher flotte, réservation ou section…",
|
"searchPlaceholder": "Rechercher flotte, réservation ou section…",
|
||||||
"searchShortcutHint": "Ctrl K",
|
"searchShortcutHint": "Ctrl K",
|
||||||
"searchSearching": "Recherche…",
|
"searchSearching": "Recherche…",
|
||||||
"searchUnavailable": "La recherche est momentanément indisponible.",
|
"searchUnavailable": "La recherche est momentanément indisponible.",
|
||||||
"searchNoResults": "Aucun résultat pour « {{query}} ».",
|
"searchNoResults": "Aucun résultat pour « {{query}} ».",
|
||||||
|
"searchVehicleSummary": "{{make}} {{model}} · {{location}}",
|
||||||
|
"searchSections": {
|
||||||
|
"overview": "Tableau de bord opérationnel",
|
||||||
|
"fleet": "Registre de la flotte",
|
||||||
|
"bookings": "Réservations de location",
|
||||||
|
"quality": "Atelier qualité",
|
||||||
|
"knowledge": "Assistant de procédures",
|
||||||
|
"integrations": "Statut d'automatisation et d'intégration",
|
||||||
|
"audit": "Historique d'audit"
|
||||||
|
},
|
||||||
"switchRole": "Changer de rôle",
|
"switchRole": "Changer de rôle",
|
||||||
"switchRoleTitle": "Changer de rôle de démo",
|
"switchRoleTitle": "Changer de rôle de démo",
|
||||||
"languageSwitcherLabel": "Changer de langue"
|
"languageSwitcherLabel": "Changer de langue"
|
||||||
|
|||||||
@@ -180,13 +180,48 @@
|
|||||||
"heading": "Résoudre le conflit de statut",
|
"heading": "Résoudre le conflit de statut",
|
||||||
"description": "Une règle faisant autorité recommande un statut opérationnel corrigé pour ce véhicule.",
|
"description": "Une règle faisant autorité recommande un statut opérationnel corrigé pour ce véhicule.",
|
||||||
"currentStatus": "Statut actuel",
|
"currentStatus": "Statut actuel",
|
||||||
"calculateAndApply": "Calculer et appliquer le statut recommandé",
|
"reviewRecommendation": "Voir la recommandation",
|
||||||
"confirmTitle": "Confirmer le changement de statut",
|
"loadingRecommendation": "Chargement de la recommandation…",
|
||||||
"confirmBody": "Appliquer le statut recommandé faisant autorité pour ce véhicule ?",
|
"recommendationFailed": "Impossible de charger une recommandation.",
|
||||||
|
"recommendedStatus": "Statut recommandé",
|
||||||
|
"whyHeading": "Pourquoi",
|
||||||
|
"evidenceHeading": "Faits",
|
||||||
|
"consequenceHeading": "Conséquence",
|
||||||
|
"consequence": {
|
||||||
|
"statusWillChange": "Le statut du véhicule sera changé en {{status}}.",
|
||||||
|
"issueWillBeRechecked": "Ce problème de qualité sera à nouveau contrôlé.",
|
||||||
|
"changeWillBeAudited": "Le changement sera enregistré dans la piste d'audit.",
|
||||||
|
"bookingsNotDeleted": "Les réservations elles-mêmes ne sont pas supprimées."
|
||||||
|
},
|
||||||
|
"changeStatusTo": "Changer le statut vers {{status}}",
|
||||||
"applying": "Application en cours…",
|
"applying": "Application en cours…",
|
||||||
"confirmYes": "Oui, appliquer",
|
|
||||||
"applied": "Appliqué {{status}} — {{reason}}",
|
"applied": "Appliqué {{status}} — {{reason}}",
|
||||||
"applyFailed": "Impossible d'appliquer un statut recommandé."
|
"applyFailed": "Impossible d'appliquer un statut recommandé.",
|
||||||
|
"staleRecommendation": "La situation a changé depuis l'affichage de cette recommandation. Consultez-la à nouveau avant de l'appliquer.",
|
||||||
|
"reviewAgain": "Revoir la recommandation",
|
||||||
|
"manualReview": {
|
||||||
|
"heading": "Évaluation manuelle requise",
|
||||||
|
"body": "Les faits concernant ce véhicule se contredisent. Un Operations Manager doit évaluer la situation en personne plutôt que d'appliquer un changement de statut automatique.",
|
||||||
|
"hint": "Utilisez « Reporter » ou « Rejeter » ci-dessous, ou examinez manuellement le véhicule et les réservations concernées."
|
||||||
|
},
|
||||||
|
"noConflict": {
|
||||||
|
"heading": "Aucun changement nécessaire",
|
||||||
|
"body": "Le statut actuel de ce véhicule correspond déjà aux faits."
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"activeBookings": "Réservation(s) active(s) : {{refs}}",
|
||||||
|
"overlappingBookings": "Réservations qui se chevauchent : {{pairs}}",
|
||||||
|
"serviceThresholdReached": "Le kilométrage ({{odometer}} km) a atteint le seuil d'entretien ({{threshold}} km).",
|
||||||
|
"openOverlapIssue": "Problème de chevauchement de réservation ouvert : {{ref}}"
|
||||||
|
},
|
||||||
|
"reasonCodes": {
|
||||||
|
"vehicle.active_rental": "Ce véhicule a une location active mais n'est pas enregistré comme loué. Le statut est corrigé pour refléter la situation réelle.",
|
||||||
|
"vehicle.service_threshold_reached": "Ce véhicule a atteint son seuil d'entretien. Il est placé en entretien afin qu'il ne soit pas utilisé avant que la révision ait eu lieu.",
|
||||||
|
"vehicle.booking_conflict": "Ce véhicule a deux réservations qui se chevauchent. Le bloquer évite qu'il soit à nouveau considéré comme disponible avant que le conflit de réservation ne soit résolu.",
|
||||||
|
"vehicle.rental_ended": "La location de ce véhicule est terminée et il n'y a plus de réservation active. Il est remis en disponible.",
|
||||||
|
"vehicle.manual_review_required": "Les faits concernant ce véhicule se contredisent (par exemple, une réservation active alors que le véhicule est en entretien, ou un seuil d'entretien atteint en même temps qu'une location active). Cela nécessite une évaluation humaine plutôt qu'un changement de statut automatique.",
|
||||||
|
"vehicle.no_conflict": "Le statut actuel de ce véhicule correspond déjà aux faits. Aucun changement n'est nécessaire."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,13 @@
|
|||||||
"odometerRegressionWarning": "Le kilométrage saisi ({{submitted}} km) est inférieur au relevé de référence ({{canonical}} km). Le kilométrage de référence ne changera pas et un problème de qualité des données sera ouvert.",
|
"odometerRegressionWarning": "Le kilométrage saisi ({{submitted}} km) est inférieur au relevé de référence ({{canonical}} km). Le kilométrage de référence ne changera pas et un problème de qualité des données sera ouvert.",
|
||||||
"nextBookingRisk": "La prochaine réservation {{ref}} débute {{when}} — peut être affectée par ce retour.",
|
"nextBookingRisk": "La prochaine réservation {{ref}} débute {{when}} — peut être affectée par ce retour.",
|
||||||
"nextBookingLowRisk": "La prochaine réservation {{ref}} débute {{when}} — risque faible.",
|
"nextBookingLowRisk": "La prochaine réservation {{ref}} débute {{when}} — risque faible.",
|
||||||
|
"reasonCodes": {
|
||||||
|
"returnBlockedDamageAndTechnical": "Des dégâts et une alerte technique ont tous deux été signalés lors de ce retour.",
|
||||||
|
"returnBlockedDamage": "Des dégâts ont été signalés lors de ce retour.",
|
||||||
|
"returnBlockedTechnicalWarning": "Une alerte technique a été signalée lors de ce retour.",
|
||||||
|
"returnServiceThresholdReached": "Le kilométrage a atteint le seuil d'entretien de {{threshold_km}} km.",
|
||||||
|
"returnRoutedToCleaning": "Aucun dégât, alerte technique ou seuil d'entretien atteint ; le véhicule part en nettoyage."
|
||||||
|
},
|
||||||
"commitList": {
|
"commitList": {
|
||||||
"inspection": "Créer une inspection de retour",
|
"inspection": "Créer une inspection de retour",
|
||||||
"updateAtomic": "Mettre à jour la réservation et le véhicule de façon atomique",
|
"updateAtomic": "Mettre à jour la réservation et le véhicule de façon atomique",
|
||||||
@@ -66,8 +73,9 @@
|
|||||||
"continueDemo": "Poursuivre la démo"
|
"continueDemo": "Poursuivre la démo"
|
||||||
},
|
},
|
||||||
"scenario": {
|
"scenario": {
|
||||||
|
"ariaLabel": "Scénario de démo",
|
||||||
"title": "Scénario de démo : anomalie de kilométrage",
|
"title": "Scénario de démo : anomalie de kilométrage",
|
||||||
"body": "Ce véhicule affiche actuellement {{odometer}} km. Le formulaire ci-dessous est pré-rempli avec un relevé de retour inférieur — signe d'une erreur de saisie ou d'un véhicule confondu. Confirmez le retour pour voir comment Fleet Ops détecte et traite cela.",
|
"body": "Ce véhicule affiche actuellement {{odometer}} km. Le formulaire ci-dessous est pré-rempli avec un relevé de retour inférieur — signe d'une erreur de saisie ou d'un véhicule confondu. Confirmez le retour pour voir comment {{productName}} détecte et traite cela.",
|
||||||
"preparing": "Préparation du scénario…"
|
"preparing": "Préparation du scénario…"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,29 @@
|
|||||||
"was": "{{field}}: was {{value}}",
|
"was": "{{field}}: was {{value}}",
|
||||||
"changed": "{{field}}: {{before}} → {{after}}"
|
"changed": "{{field}}: {{before}} → {{after}}"
|
||||||
},
|
},
|
||||||
|
"actorTypes": {
|
||||||
|
"user": "Gebruiker",
|
||||||
|
"system": "Systeem",
|
||||||
|
"service": "Dienst"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"status": "Status",
|
||||||
|
"operational_status": "Operationele status",
|
||||||
|
"registration_number": "Kenteken",
|
||||||
|
"make": "Merk",
|
||||||
|
"model": "Model",
|
||||||
|
"location": "Locatie",
|
||||||
|
"first_name": "Voornaam",
|
||||||
|
"last_name": "Achternaam",
|
||||||
|
"email": "E-mail",
|
||||||
|
"phone": "Telefoon",
|
||||||
|
"postal_code": "Postcode",
|
||||||
|
"city": "Stad",
|
||||||
|
"booking_end_odometer_km": "Eindkilometerstand boeking",
|
||||||
|
"vehicle_odometer_km": "Kilometerstand voertuig",
|
||||||
|
"survivor": "Behouden profiel",
|
||||||
|
"loser": "Samengevoegd profiel"
|
||||||
|
},
|
||||||
"actions": {
|
"actions": {
|
||||||
"demo_login": "Ingelogd",
|
"demo_login": "Ingelogd",
|
||||||
"demo_logout": "Uitgelogd",
|
"demo_logout": "Uitgelogd",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
"orgLine": "Demo-organisatie: {{orgName}} (fictief)",
|
"orgLine": "Demo-organisatie: {{orgName}} (fictief)",
|
||||||
"headline1": "Elke overdracht.",
|
"headline1": "Elke overdracht.",
|
||||||
"headline2": "Eén helder overzicht.",
|
"headline2": "Eén helder overzicht.",
|
||||||
"defaultDescription": "Fleet Ops brengt voertuig-, boekings- en operationele gegevens samen, ondersteunt verhuurprocessen, detecteert datakwaliteitsproblemen en automatiseert gecontroleerde vervolgstappen.",
|
"defaultDescription": "{{productName}} brengt voertuig-, boekings- en operationele gegevens samen, ondersteunt verhuurprocessen, detecteert datakwaliteitsproblemen en automatiseert gecontroleerde vervolgstappen.",
|
||||||
"footnote": "Synthetische demo · geen echte klant- of voertuiggegevens · op elk moment herstelbaar",
|
"footnote": "Synthetische demo · geen echte klant- of voertuiggegevens · op elk moment herstelbaar",
|
||||||
"accessEyebrow": "Demo-toegang",
|
"accessEyebrow": "Demo-toegang",
|
||||||
"accessHeading": "Kies hoe je wil starten",
|
"accessHeading": "Kies hoe je wil starten",
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"appName": "Fleet Ops",
|
|
||||||
"orgName": "Northstar Mobility",
|
"orgName": "Northstar Mobility",
|
||||||
"brandTagline": "Bedieningscentrum",
|
"brandTagline": "Bedieningscentrum",
|
||||||
"actions": {
|
"actions": {
|
||||||
@@ -35,7 +34,7 @@
|
|||||||
"fr-BE": "Français"
|
"fr-BE": "Français"
|
||||||
},
|
},
|
||||||
"footer": {
|
"footer": {
|
||||||
"productLine": "Fleet Ops Demo",
|
"productLine": "{{productName}} Demo",
|
||||||
"locale": "Europa/Brussel · Synthetische demogegevens"
|
"locale": "Europa/Brussel · Synthetische demogegevens"
|
||||||
},
|
},
|
||||||
"demo": {
|
"demo": {
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
"whatYouWillSee": "Een overzicht van wat in deze demo functioneel geïmplementeerd is, wat synthetisch is, en welke koppelingen nog niet live zijn.",
|
"whatYouWillSee": "Een overzicht van wat in deze demo functioneel geïmplementeerd is, wat synthetisch is, en welke koppelingen nog niet live zijn.",
|
||||||
"whyItMatters": "Een demo is pas overtuigend als bezoekers zelf kunnen nagaan wat echt werkt en wat nog toekomstmuziek is.",
|
"whyItMatters": "Een demo is pas overtuigend als bezoekers zelf kunnen nagaan wat echt werkt en wat nog toekomstmuziek is.",
|
||||||
"startAction": "Lees de pagina 'Over deze demo'.",
|
"startAction": "Lees de pagina 'Over deze demo'.",
|
||||||
"expectedOutcome": "Je kan zelf uitleggen wat Fleet Ops wel en niet is, zonder mondelinge toelichting."
|
"expectedOutcome": "Je kan zelf uitleggen wat {{productName}} wel en niet is, zonder mondelinge toelichting."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -146,16 +146,16 @@
|
|||||||
},
|
},
|
||||||
"about": {
|
"about": {
|
||||||
"eyebrow": "Over deze demo",
|
"eyebrow": "Over deze demo",
|
||||||
"title": "Wat Fleet Ops wel en niet is",
|
"title": "Wat {{productName}} wel en niet is",
|
||||||
"description": "{{orgName}} is een fictieve verhuurorganisatie die dient om deze demo tastbaar te maken — geen bestaand bedrijf.",
|
"description": "{{orgName}} is een fictieve verhuurorganisatie die dient om deze demo tastbaar te maken — geen bestaand bedrijf.",
|
||||||
"loading": "Demo-informatie laden…",
|
"loading": "Demo-informatie laden…",
|
||||||
"ctaTitle": "Liever meteen aan de slag?",
|
"ctaTitle": "Liever meteen aan de slag?",
|
||||||
"ctaBody": "De gegidste demo doorloopt alle acht stappen hierboven in de praktijk.",
|
"ctaBody": "De gegidste demo doorloopt alle acht stappen hierboven in de praktijk.",
|
||||||
"ctaButton": "Start begeleide demo",
|
"ctaButton": "Start begeleide demo",
|
||||||
"problemTitle": "Het fictieve probleem",
|
"problemTitle": "Het fictieve probleem",
|
||||||
"problemBody": "{{orgName}} verhuurt zo'n 50 campers en bestelwagens vanuit één hoofdlocatie. Boekingen, retours, klantgegevens en onderhoud kwamen tot nu toe uit losse spreadsheets en mondelinge afspraken, waardoor fouten (dubbele klanten, foutieve kilometerstanden, dubbel geboekte voertuigen) laat aan het licht kwamen. Fleet Ops toont hoe één samenhangend systeem die problemen vroeg signaleert en gecontroleerd laat oplossen.",
|
"problemBody": "{{orgName}} verhuurt zo'n 50 campers en bestelwagens vanuit één hoofdlocatie. Boekingen, retours, klantgegevens en onderhoud kwamen tot nu toe uit losse spreadsheets en mondelinge afspraken, waardoor fouten (dubbele klanten, foutieve kilometerstanden, dubbel geboekte voertuigen) laat aan het licht kwamen. {{productName}} toont hoe één samenhangend systeem die problemen vroeg signaleert en gecontroleerd laat oplossen.",
|
||||||
"scopeTitle": "Voor wie en met welke scope",
|
"scopeTitle": "Voor wie en met welke scope",
|
||||||
"scopeBody": "Deze demo is bedoeld voor wie wil zien hoe Fleet Ops operationele problemen bij een kleine verhuurder aanpakt: Operations Managers en Rental Employees, en iedereen die de aanpak evalueert. De scope is bewust afgebakend tot één samenhangende proof of concept — geen boekhouding, geen betalingen, geen publieke reservaties, geen volledig CRM of ERP.",
|
"scopeBody": "Deze demo is bedoeld voor wie wil zien hoe {{productName}} operationele problemen bij een kleine verhuurder aanpakt: Operations Managers en Rental Employees, en iedereen die de aanpak evalueert. De scope is bewust afgebakend tot één samenhangende proof of concept — geen boekhouding, geen betalingen, geen publieke reservaties, geen volledig CRM of ERP.",
|
||||||
"realTitle": "Wat écht werkt",
|
"realTitle": "Wat écht werkt",
|
||||||
"realBody": "Alles hieronder is functionele code, niet alleen een mockup: rol-gebaseerde toegang en sessies, voertuig- en boekingsbeheer, retourverwerking met serverzijdige validatie, vijf datakwaliteitsregels met elk een eigen oplossingsstap, een volledige audit trail, geautomatiseerde aflevering naar n8n met begrensde herpogingen, Docker-gebaseerde deployment en een geautomatiseerde testsuite (backend en Playwright end-to-end).",
|
"realBody": "Alles hieronder is functionele code, niet alleen een mockup: rol-gebaseerde toegang en sessies, voertuig- en boekingsbeheer, retourverwerking met serverzijdige validatie, vijf datakwaliteitsregels met elk een eigen oplossingsstap, een volledige audit trail, geautomatiseerde aflevering naar n8n met begrensde herpogingen, Docker-gebaseerde deployment en een geautomatiseerde testsuite (backend en Playwright end-to-end).",
|
||||||
"syntheticTitle": "Wat synthetisch is",
|
"syntheticTitle": "Wat synthetisch is",
|
||||||
|
|||||||
@@ -63,6 +63,14 @@
|
|||||||
"noQualityIssues": "Geen kwaliteitsproblemen geregistreerd.",
|
"noQualityIssues": "Geen kwaliteitsproblemen geregistreerd.",
|
||||||
"fuel": "Brandstof {{percent}}%",
|
"fuel": "Brandstof {{percent}}%",
|
||||||
"damage": "Schade",
|
"damage": "Schade",
|
||||||
"technicalWarning": "Technische melding"
|
"technicalWarning": "Technische melding",
|
||||||
|
"inspectionTypes": {
|
||||||
|
"departure": "Vertrekinspectie",
|
||||||
|
"return": "Retourinspectie"
|
||||||
|
},
|
||||||
|
"maintenanceCategories": {
|
||||||
|
"periodic_service": "Periodiek onderhoud",
|
||||||
|
"repair": "Herstelling"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,13 @@
|
|||||||
"noAction": "—",
|
"noAction": "—",
|
||||||
"eventTypes": {
|
"eventTypes": {
|
||||||
"vehicle.returned.v1": "Voertuigretour verwerkt"
|
"vehicle.returned.v1": "Voertuigretour verwerkt"
|
||||||
|
},
|
||||||
|
"errorCodes": {
|
||||||
|
"connectionError": "De workflowdienst was tijdelijk niet bereikbaar. De retour is veilig opgeslagen en kan opnieuw worden aangeboden.",
|
||||||
|
"malformedPayload": "De opdracht bevatte onvolledige gegevens en kon niet worden afgeleverd. De onderliggende gegevens blijven veilig bewaard.",
|
||||||
|
"remoteReportedFailure": "De workflowdienst heeft de aflevering geweigerd. De opdracht kan opnieuw worden aangeboden.",
|
||||||
|
"staleLeaseRecovered": "Deze opdracht werd hersteld nadat een eerdere afleverpoging vastliep zonder resultaat.",
|
||||||
|
"unknownError": "Er is een onverwachte fout opgetreden bij het afleveren van deze opdracht."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
"Welke controles zijn verplicht vóór vertrek?"
|
"Welke controles zijn verplicht vóór vertrek?"
|
||||||
],
|
],
|
||||||
"emptyTitle": "Evidentie voor antwoorden",
|
"emptyTitle": "Evidentie voor antwoorden",
|
||||||
"emptyDescription": "Vraag naar retours, schade, inspecties of een andere geïndexeerde procedure. Fleet Ops verzint geen antwoord wanneer evidentie ontbreekt.",
|
"emptyDescription": "Vraag naar retours, schade, inspecties of een andere geïndexeerde procedure. {{productName}} verzint geen antwoord wanneer evidentie ontbreekt.",
|
||||||
"retrievalFlow": {
|
"retrievalFlow": {
|
||||||
"question": "Vraag",
|
"question": "Vraag",
|
||||||
"sources": "Bronnen",
|
"sources": "Bronnen",
|
||||||
|
|||||||
@@ -27,12 +27,22 @@
|
|||||||
"resetConfirmYes": "Ja, herstellen",
|
"resetConfirmYes": "Ja, herstellen",
|
||||||
"resetCancel": "Annuleren",
|
"resetCancel": "Annuleren",
|
||||||
"resetFailed": "Demogegevens konden niet hersteld worden.",
|
"resetFailed": "Demogegevens konden niet hersteld worden.",
|
||||||
"searchLabel": "Zoek in Fleet Ops",
|
"searchLabel": "Zoek in {{productName}}",
|
||||||
"searchPlaceholder": "Zoek wagenpark, boeking of onderdeel…",
|
"searchPlaceholder": "Zoek wagenpark, boeking of onderdeel…",
|
||||||
"searchShortcutHint": "Ctrl K",
|
"searchShortcutHint": "Ctrl K",
|
||||||
"searchSearching": "Zoeken…",
|
"searchSearching": "Zoeken…",
|
||||||
"searchUnavailable": "Zoeken is momenteel niet beschikbaar.",
|
"searchUnavailable": "Zoeken is momenteel niet beschikbaar.",
|
||||||
"searchNoResults": "Geen resultaten voor \"{{query}}\".",
|
"searchNoResults": "Geen resultaten voor \"{{query}}\".",
|
||||||
|
"searchVehicleSummary": "{{make}} {{model}} · {{location}}",
|
||||||
|
"searchSections": {
|
||||||
|
"overview": "Operationeel dashboard",
|
||||||
|
"fleet": "Wagenparkregister",
|
||||||
|
"bookings": "Verhuurboekingen",
|
||||||
|
"quality": "Kwaliteitswerkbank",
|
||||||
|
"knowledge": "Procedureassistent",
|
||||||
|
"integrations": "Automatiserings- en integratiestatus",
|
||||||
|
"audit": "Auditgeschiedenis"
|
||||||
|
},
|
||||||
"switchRole": "Wissel van rol",
|
"switchRole": "Wissel van rol",
|
||||||
"switchRoleTitle": "Wissel van demo-rol",
|
"switchRoleTitle": "Wissel van demo-rol",
|
||||||
"languageSwitcherLabel": "Taal wijzigen"
|
"languageSwitcherLabel": "Taal wijzigen"
|
||||||
|
|||||||
@@ -180,13 +180,48 @@
|
|||||||
"heading": "Statusconflict oplossen",
|
"heading": "Statusconflict oplossen",
|
||||||
"description": "Eén gezaghebbende regel beveelt een gecorrigeerde operationele status voor dit voertuig aan.",
|
"description": "Eén gezaghebbende regel beveelt een gecorrigeerde operationele status voor dit voertuig aan.",
|
||||||
"currentStatus": "Huidige status",
|
"currentStatus": "Huidige status",
|
||||||
"calculateAndApply": "Aanbevolen status berekenen en toepassen",
|
"reviewRecommendation": "Aanbeveling bekijken",
|
||||||
"confirmTitle": "Statuswijziging bevestigen",
|
"loadingRecommendation": "Aanbeveling laden…",
|
||||||
"confirmBody": "De gezaghebbende aanbevolen status voor dit voertuig toepassen?",
|
"recommendationFailed": "Kon geen aanbeveling ophalen.",
|
||||||
|
"recommendedStatus": "Aanbevolen status",
|
||||||
|
"whyHeading": "Waarom",
|
||||||
|
"evidenceHeading": "Feiten",
|
||||||
|
"consequenceHeading": "Gevolg",
|
||||||
|
"consequence": {
|
||||||
|
"statusWillChange": "De voertuigstatus wordt gewijzigd naar {{status}}.",
|
||||||
|
"issueWillBeRechecked": "Dit kwaliteitsprobleem wordt opnieuw gecontroleerd.",
|
||||||
|
"changeWillBeAudited": "De wijziging wordt vastgelegd in de audit trail.",
|
||||||
|
"bookingsNotDeleted": "De boekingen zelf worden niet verwijderd."
|
||||||
|
},
|
||||||
|
"changeStatusTo": "Status wijzigen naar {{status}}",
|
||||||
"applying": "Bezig met toepassen…",
|
"applying": "Bezig met toepassen…",
|
||||||
"confirmYes": "Ja, toepassen",
|
|
||||||
"applied": "Toegepast {{status}} — {{reason}}",
|
"applied": "Toegepast {{status}} — {{reason}}",
|
||||||
"applyFailed": "Kon geen aanbevolen status toepassen."
|
"applyFailed": "Kon geen aanbevolen status toepassen.",
|
||||||
|
"staleRecommendation": "De situatie is intussen gewijzigd. Bekijk de aanbeveling opnieuw voor u ze toepast.",
|
||||||
|
"reviewAgain": "Aanbeveling opnieuw bekijken",
|
||||||
|
"manualReview": {
|
||||||
|
"heading": "Handmatige beoordeling vereist",
|
||||||
|
"body": "De feiten voor dit voertuig spreken elkaar tegen. Een Operations Manager moet dit persoonlijk beoordelen in plaats van een automatische statuswijziging toe te passen.",
|
||||||
|
"hint": "Gebruik 'Uitstellen' of 'Verwerpen' hieronder, of onderzoek het voertuig en de betrokken boekingen manueel."
|
||||||
|
},
|
||||||
|
"noConflict": {
|
||||||
|
"heading": "Geen wijziging nodig",
|
||||||
|
"body": "De huidige status van dit voertuig komt al overeen met de feiten."
|
||||||
|
},
|
||||||
|
"evidence": {
|
||||||
|
"activeBookings": "Actieve boeking(en): {{refs}}",
|
||||||
|
"overlappingBookings": "Overlappende reserveringen: {{pairs}}",
|
||||||
|
"serviceThresholdReached": "Kilometerstand ({{odometer}} km) heeft de onderhoudsdrempel ({{threshold}} km) bereikt.",
|
||||||
|
"openOverlapIssue": "Openstaand boekingsoverlapprobleem: {{ref}}"
|
||||||
|
},
|
||||||
|
"reasonCodes": {
|
||||||
|
"vehicle.active_rental": "Dit voertuig heeft een actieve verhuring maar staat niet als verhuurd geregistreerd. De status wordt gecorrigeerd zodat ze de werkelijke situatie weerspiegelt.",
|
||||||
|
"vehicle.service_threshold_reached": "Dit voertuig heeft de onderhoudsdrempel bereikt. Het wordt naar onderhoud gezet zodat het niet wordt ingezet voordat de service is uitgevoerd.",
|
||||||
|
"vehicle.booking_conflict": "Dit voertuig heeft twee overlappende reserveringen. Het blokkeren voorkomt dat het opnieuw als inzetbaar wordt beschouwd voordat het boekingsconflict is opgelost.",
|
||||||
|
"vehicle.rental_ended": "De verhuring voor dit voertuig is beëindigd en er is geen actieve boeking meer. Het wordt terug beschikbaar gezet.",
|
||||||
|
"vehicle.manual_review_required": "De feiten voor dit voertuig spreken elkaar tegen (bijvoorbeeld een actieve boeking terwijl het voertuig in onderhoud staat, of een onderhoudsdrempel die samenvalt met een actieve verhuring). Dit vereist een menselijke beoordeling in plaats van een automatische statuswijziging.",
|
||||||
|
"vehicle.no_conflict": "De huidige status van dit voertuig komt al overeen met de feiten. Er is geen wijziging nodig."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,13 @@
|
|||||||
"odometerRegressionWarning": "De ingevoerde kilometerstand ({{submitted}} km) ligt onder de laatst bevestigde stand ({{canonical}} km). De laatst bevestigde kilometerstand wijzigt niet en er wordt een datakwaliteitsprobleem geopend.",
|
"odometerRegressionWarning": "De ingevoerde kilometerstand ({{submitted}} km) ligt onder de laatst bevestigde stand ({{canonical}} km). De laatst bevestigde kilometerstand wijzigt niet en er wordt een datakwaliteitsprobleem geopend.",
|
||||||
"nextBookingRisk": "Volgende boeking {{ref}} start {{when}} — mogelijk beïnvloed door deze retour.",
|
"nextBookingRisk": "Volgende boeking {{ref}} start {{when}} — mogelijk beïnvloed door deze retour.",
|
||||||
"nextBookingLowRisk": "Volgende boeking {{ref}} start {{when}} — laag risico.",
|
"nextBookingLowRisk": "Volgende boeking {{ref}} start {{when}} — laag risico.",
|
||||||
|
"reasonCodes": {
|
||||||
|
"returnBlockedDamageAndTechnical": "Zowel schade als een technische melding werden gemeld bij deze retour.",
|
||||||
|
"returnBlockedDamage": "Er werd schade gemeld bij deze retour.",
|
||||||
|
"returnBlockedTechnicalWarning": "Er werd een technische melding gemeld bij deze retour.",
|
||||||
|
"returnServiceThresholdReached": "De kilometerstand heeft de onderhoudsdrempel van {{threshold_km}} km bereikt.",
|
||||||
|
"returnRoutedToCleaning": "Geen schade, technische melding of onderhoudsdrempel; het voertuig gaat naar reiniging."
|
||||||
|
},
|
||||||
"commitList": {
|
"commitList": {
|
||||||
"inspection": "Retourinspectie aanmaken",
|
"inspection": "Retourinspectie aanmaken",
|
||||||
"updateAtomic": "Boeking en voertuig atomair bijwerken",
|
"updateAtomic": "Boeking en voertuig atomair bijwerken",
|
||||||
@@ -66,8 +73,9 @@
|
|||||||
"continueDemo": "Ga verder met de demo"
|
"continueDemo": "Ga verder met de demo"
|
||||||
},
|
},
|
||||||
"scenario": {
|
"scenario": {
|
||||||
|
"ariaLabel": "Demonstratiescenario",
|
||||||
"title": "Demonstratiescenario: afwijkende kilometerstand",
|
"title": "Demonstratiescenario: afwijkende kilometerstand",
|
||||||
"body": "Dit voertuig staat momenteel op {{odometer}} km. Het onderstaande formulier is vooraf ingevuld met een retourstand die daaronder ligt — een teken van een foutieve invoer of een verwisseld voertuig. Bevestig de retour om te zien hoe Fleet Ops dit detecteert en afhandelt.",
|
"body": "Dit voertuig staat momenteel op {{odometer}} km. Het onderstaande formulier is vooraf ingevuld met een retourstand die daaronder ligt — een teken van een foutieve invoer of een verwisseld voertuig. Bevestig de retour om te zien hoe {{productName}} dit detecteert en afhandelt.",
|
||||||
"preparing": "Scenario voorbereiden…"
|
"preparing": "Scenario voorbereiden…"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useDemoManifest } from "../context/DemoManifestContext";
|
|||||||
import { useLocaleFormat } from "../i18n/format";
|
import { useLocaleFormat } from "../i18n/format";
|
||||||
import { Icon } from "../components/Icons";
|
import { Icon } from "../components/Icons";
|
||||||
import { IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
|
import { IntegrationMark, LoadingState, PageHeader, SectionHeading } from "../components/PageChrome";
|
||||||
|
import { PRODUCT_NAME } from "../product";
|
||||||
|
|
||||||
const INTEGRATION_ICON: Record<string, "n8n" | "rag" | "mcp"> = {
|
const INTEGRATION_ICON: Record<string, "n8n" | "rag" | "mcp"> = {
|
||||||
n8n: "n8n",
|
n8n: "n8n",
|
||||||
@@ -36,7 +37,7 @@ export function AboutDemo() {
|
|||||||
<div className="page">
|
<div className="page">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
eyebrow={t("about.eyebrow")}
|
eyebrow={t("about.eyebrow")}
|
||||||
title={t("about.title")}
|
title={t("about.title", { productName: PRODUCT_NAME })}
|
||||||
description={manifest ? t("about.description", { orgName: manifest.organization_name }) : undefined}
|
description={manifest ? t("about.description", { orgName: manifest.organization_name }) : undefined}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -58,12 +59,12 @@ export function AboutDemo() {
|
|||||||
|
|
||||||
<section className="record-surface about-card">
|
<section className="record-surface about-card">
|
||||||
<h2>{t("about.problemTitle")}</h2>
|
<h2>{t("about.problemTitle")}</h2>
|
||||||
<p>{t("about.problemBody", { orgName: manifest.organization_name })}</p>
|
<p>{t("about.problemBody", { orgName: manifest.organization_name, productName: PRODUCT_NAME })}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="record-surface about-card">
|
<section className="record-surface about-card">
|
||||||
<h2>{t("about.scopeTitle")}</h2>
|
<h2>{t("about.scopeTitle")}</h2>
|
||||||
<p>{t("about.scopeBody")}</p>
|
<p>{t("about.scopeBody", { productName: PRODUCT_NAME })}</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="record-surface about-card">
|
<section className="record-surface about-card">
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ function ChangeDiff({
|
|||||||
const b = before?.[key];
|
const b = before?.[key];
|
||||||
const a = after?.[key];
|
const a = after?.[key];
|
||||||
if (JSON.stringify(b) === JSON.stringify(a)) continue;
|
if (JSON.stringify(b) === JSON.stringify(a)) continue;
|
||||||
const field = humanizeField(key);
|
const field = t(`fields.${key}`, { defaultValue: humanizeField(key) });
|
||||||
if (b === undefined) lines.push({ field, text: t("diff.setTo", { field, value: JSON.stringify(a) }) });
|
if (b === undefined) lines.push({ field, text: t("diff.setTo", { field, value: JSON.stringify(a) }) });
|
||||||
else if (a === undefined) lines.push({ field, text: t("diff.was", { field, value: JSON.stringify(b) }) });
|
else if (a === undefined) lines.push({ field, text: t("diff.was", { field, value: JSON.stringify(b) }) });
|
||||||
else lines.push({ field, text: t("diff.changed", { field, before: JSON.stringify(b), after: JSON.stringify(a) }) });
|
else lines.push({ field, text: t("diff.changed", { field, before: JSON.stringify(b), after: JSON.stringify(a) }) });
|
||||||
@@ -163,7 +163,7 @@ export function Audit() {
|
|||||||
<span className="table-subtext">{formatDateTime(e.occurred_at)}</span>
|
<span className="table-subtext">{formatDateTime(e.occurred_at)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="audit-group-meta">
|
<div className="audit-group-meta">
|
||||||
<span><strong>{e.actor_label}</strong> <small className="table-subtext">{e.actor_type}</small></span>
|
<span><strong>{e.actor_label}</strong> <small className="table-subtext">{t(`actorTypes.${e.actor_type}`, { defaultValue: e.actor_type })}</small></span>
|
||||||
{e.entity_link ? (
|
{e.entity_link ? (
|
||||||
<Link to={e.entity_link}>{e.entity_ref ?? e.entity_type}</Link>
|
<Link to={e.entity_link}>{e.entity_ref ?? e.entity_type}</Link>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -118,7 +118,21 @@ export function Automation() {
|
|||||||
<StatusBadge status={r.status} label={t(`ledger.status${r.status.charAt(0).toUpperCase()}${r.status.slice(1)}`)} />
|
<StatusBadge status={r.status} label={t(`ledger.status${r.status.charAt(0).toUpperCase()}${r.status.slice(1)}`)} />
|
||||||
</td>
|
</td>
|
||||||
<td data-label={t("ledger.columns.attempts")}>{r.attempts}</td>
|
<td data-label={t("ledger.columns.attempts")}>{r.attempts}</td>
|
||||||
<td data-label={t("ledger.columns.lastError")}>{r.last_error ?? "—"}</td>
|
<td data-label={t("ledger.columns.lastError")}>
|
||||||
|
{r.last_error_code ? (
|
||||||
|
<>
|
||||||
|
<span>{t(`ledger.errorCodes.${r.last_error_code}`, { defaultValue: r.last_error ?? r.last_error_code })}</span>
|
||||||
|
{r.last_error && (
|
||||||
|
<details className="evidence-disclosure">
|
||||||
|
<summary>{t("common:actions.technicalDetails")}</summary>
|
||||||
|
<pre className="evidence-block">{r.last_error}</pre>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td data-label={t("ledger.columns.when")}>
|
<td data-label={t("ledger.columns.when")}>
|
||||||
<time dateTime={r.occurred_at}>{formatDateTime(r.occurred_at)}</time>
|
<time dateTime={r.occurred_at}>{formatDateTime(r.occurred_at)}</time>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { StatusBadge } from "../components/Badge";
|
|||||||
import { ReturnForm, ReturnResultPanel } from "../components/ReturnForm";
|
import { ReturnForm, ReturnResultPanel } from "../components/ReturnForm";
|
||||||
import { Icon } from "../components/Icons";
|
import { Icon } from "../components/Icons";
|
||||||
import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
|
import { ErrorState, LoadingState, PageHeader } from "../components/PageChrome";
|
||||||
|
import { PRODUCT_NAME } from "../product";
|
||||||
|
|
||||||
export function BookingDetail() {
|
export function BookingDetail() {
|
||||||
const { t } = useTranslation(["bookings", "returns"]);
|
const { t } = useTranslation(["bookings", "returns"]);
|
||||||
@@ -73,11 +74,11 @@ export function BookingDetail() {
|
|||||||
</dl></section>
|
</dl></section>
|
||||||
|
|
||||||
{isReturnAnomalyScenario && !returnResult && canonicalOdometerKm !== null && (
|
{isReturnAnomalyScenario && !returnResult && canonicalOdometerKm !== null && (
|
||||||
<section className="record-surface scenario-callout" aria-label="Demo scenario">
|
<section className="record-surface scenario-callout" aria-label={t("returns:scenario.ariaLabel")}>
|
||||||
<Icon name="spark" />
|
<Icon name="spark" />
|
||||||
<div>
|
<div>
|
||||||
<strong>{t("returns:scenario.title")}</strong>
|
<strong>{t("returns:scenario.title")}</strong>
|
||||||
<p>{t("returns:scenario.body", { odometer: formatNumber(canonicalOdometerKm) })}</p>
|
<p>{t("returns:scenario.body", { odometer: formatNumber(canonicalOdometerKm), productName: PRODUCT_NAME })}</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
ApplyRecommendedStatusResult,
|
ApplyRecommendedStatusResult,
|
||||||
DataQualityIssueDetail as IssueDetail,
|
DataQualityIssueDetail as IssueDetail,
|
||||||
EntitySnapshot,
|
EntitySnapshot,
|
||||||
|
StatusRecommendation,
|
||||||
} from "../api/types";
|
} from "../api/types";
|
||||||
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
import { SeverityBadge, StatusBadge } from "../components/Badge";
|
||||||
import { useAuth } from "../context/AuthContext";
|
import { useAuth } from "../context/AuthContext";
|
||||||
@@ -443,15 +444,62 @@ function BookingOverlapPanel({ issue, onResolved }: { issue: IssueDetail; onReso
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function statusLabel(t: (key: string, opts?: Record<string, unknown>) => string, status: string): string {
|
||||||
|
const raw = t(`fleet:statuses.${status}`, { defaultValue: status });
|
||||||
|
return raw.charAt(0).toUpperCase() + raw.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusRecommendationEvidence({ recommendation }: { recommendation: StatusRecommendation }) {
|
||||||
|
const { t } = useTranslation("quality");
|
||||||
|
const { formatNumber } = useLocaleFormat();
|
||||||
|
const facts = recommendation.facts;
|
||||||
|
const lines: string[] = [];
|
||||||
|
if (facts.active_booking_refs.length > 0) {
|
||||||
|
lines.push(
|
||||||
|
t("detail.vehicleStatusConflict.evidence.activeBookings", { refs: facts.active_booking_refs.join(", ") }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (facts.overlapping_booking_pairs.length > 0) {
|
||||||
|
lines.push(
|
||||||
|
t("detail.vehicleStatusConflict.evidence.overlappingBookings", {
|
||||||
|
pairs: facts.overlapping_booking_pairs.map((pair) => pair.join(" ↔ ")).join(", "),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (facts.service_threshold_reached) {
|
||||||
|
lines.push(
|
||||||
|
t("detail.vehicleStatusConflict.evidence.serviceThresholdReached", {
|
||||||
|
odometer: formatNumber(facts.odometer_km),
|
||||||
|
threshold: formatNumber(facts.next_service_km),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (facts.open_booking_overlap_issue_ref) {
|
||||||
|
lines.push(
|
||||||
|
t("detail.vehicleStatusConflict.evidence.openOverlapIssue", { ref: facts.open_booking_overlap_issue_ref }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (lines.length === 0) return null;
|
||||||
|
return (
|
||||||
|
<ul className="evidence-list">
|
||||||
|
{lines.map((line) => (
|
||||||
|
<li key={line}>{line}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
|
function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail; onResolved: () => void }) {
|
||||||
const { t } = useTranslation("quality");
|
const { t } = useTranslation("quality");
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { manifest } = useDemoManifest();
|
const { manifest } = useDemoManifest();
|
||||||
const { open: guideOpen, currentIndex, completeAndAdvance } = useDemoGuide();
|
const { open: guideOpen, currentIndex, completeAndAdvance } = useDemoGuide();
|
||||||
|
const [recommendation, setRecommendation] = useState<StatusRecommendation | null>(null);
|
||||||
|
const [loadingRecommendation, setLoadingRecommendation] = useState(false);
|
||||||
|
const [stale, setStale] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [result, setResult] = useState<ApplyRecommendedStatusResult | null>(null);
|
const [result, setResult] = useState<ApplyRecommendedStatusResult | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [confirming, setConfirming] = useState(false);
|
|
||||||
|
|
||||||
function continueDemo() {
|
function continueDemo() {
|
||||||
completeAndAdvance();
|
completeAndAdvance();
|
||||||
@@ -465,23 +513,52 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
|
|||||||
// visibility via `result` rather than disappearing the instant the reload lands.
|
// visibility via `result` rather than disappearing the instant the reload lands.
|
||||||
if (issue.status !== "open" && !result) return null;
|
if (issue.status !== "open" && !result) return null;
|
||||||
|
|
||||||
|
async function loadRecommendation() {
|
||||||
|
setError(null);
|
||||||
|
setStale(false);
|
||||||
|
setLoadingRecommendation(true);
|
||||||
|
try {
|
||||||
|
const preview = await api.post<StatusRecommendation>(
|
||||||
|
`/api/v1/data-quality/issues/${issue.public_ref}/status-recommendation`,
|
||||||
|
);
|
||||||
|
setRecommendation(preview);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : t("detail.vehicleStatusConflict.recommendationFailed"));
|
||||||
|
} finally {
|
||||||
|
setLoadingRecommendation(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleApply() {
|
async function handleApply() {
|
||||||
|
if (!recommendation) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const applied = await api.post<ApplyRecommendedStatusResult>(
|
const applied = await api.post<ApplyRecommendedStatusResult>(
|
||||||
`/api/v1/data-quality/issues/${issue.public_ref}/apply-recommended-status`,
|
`/api/v1/data-quality/issues/${issue.public_ref}/apply-recommended-status`,
|
||||||
|
{ recommendation_token: recommendation.recommendation_token },
|
||||||
);
|
);
|
||||||
setResult(applied);
|
setResult(applied);
|
||||||
onResolved();
|
onResolved();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : t("detail.vehicleStatusConflict.applyFailed"));
|
if (err instanceof ApiError && err.code === "RECOMMENDATION_STALE") {
|
||||||
setConfirming(false);
|
setError(t("detail.vehicleStatusConflict.staleRecommendation"));
|
||||||
|
setRecommendation(null);
|
||||||
|
setStale(true);
|
||||||
|
} else {
|
||||||
|
setError(err instanceof ApiError ? err.message : t("detail.vehicleStatusConflict.applyFailed"));
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const needsManualReview =
|
||||||
|
recommendation !== null && (recommendation.manual_review_required || !recommendation.safe_to_apply);
|
||||||
|
const noChangeNeeded = recommendation !== null && !needsManualReview && recommendation.recommended_status === null;
|
||||||
|
const hasSafeRecommendation =
|
||||||
|
recommendation !== null && !needsManualReview && recommendation.recommended_status !== null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="panel" aria-labelledby="status-conflict-heading">
|
<section className="panel" aria-labelledby="status-conflict-heading">
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
@@ -496,7 +573,12 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
|
|||||||
{result ? (
|
{result ? (
|
||||||
<>
|
<>
|
||||||
<p className="quiet-empty">
|
<p className="quiet-empty">
|
||||||
<Icon name="check" /> {t("detail.vehicleStatusConflict.applied", { status: result.applied_status, reason: result.reason })}
|
<Icon name="check" /> {t("detail.vehicleStatusConflict.applied", {
|
||||||
|
status: statusLabel(t, result.applied_status),
|
||||||
|
reason: t(`detail.vehicleStatusConflict.reasonCodes.${result.reason_code}`, {
|
||||||
|
defaultValue: result.reason_code,
|
||||||
|
}),
|
||||||
|
})}
|
||||||
</p>
|
</p>
|
||||||
<div className="result-links">
|
<div className="result-links">
|
||||||
<Link className="button button-secondary" to="/audit">{t("detail.resolved.viewAudit")}<Icon name="chevron" /></Link>
|
<Link className="button button-secondary" to="/audit">{t("detail.resolved.viewAudit")}<Icon name="chevron" /></Link>
|
||||||
@@ -508,21 +590,63 @@ function VehicleStatusConflictPanel({ issue, onResolved }: { issue: IssueDetail;
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : !confirming ? (
|
) : !recommendation ? (
|
||||||
<button type="button" className="button button-primary" onClick={() => setConfirming(true)}>
|
<button type="button" className="button button-primary" onClick={loadRecommendation} disabled={loadingRecommendation}>
|
||||||
{t("detail.vehicleStatusConflict.calculateAndApply")}
|
{loadingRecommendation
|
||||||
|
? t("detail.vehicleStatusConflict.loadingRecommendation")
|
||||||
|
: t(stale ? "detail.vehicleStatusConflict.reviewAgain" : "detail.vehicleStatusConflict.reviewRecommendation")}
|
||||||
</button>
|
</button>
|
||||||
) : (
|
) : needsManualReview ? (
|
||||||
<div className="confirm-bar" role="alertdialog" aria-label={t("detail.vehicleStatusConflict.confirmTitle")}>
|
<div className="status-decision" role="status">
|
||||||
<p>{t("detail.vehicleStatusConflict.confirmBody")}</p>
|
<h3>{t("detail.vehicleStatusConflict.manualReview.heading")}</h3>
|
||||||
|
<p>{t("detail.vehicleStatusConflict.manualReview.body")}</p>
|
||||||
|
<StatusRecommendationEvidence recommendation={recommendation} />
|
||||||
|
<p className="table-subtext">{t("detail.vehicleStatusConflict.manualReview.hint")}</p>
|
||||||
|
</div>
|
||||||
|
) : noChangeNeeded ? (
|
||||||
|
<div className="status-decision" role="status">
|
||||||
|
<h3>{t("detail.vehicleStatusConflict.noConflict.heading")}</h3>
|
||||||
|
<p>{t("detail.vehicleStatusConflict.noConflict.body")}</p>
|
||||||
|
</div>
|
||||||
|
) : hasSafeRecommendation && recommendation.recommended_status ? (
|
||||||
|
<div className="status-decision" role="group" aria-label={t("detail.vehicleStatusConflict.recommendedStatus")}>
|
||||||
|
<dl className="detail-grid">
|
||||||
|
<div>
|
||||||
|
<dt>{t("detail.vehicleStatusConflict.recommendedStatus")}</dt>
|
||||||
|
<dd>
|
||||||
|
<StatusBadge
|
||||||
|
status={recommendation.recommended_status}
|
||||||
|
label={t(`fleet:statuses.${recommendation.recommended_status}`, {
|
||||||
|
defaultValue: recommendation.recommended_status,
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<h3>{t("detail.vehicleStatusConflict.whyHeading")}</h3>
|
||||||
|
<p>{t(`detail.vehicleStatusConflict.reasonCodes.${recommendation.recommendation_code}`, {
|
||||||
|
defaultValue: recommendation.recommendation_code,
|
||||||
|
})}</p>
|
||||||
|
<h3>{t("detail.vehicleStatusConflict.evidenceHeading")}</h3>
|
||||||
|
<StatusRecommendationEvidence recommendation={recommendation} />
|
||||||
|
<h3>{t("detail.vehicleStatusConflict.consequenceHeading")}</h3>
|
||||||
|
<ul className="evidence-list">
|
||||||
|
<li>{t("detail.vehicleStatusConflict.consequence.statusWillChange", {
|
||||||
|
status: statusLabel(t, recommendation.recommended_status),
|
||||||
|
})}</li>
|
||||||
|
<li>{t("detail.vehicleStatusConflict.consequence.issueWillBeRechecked")}</li>
|
||||||
|
<li>{t("detail.vehicleStatusConflict.consequence.changeWillBeAudited")}</li>
|
||||||
|
<li>{t("detail.vehicleStatusConflict.consequence.bookingsNotDeleted")}</li>
|
||||||
|
</ul>
|
||||||
<button type="button" className="button button-primary" onClick={handleApply} disabled={submitting}>
|
<button type="button" className="button button-primary" onClick={handleApply} disabled={submitting}>
|
||||||
{submitting ? t("detail.vehicleStatusConflict.applying") : t("detail.vehicleStatusConflict.confirmYes")}
|
{submitting
|
||||||
</button>
|
? t("detail.vehicleStatusConflict.applying")
|
||||||
<button type="button" className="button button-secondary" onClick={() => setConfirming(false)} disabled={submitting}>
|
: t("detail.vehicleStatusConflict.changeStatusTo", {
|
||||||
{t("list.cancel")}
|
status: statusLabel(t, recommendation.recommended_status),
|
||||||
|
})}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { api, ApiError } from "../api/client";
|
|||||||
import type { GroundedAnswer, KnowledgeHealth } from "../api/types";
|
import type { GroundedAnswer, KnowledgeHealth } from "../api/types";
|
||||||
import { Icon } from "../components/Icons";
|
import { Icon } from "../components/Icons";
|
||||||
import { PageHeader } from "../components/PageChrome";
|
import { PageHeader } from "../components/PageChrome";
|
||||||
|
import { PRODUCT_NAME } from "../product";
|
||||||
|
|
||||||
interface Exchange {
|
interface Exchange {
|
||||||
question: string;
|
question: string;
|
||||||
@@ -121,7 +122,7 @@ export function Knowledge() {
|
|||||||
<div className="knowledge-empty">
|
<div className="knowledge-empty">
|
||||||
<span><Icon name="knowledge" /></span>
|
<span><Icon name="knowledge" /></span>
|
||||||
<h2>{t("emptyTitle")}</h2>
|
<h2>{t("emptyTitle")}</h2>
|
||||||
<p>{t("emptyDescription")}</p>
|
<p>{t("emptyDescription", { productName: PRODUCT_NAME })}</p>
|
||||||
<div className="retrieval-flow" aria-hidden="true">
|
<div className="retrieval-flow" aria-hidden="true">
|
||||||
<span>{t("retrievalFlow.question")}</span><i />
|
<span>{t("retrievalFlow.question")}</span><i />
|
||||||
<span>{providerLabel}</span><i />
|
<span>{providerLabel}</span><i />
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useDemoManifest } from "../context/DemoManifestContext";
|
|||||||
import { LanguageSwitcher } from "../components/LanguageSwitcher";
|
import { LanguageSwitcher } from "../components/LanguageSwitcher";
|
||||||
import type { Role } from "../api/types";
|
import type { Role } from "../api/types";
|
||||||
import { BrandMark, Icon } from "../components/Icons";
|
import { BrandMark, Icon } from "../components/Icons";
|
||||||
|
import { PRODUCT_NAME } from "../product";
|
||||||
|
|
||||||
export function Login() {
|
export function Login() {
|
||||||
const { t } = useTranslation(["auth", "common"]);
|
const { t } = useTranslation(["auth", "common"]);
|
||||||
@@ -25,14 +26,14 @@ export function Login() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const orgName = manifest?.organization_name ?? t("common:orgName");
|
const orgName = manifest?.organization_name ?? t("common:orgName");
|
||||||
const description = t("defaultDescription");
|
const description = t("defaultDescription", { productName: PRODUCT_NAME });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="login-shell">
|
<main className="login-shell">
|
||||||
<section className="login-story" aria-labelledby="product-name">
|
<section className="login-story" aria-labelledby="product-name">
|
||||||
<div className="brand-lockup login-brand">
|
<div className="brand-lockup login-brand">
|
||||||
<BrandMark className="brand-mark" />
|
<BrandMark className="brand-mark" />
|
||||||
<div><strong>{t("common:appName")}</strong><span>{t("brandTagline")}</span></div>
|
<div><strong>{PRODUCT_NAME}</strong><span>{t("brandTagline")}</span></div>
|
||||||
</div>
|
</div>
|
||||||
<div className="login-message">
|
<div className="login-message">
|
||||||
<p className="eyebrow">{t("orgLine", { orgName })}</p>
|
<p className="eyebrow">{t("orgLine", { orgName })}</p>
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export function VehicleDetail() {
|
|||||||
{vehicle.inspections.length === 0 && <li>{t("detail.noInspections")}</li>}
|
{vehicle.inspections.length === 0 && <li>{t("detail.noInspections")}</li>}
|
||||||
{vehicle.inspections.map((i) => (
|
{vehicle.inspections.map((i) => (
|
||||||
<li key={i.public_ref}>
|
<li key={i.public_ref}>
|
||||||
<span>{i.type}</span>
|
<span>{t(`fleet:detail.inspectionTypes.${i.type}`, { defaultValue: i.type })}</span>
|
||||||
<span>{formatNumber(i.odometer_km)} km</span>
|
<span>{formatNumber(i.odometer_km)} km</span>
|
||||||
<span>{t("detail.fuel", { percent: i.fuel_level_percent })}</span>
|
<span>{t("detail.fuel", { percent: i.fuel_level_percent })}</span>
|
||||||
{i.damage_reported && <span className="badge severity-high">{t("detail.damage")}</span>}
|
{i.damage_reported && <span className="badge severity-high">{t("detail.damage")}</span>}
|
||||||
@@ -99,7 +99,7 @@ export function VehicleDetail() {
|
|||||||
{vehicle.maintenance.length === 0 && <li>{t("detail.noMaintenance")}</li>}
|
{vehicle.maintenance.length === 0 && <li>{t("detail.noMaintenance")}</li>}
|
||||||
{vehicle.maintenance.map((m) => (
|
{vehicle.maintenance.map((m) => (
|
||||||
<li key={m.public_ref}>
|
<li key={m.public_ref}>
|
||||||
<span>{m.category}</span>
|
<span>{t(`fleet:detail.maintenanceCategories.${m.category}`, { defaultValue: m.category })}</span>
|
||||||
<span>{m.summary}</span>
|
<span>{m.summary}</span>
|
||||||
<time dateTime={m.occurred_at}>{formatShortDate(m.occurred_at)}</time>
|
<time dateTime={m.occurred_at}>{formatShortDate(m.occurred_at)}</time>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// The visible product name is a fixed brand constant, never a translatable string: it
|
||||||
|
// must read identically ("Fleet Ops") in every supported locale. Keeping it out of the
|
||||||
|
// i18n resource files makes that a structural guarantee rather than a convention a
|
||||||
|
// translator could accidentally break by editing one locale's JSON.
|
||||||
|
export const PRODUCT_NAME = "Fleet Ops";
|
||||||
@@ -273,6 +273,14 @@ details summary { cursor: pointer; color: var(--teal-dark); }.data-table details
|
|||||||
.scenario-callout strong { display: block; margin-bottom: 4px; color: var(--ink); font-size: .82rem; }
|
.scenario-callout strong { display: block; margin-bottom: 4px; color: var(--ink); font-size: .82rem; }
|
||||||
.scenario-callout p { margin: 0; color: var(--ink-soft); font-size: .78rem; line-height: 1.55; }
|
.scenario-callout p { margin: 0; color: var(--ink-soft); font-size: .78rem; line-height: 1.55; }
|
||||||
|
|
||||||
|
.status-decision { margin-top: 16px; padding: 16px 18px; background: var(--surface-subtle); border: 1px solid var(--line); border-radius: var(--radius); }
|
||||||
|
.status-decision h3 { margin: 16px 0 6px; color: var(--ink); font-size: .72rem; font-weight: 700; text-transform: uppercase; letter-spacing: .05em; }
|
||||||
|
.status-decision h3:first-child { margin-top: 0; }
|
||||||
|
.status-decision > .detail-grid { margin-bottom: 4px; }
|
||||||
|
.evidence-list { margin: 0; padding-left: 18px; color: var(--ink-soft); font-size: .78rem; line-height: 1.6; }
|
||||||
|
.evidence-list li { margin-bottom: 2px; }
|
||||||
|
.status-decision .button { margin-top: 16px; }
|
||||||
|
|
||||||
.duplicate-compare { margin-top: 18px; }.duplicate-compare fieldset { margin: 15px 0; padding: 13px; border: 1px solid var(--line); }.duplicate-compare legend { padding: 0 5px; color: var(--ink-soft); font-size: .69rem; font-weight: 700; }.duplicate-compare fieldset label { display: inline-flex !important; margin-right: 18px; }
|
.duplicate-compare { margin-top: 18px; }.duplicate-compare fieldset { margin: 15px 0; padding: 13px; border: 1px solid var(--line); }.duplicate-compare legend { padding: 0 5px; color: var(--ink-soft); font-size: .69rem; font-weight: 700; }.duplicate-compare fieldset label { display: inline-flex !important; margin-right: 18px; }
|
||||||
.compare-table th, .compare-table td { vertical-align: top; }.compare-table label { display: inline-flex; flex-direction: row; align-items: center; gap: 6px; }.difference-mark, .match-mark { display: block; width: max-content; margin-top: 4px; padding: 2px 5px; font-size: .52rem; border-radius: 2px; }.difference-mark { color: var(--warning); background: var(--warning-pale); }.match-mark { color: var(--success); background: var(--success-pale); }
|
.compare-table th, .compare-table td { vertical-align: top; }.compare-table label { display: inline-flex; flex-direction: row; align-items: center; gap: 6px; }.difference-mark, .match-mark { display: block; width: max-content; margin-top: 4px; padding: 2px 5px; font-size: .52rem; border-radius: 2px; }.difference-mark { color: var(--warning); background: var(--warning-pale); }.match-mark { color: var(--success); background: var(--success-pale); }
|
||||||
.merge-preview { padding: 13px; color: var(--ink-soft); background: var(--surface-subtle); border-left: 3px solid var(--teal); font-size: .75rem; }.duplicate-compare > button { color: white; background: var(--teal-dark); border-color: var(--teal-dark); }
|
.merge-preview { padding: 13px; color: var(--ink-soft); background: var(--surface-subtle); border-left: 3px solid var(--teal); font-size: .75rem; }.duplicate-compare > button { color: white; background: var(--teal-dark); border-color: var(--teal-dark); }
|
||||||
|
|||||||
Reference in New Issue
Block a user