M36: deepen operational and mobile UX

This commit is contained in:
NuklearRabbit
2026-08-10 22:53:45 +02:00
parent 809ba0ddcc
commit 9dfbd7c4bf
25 changed files with 341 additions and 60 deletions
+58 -2
View File
@@ -26,6 +26,7 @@ from app.schemas import (
NextBookingRisk,
RegisterReturnRequest,
RegisterReturnResult,
RescheduleBookingRequest,
ReturnPreviewResult,
)
from app.services.audit import record_audit_event
@@ -112,8 +113,16 @@ def list_bookings(
bookings = db.scalars(
stmt if page is None else stmt.offset((page_number - 1) * page_size).limit(page_size)
).all()
customers = {c.id: c for c in db.scalars(select(Customer)).all()}
vehicles = {v.id: v for v in db.scalars(select(Vehicle)).all()}
customer_ids = {booking.customer_id for booking in bookings}
vehicle_ids = {booking.vehicle_id for booking in bookings}
customers = {
customer.id: customer
for customer in db.scalars(select(Customer).where(Customer.id.in_(customer_ids))).all()
}
vehicles = {
vehicle.id: vehicle
for vehicle in db.scalars(select(Vehicle).where(Vehicle.id.in_(vehicle_ids))).all()
}
items = [_to_out(b, customers[b.customer_id], vehicles[b.vehicle_id]) for b in bookings]
if page is None:
return items
@@ -379,6 +388,53 @@ def complete_booking_requirements(
return _to_out(booking, customer, vehicle)
@router.patch("/{public_ref}/schedule", response_model=BookingOut)
def reschedule_booking(
public_ref: str,
body: RescheduleBookingRequest,
db: Session = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
) -> BookingOut:
if body.ends_at <= body.starts_at:
raise HTTPException(status_code=422, detail="Booking end must be after its start")
booking = db.scalar(select(Booking).where(Booking.public_ref == public_ref).with_for_update())
if booking is None:
raise HTTPException(status_code=404, detail="Booking not found")
if booking.status != "reserved":
raise HTTPException(status_code=409, detail="Only a reserved booking can be rescheduled")
vehicle = db.scalar(select(Vehicle).where(Vehicle.id == booking.vehicle_id).with_for_update())
customer = db.get(Customer, booking.customer_id)
if customer is None or vehicle is None:
raise HTTPException(status_code=500, detail="Booking references a missing record")
overlap = db.scalar(
select(Booking.id).where(
Booking.vehicle_id == booking.vehicle_id,
Booking.id != booking.id,
Booking.status.in_(("reserved", "active")),
Booking.starts_at < body.ends_at,
Booking.ends_at > body.starts_at,
)
)
if overlap is not None:
raise HTTPException(status_code=409, detail="Vehicle already has an overlapping booking")
before = {"starts_at": booking.starts_at.isoformat(), "ends_at": booking.ends_at.isoformat()}
booking.starts_at = body.starts_at
booking.ends_at = body.ends_at
record_audit_event(
db,
actor_type="user",
actor_label=user.display_name,
action="booking_rescheduled",
entity_type="booking",
entity_id=booking.id,
before=before,
after={"starts_at": booking.starts_at.isoformat(), "ends_at": booking.ends_at.isoformat()},
metadata={"reason": body.reason.strip()},
)
db.commit()
return _to_out(booking, customer, vehicle)
@router.post("/{public_ref}/cancel", response_model=BookingOut)
def cancel_booking(
public_ref: str,
+6
View File
@@ -99,6 +99,12 @@ class CompleteBookingRequirementsRequest(BaseModel):
confirmation: str = Field(min_length=3, max_length=500)
class RescheduleBookingRequest(BaseModel):
starts_at: datetime
ends_at: datetime
reason: str = Field(min_length=3, max_length=500)
class CustomerOptionOut(BaseModel):
public_ref: str
display_name: str
+53 -39
View File
@@ -133,47 +133,61 @@ def _scan_duplicate_customers(db: Session, scan: ScanResult) -> None:
db.scalars(select(Customer).where(Customer.merged_into_customer_id.is_(None))).all()
)
customers.sort(key=lambda c: c.public_ref)
# The threshold cannot be reached without an exact email (60 points) or phone
# (50 points). Block on those normalized identifiers first, so similarity scoring
# scales with plausible candidates instead of comparing every customer pair.
candidate_pairs: set[tuple[int, int]] = set()
for attribute in ("email", "phone"):
blocks: dict[str, list[int]] = {}
for index, customer in enumerate(customers):
key = _normalize(getattr(customer, attribute))
if key:
blocks.setdefault(key, []).append(index)
for indices in blocks.values():
for offset, left in enumerate(indices):
candidate_pairs.update((left, right) for right in indices[offset + 1 :])
for i, a in enumerate(customers):
for b in customers[i + 1 :]:
score = 0
signals: list[dict] = []
summary_parts: list[str] = []
if _normalize(a.email) and _normalize(a.email) == _normalize(b.email):
score += 60
signals.append({"code": "duplicate.exact_email"})
summary_parts.append("exact email")
if _normalize(a.phone) and _normalize(a.phone) == _normalize(b.phone):
score += 50
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):
score += 10
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_b = f"{_normalize(b.first_name)} {_normalize(b.last_name)}"
ratio = SequenceMatcher(None, name_a, name_b).ratio()
if ratio >= 0.5:
score += round(ratio * 30)
signals.append(
{"code": "duplicate.similar_name", "params": {"score": round(ratio, 2)}}
)
summary_parts.append("similar name")
for left, right in sorted(candidate_pairs):
a = customers[left]
b = customers[right]
score = 0
signals: list[dict] = []
summary_parts: list[str] = []
if _normalize(a.email) and _normalize(a.email) == _normalize(b.email):
score += 60
signals.append({"code": "duplicate.exact_email"})
summary_parts.append("exact email")
if _normalize(a.phone) and _normalize(a.phone) == _normalize(b.phone):
score += 50
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):
score += 10
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_b = f"{_normalize(b.first_name)} {_normalize(b.last_name)}"
ratio = SequenceMatcher(None, name_a, name_b).ratio()
if ratio >= 0.5:
score += round(ratio * 30)
signals.append(
{"code": "duplicate.similar_name", "params": {"score": round(ratio, 2)}}
)
summary_parts.append("similar name")
if score >= DUPLICATE_THRESHOLD:
_open_issue(
db,
scan,
rule_type="possible_duplicate_customer",
entity_type="customer",
entity_id=a.id,
severity="high",
summary="; ".join(summary_parts) + f" (score {score})",
entity_ref=a.public_ref,
related_refs=[b.public_ref],
signals=signals,
)
if score >= DUPLICATE_THRESHOLD:
_open_issue(
db,
scan,
rule_type="possible_duplicate_customer",
entity_type="customer",
entity_id=a.id,
severity="high",
summary="; ".join(summary_parts) + f" (score {score})",
entity_ref=a.public_ref,
related_refs=[b.public_ref],
signals=signals,
)
def _scan_missing_required_fields(db: Session, scan: ScanResult) -> None:
+29
View File
@@ -150,6 +150,35 @@ def test_reserved_booking_can_be_cancelled_once(ops_client):
assert repeated.status_code == 409
def test_reserved_booking_can_be_rescheduled_with_overlap_protection(ops_client):
window = {"starts_at": "2033-09-01T10:00:00Z", "ends_at": "2033-09-02T12:00:00Z"}
existing = ops_client.get("/api/v1/bookings/BK-DEMO-RETURN").json()
booking = ops_client.post(
"/api/v1/bookings",
json={"customer_ref": "CUS-0001", "vehicle_ref": existing["vehicle_ref"], **window},
).json()
updated = ops_client.patch(
f"/api/v1/bookings/{booking['public_ref']}/schedule",
json={
"starts_at": "2033-09-03T10:00:00Z",
"ends_at": "2033-09-04T12:00:00Z",
"reason": "Customer requested a later collection",
},
)
assert updated.status_code == 200
assert updated.json()["starts_at"].startswith("2033-09-03T10:00:00")
conflict = ops_client.patch(
f"/api/v1/bookings/{booking['public_ref']}/schedule",
json={
"starts_at": existing["starts_at"],
"ends_at": existing["ends_at"],
"reason": "Conflicting test move",
},
)
assert conflict.status_code == 409
def test_concurrent_bookings_only_reserve_vehicle_once():
results: list[int] = []
seed_client = TestClient(app)