M0: implement operational core

Backend: SQLAlchemy models, Alembic migrations, requirements lockfile. Frontend: pinned deps, package-lock, vite-env types fix. Verified compose build/health, pytest, ruff clean.
This commit is contained in:
NuklearRabbit
2026-08-01 21:01:04 +02:00
parent 24188d9b10
commit 04d26f1f2e
24 changed files with 2539 additions and 14 deletions
+23
View File
@@ -0,0 +1,23 @@
from app.core.db import Base
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.inspection import Inspection
from app.models.maintenance import MaintenanceRecord
from app.models.outbox import OutboxEvent
from app.models.user import User
from app.models.vehicle import Vehicle
__all__ = [
"Base",
"AuditEvent",
"Booking",
"Customer",
"DataQualityIssue",
"Inspection",
"MaintenanceRecord",
"OutboxEvent",
"User",
"Vehicle",
]
+27
View File
@@ -0,0 +1,27 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, 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 UUIDPrimaryKeyMixin
ACTOR_TYPES = ("user", "service", "system")
class AuditEvent(UUIDPrimaryKeyMixin, Base):
__tablename__ = "audit_events"
actor_type: Mapped[str] = mapped_column(String(20), nullable=False)
actor_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
actor_label: Mapped[str] = mapped_column(String(120), nullable=False)
action: Mapped[str] = mapped_column(String(80), nullable=False)
entity_type: Mapped[str] = mapped_column(String(30), nullable=False)
entity_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True))
correlation_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
before_json: Mapped[dict | None] = mapped_column(JSONB)
after_json: Mapped[dict | None] = mapped_column(JSONB)
metadata_json: Mapped[dict | None] = mapped_column(JSONB)
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
+29
View File
@@ -0,0 +1,29 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base
from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
BOOKING_STATUSES = ("reserved", "active", "returned", "cancelled", "blocked")
class Booking(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "bookings"
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
customer_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("customers.id"), nullable=False
)
vehicle_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("vehicles.id"), nullable=False
)
starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
ends_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False)
start_odometer_km: Mapped[int | None] = mapped_column(Integer)
end_odometer_km: Mapped[int | None] = mapped_column(Integer)
requirements_complete: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+25
View File
@@ -0,0 +1,25 @@
import uuid
from datetime import date
from sqlalchemy import Date, ForeignKey, String
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base
from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
class Customer(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "customers"
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
first_name: Mapped[str] = mapped_column(String(80), nullable=False)
last_name: Mapped[str] = mapped_column(String(80), nullable=False)
email: Mapped[str | None] = mapped_column(String(200))
phone: Mapped[str | None] = mapped_column(String(40))
postal_code: Mapped[str | None] = mapped_column(String(20))
city: Mapped[str | None] = mapped_column(String(120))
date_of_birth: Mapped[date | None] = mapped_column(Date)
merged_into_customer_id: Mapped[uuid.UUID | None] = mapped_column(
UUID(as_uuid=True), ForeignKey("customers.id")
)
+35
View File
@@ -0,0 +1,35 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, 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
RULE_TYPES = (
"possible_duplicate_customer",
"missing_required_field",
"odometer_regression",
"booking_overlap",
"vehicle_status_conflict",
)
SEVERITIES = ("low", "medium", "high")
ISSUE_STATUSES = ("open", "deferred", "resolved", "rejected")
class DataQualityIssue(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "data_quality_issues"
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
rule_type: Mapped[str] = mapped_column(String(40), nullable=False)
entity_type: Mapped[str] = mapped_column(String(30), nullable=False)
entity_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
severity: Mapped[str] = mapped_column(String(10), nullable=False)
status: Mapped[str] = mapped_column(String(20), nullable=False, default="open")
evidence_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
proposed_action_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
detected_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
resolved_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
resolved_by: Mapped[str | None] = mapped_column(String(120))
+32
View File
@@ -0,0 +1,32 @@
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base
from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
INSPECTION_TYPES = ("checkout", "return")
class Inspection(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "inspections"
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
booking_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("bookings.id"), nullable=False
)
vehicle_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("vehicles.id"), nullable=False
)
type: Mapped[str] = mapped_column(String(20), nullable=False)
fuel_level_percent: Mapped[int] = mapped_column(Integer, nullable=False)
cleanliness_ok: Mapped[bool] = mapped_column(Boolean, nullable=False)
damage_reported: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
technical_warning: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
notes: Mapped[str | None] = mapped_column(Text)
odometer_km: Mapped[int] = mapped_column(Integer, nullable=False)
completed_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
completed_by: Mapped[str | None] = mapped_column(String(120))
+22
View File
@@ -0,0 +1,22 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base
from app.models.mixins import UUIDPrimaryKeyMixin
class MaintenanceRecord(UUIDPrimaryKeyMixin, Base):
__tablename__ = "maintenance_records"
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
vehicle_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("vehicles.id"), nullable=False
)
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
odometer_km: Mapped[int] = mapped_column(Integer, nullable=False)
category: Mapped[str] = mapped_column(String(60), nullable=False)
summary: Mapped[str] = mapped_column(Text, nullable=False)
+24
View File
@@ -0,0 +1,24 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, func
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
class UUIDPrimaryKeyMixin:
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
class TimestampMixin:
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
onupdate=func.now(),
nullable=False,
)
+29
View File
@@ -0,0 +1,29 @@
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Integer, String, Text
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
DELIVERY_STATUSES = ("pending", "delivering", "succeeded", "failed")
class OutboxEvent(TimestampMixin, Base):
__tablename__ = "outbox_events"
event_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
event_type: Mapped[str] = mapped_column(String(60), nullable=False)
aggregate_type: Mapped[str] = mapped_column(String(30), nullable=False)
aggregate_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), nullable=False)
payload_json: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
occurred_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
delivery_status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending")
attempts: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
next_attempt_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_error: Mapped[str | None] = mapped_column(Text)
external_run_id: Mapped[str | None] = mapped_column(String(120))
+16
View File
@@ -0,0 +1,16 @@
from sqlalchemy import Boolean, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base
from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
ROLES = ("operations_manager", "rental_employee")
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "users"
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
display_name: Mapped[str] = mapped_column(String(120), nullable=False)
role: Mapped[str] = mapped_column(String(30), nullable=False)
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
+23
View File
@@ -0,0 +1,23 @@
from sqlalchemy import Boolean, Integer, String
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db import Base
from app.models.mixins import TimestampMixin, UUIDPrimaryKeyMixin
OPERATIONAL_STATUSES = ("available", "rented", "cleaning", "maintenance", "blocked")
class Vehicle(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "vehicles"
public_ref: Mapped[str] = mapped_column(String(20), unique=True, nullable=False)
make: Mapped[str] = mapped_column(String(80), nullable=False)
model: Mapped[str] = mapped_column(String(80), nullable=False)
model_year: Mapped[int] = mapped_column(Integer, nullable=False)
registration_number: Mapped[str] = mapped_column(String(40), unique=True, nullable=False)
location: Mapped[str] = mapped_column(String(120), nullable=False)
operational_status: Mapped[str] = mapped_column(String(20), nullable=False)
odometer_km: Mapped[int] = mapped_column(Integer, nullable=False)
next_service_km: Mapped[int] = mapped_column(Integer, nullable=False)
active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)