M2: implement vehicle return vertical slice

Transactional return command with idempotency, row-lock concurrency control, odometer-regression handling, vehicle status derivation, outbox event, audit trail. Result-summary UI on booking detail. 26 backend tests passing, ruff clean. Verified end-to-end via browser against S1 demo scenario; fixed two real defects found only through browser testing (UI state loss on status transition, unflushed UUID default).
This commit is contained in:
NuklearRabbit
2026-08-01 21:49:46 +02:00
parent 03c5b60235
commit 0091c57c7f
14 changed files with 763 additions and 10 deletions
@@ -0,0 +1,41 @@
"""idempotency records
Revision ID: e7b08389f47f
Revises: c9498525abb5
Create Date: 2026-08-01 21:24:35.472070
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = 'e7b08389f47f'
down_revision: Union[str, None] = 'c9498525abb5'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('idempotency_records',
sa.Column('idempotency_key', sa.String(length=128), nullable=False),
sa.Column('booking_id', sa.UUID(), nullable=False),
sa.Column('response_status', sa.Integer(), nullable=False),
sa.Column('response_body', postgresql.JSONB(astext_type=sa.Text()), nullable=False),
sa.Column('id', sa.UUID(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=False),
sa.ForeignKeyConstraint(['booking_id'], ['bookings.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('idempotency_key')
)
# ### end Alembic commands ###
def downgrade() -> None:
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table('idempotency_records')
# ### end Alembic commands ###
+17 -2
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Depends, Header, HTTPException, Query, Response
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -8,7 +8,8 @@ from app.api.deps import get_current_user, get_db
from app.models.booking import Booking
from app.models.customer import Customer
from app.models.vehicle import Vehicle
from app.schemas import BookingOut, CurrentUser
from app.schemas import BookingOut, CurrentUser, RegisterReturnRequest
from app.services.returns import register_vehicle_return
router = APIRouter(prefix="/api/v1/bookings", tags=["bookings"])
@@ -61,3 +62,17 @@ def get_booking(
customer = db.get(Customer, booking.customer_id)
vehicle = db.get(Vehicle, booking.vehicle_id)
return _to_out(booking, customer, vehicle)
@router.post("/{public_ref}/return")
def register_return(
public_ref: str,
body: RegisterReturnRequest,
response: Response,
idempotency_key: str = Header(..., alias="Idempotency-Key", min_length=8, max_length=128),
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> dict:
status_code, result = register_vehicle_return(db, public_ref, body, idempotency_key, user)
response.status_code = status_code
return result
+2
View File
@@ -3,6 +3,7 @@ from app.models.audit import AuditEvent
from app.models.booking import Booking
from app.models.customer import Customer
from app.models.data_quality import DataQualityIssue
from app.models.idempotency import IdempotencyRecord
from app.models.inspection import Inspection
from app.models.maintenance import MaintenanceRecord
from app.models.outbox import OutboxEvent
@@ -15,6 +16,7 @@ __all__ = [
"Booking",
"Customer",
"DataQualityIssue",
"IdempotencyRecord",
"Inspection",
"MaintenanceRecord",
"OutboxEvent",
+19
View File
@@ -0,0 +1,19 @@
import uuid
from sqlalchemy import ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import JSONB, UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base
from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
class IdempotencyRecord(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "idempotency_records"
idempotency_key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False)
booking_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("bookings.id"), nullable=False
)
response_status: Mapped[int] = mapped_column(Integer, nullable=False)
response_body: Mapped[dict] = mapped_column(JSONB, nullable=False)
+27 -1
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, conint
Role = Literal["operations_manager", "rental_employee"]
@@ -48,6 +48,32 @@ class BookingOut(BookingSummaryOut):
customer_name: str
class RegisterReturnRequest(BaseModel):
end_odometer_km: conint(ge=0)
fuel_level_percent: conint(ge=0, le=100)
cleanliness_ok: bool
damage_reported: bool
technical_warning: bool
notes: str | None = Field(default=None, max_length=2000)
class NextBookingRisk(BaseModel):
booking_ref: str
starts_at: datetime
at_risk: bool
class RegisterReturnResult(BaseModel):
booking_ref: str
vehicle_ref: str
inspection_ref: str
resulting_vehicle_status: str
odometer_regression: bool
quality_issue_ref: str | None
workflow_event_id: str
next_booking_risk: NextBookingRisk | None
class InspectionOut(BaseModel):
public_ref: str
booking_ref: str
+2
View File
@@ -14,6 +14,7 @@ from app.models.audit import AuditEvent
from app.models.booking import Booking
from app.models.customer import Customer
from app.models.data_quality import DataQualityIssue
from app.models.idempotency import IdempotencyRecord
from app.models.inspection import Inspection
from app.models.maintenance import MaintenanceRecord
from app.models.outbox import OutboxEvent
@@ -68,6 +69,7 @@ def clear_all(db: Session) -> None:
for model in (
AuditEvent,
OutboxEvent,
IdempotencyRecord,
DataQualityIssue,
Inspection,
MaintenanceRecord,
+247
View File
@@ -0,0 +1,247 @@
from __future__ import annotations
import uuid
from datetime import UTC, datetime
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.core.errors import AppError
from app.models.booking import Booking
from app.models.data_quality import DataQualityIssue
from app.models.idempotency import IdempotencyRecord
from app.models.inspection import Inspection
from app.models.outbox import OutboxEvent
from app.models.vehicle import Vehicle
from app.schemas import CurrentUser, RegisterReturnRequest
from app.services.audit import record_audit_event
REF_PREFIX = "INSP"
def _next_public_ref(db: Session) -> str:
existing = db.execute(select(Inspection.public_ref)).scalars().all()
return f"{REF_PREFIX}-{len(existing) + 1:04d}"
def _derive_vehicle_status(body: RegisterReturnRequest, vehicle: Vehicle, new_odometer: int) -> str:
if body.damage_reported or body.technical_warning:
return "blocked"
if new_odometer >= vehicle.next_service_km:
return "maintenance"
return "cleaning"
def register_vehicle_return(
db: Session,
booking_ref: str,
body: RegisterReturnRequest,
idempotency_key: str,
actor: CurrentUser,
) -> tuple[int, dict]:
existing = db.scalar(
select(IdempotencyRecord).where(IdempotencyRecord.idempotency_key == idempotency_key)
)
if existing is not None:
booking = db.get(Booking, existing.booking_id)
if booking is None or booking.public_ref != booking_ref:
raise AppError(
"IDEMPOTENCY_KEY_REUSED",
"This idempotency key was already used for a different booking.",
status_code=409,
)
return existing.response_status, existing.response_body
booking = db.scalar(select(Booking).where(Booking.public_ref == booking_ref).with_for_update())
if booking is None:
raise AppError("BOOKING_NOT_FOUND", "Booking not found.", status_code=404)
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == booking.vehicle_id).with_for_update())
# Re-check after acquiring the row lock: a concurrent identical-key request may have
# just committed while we were waiting.
existing = db.scalar(
select(IdempotencyRecord).where(IdempotencyRecord.idempotency_key == idempotency_key)
)
if existing is not None:
return existing.response_status, existing.response_body
if booking.status != "active":
raise AppError(
"INVALID_BOOKING_STATE",
f"Booking is '{booking.status}', not 'active'; it cannot be returned.",
status_code=409,
)
now = datetime.now(UTC)
correlation_id = uuid.uuid4()
inspection = Inspection(
public_ref=_next_public_ref(db),
booking_id=booking.id,
vehicle_id=vehicle.id,
type="return",
fuel_level_percent=body.fuel_level_percent,
cleanliness_ok=body.cleanliness_ok,
damage_reported=body.damage_reported,
technical_warning=body.technical_warning,
notes=body.notes,
odometer_km=body.end_odometer_km,
completed_at=now,
completed_by=actor.display_name,
)
db.add(inspection)
before_vehicle = {
"operational_status": vehicle.operational_status,
"odometer_km": vehicle.odometer_km,
}
booking.status = "returned"
booking.end_odometer_km = body.end_odometer_km
odometer_regression = body.end_odometer_km < vehicle.odometer_km
quality_issue_ref: str | None = None
canonical_odometer = vehicle.odometer_km
if not odometer_regression:
canonical_odometer = body.end_odometer_km
vehicle.odometer_km = canonical_odometer
else:
issue = DataQualityIssue(
public_ref=f"DQ-RET-{str(inspection.public_ref).split('-')[-1]}",
rule_type="odometer_regression",
entity_type="vehicle",
entity_id=vehicle.id,
severity="medium",
status="open",
evidence_json={
"summary": (
f"Return submitted {body.end_odometer_km} km, below canonical "
f"{vehicle.odometer_km} km."
),
"entity_ref": vehicle.public_ref,
"related_refs": [booking.public_ref, inspection.public_ref],
},
proposed_action_json={},
detected_at=now,
)
db.add(issue)
db.flush()
quality_issue_ref = issue.public_ref
resulting_status = _derive_vehicle_status(body, vehicle, canonical_odometer)
vehicle.operational_status = resulting_status
vehicle.version += 1
record_audit_event(
db,
actor_type="user",
actor_label=actor.display_name,
action="return_registered",
entity_type="booking",
entity_id=booking.id,
correlation_id=correlation_id,
before={"status": "active"},
after={"status": "returned", "end_odometer_km": body.end_odometer_km},
metadata={"idempotency_key": idempotency_key},
)
record_audit_event(
db,
actor_type="user",
actor_label=actor.display_name,
action="vehicle_status_changed",
entity_type="vehicle",
entity_id=vehicle.id,
correlation_id=correlation_id,
before=before_vehicle,
after={
"operational_status": vehicle.operational_status,
"odometer_km": vehicle.odometer_km,
},
)
attention_reasons = []
if body.damage_reported:
attention_reasons.append("damage_reported")
if body.technical_warning:
attention_reasons.append("technical_warning")
if odometer_regression:
attention_reasons.append("odometer_regression")
event = OutboxEvent(
event_id=uuid.uuid4(),
event_type="vehicle.returned.v1",
aggregate_type="booking",
aggregate_id=booking.id,
payload_json={
"event_type": "vehicle.returned.v1",
"correlation_id": str(correlation_id),
"aggregate": {
"type": "booking",
"id": str(booking.id),
"public_ref": booking.public_ref,
},
"data": {
"vehicle_ref": vehicle.public_ref,
"inspection_ref": inspection.public_ref,
"resulting_vehicle_status": resulting_status,
"attention_reasons": attention_reasons,
},
"aggregate_ref": booking.public_ref,
},
occurred_at=now,
delivery_status="pending",
attempts=0,
)
db.add(event)
next_booking = db.scalar(
select(Booking)
.where(
Booking.vehicle_id == vehicle.id,
Booking.status == "reserved",
Booking.starts_at > now,
)
.order_by(Booking.starts_at.asc())
)
next_booking_risk = None
if next_booking is not None:
hours_until = (next_booking.starts_at - now).total_seconds() / 3600
next_booking_risk = {
"booking_ref": next_booking.public_ref,
"starts_at": next_booking.starts_at.isoformat(),
"at_risk": resulting_status != "cleaning" or hours_until < 4,
}
response_body = {
"booking_ref": booking.public_ref,
"vehicle_ref": vehicle.public_ref,
"inspection_ref": inspection.public_ref,
"resulting_vehicle_status": resulting_status,
"odometer_regression": odometer_regression,
"quality_issue_ref": quality_issue_ref,
"workflow_event_id": str(event.event_id),
"next_booking_risk": next_booking_risk,
}
db.add(
IdempotencyRecord(
idempotency_key=idempotency_key,
booking_id=booking.id,
response_status=201,
response_body=response_body,
)
)
try:
db.commit()
except IntegrityError:
db.rollback()
existing = db.scalar(
select(IdempotencyRecord).where(IdempotencyRecord.idempotency_key == idempotency_key)
)
if existing is not None:
return existing.response_status, existing.response_body
raise
return 201, response_body
+160
View File
@@ -0,0 +1,160 @@
import threading
from fastapi.testclient import TestClient
from sqlalchemy import select
from app.core.db import SessionLocal
from app.main import app
from app.models.booking import Booking
from app.models.vehicle import Vehicle
def _return_body(**overrides):
body = {
"end_odometer_km": 54200,
"fuel_level_percent": 60,
"cleanliness_ok": True,
"damage_reported": False,
"technical_warning": False,
"notes": "Handed back on time.",
}
body.update(overrides)
return body
def _activate_booking(vehicle_ref: str, start_odometer_km: int) -> str:
"""Flip one returned booking for the given vehicle back to 'active' for a fresh test fixture."""
db = SessionLocal()
try:
vehicle = db.scalar(select(Vehicle).where(Vehicle.public_ref == vehicle_ref))
booking = db.scalar(
select(Booking).where(Booking.vehicle_id == vehicle.id, Booking.status == "returned")
)
booking.status = "active"
booking.start_odometer_km = start_odometer_km
booking.end_odometer_km = None
db.commit()
return booking.public_ref
finally:
db.close()
def test_register_return_success_updates_canonical_odometer(ops_client):
booking_ref = _activate_booking("MO-003", start_odometer_km=20000)
vehicle_before = ops_client.get("/api/v1/vehicles/MO-003").json()
new_reading = vehicle_before["odometer_km"] + 10
response = ops_client.post(
f"/api/v1/bookings/{booking_ref}/return",
json=_return_body(end_odometer_km=new_reading),
headers={"Idempotency-Key": "test-return-success-001"},
)
assert response.status_code == 201
body = response.json()
assert body["resulting_vehicle_status"] in ("cleaning", "maintenance")
assert body["odometer_regression"] is False
assert body["quality_issue_ref"] is None
booking = ops_client.get(f"/api/v1/bookings/{booking_ref}").json()
assert booking["status"] == "returned"
assert booking["end_odometer_km"] == new_reading
vehicle = ops_client.get("/api/v1/vehicles/MO-003").json()
assert vehicle["odometer_km"] == new_reading
assert vehicle["operational_status"] in ("cleaning", "maintenance")
def test_register_return_matches_s1_demo_scenario(ops_client):
vehicle_before = ops_client.get("/api/v1/vehicles/MO-024").json()
low_reading = vehicle_before["odometer_km"] - 500
response = ops_client.post(
"/api/v1/bookings/BK-DEMO-RETURN/return",
json=_return_body(end_odometer_km=low_reading),
headers={"Idempotency-Key": "test-return-s1-001"},
)
assert response.status_code == 201
body = response.json()
assert body["odometer_regression"] is True
assert body["quality_issue_ref"] is not None
vehicle = ops_client.get("/api/v1/vehicles/MO-024").json()
assert vehicle["odometer_km"] == vehicle_before["odometer_km"] # canonical odometer unchanged
booking = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN").json()
assert booking["status"] == "returned"
assert booking["end_odometer_km"] == low_reading # submitted reading is still recorded
def test_register_return_damage_blocks_vehicle(employee_client):
booking_ref = _activate_booking("MO-005", start_odometer_km=22000)
response = employee_client.post(
f"/api/v1/bookings/{booking_ref}/return",
json=_return_body(end_odometer_km=22500, damage_reported=True),
headers={"Idempotency-Key": "test-return-damage-001"},
)
assert response.status_code == 201
assert response.json()["resulting_vehicle_status"] == "blocked"
def test_register_return_replays_on_same_idempotency_key(ops_client):
booking_ref = _activate_booking("MO-008", start_odometer_km=24000)
key = "test-return-replay-001"
first = ops_client.post(
f"/api/v1/bookings/{booking_ref}/return",
json=_return_body(end_odometer_km=24500),
headers={"Idempotency-Key": key},
)
second = ops_client.post(
f"/api/v1/bookings/{booking_ref}/return",
json=_return_body(end_odometer_km=24500),
headers={"Idempotency-Key": key},
)
assert first.status_code == 201
assert second.status_code == 201
assert first.json() == second.json()
def test_register_return_rejects_already_returned_booking(ops_client):
booking_ref = _activate_booking("MO-010", start_odometer_km=25000)
ops_client.post(
f"/api/v1/bookings/{booking_ref}/return",
json=_return_body(end_odometer_km=25500),
headers={"Idempotency-Key": "test-return-double-001"},
)
second = ops_client.post(
f"/api/v1/bookings/{booking_ref}/return",
json=_return_body(end_odometer_km=25999),
headers={"Idempotency-Key": "test-return-double-002"},
)
assert second.status_code == 409
assert second.json()["error"]["code"] == "INVALID_BOOKING_STATE"
def test_register_return_requires_idempotency_key(ops_client):
booking_ref = _activate_booking("MO-012", start_odometer_km=26500)
response = ops_client.post(f"/api/v1/bookings/{booking_ref}/return", json=_return_body())
assert response.status_code == 422
def test_concurrent_returns_only_one_succeeds():
booking_ref = _activate_booking("MO-013", start_odometer_km=27000)
results: list[int] = []
def submit(key: str) -> None:
client = TestClient(app)
client.post("/api/v1/demo/login", json={"role": "operations_manager"})
resp = client.post(
f"/api/v1/bookings/{booking_ref}/return",
json=_return_body(end_odometer_km=27500),
headers={"Idempotency-Key": key},
)
results.append(resp.status_code)
threads = [threading.Thread(target=submit, args=(f"concurrent-key-{i}",)) for i in range(3)]
for t in threads:
t.start()
for t in threads:
t.join()
assert results.count(201) == 1
assert results.count(409) == 2
+7
View File
@@ -7,11 +7,16 @@ from app.models.data_quality import DataQualityIssue
from app.models.outbox import OutboxEvent
from app.models.user import User
from app.models.vehicle import Vehicle
from app.seed_loader import reset_and_seed
def test_seed_counts_match_deterministic_dataset():
# Other test modules mutate shared demo state (returns, resets), so this test
# re-seeds immediately before asserting counts rather than trusting whatever
# order pytest happened to run modules in.
db = SessionLocal()
try:
reset_and_seed(db)
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(Booking)) == 246
@@ -25,6 +30,8 @@ def test_seed_counts_match_deterministic_dataset():
def test_seed_demo_scenarios_present():
db = SessionLocal()
try:
reset_and_seed(db)
booking = db.scalar(select(Booking).where(Booking.public_ref == "BK-DEMO-RETURN"))
assert booking is not None
assert booking.status == "active"