Compare commits

...

15 Commits

Author SHA1 Message Date
Mortezakoohjani
091b33a638 feat(platform): finalize commercial runtime, platform shell and hospitality production frontend
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 20:39:10 +03:30
Mortezakoohjani
ef048219cd docs: record official platform deploy on 192.168.10.162
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 21:10:50 +03:30
Mortezakoohjani
0eec9f729b feat(experience): deploy Experience frontend FE-11.0-11.5 portal
Commit completed Torbat Pages admin frontend module, BFF proxy, routes, and validation artifacts for official platform deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 21:01:50 +03:30
Mortezakoohjani
72077908f1 feat(delivery): deploy backend phases 10.2-10.8 through settlement
Ship fleet, availability, pricing, dispatch, routing, tracking, and settlement on delivery-service 0.10.8.0 with migrations and phase tests green.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 21:01:23 +03:30
Mortezakoohjani
4451b32a33 feat(payment-frontend): ship Torbat Pay admin portal for MVP 14.0-14.5
Add payment module, BFF proxy, hub/dashboard/ops/PSP/merchant screens wired to real APIs, and mark payment-frontend complete in project status.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 18:35:07 +03:30
Mortezakoohjani
9fac160258 feat(payment): ship Torbat Pay MVP phases 14.0-14.5
Add independent payment-service (port 8012, payment_db) with foundation licensing, BYO-PSP, merchant accounts, idempotent requests, callbacks, and immutable ledger.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 17:57:04 +03:30
Mortezakoohjani
047cd17afd fix(frontend): pass all BFF service URLs to frontend container
Wire CRM, loyalty, communication, and delivery service URLs into docker-compose
so Next.js API proxies reach Docker network hosts instead of localhost fallbacks.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 13:28:50 +03:30
Mortezakoohjani
fb05937790 fix(frontend): wire sports-center service URL into frontend container
The BFF proxy defaulted to localhost:8006 inside Docker, causing fetch failed on all /api/sports-center routes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 13:23:27 +03:30
Mortezakoohjani
57ea594334 Fix experience-service migration revision IDs exceeding Alembic varchar(32) limit.
Shorten four revision slugs so startup alembic upgrade head succeeds on production.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 12:42:32 +03:30
Mortezakoohjani
5c6a2e78cf feat(platform): seed service registry, deploy all modules, and fix homepage catalog.
Register all active platform services and base features in core DB so admin can manage them, add delivery/hospitality/sports-center backend phases, update apps catalog and production deploy tooling.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 12:39:51 +03:30
Mortezakoohjani
7978970783 feat(delivery): add Torbat Driver frontend module with real API wiring.
Ship the delivery platform UI under modules/delivery with thin app routes, BFF proxy, CRUD for phase 10.0-10.1 APIs, and capability-gated future screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 12:02:01 +03:30
Mortezakoohjani
203671a7bf feat(experience): ship Experience Platform phases 11.0-11.10
Add the experience service with sites through analytics/AI hooks, migrations through 0011, and phase docs/manifests marking the track complete.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 11:43:10 +03:30
Mortezakoohjani
625275e55b feat(healthcare): add backend service and finalize frontend build
Add the Healthcare platform backend (phases 13.0–13.7) with Alembic migration revision fix, production deploy script, validation reports, shared UI exports, and loyalty list page client directives so Next.js build succeeds.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 11:38:31 +03:30
Mortezakoohjani
f89ca52e02 feat(loyalty): register Loyalty app in catalog and architecture docs
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 11:17:07 +03:30
Mortezakoohjani
10c3c43a75 feat(communication): add SMS MVP frontend with governance artifacts.
Ship the full Communication portal (real SMS API, feature-locked future channels), BFF proxy, docs, snapshot, and phase handover.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 11:11:25 +03:30
1994 changed files with 141319 additions and 2720 deletions

View File

@ -164,6 +164,15 @@ NEXT_PUBLIC_KEYCLOAK_URL=https://auth.torbatyar.ir
NEXT_PUBLIC_KEYCLOAK_REALM=superapp NEXT_PUBLIC_KEYCLOAK_REALM=superapp
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=superapp-frontend NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=superapp-frontend
NEXT_PUBLIC_ACCOUNTING_API_URL=http://localhost:8002 NEXT_PUBLIC_ACCOUNTING_API_URL=http://localhost:8002
NEXT_PUBLIC_CRM_API_URL=http://localhost:8003
NEXT_PUBLIC_LOYALTY_API_URL=http://localhost:8004
NEXT_PUBLIC_COMMUNICATION_API_URL=http://localhost:8005
NEXT_PUBLIC_SPORTS_CENTER_API_URL=http://localhost:8006
NEXT_PUBLIC_DELIVERY_API_URL=http://localhost:8007
NEXT_PUBLIC_EXPERIENCE_API_URL=http://localhost:8008
NEXT_PUBLIC_HOSPITALITY_API_URL=http://localhost:8009
NEXT_PUBLIC_HEALTHCARE_API_URL=http://localhost:8010
NEXT_PUBLIC_BEAUTY_BUSINESS_API_URL=http://localhost:8011
INTERNAL_TOKEN_SECRET=change-me-internal-secret INTERNAL_TOKEN_SECRET=change-me-internal-secret

View File

@ -15,7 +15,7 @@
| [`docs/reference/services-contracts.md`](./docs/reference/services-contracts.md) | Service contracts | | [`docs/reference/services-contracts.md`](./docs/reference/services-contracts.md) | Service contracts |
| [`docs/development/developer-guide.md`](./docs/development/developer-guide.md) | Developer guide | | [`docs/development/developer-guide.md`](./docs/development/developer-guide.md) | Developer guide |
| [`docs/module-registry.md`](./docs/module-registry.md) | Module registry | | [`docs/module-registry.md`](./docs/module-registry.md) | Module registry |
| [`docs/ai-framework/`](./docs/ai-framework/) | AI Development Framework (implementation lifecycle) | | [`docs/ai-framework/`](./docs/ai-framework/) | Enterprise / AI Development Framework (implementation lifecycle; production-ready by default) |
| [`docs/provider-registry.md`](./docs/provider-registry.md) | Provider registry | | [`docs/provider-registry.md`](./docs/provider-registry.md) | Provider registry |
| [`docs/glossary.md`](./docs/glossary.md) | Glossary | | [`docs/glossary.md`](./docs/glossary.md) | Glossary |
| [`docs/progress.md`](./docs/progress.md) | Completed work | | [`docs/progress.md`](./docs/progress.md) | Completed work |
@ -62,6 +62,7 @@ docker compose up -d --build
- **Loyalty API:** http://localhost:8004/docs - **Loyalty API:** http://localhost:8004/docs
- **Communication API:** http://localhost:8005/docs - **Communication API:** http://localhost:8005/docs
- **Sports Center API:** http://localhost:8006/docs - **Sports Center API:** http://localhost:8006/docs
- **Hospitality API:** http://localhost:8009/docs
- **Keycloak:** http://localhost:8080 - **Keycloak:** http://localhost:8080
Details: [`docs/development/developer-guide.md`](./docs/development/developer-guide.md). Details: [`docs/development/developer-guide.md`](./docs/development/developer-guide.md).

View File

@ -0,0 +1,103 @@
"""seed platform service registry and base features
Revision ID: 0006_platform_catalog_seed
Revises: 0005_tenant_onboarding
Create Date: 2026-07-27
"""
from __future__ import annotations
import uuid
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0006_platform_catalog_seed"
down_revision: Union[str, None] = "0005_tenant_onboarding"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
SERVICES = [
("accounting", "حسابداری آنلاین", "http://accounting-service:8002"),
("healthcare", "تربت هلث", "http://healthcare-service:8010"),
("beauty", "تربت بیوتی", "http://beauty-business-service:8011"),
("crm", "سیستم CRM", "http://crm-service:8003"),
("loyalty", "تربت وفاداری", "http://loyalty-service:8004"),
("communication", "ارتباطات و پیامک", "http://communication-service:8005"),
("sports_center", "مرکز ورزشی", "http://sports-center-service:8006"),
("delivery", "تربت درایور", "http://delivery-service:8007"),
("experience", "پلتفرم تجربه", "http://experience-service:8008"),
("hospitality", "تربت فود", "http://hospitality-service:8009"),
("identity", "هویت و دسترسی", "http://identity-access-service:8001"),
]
FEATURES = [
("accounting.access", "دسترسی حسابداری", "accounting"),
("healthcare.access", "دسترسی سلامت", "healthcare"),
("beauty.access", "دسترسی زیبایی", "beauty"),
("crm.access", "دسترسی CRM", "crm"),
("loyalty.access", "دسترسی وفاداری", "loyalty"),
("communication.access", "دسترسی ارتباطات", "communication"),
("sports_center.access", "دسترسی مرکز ورزشی", "sports_center"),
("delivery.access", "دسترسی لجستیک", "delivery"),
("experience.access", "دسترسی تجربه", "experience"),
("hospitality.access", "دسترسی رستوران", "hospitality"),
]
def upgrade() -> None:
for service_key, name, base_url in SERVICES:
op.execute(
sa.text(
"""
INSERT INTO service_registry (id, service_key, name, base_url, health_check_url, status)
VALUES (CAST(:id AS uuid), :service_key, :name, :base_url, :health_url, 'active')
ON CONFLICT (service_key) DO NOTHING
"""
).bindparams(
id=str(uuid.uuid4()),
service_key=service_key,
name=name,
base_url=base_url,
health_url=f"{base_url}/health",
)
)
for feature_key, name, service_key in FEATURES:
op.execute(
sa.text(
"""
INSERT INTO features (id, feature_key, name, description, service_key, is_active)
VALUES (CAST(:id AS uuid), :feature_key, :name, :description, :service_key, true)
ON CONFLICT (feature_key) DO NOTHING
"""
).bindparams(
id=str(uuid.uuid4()),
feature_key=feature_key,
name=name,
description=f"دسترسی پایه به سرویس {service_key}",
service_key=service_key,
)
)
for feature_key, _, _ in FEATURES:
op.execute(
sa.text(
"""
INSERT INTO plan_features (id, plan_id, feature_id, is_enabled)
SELECT CAST(:id AS uuid), p.id, f.id, true
FROM plans p
CROSS JOIN features f
WHERE p.code = 'FREE' AND f.feature_key = :feature_key
ON CONFLICT (plan_id, feature_id) DO NOTHING
"""
).bindparams(id=str(uuid.uuid4()), feature_key=feature_key)
)
def downgrade() -> None:
feature_keys = ", ".join(f"'{key}'" for key, _, _ in FEATURES)
service_keys = ", ".join(f"'{key}'" for key, _, _ in SERVICES)
op.execute(f"DELETE FROM plan_features WHERE feature_id IN (SELECT id FROM features WHERE feature_key IN ({feature_keys}))")
op.execute(f"DELETE FROM features WHERE feature_key IN ({feature_keys})")
op.execute(f"DELETE FROM service_registry WHERE service_key IN ({service_keys})")

View File

@ -0,0 +1,175 @@
#!/usr/bin/env python3
"""Idempotent seed for service_registry and base platform features."""
from __future__ import annotations
import asyncio
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from app.core.config import settings
from app.models.enums import ServiceStatus
from app.models.plan import Feature, Plan, PlanFeature
from app.models.registry import ServiceRegistry
PLATFORM_SERVICES: list[dict[str, str | None]] = [
{
"service_key": "accounting",
"name": "حسابداری آنلاین",
"base_url": "http://accounting-service:8002",
"health_check_url": "http://accounting-service:8002/health",
},
{
"service_key": "healthcare",
"name": "تربت هلث",
"base_url": "http://healthcare-service:8010",
"health_check_url": "http://healthcare-service:8010/health",
},
{
"service_key": "beauty",
"name": "تربت بیوتی",
"base_url": "http://beauty-business-service:8011",
"health_check_url": "http://beauty-business-service:8011/health",
},
{
"service_key": "crm",
"name": "سیستم CRM",
"base_url": "http://crm-service:8003",
"health_check_url": "http://crm-service:8003/health",
},
{
"service_key": "loyalty",
"name": "تربت وفاداری",
"base_url": "http://loyalty-service:8004",
"health_check_url": "http://loyalty-service:8004/health",
},
{
"service_key": "communication",
"name": "ارتباطات و پیامک",
"base_url": "http://communication-service:8005",
"health_check_url": "http://communication-service:8005/health",
},
{
"service_key": "sports_center",
"name": "مرکز ورزشی",
"base_url": "http://sports-center-service:8006",
"health_check_url": "http://sports-center-service:8006/health",
},
{
"service_key": "delivery",
"name": "تربت درایور",
"base_url": "http://delivery-service:8007",
"health_check_url": "http://delivery-service:8007/health",
},
{
"service_key": "experience",
"name": "پلتفرم تجربه",
"base_url": "http://experience-service:8008",
"health_check_url": "http://experience-service:8008/health",
},
{
"service_key": "hospitality",
"name": "تربت فود",
"base_url": "http://hospitality-service:8009",
"health_check_url": "http://hospitality-service:8009/health",
},
{
"service_key": "identity",
"name": "هویت و دسترسی",
"base_url": "http://identity-access-service:8001",
"health_check_url": "http://identity-access-service:8001/health",
},
]
PLATFORM_FEATURES: list[dict[str, str]] = [
{"feature_key": "accounting.access", "name": "دسترسی حسابداری", "service_key": "accounting"},
{"feature_key": "healthcare.access", "name": "دسترسی سلامت", "service_key": "healthcare"},
{"feature_key": "beauty.access", "name": "دسترسی زیبایی", "service_key": "beauty"},
{"feature_key": "crm.access", "name": "دسترسی CRM", "service_key": "crm"},
{"feature_key": "loyalty.access", "name": "دسترسی وفاداری", "service_key": "loyalty"},
{"feature_key": "communication.access", "name": "دسترسی ارتباطات", "service_key": "communication"},
{"feature_key": "sports_center.access", "name": "دسترسی مرکز ورزشی", "service_key": "sports_center"},
{"feature_key": "delivery.access", "name": "دسترسی لجستیک", "service_key": "delivery"},
{"feature_key": "experience.access", "name": "دسترسی تجربه", "service_key": "experience"},
{"feature_key": "hospitality.access", "name": "دسترسی رستوران", "service_key": "hospitality"},
]
async def seed(session: AsyncSession) -> tuple[int, int]:
services_added = 0
features_added = 0
for row in PLATFORM_SERVICES:
existing = await session.scalar(
select(ServiceRegistry).where(ServiceRegistry.service_key == row["service_key"])
)
if existing is None:
session.add(
ServiceRegistry(
service_key=str(row["service_key"]),
name=str(row["name"]),
base_url=row["base_url"],
health_check_url=row["health_check_url"],
status=ServiceStatus.ACTIVE,
)
)
services_added += 1
for row in PLATFORM_FEATURES:
existing = await session.scalar(
select(Feature).where(Feature.feature_key == row["feature_key"])
)
if existing is None:
session.add(
Feature(
feature_key=row["feature_key"],
name=row["name"],
description=f"دسترسی پایه به سرویس {row['service_key']}",
service_key=row["service_key"],
is_active=True,
)
)
features_added += 1
await session.flush()
free_plan = await session.scalar(select(Plan).where(Plan.code == "FREE"))
if free_plan is not None:
for row in PLATFORM_FEATURES:
feature = await session.scalar(
select(Feature).where(Feature.feature_key == row["feature_key"])
)
if feature is None:
continue
link = await session.scalar(
select(PlanFeature).where(
PlanFeature.plan_id == free_plan.id,
PlanFeature.feature_id == feature.id,
)
)
if link is None:
session.add(
PlanFeature(
plan_id=free_plan.id,
feature_id=feature.id,
is_enabled=True,
)
)
await session.commit()
return services_added, features_added
async def main() -> None:
engine = create_async_engine(settings.database_url, echo=False)
session_factory = async_sessionmaker(engine, expire_on_commit=False)
async with session_factory() as session:
services_added, features_added = await seed(session)
await engine.dispose()
print(
f"seed_platform_catalog: services_added={services_added}, features_added={features_added}"
)
if __name__ == "__main__":
asyncio.run(main())

View File

@ -1,11 +1,12 @@
# Communication Service # Communication Service
Independent **Enterprise Communication Platform** for TorbatYar SuperApp. Independent **Enterprise Communication Platform** (**Torbat Communication**) for TorbatYar SuperApp.
- Database: `communication_db` (sole owner) - Database: `communication_db` (sole owner)
- Port: `8005` - Port: `8005`
- Version: `0.8.10.1` - Version: `0.8.10.1`
- Permission prefix: `communication.*` - Permission prefix: `communication.*`
- Status: **Production Ready (SMS MVP)**
## Boundaries ## Boundaries
@ -15,10 +16,14 @@ Consumers interact only via HTTP APIs and events. No module may call external SM
## Channels ## Channels
Designed for: SMS, Email, Push, WhatsApp, Telegram, Rubika, Voice, Future. | Channel | Status |
| --- | --- |
**Initially active:** SMS (mock + Payamak adapters). | SMS (mock + Payamak) | **Production Ready** |
| Email, Push, WhatsApp, Telegram, Rubika, Voice | Architecture-ready stubs — see [communication-roadmap.md](../../../docs/communication-roadmap.md) |
## Docs ## Docs
See `docs/communication-phase-8.md`. - [communication-phase-8.md](../../../docs/communication-phase-8.md)
- [communication-roadmap.md](../../../docs/communication-roadmap.md)
- [phase-handover/phase-8.md](../../../docs/phase-handover/phase-8.md)
- [service-snapshots/communication.yaml](../../../docs/service-snapshots/communication.yaml)

View File

@ -0,0 +1,22 @@
# Dev image: dependencies only — code mounted with uvicorn --reload
FROM python:3.11-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PIP_NO_CACHE_DIR=1 \
PYTHONPATH=/app
WORKDIR /app
RUN apt-get update \
&& apt-get install -y --no-install-recommends build-essential libpq-dev \
&& rm -rf /var/lib/apt/lists/*
COPY backend/shared-lib/ /shared-lib/
COPY backend/services/delivery/requirements.txt /app/requirements.txt
RUN sed -i 's#-e ../../shared-lib#-e /shared-lib#' /app/requirements.txt \
&& pip install --upgrade pip \
&& pip install -r requirements.txt
EXPOSE 8007

View File

@ -0,0 +1,63 @@
# Delivery & Fleet Platform (Torbat Driver)
| Field | Value |
| --- | --- |
| Service | `delivery-service` |
| Database | `delivery_db` |
| Port | 8007 |
| Permission prefix | `delivery.*` |
| Version | 0.10.1.0 |
| Phase | 10.1 Driver Management |
| ADR | [ADR-015](../../../docs/architecture/adr/ADR-015.md) |
## Owns
- Delivery organizations, hubs, configuration/settings shells
- External provider / routing-engine **registrations** (adapters later)
- **Drivers**: profiles, lifecycle, credential/document refs, lifecycle history
- Delivery audit log + transactional outbox for driver events
- Health / capabilities / metrics discovery
- Publish-only `delivery.*` events
## Does not own
- Accounting journals / Posting Engine
- Communication SMS/email providers or message delivery timeline
- Loyalty ledger
- Vertical order aggregates (Hospitality, Marketplace, Store, Pharmacy, Clinic, Sports Center)
- Fleet / vehicles / dispatch / routing / tracking **engines** (later phases)
- Identity user admin / CRM contact master / Storage blobs
- Driver App / Dispatcher Panel UI (frontend)
## APIs
| Method | Path |
| --- | --- |
| GET | `/health` |
| GET | `/capabilities` |
| GET | `/metrics` |
| CRUD | `/api/v1/organizations` |
| CRUD | `/api/v1/hubs` |
| CRUD | `/api/v1/external-providers` |
| CRUD | `/api/v1/routing-engines` |
| CRUD | `/api/v1/configurations` |
| PUT/GET | `/api/v1/settings` |
| GET | `/api/v1/audit` |
| CRUD + lifecycle | `/api/v1/drivers` |
| GET | `/api/v1/permissions/catalog` |
## Run (dev)
```bash
export DELIVERY_DATABASE_URL=postgresql+asyncpg://...
export DELIVERY_DATABASE_URL_SYNC=postgresql+psycopg://...
python scripts/ensure_db.py
alembic upgrade head
uvicorn app.main:app --host 0.0.0.0 --port 8007 --reload
```
## Tests
```bash
pytest
```

View File

@ -0,0 +1,41 @@
[alembic]
script_location = alembic
prepend_sys_path = .
path_separator = os
version_path_separator = os
sqlalchemy.url = driver://user:pass@localhost/dbname
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

View File

@ -0,0 +1,42 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from app.core.config import settings
from app.core.database import Base
import app.models # noqa: F401
config = context.config
config.set_main_option("sqlalchemy.url", settings.database_url_sync)
if config.config_file_name:
fileConfig(config.config_file_name)
target_metadata = Base.metadata
def run_migrations_offline():
context.configure(
url=settings.database_url_sync,
target_metadata=target_metadata,
literal_binds=True,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online():
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@ -0,0 +1,19 @@
"""Initial Delivery schema — Phase 10.0 foundation."""
from alembic import op
from app.core.database import Base
import app.models # noqa: F401
revision = "0001_initial"
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
Base.metadata.drop_all(bind=bind)

View File

@ -0,0 +1,30 @@
"""Phase 10.1 — Driver Management schema (additive)."""
from alembic import op
from app.core.database import Base
import app.models # noqa: F401
revision = "0002_phase_101_drivers"
down_revision = "0001_initial"
branch_labels = None
depends_on = None
_NEW_TABLES = (
"drivers",
"driver_credentials",
"driver_documents",
"driver_lifecycle_events",
"outbox_events",
)
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
for name in reversed(_NEW_TABLES):
table = Base.metadata.tables.get(name)
if table is not None:
table.drop(bind=bind, checkfirst=True)

View File

@ -0,0 +1,29 @@
"""Phase 10.2 — Fleet & Vehicle Types schema (additive)."""
from alembic import op
from app.core.database import Base
import app.models # noqa: F401
revision = "0003_phase_102_fleet"
down_revision = "0002_phase_101_drivers"
branch_labels = None
depends_on = None
_NEW_TABLES = (
"fleets",
"vehicle_types",
"vehicles",
"vehicle_assignments",
)
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
for name in reversed(_NEW_TABLES):
table = Base.metadata.tables.get(name)
if table is not None:
table.drop(bind=bind, checkfirst=True)

View File

@ -0,0 +1,29 @@
"""Phase 10.3 — Availability, Shifts & Working Zones schema (additive)."""
from alembic import op
from app.core.database import Base
import app.models # noqa: F401
revision = "0004_phase_103_availability"
down_revision = "0003_phase_102_fleet"
branch_labels = None
depends_on = None
_NEW_TABLES = (
"driver_availabilities",
"shifts",
"shift_assignments",
"working_zones",
)
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
for name in reversed(_NEW_TABLES):
table = Base.metadata.tables.get(name)
if table is not None:
table.drop(bind=bind, checkfirst=True)

View File

@ -0,0 +1,28 @@
"""Phase 10.4 — Pricing, Capabilities & Bundles schema (additive)."""
from alembic import op
from app.core.database import Base
import app.models # noqa: F401
revision = "0005_phase_104_pricing"
down_revision = "0004_phase_103_availability"
branch_labels = None
depends_on = None
_NEW_TABLES = (
"pricing_rules",
"capability_definitions",
"capability_bundles",
)
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
for name in reversed(_NEW_TABLES):
table = Base.metadata.tables.get(name)
if table is not None:
table.drop(bind=bind, checkfirst=True)

View File

@ -0,0 +1,27 @@
"""Phase 10.5 — Dispatch Engine schema (additive)."""
from alembic import op
from app.core.database import Base
import app.models # noqa: F401
revision = "0006_phase_105_dispatch"
down_revision = "0005_phase_104_pricing"
branch_labels = None
depends_on = None
_NEW_TABLES = (
"dispatch_jobs",
"job_assignments",
)
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
for name in reversed(_NEW_TABLES):
table = Base.metadata.tables.get(name)
if table is not None:
table.drop(bind=bind, checkfirst=True)

View File

@ -0,0 +1,28 @@
"""Phase 10.6 — Routing & Optimization schema (additive)."""
from alembic import op
from app.core.database import Base
import app.models # noqa: F401
revision = "0007_phase_106_routing"
down_revision = "0006_phase_105_dispatch"
branch_labels = None
depends_on = None
_NEW_TABLES = (
"route_plans",
"route_stops",
"optimization_runs",
)
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
for name in reversed(_NEW_TABLES):
table = Base.metadata.tables.get(name)
if table is not None:
table.drop(bind=bind, checkfirst=True)

View File

@ -0,0 +1,31 @@
"""Phase 10.7+10.8 — Tracking, POD & Settlement schema (additive)."""
from alembic import op
from app.core.database import Base
import app.models # noqa: F401
revision = "0008_phase_107_108_tracking_settlement"
down_revision = "0007_phase_106_routing"
branch_labels = None
depends_on = None
_NEW_TABLES = (
"tracking_sessions",
"tracking_points",
"proof_of_delivery",
"customer_tracking_tokens",
"settlement_intents",
"settlement_lines",
)
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
for name in reversed(_NEW_TABLES):
table = Base.metadata.tables.get(name)
if table is not None:
table.drop(bind=bind, checkfirst=True)

View File

@ -0,0 +1 @@
__version__ = "0.10.8.0"

View File

@ -0,0 +1,39 @@
"""Common API dependencies."""
from __future__ import annotations
from uuid import UUID
from fastapi import Depends, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.core.security import get_current_user
from shared.exceptions import TenantNotResolvedError
from shared.pagination import PaginationParams
from shared.security import CurrentUser
from shared.tenant import STATE_TENANT_ID
__all__ = [
"get_db",
"get_pagination",
"require_tenant",
"get_current_user",
"AsyncSession",
"CurrentUser",
]
def get_pagination(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=500),
) -> PaginationParams:
return PaginationParams(page=page, page_size=page_size)
def require_tenant(request: Request) -> UUID:
tenant_id = getattr(request.state, STATE_TENANT_ID, None)
if tenant_id is None:
raise TenantNotResolvedError(
"tenant قابل تشخیص نبود. هدر X-Tenant-ID را ارسال کنید."
)
return tenant_id

View File

@ -0,0 +1,48 @@
"""Permission enforcement helpers for Delivery APIs."""
from __future__ import annotations
from collections.abc import Callable
from fastapi import Depends
from app.core.config import settings
from app.core.security import get_current_user
from shared.exceptions import ForbiddenError
from shared.security import CurrentUser
_ADMIN_ROLES = frozenset({"platform_admin", "tenant_owner", "tenant_admin"})
def user_has_permission(user: CurrentUser, permission: str) -> bool:
if any(role in _ADMIN_ROLES for role in user.roles):
return True
roles = set(user.roles)
if "delivery.manage" in roles or permission in roles:
return True
if "delivery.view" in roles and permission.endswith(".view"):
return True
parts = permission.split(".")
if len(parts) >= 3:
manage = f"{parts[0]}.{parts[1]}.manage"
if manage in roles:
return True
return False
def require_permissions(*permissions: str) -> Callable:
"""Deny unless AUTH is off, user is admin, or any listed permission is present."""
async def _dependency(
user: CurrentUser = Depends(get_current_user),
) -> CurrentUser:
if not settings.auth_required:
return user
if any(user_has_permission(user, perm) for perm in permissions):
return user
raise ForbiddenError(
"دسترسی مجاز نیست",
error_code="permission_denied",
details={"required": list(permissions)},
)
return _dependency

View File

@ -0,0 +1,98 @@
from fastapi import APIRouter
from app.api.v1 import (
audit,
availability,
bundles,
capabilities,
configurations,
customer_tracking,
dispatch_assignments,
dispatch_jobs,
drivers,
external_providers,
fleets,
hubs,
optimization,
organizations,
permissions,
pricing_rules,
proof_of_delivery,
routes,
routing_engines,
settings,
settlements,
shifts,
tracking,
vehicle_types,
vehicles,
working_zones,
)
api_router = APIRouter()
api_router.include_router(
organizations.router, prefix="/organizations", tags=["organizations"]
)
api_router.include_router(hubs.router, prefix="/hubs", tags=["hubs"])
api_router.include_router(
external_providers.router,
prefix="/external-providers",
tags=["external-providers"],
)
api_router.include_router(
routing_engines.router, prefix="/routing-engines", tags=["routing-engines"]
)
api_router.include_router(
configurations.router, prefix="/configurations", tags=["configurations"]
)
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
api_router.include_router(audit.router, prefix="/audit", tags=["audit"])
api_router.include_router(drivers.router, prefix="/drivers", tags=["drivers"])
api_router.include_router(
availability.router, prefix="/drivers", tags=["driver-availability"]
)
api_router.include_router(fleets.router, prefix="/fleets", tags=["fleets"])
api_router.include_router(
vehicle_types.router, prefix="/vehicle-types", tags=["vehicle-types"]
)
api_router.include_router(vehicles.router, prefix="/vehicles", tags=["vehicles"])
api_router.include_router(shifts.router, prefix="/shifts", tags=["shifts"])
api_router.include_router(
working_zones.router, prefix="/working-zones", tags=["working-zones"]
)
api_router.include_router(
pricing_rules.router, prefix="/pricing-rules", tags=["pricing-rules"]
)
api_router.include_router(
capabilities.router, prefix="/capabilities", tags=["capabilities"]
)
api_router.include_router(bundles.router, prefix="/bundles", tags=["bundles"])
api_router.include_router(
dispatch_jobs.router, prefix="/dispatch/jobs", tags=["dispatch-jobs"]
)
api_router.include_router(
dispatch_assignments.router,
prefix="/dispatch/assignments",
tags=["dispatch-assignments"],
)
api_router.include_router(routes.router, prefix="/routes", tags=["routes"])
api_router.include_router(
optimization.router, prefix="/optimization", tags=["optimization"]
)
api_router.include_router(tracking.router, prefix="/tracking", tags=["tracking"])
api_router.include_router(
proof_of_delivery.router,
prefix="/proof-of-delivery",
tags=["proof-of-delivery"],
)
api_router.include_router(
customer_tracking.router,
prefix="/customer-tracking",
tags=["customer-tracking"],
)
api_router.include_router(
settlements.router, prefix="/settlements", tags=["settlements"]
)
api_router.include_router(
permissions.router, prefix="/permissions", tags=["permissions"]
)

View File

@ -0,0 +1,30 @@
"""Delivery audit APIs — Phase 10.0."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, require_tenant
from app.api.permissions import require_permissions
from app.permissions.definitions import AUDIT_VIEW
from app.schemas.foundation import DeliveryAuditLogRead
from app.services.audit_service import AuditService
from shared.security import CurrentUser
router = APIRouter()
@router.get("", response_model=list[DeliveryAuditLogRead])
async def list_audit(
entity_type: str = Query(...),
entity_id: UUID = Query(...),
limit: int = Query(default=100, ge=1, le=500),
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(AUDIT_VIEW)),
):
return await AuditService(db).list_for_entity(
tenant_id, entity_type, entity_id, limit=limit
)

View File

@ -0,0 +1,40 @@
"""Driver availability APIs — Phase 10.3."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, require_tenant
from app.api.permissions import require_permissions
from app.commands.availability import AvailabilityCommands
from app.permissions.definitions import AVAILABILITY_MANAGE, AVAILABILITY_VIEW
from app.queries.availability import AvailabilityQueries
from app.schemas.availability import DriverAvailabilityRead, DriverAvailabilityUpsert
from shared.security import CurrentUser
router = APIRouter()
@router.get("/{driver_id}/availability", response_model=DriverAvailabilityRead)
async def get_availability(
driver_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(AVAILABILITY_VIEW)),
):
return await AvailabilityQueries(db).get_availability(tenant_id, driver_id)
@router.put("/{driver_id}/availability", response_model=DriverAvailabilityRead)
async def upsert_availability(
driver_id: UUID,
body: DriverAvailabilityUpsert,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(AVAILABILITY_MANAGE)),
):
return await AvailabilityCommands(db).upsert_availability(
tenant_id, driver_id, body, actor=user
)

View File

@ -0,0 +1,100 @@
"""Capability bundle APIs — Phase 10.4."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.pricing import PricingCommands
from app.models.types import CapabilityBundleStatus
from app.permissions.definitions import (
BUNDLES_CREATE,
BUNDLES_DELETE,
BUNDLES_UPDATE,
BUNDLES_VIEW,
)
from app.queries.pricing import PricingQueries
from app.schemas.pricing import (
BundleCreate,
BundleListResponse,
BundleRead,
BundleUpdate,
)
from app.specifications.pricing import BundleListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: CapabilityBundleStatus | None = Query(default=None, alias="status"),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> BundleListSpec:
return BundleListSpec(
status=status_filter, q=q, sort_by=sort_by, sort_dir=sort_dir.lower()
)
@router.post("", response_model=BundleRead, status_code=status.HTTP_201_CREATED)
async def create_bundle(
body: BundleCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(BUNDLES_CREATE)),
):
return await PricingCommands(db).create_bundle(tenant_id, body, actor=user)
@router.get("", response_model=BundleListResponse)
async def list_bundles(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: BundleListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(BUNDLES_VIEW)),
):
items, total = await PricingQueries(db).list_bundles(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return BundleListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{bundle_id}", response_model=BundleRead)
async def get_bundle(
bundle_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(BUNDLES_VIEW)),
):
return await PricingQueries(db).get_bundle(tenant_id, bundle_id)
@router.patch("/{bundle_id}", response_model=BundleRead)
async def update_bundle(
bundle_id: UUID,
body: BundleUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(BUNDLES_UPDATE)),
):
return await PricingCommands(db).update_bundle(
tenant_id, bundle_id, body, actor=user
)
@router.post("/{bundle_id}/delete", response_model=BundleRead)
async def delete_bundle(
bundle_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(BUNDLES_DELETE)),
):
return await PricingCommands(db).delete_bundle(tenant_id, bundle_id, actor=user)

View File

@ -0,0 +1,100 @@
"""Capability APIs — Phase 10.4."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.pricing import PricingCommands
from app.models.types import CapabilityStatus
from app.permissions.definitions import (
CAPABILITIES_CREATE,
CAPABILITIES_DELETE,
CAPABILITIES_UPDATE,
CAPABILITIES_VIEW,
)
from app.queries.pricing import PricingQueries
from app.schemas.pricing import (
CapabilityCreate,
CapabilityListResponse,
CapabilityRead,
CapabilityUpdate,
)
from app.specifications.pricing import CapabilityListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: CapabilityStatus | None = Query(default=None, alias="status"),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> CapabilityListSpec:
return CapabilityListSpec(
status=status_filter, q=q, sort_by=sort_by, sort_dir=sort_dir.lower()
)
@router.post("", response_model=CapabilityRead, status_code=status.HTTP_201_CREATED)
async def create_capability(
body: CapabilityCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(CAPABILITIES_CREATE)),
):
return await PricingCommands(db).create_capability(tenant_id, body, actor=user)
@router.get("", response_model=CapabilityListResponse)
async def list_capabilities(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: CapabilityListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(CAPABILITIES_VIEW)),
):
items, total = await PricingQueries(db).list_capabilities(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return CapabilityListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{cap_id}", response_model=CapabilityRead)
async def get_capability(
cap_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(CAPABILITIES_VIEW)),
):
return await PricingQueries(db).get_capability(tenant_id, cap_id)
@router.patch("/{cap_id}", response_model=CapabilityRead)
async def update_capability(
cap_id: UUID,
body: CapabilityUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(CAPABILITIES_UPDATE)),
):
return await PricingCommands(db).update_capability(
tenant_id, cap_id, body, actor=user
)
@router.post("/{cap_id}/delete", response_model=CapabilityRead)
async def delete_capability(
cap_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(CAPABILITIES_DELETE)),
):
return await PricingCommands(db).delete_capability(tenant_id, cap_id, actor=user)

View File

@ -0,0 +1,83 @@
"""Delivery configuration APIs — Phase 10.0."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.permissions.definitions import (
CONFIGURATIONS_CREATE,
CONFIGURATIONS_DELETE,
CONFIGURATIONS_UPDATE,
CONFIGURATIONS_VIEW,
)
from app.schemas.foundation import (
DeliveryConfigurationCreate,
DeliveryConfigurationRead,
DeliveryConfigurationUpdate,
)
from app.services.foundation import DeliveryConfigurationService
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
@router.post("", response_model=DeliveryConfigurationRead, status_code=status.HTTP_201_CREATED)
async def create_configuration(
body: DeliveryConfigurationCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_CREATE)),
):
return await DeliveryConfigurationService(db).create(tenant_id, body, actor=user)
@router.get("", response_model=list[DeliveryConfigurationRead])
async def list_configurations(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_VIEW)),
):
return await DeliveryConfigurationService(db).list(
tenant_id, offset=pagination.offset, limit=pagination.page_size
)
@router.get("/{configuration_id}", response_model=DeliveryConfigurationRead)
async def get_configuration(
configuration_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_VIEW)),
):
return await DeliveryConfigurationService(db).get(tenant_id, configuration_id)
@router.patch("/{configuration_id}", response_model=DeliveryConfigurationRead)
async def update_configuration(
configuration_id: UUID,
body: DeliveryConfigurationUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_UPDATE)),
):
return await DeliveryConfigurationService(db).update(
tenant_id, configuration_id, body, actor=user
)
@router.post("/{configuration_id}/delete", response_model=DeliveryConfigurationRead)
async def soft_delete_configuration(
configuration_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_DELETE)),
):
return await DeliveryConfigurationService(db).soft_delete(
tenant_id, configuration_id, actor=user
)

View File

@ -0,0 +1,88 @@
"""Customer tracking token APIs — Phase 10.7."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.tracking import TrackingCommands
from app.models.types import CustomerTrackingTokenStatus
from app.permissions.definitions import (
CUSTOMER_TRACKING_CREATE,
CUSTOMER_TRACKING_REVOKE,
CUSTOMER_TRACKING_VIEW,
)
from app.queries.tracking import TrackingQueries
from app.schemas.tracking import (
CustomerTrackingTokenCreate,
CustomerTrackingTokenListResponse,
CustomerTrackingTokenRead,
)
from app.specifications.tracking import CustomerTrackingTokenListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: CustomerTrackingTokenStatus | None = Query(default=None, alias="status"),
dispatch_job_id: UUID | None = Query(default=None),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> CustomerTrackingTokenListSpec:
return CustomerTrackingTokenListSpec(
status=status_filter,
dispatch_job_id=dispatch_job_id,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("/tokens", response_model=CustomerTrackingTokenRead, status_code=status.HTTP_201_CREATED)
async def create_token(
body: CustomerTrackingTokenCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(CUSTOMER_TRACKING_CREATE)),
):
return await TrackingCommands(db).create_token(tenant_id, body, actor=user)
@router.get("/tokens", response_model=CustomerTrackingTokenListResponse)
async def list_tokens(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: CustomerTrackingTokenListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(CUSTOMER_TRACKING_VIEW)),
):
items, total = await TrackingQueries(db).list_tokens(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return CustomerTrackingTokenListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/tokens/{token_id}", response_model=CustomerTrackingTokenRead)
async def get_token(
token_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(CUSTOMER_TRACKING_VIEW)),
):
return await TrackingQueries(db).get_token(tenant_id, token_id)
@router.post("/tokens/{token_id}/revoke", response_model=CustomerTrackingTokenRead)
async def revoke_token(
token_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(CUSTOMER_TRACKING_REVOKE)),
):
return await TrackingCommands(db).revoke_token(tenant_id, token_id, actor=user)

View File

@ -0,0 +1,94 @@
"""Dispatch assignment APIs — Phase 10.5."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.dispatch import DispatchCommands
from app.models.types import JobAssignmentStatus
from app.permissions.definitions import (
DISPATCH_ASSIGNMENTS_CREATE,
DISPATCH_ASSIGNMENTS_UPDATE,
DISPATCH_ASSIGNMENTS_VIEW,
)
from app.queries.dispatch import DispatchQueries
from app.schemas.dispatch import (
JobAssignmentCreate,
JobAssignmentListResponse,
JobAssignmentRead,
JobAssignmentUpdate,
)
from app.specifications.dispatch import JobAssignmentListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
dispatch_job_id: UUID | None = Query(default=None),
driver_id: UUID | None = Query(default=None),
status_filter: JobAssignmentStatus | None = Query(default=None, alias="status"),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> JobAssignmentListSpec:
return JobAssignmentListSpec(
dispatch_job_id=dispatch_job_id,
driver_id=driver_id,
status=status_filter,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("", response_model=JobAssignmentRead, status_code=status.HTTP_201_CREATED)
async def create_assignment(
body: JobAssignmentCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DISPATCH_ASSIGNMENTS_CREATE)),
):
return await DispatchCommands(db).create_assignment(tenant_id, body, actor=user)
@router.get("", response_model=JobAssignmentListResponse)
async def list_assignments(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: JobAssignmentListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(DISPATCH_ASSIGNMENTS_VIEW)),
):
items, total = await DispatchQueries(db).list_assignments(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return JobAssignmentListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{assignment_id}", response_model=JobAssignmentRead)
async def get_assignment(
assignment_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(DISPATCH_ASSIGNMENTS_VIEW)),
):
return await DispatchQueries(db).get_assignment(tenant_id, assignment_id)
@router.patch("/{assignment_id}", response_model=JobAssignmentRead)
async def update_assignment(
assignment_id: UUID,
body: JobAssignmentUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DISPATCH_ASSIGNMENTS_UPDATE)),
):
return await DispatchCommands(db).update_assignment(
tenant_id, assignment_id, body, actor=user
)

View File

@ -0,0 +1,120 @@
"""Dispatch job APIs — Phase 10.5."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.dispatch import DispatchCommands
from app.models.types import DispatchJobStatus
from app.permissions.definitions import (
DISPATCH_JOBS_CREATE,
DISPATCH_JOBS_DELETE,
DISPATCH_JOBS_STATUS,
DISPATCH_JOBS_UPDATE,
DISPATCH_JOBS_VIEW,
)
from app.queries.dispatch import DispatchQueries
from app.schemas.dispatch import (
DispatchJobCreate,
DispatchJobListResponse,
DispatchJobRead,
DispatchJobStatusRequest,
DispatchJobUpdate,
)
from app.specifications.dispatch import DispatchJobListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: DispatchJobStatus | None = Query(default=None, alias="status"),
organization_id: UUID | None = Query(default=None),
hub_id: UUID | None = Query(default=None),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> DispatchJobListSpec:
return DispatchJobListSpec(
status=status_filter,
organization_id=organization_id,
hub_id=hub_id,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("", response_model=DispatchJobRead, status_code=status.HTTP_201_CREATED)
async def create_dispatch_job(
body: DispatchJobCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_CREATE)),
):
return await DispatchCommands(db).create_job(tenant_id, body, actor=user)
@router.get("", response_model=DispatchJobListResponse)
async def list_dispatch_jobs(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: DispatchJobListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_VIEW)),
):
items, total = await DispatchQueries(db).list_jobs(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return DispatchJobListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{job_id}", response_model=DispatchJobRead)
async def get_dispatch_job(
job_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_VIEW)),
):
return await DispatchQueries(db).get_job(tenant_id, job_id)
@router.patch("/{job_id}", response_model=DispatchJobRead)
async def update_dispatch_job(
job_id: UUID,
body: DispatchJobUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_UPDATE)),
):
return await DispatchCommands(db).update_job(tenant_id, job_id, body, actor=user)
@router.post("/{job_id}/status", response_model=DispatchJobRead)
async def apply_dispatch_status(
job_id: UUID,
body: DispatchJobStatusRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_STATUS)),
):
return await DispatchCommands(db).apply_status(
tenant_id, job_id, body, actor=user
)
@router.post("/{job_id}/delete", response_model=DispatchJobRead)
async def delete_dispatch_job(
job_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_DELETE)),
):
return await DispatchCommands(db).delete_job(tenant_id, job_id, actor=user)

View File

@ -0,0 +1,273 @@
"""Driver management APIs — Phase 10.1."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.drivers import DriverCommands
from app.models.types import DriverStatus
from app.permissions.definitions import (
DRIVERS_ACTIVATE,
DRIVERS_ARCHIVE,
DRIVERS_BLOCK,
DRIVERS_CREATE,
DRIVERS_CREDENTIALS_MANAGE,
DRIVERS_CREDENTIALS_VIEW,
DRIVERS_DEACTIVATE,
DRIVERS_DELETE,
DRIVERS_DOCUMENTS_MANAGE,
DRIVERS_DOCUMENTS_VIEW,
DRIVERS_LIFECYCLE_VIEW,
DRIVERS_RESUME,
DRIVERS_SUSPEND,
DRIVERS_UNBLOCK,
DRIVERS_UPDATE,
DRIVERS_VIEW,
)
from app.queries.drivers import DriverQueries
from app.schemas.drivers import (
DriverCreate,
DriverCredentialCreate,
DriverCredentialRead,
DriverDocumentCreate,
DriverDocumentRead,
DriverLifecycleEventRead,
DriverLifecycleRequest,
DriverListResponse,
DriverRead,
DriverUpdate,
)
from app.specifications.drivers import ALLOWED_SORT_FIELDS, DriverListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: DriverStatus | None = Query(default=None, alias="status"),
organization_id: UUID | None = Query(default=None),
hub_id: UUID | None = Query(default=None),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> DriverListSpec:
if sort_by not in ALLOWED_SORT_FIELDS:
sort_by = "created_at"
return DriverListSpec(
status=status_filter,
organization_id=organization_id,
hub_id=hub_id,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("", response_model=DriverRead, status_code=status.HTTP_201_CREATED)
async def create_driver(
body: DriverCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DRIVERS_CREATE)),
):
return await DriverCommands(db).create(tenant_id, body, actor=user)
@router.get("", response_model=DriverListResponse)
async def list_drivers(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: DriverListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(DRIVERS_VIEW)),
):
items, total = await DriverQueries(db).list(
tenant_id,
offset=pagination.offset,
limit=pagination.page_size,
spec=spec,
)
return DriverListResponse(
items=items,
total=total,
page=pagination.page,
page_size=pagination.page_size,
)
@router.get("/{driver_id}", response_model=DriverRead)
async def get_driver(
driver_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(DRIVERS_VIEW)),
):
return await DriverQueries(db).get(tenant_id, driver_id)
@router.patch("/{driver_id}", response_model=DriverRead)
async def update_driver(
driver_id: UUID,
body: DriverUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DRIVERS_UPDATE)),
):
return await DriverCommands(db).update(tenant_id, driver_id, body, actor=user)
@router.post("/{driver_id}/activate", response_model=DriverRead)
async def activate_driver(
driver_id: UUID,
body: DriverLifecycleRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DRIVERS_ACTIVATE)),
):
return await DriverCommands(db).activate(tenant_id, driver_id, body, actor=user)
@router.post("/{driver_id}/suspend", response_model=DriverRead)
async def suspend_driver(
driver_id: UUID,
body: DriverLifecycleRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DRIVERS_SUSPEND)),
):
return await DriverCommands(db).suspend(tenant_id, driver_id, body, actor=user)
@router.post("/{driver_id}/resume", response_model=DriverRead)
async def resume_driver(
driver_id: UUID,
body: DriverLifecycleRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DRIVERS_RESUME)),
):
return await DriverCommands(db).resume(tenant_id, driver_id, body, actor=user)
@router.post("/{driver_id}/deactivate", response_model=DriverRead)
async def deactivate_driver(
driver_id: UUID,
body: DriverLifecycleRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DRIVERS_DEACTIVATE)),
):
return await DriverCommands(db).deactivate(tenant_id, driver_id, body, actor=user)
@router.post("/{driver_id}/block", response_model=DriverRead)
async def block_driver(
driver_id: UUID,
body: DriverLifecycleRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DRIVERS_BLOCK)),
):
return await DriverCommands(db).block(tenant_id, driver_id, body, actor=user)
@router.post("/{driver_id}/unblock", response_model=DriverRead)
async def unblock_driver(
driver_id: UUID,
body: DriverLifecycleRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DRIVERS_UNBLOCK)),
):
return await DriverCommands(db).unblock(tenant_id, driver_id, body, actor=user)
@router.post("/{driver_id}/archive", response_model=DriverRead)
async def archive_driver(
driver_id: UUID,
body: DriverLifecycleRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DRIVERS_ARCHIVE)),
):
return await DriverCommands(db).archive(tenant_id, driver_id, body, actor=user)
@router.get("/{driver_id}/lifecycle", response_model=list[DriverLifecycleEventRead])
async def list_lifecycle(
driver_id: UUID,
limit: int = Query(default=100, ge=1, le=500),
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(DRIVERS_LIFECYCLE_VIEW)),
):
return await DriverQueries(db).lifecycle(tenant_id, driver_id, limit=limit)
@router.post(
"/{driver_id}/credentials",
response_model=DriverCredentialRead,
status_code=status.HTTP_201_CREATED,
)
async def add_credential(
driver_id: UUID,
body: DriverCredentialCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DRIVERS_CREDENTIALS_MANAGE)),
):
return await DriverCommands(db).add_credential(
tenant_id, driver_id, body, actor=user
)
@router.get("/{driver_id}/credentials", response_model=list[DriverCredentialRead])
async def list_credentials(
driver_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(DRIVERS_CREDENTIALS_VIEW)),
):
return await DriverQueries(db).credentials(tenant_id, driver_id)
@router.post(
"/{driver_id}/documents",
response_model=DriverDocumentRead,
status_code=status.HTTP_201_CREATED,
)
async def add_document(
driver_id: UUID,
body: DriverDocumentCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DRIVERS_DOCUMENTS_MANAGE)),
):
return await DriverCommands(db).add_document(
tenant_id, driver_id, body, actor=user
)
@router.get("/{driver_id}/documents", response_model=list[DriverDocumentRead])
async def list_documents(
driver_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(DRIVERS_DOCUMENTS_VIEW)),
):
return await DriverQueries(db).documents(tenant_id, driver_id)
@router.post("/{driver_id}/delete", response_model=DriverRead)
async def soft_delete_driver(
driver_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DRIVERS_DELETE)),
):
return await DriverCommands(db).soft_delete(tenant_id, driver_id, actor=user)

View File

@ -0,0 +1,83 @@
"""External provider config APIs — Phase 10.0."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.permissions.definitions import (
EXTERNAL_PROVIDERS_CREATE,
EXTERNAL_PROVIDERS_DELETE,
EXTERNAL_PROVIDERS_UPDATE,
EXTERNAL_PROVIDERS_VIEW,
)
from app.schemas.foundation import (
ExternalProviderConfigCreate,
ExternalProviderConfigRead,
ExternalProviderConfigUpdate,
)
from app.services.foundation import ExternalProviderConfigService
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
@router.post("", response_model=ExternalProviderConfigRead, status_code=status.HTTP_201_CREATED)
async def create_provider(
body: ExternalProviderConfigCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_CREATE)),
):
return await ExternalProviderConfigService(db).create(tenant_id, body, actor=user)
@router.get("", response_model=list[ExternalProviderConfigRead])
async def list_providers(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_VIEW)),
):
return await ExternalProviderConfigService(db).list(
tenant_id, offset=pagination.offset, limit=pagination.page_size
)
@router.get("/{provider_id}", response_model=ExternalProviderConfigRead)
async def get_provider(
provider_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_VIEW)),
):
return await ExternalProviderConfigService(db).get(tenant_id, provider_id)
@router.patch("/{provider_id}", response_model=ExternalProviderConfigRead)
async def update_provider(
provider_id: UUID,
body: ExternalProviderConfigUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_UPDATE)),
):
return await ExternalProviderConfigService(db).update(
tenant_id, provider_id, body, actor=user
)
@router.post("/{provider_id}/delete", response_model=ExternalProviderConfigRead)
async def soft_delete_provider(
provider_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_DELETE)),
):
return await ExternalProviderConfigService(db).soft_delete(
tenant_id, provider_id, actor=user
)

View File

@ -0,0 +1,102 @@
"""Fleet management APIs — Phase 10.2."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.fleet import FleetCommands
from app.models.types import FleetStatus, VehicleStatus
from app.permissions.definitions import (
FLEETS_CREATE,
FLEETS_DELETE,
FLEETS_UPDATE,
FLEETS_VIEW,
)
from app.queries.fleet import FleetQueries
from app.schemas.fleet import FleetCreate, FleetListResponse, FleetRead, FleetUpdate
from app.specifications.fleet import FLEET_SORT, FleetListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: FleetStatus | None = Query(default=None, alias="status"),
organization_id: UUID | None = Query(default=None),
hub_id: UUID | None = Query(default=None),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> FleetListSpec:
if sort_by not in FLEET_SORT:
sort_by = "created_at"
return FleetListSpec(
status=status_filter,
organization_id=organization_id,
hub_id=hub_id,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("", response_model=FleetRead, status_code=status.HTTP_201_CREATED)
async def create_fleet(
body: FleetCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(FLEETS_CREATE)),
):
return await FleetCommands(db).create_fleet(tenant_id, body, actor=user)
@router.get("", response_model=FleetListResponse)
async def list_fleets(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: FleetListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(FLEETS_VIEW)),
):
items, total = await FleetQueries(db).list_fleets(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return FleetListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{fleet_id}", response_model=FleetRead)
async def get_fleet(
fleet_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(FLEETS_VIEW)),
):
return await FleetQueries(db).get_fleet(tenant_id, fleet_id)
@router.patch("/{fleet_id}", response_model=FleetRead)
async def update_fleet(
fleet_id: UUID,
body: FleetUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(FLEETS_UPDATE)),
):
return await FleetCommands(db).update_fleet(tenant_id, fleet_id, body, actor=user)
@router.post("/{fleet_id}/delete", response_model=FleetRead)
async def delete_fleet(
fleet_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(FLEETS_DELETE)),
):
return await FleetCommands(db).delete_fleet(tenant_id, fleet_id, actor=user)

View File

@ -0,0 +1,100 @@
from fastapi import APIRouter
from app import __version__
from app.core.config import settings
router = APIRouter()
@router.get("/health")
async def health_check():
return {
"status": "ok",
"service": settings.service_name,
"version": __version__,
}
@router.get("/capabilities")
async def capabilities():
return {
"service": settings.service_name,
"version": __version__,
"phase": "10.8",
"commercial_product": "Torbat Driver",
"features": {
"foundation": True,
"health": True,
"capabilities": True,
"metrics": True,
"external_providers_ready": True,
"future_routing_engines_ready": True,
"drivers": True,
"driver_lifecycle": True,
"driver_credentials": True,
"driver_documents": True,
"fleet": True,
"vehicle_types": True,
"vehicles": True,
"vehicle_assignments": True,
"driver_availability": True,
"shifts": True,
"working_zones": True,
"pricing_rules": True,
"capabilities_catalog": True,
"capability_bundles": True,
"dispatch_engine": True,
"routing_engine": True,
"optimization_runs": True,
"tracking": True,
"proof_of_delivery": True,
"customer_tracking": True,
"settlement": True,
"ai": False,
},
"independence": {
"standalone": True,
"superapp": True,
"integrations": [
"accounting",
"communication",
"loyalty",
"crm",
"hospitality",
"marketplace",
"sports_center",
"clinic",
"pharmacy",
],
"integration_mode": "api_and_events_only",
"accounting_mode": "settlement_refs_only",
"no_journal_entries_in_delivery_db": True,
},
}
@router.get("/metrics")
async def metrics():
"""Phase 10.8 metrics discovery — tenant-scoped counters."""
return {
"service": settings.service_name,
"version": __version__,
"phase": "10.8",
"metrics": {
"organizations": "tenant_scoped",
"hubs": "tenant_scoped",
"drivers": "tenant_scoped",
"fleets": "tenant_scoped",
"vehicles": "tenant_scoped",
"shifts": "tenant_scoped",
"pricing_rules": "tenant_scoped",
"dispatch_jobs": "tenant_scoped",
"route_plans": "tenant_scoped",
"optimization_runs": "tenant_scoped",
"tracking_sessions": "tenant_scoped",
"proof_of_delivery": "tenant_scoped",
"settlement_intents": "tenant_scoped",
"outbox_events": "tenant_scoped",
},
"note": "Full delivery platform through phase 10.8; settlement uses AccountingProvider refs only",
}

View File

@ -0,0 +1,70 @@
"""Delivery hub APIs — Phase 10.0."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.permissions.definitions import HUBS_CREATE, HUBS_DELETE, HUBS_UPDATE, HUBS_VIEW
from app.schemas.foundation import DeliveryHubCreate, DeliveryHubRead, DeliveryHubUpdate
from app.services.foundation import DeliveryHubService
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
@router.post("", response_model=DeliveryHubRead, status_code=status.HTTP_201_CREATED)
async def create_hub(
body: DeliveryHubCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(HUBS_CREATE)),
):
return await DeliveryHubService(db).create(tenant_id, body, actor=user)
@router.get("", response_model=list[DeliveryHubRead])
async def list_hubs(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(HUBS_VIEW)),
):
return await DeliveryHubService(db).list(
tenant_id, offset=pagination.offset, limit=pagination.page_size
)
@router.get("/{hub_id}", response_model=DeliveryHubRead)
async def get_hub(
hub_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(HUBS_VIEW)),
):
return await DeliveryHubService(db).get(tenant_id, hub_id)
@router.patch("/{hub_id}", response_model=DeliveryHubRead)
async def update_hub(
hub_id: UUID,
body: DeliveryHubUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(HUBS_UPDATE)),
):
return await DeliveryHubService(db).update(tenant_id, hub_id, body, actor=user)
@router.post("/{hub_id}/delete", response_model=DeliveryHubRead)
async def soft_delete_hub(
hub_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(HUBS_DELETE)),
):
return await DeliveryHubService(db).soft_delete(tenant_id, hub_id, actor=user)

View File

@ -0,0 +1,74 @@
"""Optimization run APIs — Phase 10.6."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.routing import RoutingCommands
from app.models.types import OptimizationRunStatus
from app.permissions.definitions import OPTIMIZATION_RUNS_CREATE, OPTIMIZATION_RUNS_VIEW
from app.queries.routing import RoutingQueries
from app.schemas.routing import (
OptimizationRunCreate,
OptimizationRunListResponse,
OptimizationRunRead,
)
from app.specifications.routing import OptimizationRunListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
route_plan_id: UUID | None = Query(default=None),
status_filter: OptimizationRunStatus | None = Query(default=None, alias="status"),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> OptimizationRunListSpec:
return OptimizationRunListSpec(
route_plan_id=route_plan_id,
status=status_filter,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("/runs", response_model=OptimizationRunRead, status_code=status.HTTP_201_CREATED)
async def start_optimization(
body: OptimizationRunCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(OPTIMIZATION_RUNS_CREATE)),
):
return await RoutingCommands(db).start_optimization(tenant_id, body, actor=user)
@router.get("/runs", response_model=OptimizationRunListResponse)
async def list_optimization_runs(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: OptimizationRunListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(OPTIMIZATION_RUNS_VIEW)),
):
items, total = await RoutingQueries(db).list_optimization_runs(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return OptimizationRunListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/runs/{run_id}", response_model=OptimizationRunRead)
async def get_optimization_run(
run_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(OPTIMIZATION_RUNS_VIEW)),
):
return await RoutingQueries(db).get_optimization_run(tenant_id, run_id)

View File

@ -0,0 +1,83 @@
"""Delivery organization APIs — Phase 10.0."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.permissions.definitions import (
ORGANIZATIONS_CREATE,
ORGANIZATIONS_DELETE,
ORGANIZATIONS_UPDATE,
ORGANIZATIONS_VIEW,
)
from app.schemas.foundation import (
DeliveryOrganizationCreate,
DeliveryOrganizationRead,
DeliveryOrganizationUpdate,
)
from app.services.foundation import DeliveryOrganizationService
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
@router.post("", response_model=DeliveryOrganizationRead, status_code=status.HTTP_201_CREATED)
async def create_organization(
body: DeliveryOrganizationCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(ORGANIZATIONS_CREATE)),
):
return await DeliveryOrganizationService(db).create(tenant_id, body, actor=user)
@router.get("", response_model=list[DeliveryOrganizationRead])
async def list_organizations(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(ORGANIZATIONS_VIEW)),
):
return await DeliveryOrganizationService(db).list(
tenant_id, offset=pagination.offset, limit=pagination.page_size
)
@router.get("/{organization_id}", response_model=DeliveryOrganizationRead)
async def get_organization(
organization_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(ORGANIZATIONS_VIEW)),
):
return await DeliveryOrganizationService(db).get(tenant_id, organization_id)
@router.patch("/{organization_id}", response_model=DeliveryOrganizationRead)
async def update_organization(
organization_id: UUID,
body: DeliveryOrganizationUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(ORGANIZATIONS_UPDATE)),
):
return await DeliveryOrganizationService(db).update(
tenant_id, organization_id, body, actor=user
)
@router.post("/{organization_id}/delete", response_model=DeliveryOrganizationRead)
async def soft_delete_organization(
organization_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(ORGANIZATIONS_DELETE)),
):
return await DeliveryOrganizationService(db).soft_delete(
tenant_id, organization_id, actor=user
)

View File

@ -0,0 +1,27 @@
"""Permission catalog discovery API — Phase 10.1."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from app.api.permissions import require_permissions
from app.permissions.definitions import (
ALL_PERMISSIONS,
DELIVERY_VIEW,
PERMISSION_PREFIXES,
)
from shared.security import CurrentUser
router = APIRouter()
@router.get("/catalog")
async def permission_catalog(
_user: CurrentUser = Depends(require_permissions(DELIVERY_VIEW)),
):
"""Static permission discovery for Identity / admin consoles."""
return {
"prefix": "delivery.",
"prefixes": list(PERMISSION_PREFIXES),
"permissions": list(ALL_PERMISSIONS),
"count": len(ALL_PERMISSIONS),
}

View File

@ -0,0 +1,103 @@
"""Pricing rule APIs — Phase 10.4."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.pricing import PricingCommands
from app.models.types import PricingRuleStatus
from app.permissions.definitions import (
PRICING_RULES_CREATE,
PRICING_RULES_DELETE,
PRICING_RULES_UPDATE,
PRICING_RULES_VIEW,
)
from app.queries.pricing import PricingQueries
from app.schemas.pricing import (
PricingRuleCreate,
PricingRuleListResponse,
PricingRuleRead,
PricingRuleUpdate,
)
from app.specifications.pricing import PricingRuleListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: PricingRuleStatus | None = Query(default=None, alias="status"),
organization_id: UUID | None = Query(default=None),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> PricingRuleListSpec:
return PricingRuleListSpec(
status=status_filter,
organization_id=organization_id,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("", response_model=PricingRuleRead, status_code=status.HTTP_201_CREATED)
async def create_pricing_rule(
body: PricingRuleCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(PRICING_RULES_CREATE)),
):
return await PricingCommands(db).create_rule(tenant_id, body, actor=user)
@router.get("", response_model=PricingRuleListResponse)
async def list_pricing_rules(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: PricingRuleListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(PRICING_RULES_VIEW)),
):
items, total = await PricingQueries(db).list_rules(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return PricingRuleListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{rule_id}", response_model=PricingRuleRead)
async def get_pricing_rule(
rule_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(PRICING_RULES_VIEW)),
):
return await PricingQueries(db).get_rule(tenant_id, rule_id)
@router.patch("/{rule_id}", response_model=PricingRuleRead)
async def update_pricing_rule(
rule_id: UUID,
body: PricingRuleUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(PRICING_RULES_UPDATE)),
):
return await PricingCommands(db).update_rule(tenant_id, rule_id, body, actor=user)
@router.post("/{rule_id}/delete", response_model=PricingRuleRead)
async def delete_pricing_rule(
rule_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(PRICING_RULES_DELETE)),
):
return await PricingCommands(db).delete_rule(tenant_id, rule_id, actor=user)

View File

@ -0,0 +1,90 @@
"""Proof of delivery APIs — Phase 10.7."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.tracking import TrackingCommands
from app.models.types import ProofOfDeliveryStatus
from app.permissions.definitions import (
PROOF_OF_DELIVERY_CREATE,
PROOF_OF_DELIVERY_UPDATE,
PROOF_OF_DELIVERY_VIEW,
)
from app.queries.tracking import TrackingQueries
from app.schemas.tracking import (
ProofOfDeliveryCreate,
ProofOfDeliveryListResponse,
ProofOfDeliveryRead,
ProofOfDeliveryUpdate,
)
from app.specifications.tracking import ProofOfDeliveryListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: ProofOfDeliveryStatus | None = Query(default=None, alias="status"),
dispatch_job_id: UUID | None = Query(default=None),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> ProofOfDeliveryListSpec:
return ProofOfDeliveryListSpec(
status=status_filter,
dispatch_job_id=dispatch_job_id,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("", response_model=ProofOfDeliveryRead, status_code=status.HTTP_201_CREATED)
async def capture_pod(
body: ProofOfDeliveryCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(PROOF_OF_DELIVERY_CREATE)),
):
return await TrackingCommands(db).capture_pod(tenant_id, body, actor=user)
@router.get("", response_model=ProofOfDeliveryListResponse)
async def list_pods(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: ProofOfDeliveryListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(PROOF_OF_DELIVERY_VIEW)),
):
items, total = await TrackingQueries(db).list_pods(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return ProofOfDeliveryListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{pod_id}", response_model=ProofOfDeliveryRead)
async def get_pod(
pod_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(PROOF_OF_DELIVERY_VIEW)),
):
return await TrackingQueries(db).get_pod(tenant_id, pod_id)
@router.patch("/{pod_id}", response_model=ProofOfDeliveryRead)
async def update_pod(
pod_id: UUID,
body: ProofOfDeliveryUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(PROOF_OF_DELIVERY_UPDATE)),
):
return await TrackingCommands(db).update_pod(tenant_id, pod_id, body, actor=user)

View File

@ -0,0 +1,134 @@
"""Route plan APIs — Phase 10.6."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.routing import RoutingCommands
from app.models.types import RoutePlanStatus
from app.permissions.definitions import (
ROUTES_CREATE,
ROUTES_DELETE,
ROUTES_STOPS_MANAGE,
ROUTES_STOPS_VIEW,
ROUTES_UPDATE,
ROUTES_VIEW,
)
from app.queries.routing import RoutingQueries
from app.schemas.routing import (
RoutePlanCreate,
RoutePlanListResponse,
RoutePlanRead,
RoutePlanUpdate,
RouteStopCreate,
RouteStopRead,
)
from app.specifications.routing import RoutePlanListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: RoutePlanStatus | None = Query(default=None, alias="status"),
organization_id: UUID | None = Query(default=None),
driver_id: UUID | None = Query(default=None),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> RoutePlanListSpec:
return RoutePlanListSpec(
status=status_filter,
organization_id=organization_id,
driver_id=driver_id,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("", response_model=RoutePlanRead, status_code=status.HTTP_201_CREATED)
async def create_route(
body: RoutePlanCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(ROUTES_CREATE)),
):
return await RoutingCommands(db).create_route(tenant_id, body, actor=user)
@router.get("", response_model=RoutePlanListResponse)
async def list_routes(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: RoutePlanListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(ROUTES_VIEW)),
):
items, total = await RoutingQueries(db).list_routes(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return RoutePlanListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{plan_id}", response_model=RoutePlanRead)
async def get_route(
plan_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(ROUTES_VIEW)),
):
return await RoutingQueries(db).get_route(tenant_id, plan_id)
@router.patch("/{plan_id}", response_model=RoutePlanRead)
async def update_route(
plan_id: UUID,
body: RoutePlanUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(ROUTES_UPDATE)),
):
return await RoutingCommands(db).update_route(tenant_id, plan_id, body, actor=user)
@router.post("/{plan_id}/delete", response_model=RoutePlanRead)
async def delete_route(
plan_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(ROUTES_DELETE)),
):
return await RoutingCommands(db).delete_route(tenant_id, plan_id, actor=user)
@router.post(
"/{plan_id}/stops",
response_model=RouteStopRead,
status_code=status.HTTP_201_CREATED,
)
async def add_stop(
plan_id: UUID,
body: RouteStopCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(ROUTES_STOPS_MANAGE)),
):
return await RoutingCommands(db).add_stop(tenant_id, plan_id, body, actor=user)
@router.get("/{plan_id}/stops", response_model=list[RouteStopRead])
async def list_stops(
plan_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(ROUTES_STOPS_VIEW)),
):
return await RoutingQueries(db).list_stops(tenant_id, plan_id)

View File

@ -0,0 +1,83 @@
"""Routing engine registration APIs — Phase 10.0."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.permissions.definitions import (
ROUTING_ENGINES_CREATE,
ROUTING_ENGINES_DELETE,
ROUTING_ENGINES_UPDATE,
ROUTING_ENGINES_VIEW,
)
from app.schemas.foundation import (
RoutingEngineRegistrationCreate,
RoutingEngineRegistrationRead,
RoutingEngineRegistrationUpdate,
)
from app.services.foundation import RoutingEngineRegistrationService
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
@router.post("", response_model=RoutingEngineRegistrationRead, status_code=status.HTTP_201_CREATED)
async def create_engine(
body: RoutingEngineRegistrationCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(ROUTING_ENGINES_CREATE)),
):
return await RoutingEngineRegistrationService(db).create(tenant_id, body, actor=user)
@router.get("", response_model=list[RoutingEngineRegistrationRead])
async def list_engines(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(ROUTING_ENGINES_VIEW)),
):
return await RoutingEngineRegistrationService(db).list(
tenant_id, offset=pagination.offset, limit=pagination.page_size
)
@router.get("/{engine_id}", response_model=RoutingEngineRegistrationRead)
async def get_engine(
engine_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(ROUTING_ENGINES_VIEW)),
):
return await RoutingEngineRegistrationService(db).get(tenant_id, engine_id)
@router.patch("/{engine_id}", response_model=RoutingEngineRegistrationRead)
async def update_engine(
engine_id: UUID,
body: RoutingEngineRegistrationUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(ROUTING_ENGINES_UPDATE)),
):
return await RoutingEngineRegistrationService(db).update(
tenant_id, engine_id, body, actor=user
)
@router.post("/{engine_id}/delete", response_model=RoutingEngineRegistrationRead)
async def soft_delete_engine(
engine_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(ROUTING_ENGINES_DELETE)),
):
return await RoutingEngineRegistrationService(db).soft_delete(
tenant_id, engine_id, actor=user
)

View File

@ -0,0 +1,39 @@
"""Delivery settings APIs — Phase 10.0."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.permissions.definitions import SETTINGS_MANAGE, SETTINGS_VIEW
from app.schemas.foundation import DeliverySettingRead, DeliverySettingUpsert
from app.services.foundation import DeliverySettingService
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
@router.put("", response_model=DeliverySettingRead, status_code=status.HTTP_200_OK)
async def upsert_setting(
body: DeliverySettingUpsert,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SETTINGS_MANAGE)),
):
return await DeliverySettingService(db).upsert(tenant_id, body, actor=user)
@router.get("", response_model=list[DeliverySettingRead])
async def list_settings(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(SETTINGS_VIEW)),
):
return await DeliverySettingService(db).list(
tenant_id, offset=pagination.offset, limit=pagination.page_size
)

View File

@ -0,0 +1,169 @@
"""Settlement APIs — Phase 10.8."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.settlement import SettlementCommands
from app.models.types import SettlementIntentStatus
from app.permissions.definitions import (
SETTLEMENTS_CREATE,
SETTLEMENTS_LINES_MANAGE,
SETTLEMENTS_LINES_VIEW,
SETTLEMENTS_STATUS,
SETTLEMENTS_UPDATE,
SETTLEMENTS_VIEW,
)
from app.queries.settlement import SettlementQueries
from app.schemas.settlement import (
SettlementActionRequest,
SettlementIntentCreate,
SettlementIntentListResponse,
SettlementIntentRead,
SettlementIntentUpdate,
SettlementLineCreate,
SettlementLineRead,
)
from app.specifications.settlement import SettlementIntentListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: SettlementIntentStatus | None = Query(default=None, alias="status"),
organization_id: UUID | None = Query(default=None),
driver_id: UUID | None = Query(default=None),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> SettlementIntentListSpec:
return SettlementIntentListSpec(
status=status_filter,
organization_id=organization_id,
driver_id=driver_id,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("", response_model=SettlementIntentRead, status_code=status.HTTP_201_CREATED)
async def create_settlement(
body: SettlementIntentCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_CREATE)),
):
return await SettlementCommands(db).create(tenant_id, body, actor=user)
@router.get("", response_model=SettlementIntentListResponse)
async def list_settlements(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: SettlementIntentListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(SETTLEMENTS_VIEW)),
):
items, total = await SettlementQueries(db).list(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return SettlementIntentListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{intent_id}", response_model=SettlementIntentRead)
async def get_settlement(
intent_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(SETTLEMENTS_VIEW)),
):
return await SettlementQueries(db).get(tenant_id, intent_id)
@router.patch("/{intent_id}", response_model=SettlementIntentRead)
async def update_settlement(
intent_id: UUID,
body: SettlementIntentUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_UPDATE)),
):
return await SettlementCommands(db).update(tenant_id, intent_id, body, actor=user)
@router.post("/{intent_id}/submit", response_model=SettlementIntentRead)
async def submit_settlement(
intent_id: UUID,
body: SettlementActionRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_STATUS)),
):
return await SettlementCommands(db).submit(tenant_id, intent_id, body, actor=user)
@router.post("/{intent_id}/settle", response_model=SettlementIntentRead)
async def settle_settlement(
intent_id: UUID,
body: SettlementActionRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_STATUS)),
):
return await SettlementCommands(db).settle(tenant_id, intent_id, body, actor=user)
@router.post("/{intent_id}/fail", response_model=SettlementIntentRead)
async def fail_settlement(
intent_id: UUID,
body: SettlementActionRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_STATUS)),
):
return await SettlementCommands(db).fail(tenant_id, intent_id, body, actor=user)
@router.post("/{intent_id}/cancel", response_model=SettlementIntentRead)
async def cancel_settlement(
intent_id: UUID,
body: SettlementActionRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_STATUS)),
):
return await SettlementCommands(db).cancel(tenant_id, intent_id, body, actor=user)
@router.post(
"/{intent_id}/lines",
response_model=SettlementLineRead,
status_code=status.HTTP_201_CREATED,
)
async def add_settlement_line(
intent_id: UUID,
body: SettlementLineCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_LINES_MANAGE)),
):
return await SettlementCommands(db).add_line(tenant_id, intent_id, body, actor=user)
@router.get("/{intent_id}/lines", response_model=list[SettlementLineRead])
async def list_settlement_lines(
intent_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(SETTLEMENTS_LINES_VIEW)),
):
return await SettlementQueries(db).lines(tenant_id, intent_id)

View File

@ -0,0 +1,156 @@
"""Shift APIs — Phase 10.3."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.availability import AvailabilityCommands
from app.models.types import ShiftStatus
from app.permissions.definitions import (
SHIFTS_ASSIGNMENTS_MANAGE,
SHIFTS_ASSIGNMENTS_VIEW,
SHIFTS_CREATE,
SHIFTS_DELETE,
SHIFTS_UPDATE,
SHIFTS_VIEW,
)
from app.queries.availability import AvailabilityQueries
from app.schemas.availability import (
ShiftAssignmentCreate,
ShiftAssignmentRead,
ShiftAssignmentUpdate,
ShiftCreate,
ShiftListResponse,
ShiftRead,
ShiftUpdate,
)
from app.specifications.availability import ShiftListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: ShiftStatus | None = Query(default=None, alias="status"),
organization_id: UUID | None = Query(default=None),
hub_id: UUID | None = Query(default=None),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="starts_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> ShiftListSpec:
return ShiftListSpec(
status=status_filter,
organization_id=organization_id,
hub_id=hub_id,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("", response_model=ShiftRead, status_code=status.HTTP_201_CREATED)
async def create_shift(
body: ShiftCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SHIFTS_CREATE)),
):
return await AvailabilityCommands(db).create_shift(tenant_id, body, actor=user)
@router.get("", response_model=ShiftListResponse)
async def list_shifts(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: ShiftListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(SHIFTS_VIEW)),
):
items, total = await AvailabilityQueries(db).list_shifts(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return ShiftListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{shift_id}", response_model=ShiftRead)
async def get_shift(
shift_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(SHIFTS_VIEW)),
):
return await AvailabilityQueries(db).get_shift(tenant_id, shift_id)
@router.patch("/{shift_id}", response_model=ShiftRead)
async def update_shift(
shift_id: UUID,
body: ShiftUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SHIFTS_UPDATE)),
):
return await AvailabilityCommands(db).update_shift(
tenant_id, shift_id, body, actor=user
)
@router.post("/{shift_id}/delete", response_model=ShiftRead)
async def delete_shift(
shift_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SHIFTS_DELETE)),
):
return await AvailabilityCommands(db).delete_shift(tenant_id, shift_id, actor=user)
@router.post(
"/{shift_id}/assignments",
response_model=ShiftAssignmentRead,
status_code=status.HTTP_201_CREATED,
)
async def assign_shift(
shift_id: UUID,
body: ShiftAssignmentCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SHIFTS_ASSIGNMENTS_MANAGE)),
):
return await AvailabilityCommands(db).assign_shift(
tenant_id, shift_id, body, actor=user
)
@router.get("/{shift_id}/assignments", response_model=list[ShiftAssignmentRead])
async def list_shift_assignments(
shift_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(SHIFTS_ASSIGNMENTS_VIEW)),
):
return await AvailabilityQueries(db).list_shift_assignments(tenant_id, shift_id)
@router.patch(
"/{shift_id}/assignments/{assignment_id}",
response_model=ShiftAssignmentRead,
)
async def update_shift_assignment(
shift_id: UUID,
assignment_id: UUID,
body: ShiftAssignmentUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SHIFTS_ASSIGNMENTS_MANAGE)),
):
return await AvailabilityCommands(db).update_shift_assignment(
tenant_id, shift_id, assignment_id, body, actor=user
)

View File

@ -0,0 +1,126 @@
"""Tracking session APIs — Phase 10.7."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.tracking import TrackingCommands
from app.models.types import TrackingSessionStatus
from app.permissions.definitions import (
TRACKING_POINTS_MANAGE,
TRACKING_POINTS_VIEW,
TRACKING_SESSIONS_CREATE,
TRACKING_SESSIONS_UPDATE,
TRACKING_SESSIONS_VIEW,
)
from app.queries.tracking import TrackingQueries
from app.schemas.tracking import (
TrackingPointCreate,
TrackingPointRead,
TrackingSessionCreate,
TrackingSessionListResponse,
TrackingSessionRead,
TrackingSessionUpdate,
)
from app.specifications.tracking import TrackingSessionListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: TrackingSessionStatus | None = Query(default=None, alias="status"),
dispatch_job_id: UUID | None = Query(default=None),
driver_id: UUID | None = Query(default=None),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> TrackingSessionListSpec:
return TrackingSessionListSpec(
status=status_filter,
dispatch_job_id=dispatch_job_id,
driver_id=driver_id,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("/sessions", response_model=TrackingSessionRead, status_code=status.HTTP_201_CREATED)
async def start_session(
body: TrackingSessionCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(TRACKING_SESSIONS_CREATE)),
):
return await TrackingCommands(db).start_session(tenant_id, body, actor=user)
@router.get("/sessions", response_model=TrackingSessionListResponse)
async def list_sessions(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: TrackingSessionListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(TRACKING_SESSIONS_VIEW)),
):
items, total = await TrackingQueries(db).list_sessions(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return TrackingSessionListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/sessions/{session_id}", response_model=TrackingSessionRead)
async def get_session(
session_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(TRACKING_SESSIONS_VIEW)),
):
return await TrackingQueries(db).get_session(tenant_id, session_id)
@router.patch("/sessions/{session_id}", response_model=TrackingSessionRead)
async def update_session(
session_id: UUID,
body: TrackingSessionUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(TRACKING_SESSIONS_UPDATE)),
):
return await TrackingCommands(db).update_session(
tenant_id, session_id, body, actor=user
)
@router.post(
"/sessions/{session_id}/points",
response_model=TrackingPointRead,
status_code=status.HTTP_201_CREATED,
)
async def record_point(
session_id: UUID,
body: TrackingPointCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(TRACKING_POINTS_MANAGE)),
):
return await TrackingCommands(db).record_point(
tenant_id, session_id, body, actor=user
)
@router.get("/sessions/{session_id}/points", response_model=list[TrackingPointRead])
async def list_points(
session_id: UUID,
limit: int = Query(default=500, ge=1, le=2000),
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(TRACKING_POINTS_VIEW)),
):
return await TrackingQueries(db).list_points(tenant_id, session_id, limit=limit)

View File

@ -0,0 +1,96 @@
"""Vehicle type APIs — Phase 10.2."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.fleet import FleetCommands
from app.permissions.definitions import (
VEHICLE_TYPES_CREATE,
VEHICLE_TYPES_DELETE,
VEHICLE_TYPES_UPDATE,
VEHICLE_TYPES_VIEW,
)
from app.queries.fleet import FleetQueries
from app.schemas.fleet import (
VehicleTypeCreate,
VehicleTypeListResponse,
VehicleTypeRead,
VehicleTypeUpdate,
)
from app.specifications.fleet import VehicleTypeListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> VehicleTypeListSpec:
return VehicleTypeListSpec(q=q, sort_by=sort_by, sort_dir=sort_dir.lower())
@router.post("", response_model=VehicleTypeRead, status_code=status.HTTP_201_CREATED)
async def create_vehicle_type(
body: VehicleTypeCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(VEHICLE_TYPES_CREATE)),
):
return await FleetCommands(db).create_vehicle_type(tenant_id, body, actor=user)
@router.get("", response_model=VehicleTypeListResponse)
async def list_vehicle_types(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: VehicleTypeListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(VEHICLE_TYPES_VIEW)),
):
items, total = await FleetQueries(db).list_vehicle_types(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return VehicleTypeListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{type_id}", response_model=VehicleTypeRead)
async def get_vehicle_type(
type_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(VEHICLE_TYPES_VIEW)),
):
return await FleetQueries(db).get_vehicle_type(tenant_id, type_id)
@router.patch("/{type_id}", response_model=VehicleTypeRead)
async def update_vehicle_type(
type_id: UUID,
body: VehicleTypeUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(VEHICLE_TYPES_UPDATE)),
):
return await FleetCommands(db).update_vehicle_type(
tenant_id, type_id, body, actor=user
)
@router.post("/{type_id}/delete", response_model=VehicleTypeRead)
async def delete_vehicle_type(
type_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(VEHICLE_TYPES_DELETE)),
):
return await FleetCommands(db).delete_vehicle_type(tenant_id, type_id, actor=user)

View File

@ -0,0 +1,154 @@
"""Vehicle APIs — Phase 10.2."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.fleet import FleetCommands
from app.models.types import VehicleStatus
from app.permissions.definitions import (
VEHICLES_ASSIGNMENTS_MANAGE,
VEHICLES_ASSIGNMENTS_VIEW,
VEHICLES_CREATE,
VEHICLES_DELETE,
VEHICLES_UPDATE,
VEHICLES_VIEW,
)
from app.queries.fleet import FleetQueries
from app.schemas.fleet import (
VehicleAssignmentCreate,
VehicleAssignmentRead,
VehicleAssignmentUpdate,
VehicleCreate,
VehicleListResponse,
VehicleRead,
VehicleUpdate,
)
from app.specifications.fleet import VehicleListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
fleet_id: UUID | None = Query(default=None),
status_filter: VehicleStatus | None = Query(default=None, alias="status"),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> VehicleListSpec:
return VehicleListSpec(
fleet_id=fleet_id,
status=status_filter,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("", response_model=VehicleRead, status_code=status.HTTP_201_CREATED)
async def create_vehicle(
body: VehicleCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(VEHICLES_CREATE)),
):
return await FleetCommands(db).create_vehicle(tenant_id, body, actor=user)
@router.get("", response_model=VehicleListResponse)
async def list_vehicles(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: VehicleListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(VEHICLES_VIEW)),
):
items, total = await FleetQueries(db).list_vehicles(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return VehicleListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{vehicle_id}", response_model=VehicleRead)
async def get_vehicle(
vehicle_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(VEHICLES_VIEW)),
):
return await FleetQueries(db).get_vehicle(tenant_id, vehicle_id)
@router.patch("/{vehicle_id}", response_model=VehicleRead)
async def update_vehicle(
vehicle_id: UUID,
body: VehicleUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(VEHICLES_UPDATE)),
):
return await FleetCommands(db).update_vehicle(
tenant_id, vehicle_id, body, actor=user
)
@router.post("/{vehicle_id}/delete", response_model=VehicleRead)
async def delete_vehicle(
vehicle_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(VEHICLES_DELETE)),
):
return await FleetCommands(db).delete_vehicle(tenant_id, vehicle_id, actor=user)
@router.post(
"/{vehicle_id}/assignments",
response_model=VehicleAssignmentRead,
status_code=status.HTTP_201_CREATED,
)
async def create_assignment(
vehicle_id: UUID,
body: VehicleAssignmentCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(VEHICLES_ASSIGNMENTS_MANAGE)),
):
return await FleetCommands(db).create_assignment(
tenant_id, vehicle_id, body, actor=user
)
@router.get("/{vehicle_id}/assignments", response_model=list[VehicleAssignmentRead])
async def list_assignments(
vehicle_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(VEHICLES_ASSIGNMENTS_VIEW)),
):
return await FleetQueries(db).list_assignments(tenant_id, vehicle_id)
@router.patch(
"/{vehicle_id}/assignments/{assignment_id}",
response_model=VehicleAssignmentRead,
)
async def update_assignment(
vehicle_id: UUID,
assignment_id: UUID,
body: VehicleAssignmentUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(VEHICLES_ASSIGNMENTS_MANAGE)),
):
return await FleetCommands(db).update_assignment(
tenant_id, vehicle_id, assignment_id, body, actor=user
)

View File

@ -0,0 +1,105 @@
"""Working zone APIs — Phase 10.3."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.availability import AvailabilityCommands
from app.models.types import WorkingZoneStatus
from app.permissions.definitions import (
WORKING_ZONES_CREATE,
WORKING_ZONES_DELETE,
WORKING_ZONES_UPDATE,
WORKING_ZONES_VIEW,
)
from app.queries.availability import AvailabilityQueries
from app.schemas.availability import (
WorkingZoneCreate,
WorkingZoneListResponse,
WorkingZoneRead,
WorkingZoneUpdate,
)
from app.specifications.availability import WorkingZoneListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: WorkingZoneStatus | None = Query(default=None, alias="status"),
organization_id: UUID | None = Query(default=None),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> WorkingZoneListSpec:
return WorkingZoneListSpec(
status=status_filter,
organization_id=organization_id,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("", response_model=WorkingZoneRead, status_code=status.HTTP_201_CREATED)
async def create_zone(
body: WorkingZoneCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(WORKING_ZONES_CREATE)),
):
return await AvailabilityCommands(db).create_zone(tenant_id, body, actor=user)
@router.get("", response_model=WorkingZoneListResponse)
async def list_zones(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: WorkingZoneListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(WORKING_ZONES_VIEW)),
):
items, total = await AvailabilityQueries(db).list_zones(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return WorkingZoneListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{zone_id}", response_model=WorkingZoneRead)
async def get_zone(
zone_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(WORKING_ZONES_VIEW)),
):
return await AvailabilityQueries(db).get_zone(tenant_id, zone_id)
@router.patch("/{zone_id}", response_model=WorkingZoneRead)
async def update_zone(
zone_id: UUID,
body: WorkingZoneUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(WORKING_ZONES_UPDATE)),
):
return await AvailabilityCommands(db).update_zone(
tenant_id, zone_id, body, actor=user
)
@router.post("/{zone_id}/delete", response_model=WorkingZoneRead)
async def delete_zone(
zone_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(WORKING_ZONES_DELETE)),
):
return await AvailabilityCommands(db).delete_zone(tenant_id, zone_id, actor=user)

View File

@ -0,0 +1,4 @@
"""Driver commands package."""
from app.commands.drivers import DriverCommands
__all__ = ["DriverCommands"]

View File

@ -0,0 +1,90 @@
"""Availability command handlers — Phase 10.3."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.availability import (
DriverAvailabilityUpsert,
ShiftAssignmentCreate,
ShiftAssignmentUpdate,
ShiftCreate,
ShiftUpdate,
WorkingZoneCreate,
WorkingZoneUpdate,
)
from app.services.availability import AvailabilityService, ShiftService, WorkingZoneService
from shared.security import CurrentUser
class AvailabilityCommands:
def __init__(self, session: AsyncSession) -> None:
self.availability = AvailabilityService(session)
self.shifts = ShiftService(session)
self.zones = WorkingZoneService(session)
async def upsert_availability(
self,
tenant_id: UUID,
driver_id: UUID,
body: DriverAvailabilityUpsert,
*,
actor: CurrentUser | None,
):
return await self.availability.upsert_for_driver(
tenant_id, driver_id, body, actor=actor
)
async def create_shift(
self, tenant_id: UUID, body: ShiftCreate, *, actor: CurrentUser | None
):
return await self.shifts.create(tenant_id, body, actor=actor)
async def update_shift(
self, tenant_id: UUID, shift_id: UUID, body: ShiftUpdate, *, actor: CurrentUser | None
):
return await self.shifts.update(tenant_id, shift_id, body, actor=actor)
async def delete_shift(
self, tenant_id: UUID, shift_id: UUID, *, actor: CurrentUser | None
):
return await self.shifts.soft_delete(tenant_id, shift_id, actor=actor)
async def assign_shift(
self,
tenant_id: UUID,
shift_id: UUID,
body: ShiftAssignmentCreate,
*,
actor: CurrentUser | None,
):
return await self.shifts.assign_driver(tenant_id, shift_id, body, actor=actor)
async def update_shift_assignment(
self,
tenant_id: UUID,
shift_id: UUID,
assignment_id: UUID,
body: ShiftAssignmentUpdate,
*,
actor: CurrentUser | None,
):
return await self.shifts.update_assignment(
tenant_id, shift_id, assignment_id, body, actor=actor
)
async def create_zone(
self, tenant_id: UUID, body: WorkingZoneCreate, *, actor: CurrentUser | None
):
return await self.zones.create(tenant_id, body, actor=actor)
async def update_zone(
self, tenant_id: UUID, zone_id: UUID, body: WorkingZoneUpdate, *, actor: CurrentUser | None
):
return await self.zones.update(tenant_id, zone_id, body, actor=actor)
async def delete_zone(
self, tenant_id: UUID, zone_id: UUID, *, actor: CurrentUser | None
):
return await self.zones.soft_delete(tenant_id, zone_id, actor=actor)

View File

@ -0,0 +1,62 @@
"""Dispatch command handlers — Phase 10.5."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.dispatch import (
DispatchJobCreate,
DispatchJobStatusRequest,
DispatchJobUpdate,
JobAssignmentCreate,
JobAssignmentUpdate,
)
from app.services.dispatch import DispatchJobService, JobAssignmentService
from shared.security import CurrentUser
class DispatchCommands:
def __init__(self, session: AsyncSession) -> None:
self.jobs = DispatchJobService(session)
self.assignments = JobAssignmentService(session)
async def create_job(
self, tenant_id: UUID, body: DispatchJobCreate, *, actor: CurrentUser | None
):
return await self.jobs.create(tenant_id, body, actor=actor)
async def update_job(
self, tenant_id: UUID, job_id: UUID, body: DispatchJobUpdate, *, actor: CurrentUser | None
):
return await self.jobs.update(tenant_id, job_id, body, actor=actor)
async def apply_status(
self,
tenant_id: UUID,
job_id: UUID,
body: DispatchJobStatusRequest,
*,
actor: CurrentUser | None,
):
return await self.jobs.apply_status(tenant_id, job_id, body, actor=actor)
async def delete_job(
self, tenant_id: UUID, job_id: UUID, *, actor: CurrentUser | None
):
return await self.jobs.soft_delete(tenant_id, job_id, actor=actor)
async def create_assignment(
self, tenant_id: UUID, body: JobAssignmentCreate, *, actor: CurrentUser | None
):
return await self.assignments.create(tenant_id, body, actor=actor)
async def update_assignment(
self,
tenant_id: UUID,
assignment_id: UUID,
body: JobAssignmentUpdate,
*,
actor: CurrentUser | None,
):
return await self.assignments.update(tenant_id, assignment_id, body, actor=actor)

View File

@ -0,0 +1,148 @@
"""Driver command handlers — Phase 10.1."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.types import DriverLifecycleAction
from app.schemas.drivers import (
DriverCreate,
DriverCredentialCreate,
DriverDocumentCreate,
DriverLifecycleRequest,
DriverUpdate,
)
from app.services.drivers import DriverService
from shared.security import CurrentUser
class DriverCommands:
def __init__(self, session: AsyncSession) -> None:
self.service = DriverService(session)
async def create(
self, tenant_id: UUID, body: DriverCreate, *, actor: CurrentUser | None
):
return await self.service.create(tenant_id, body, actor=actor)
async def update(
self,
tenant_id: UUID,
driver_id: UUID,
body: DriverUpdate,
*,
actor: CurrentUser | None,
):
return await self.service.update(tenant_id, driver_id, body, actor=actor)
async def activate(
self,
tenant_id: UUID,
driver_id: UUID,
body: DriverLifecycleRequest,
*,
actor: CurrentUser | None,
):
return await self.service.apply_lifecycle(
tenant_id, driver_id, DriverLifecycleAction.ACTIVATE, body, actor=actor
)
async def suspend(
self,
tenant_id: UUID,
driver_id: UUID,
body: DriverLifecycleRequest,
*,
actor: CurrentUser | None,
):
return await self.service.apply_lifecycle(
tenant_id, driver_id, DriverLifecycleAction.SUSPEND, body, actor=actor
)
async def resume(
self,
tenant_id: UUID,
driver_id: UUID,
body: DriverLifecycleRequest,
*,
actor: CurrentUser | None,
):
return await self.service.apply_lifecycle(
tenant_id, driver_id, DriverLifecycleAction.RESUME, body, actor=actor
)
async def deactivate(
self,
tenant_id: UUID,
driver_id: UUID,
body: DriverLifecycleRequest,
*,
actor: CurrentUser | None,
):
return await self.service.apply_lifecycle(
tenant_id, driver_id, DriverLifecycleAction.DEACTIVATE, body, actor=actor
)
async def block(
self,
tenant_id: UUID,
driver_id: UUID,
body: DriverLifecycleRequest,
*,
actor: CurrentUser | None,
):
return await self.service.apply_lifecycle(
tenant_id, driver_id, DriverLifecycleAction.BLOCK, body, actor=actor
)
async def unblock(
self,
tenant_id: UUID,
driver_id: UUID,
body: DriverLifecycleRequest,
*,
actor: CurrentUser | None,
):
return await self.service.apply_lifecycle(
tenant_id, driver_id, DriverLifecycleAction.UNBLOCK, body, actor=actor
)
async def archive(
self,
tenant_id: UUID,
driver_id: UUID,
body: DriverLifecycleRequest,
*,
actor: CurrentUser | None,
):
return await self.service.apply_lifecycle(
tenant_id, driver_id, DriverLifecycleAction.ARCHIVE, body, actor=actor
)
async def soft_delete(
self, tenant_id: UUID, driver_id: UUID, *, actor: CurrentUser | None
):
return await self.service.soft_delete(tenant_id, driver_id, actor=actor)
async def add_credential(
self,
tenant_id: UUID,
driver_id: UUID,
body: DriverCredentialCreate,
*,
actor: CurrentUser | None,
):
return await self.service.add_credential(
tenant_id, driver_id, body, actor=actor
)
async def add_document(
self,
tenant_id: UUID,
driver_id: UUID,
body: DriverDocumentCreate,
*,
actor: CurrentUser | None,
):
return await self.service.add_document(tenant_id, driver_id, body, actor=actor)

View File

@ -0,0 +1,92 @@
"""Fleet command handlers — Phase 10.2."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.fleet import (
FleetCreate,
FleetUpdate,
VehicleAssignmentCreate,
VehicleAssignmentUpdate,
VehicleCreate,
VehicleTypeCreate,
VehicleTypeUpdate,
VehicleUpdate,
)
from app.services.fleet import FleetService, VehicleService, VehicleTypeService
from shared.security import CurrentUser
class FleetCommands:
def __init__(self, session: AsyncSession) -> None:
self.fleets = FleetService(session)
self.types = VehicleTypeService(session)
self.vehicles = VehicleService(session)
async def create_fleet(self, tenant_id: UUID, body: FleetCreate, *, actor: CurrentUser | None):
return await self.fleets.create(tenant_id, body, actor=actor)
async def update_fleet(
self, tenant_id: UUID, fleet_id: UUID, body: FleetUpdate, *, actor: CurrentUser | None
):
return await self.fleets.update(tenant_id, fleet_id, body, actor=actor)
async def delete_fleet(self, tenant_id: UUID, fleet_id: UUID, *, actor: CurrentUser | None):
return await self.fleets.soft_delete(tenant_id, fleet_id, actor=actor)
async def create_vehicle_type(
self, tenant_id: UUID, body: VehicleTypeCreate, *, actor: CurrentUser | None
):
return await self.types.create(tenant_id, body, actor=actor)
async def update_vehicle_type(
self, tenant_id: UUID, type_id: UUID, body: VehicleTypeUpdate, *, actor: CurrentUser | None
):
return await self.types.update(tenant_id, type_id, body, actor=actor)
async def delete_vehicle_type(
self, tenant_id: UUID, type_id: UUID, *, actor: CurrentUser | None
):
return await self.types.soft_delete(tenant_id, type_id, actor=actor)
async def create_vehicle(
self, tenant_id: UUID, body: VehicleCreate, *, actor: CurrentUser | None
):
return await self.vehicles.create(tenant_id, body, actor=actor)
async def update_vehicle(
self, tenant_id: UUID, vehicle_id: UUID, body: VehicleUpdate, *, actor: CurrentUser | None
):
return await self.vehicles.update(tenant_id, vehicle_id, body, actor=actor)
async def delete_vehicle(
self, tenant_id: UUID, vehicle_id: UUID, *, actor: CurrentUser | None
):
return await self.vehicles.soft_delete(tenant_id, vehicle_id, actor=actor)
async def create_assignment(
self,
tenant_id: UUID,
vehicle_id: UUID,
body: VehicleAssignmentCreate,
*,
actor: CurrentUser | None,
):
return await self.vehicles.create_assignment(
tenant_id, vehicle_id, body, actor=actor
)
async def update_assignment(
self,
tenant_id: UUID,
vehicle_id: UUID,
assignment_id: UUID,
body: VehicleAssignmentUpdate,
*,
actor: CurrentUser | None,
):
return await self.vehicles.update_assignment(
tenant_id, vehicle_id, assignment_id, body, actor=actor
)

View File

@ -0,0 +1,69 @@
"""Pricing command handlers — Phase 10.4."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.pricing import (
BundleCreate,
BundleUpdate,
CapabilityCreate,
CapabilityUpdate,
PricingRuleCreate,
PricingRuleUpdate,
)
from app.services.pricing import BundleService, CapabilityService, PricingRuleService
from shared.security import CurrentUser
class PricingCommands:
def __init__(self, session: AsyncSession) -> None:
self.rules = PricingRuleService(session)
self.capabilities = CapabilityService(session)
self.bundles = BundleService(session)
async def create_rule(
self, tenant_id: UUID, body: PricingRuleCreate, *, actor: CurrentUser | None
):
return await self.rules.create(tenant_id, body, actor=actor)
async def update_rule(
self, tenant_id: UUID, rule_id: UUID, body: PricingRuleUpdate, *, actor: CurrentUser | None
):
return await self.rules.update(tenant_id, rule_id, body, actor=actor)
async def delete_rule(
self, tenant_id: UUID, rule_id: UUID, *, actor: CurrentUser | None
):
return await self.rules.soft_delete(tenant_id, rule_id, actor=actor)
async def create_capability(
self, tenant_id: UUID, body: CapabilityCreate, *, actor: CurrentUser | None
):
return await self.capabilities.create(tenant_id, body, actor=actor)
async def update_capability(
self, tenant_id: UUID, cap_id: UUID, body: CapabilityUpdate, *, actor: CurrentUser | None
):
return await self.capabilities.update(tenant_id, cap_id, body, actor=actor)
async def delete_capability(
self, tenant_id: UUID, cap_id: UUID, *, actor: CurrentUser | None
):
return await self.capabilities.soft_delete(tenant_id, cap_id, actor=actor)
async def create_bundle(
self, tenant_id: UUID, body: BundleCreate, *, actor: CurrentUser | None
):
return await self.bundles.create(tenant_id, body, actor=actor)
async def update_bundle(
self, tenant_id: UUID, bundle_id: UUID, body: BundleUpdate, *, actor: CurrentUser | None
):
return await self.bundles.update(tenant_id, bundle_id, body, actor=actor)
async def delete_bundle(
self, tenant_id: UUID, bundle_id: UUID, *, actor: CurrentUser | None
):
return await self.bundles.soft_delete(tenant_id, bundle_id, actor=actor)

View File

@ -0,0 +1,51 @@
"""Routing command handlers — Phase 10.6."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.routing import (
OptimizationRunCreate,
RoutePlanCreate,
RoutePlanUpdate,
RouteStopCreate,
)
from app.services.routing import OptimizationRunService, RoutePlanService
from shared.security import CurrentUser
class RoutingCommands:
def __init__(self, session: AsyncSession) -> None:
self.routes = RoutePlanService(session)
self.optimization = OptimizationRunService(session)
async def create_route(
self, tenant_id: UUID, body: RoutePlanCreate, *, actor: CurrentUser | None
):
return await self.routes.create(tenant_id, body, actor=actor)
async def update_route(
self, tenant_id: UUID, plan_id: UUID, body: RoutePlanUpdate, *, actor: CurrentUser | None
):
return await self.routes.update(tenant_id, plan_id, body, actor=actor)
async def delete_route(
self, tenant_id: UUID, plan_id: UUID, *, actor: CurrentUser | None
):
return await self.routes.soft_delete(tenant_id, plan_id, actor=actor)
async def add_stop(
self,
tenant_id: UUID,
plan_id: UUID,
body: RouteStopCreate,
*,
actor: CurrentUser | None,
):
return await self.routes.add_stop(tenant_id, plan_id, body, actor=actor)
async def start_optimization(
self, tenant_id: UUID, body: OptimizationRunCreate, *, actor: CurrentUser | None
):
return await self.optimization.start(tenant_id, body, actor=actor)

View File

@ -0,0 +1,85 @@
"""Settlement command handlers — Phase 10.8."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.settlement import (
SettlementActionRequest,
SettlementIntentCreate,
SettlementIntentUpdate,
SettlementLineCreate,
)
from app.services.settlement import SettlementService
from shared.security import CurrentUser
class SettlementCommands:
def __init__(self, session: AsyncSession) -> None:
self.service = SettlementService(session)
async def create(
self, tenant_id: UUID, body: SettlementIntentCreate, *, actor: CurrentUser | None
):
return await self.service.create(tenant_id, body, actor=actor)
async def update(
self,
tenant_id: UUID,
intent_id: UUID,
body: SettlementIntentUpdate,
*,
actor: CurrentUser | None,
):
return await self.service.update(tenant_id, intent_id, body, actor=actor)
async def submit(
self,
tenant_id: UUID,
intent_id: UUID,
body: SettlementActionRequest,
*,
actor: CurrentUser | None,
):
return await self.service.submit(tenant_id, intent_id, body, actor=actor)
async def settle(
self,
tenant_id: UUID,
intent_id: UUID,
body: SettlementActionRequest,
*,
actor: CurrentUser | None,
):
return await self.service.settle(tenant_id, intent_id, body, actor=actor)
async def fail(
self,
tenant_id: UUID,
intent_id: UUID,
body: SettlementActionRequest,
*,
actor: CurrentUser | None,
):
return await self.service.fail(tenant_id, intent_id, body, actor=actor)
async def cancel(
self,
tenant_id: UUID,
intent_id: UUID,
body: SettlementActionRequest,
*,
actor: CurrentUser | None,
):
return await self.service.cancel(tenant_id, intent_id, body, actor=actor)
async def add_line(
self,
tenant_id: UUID,
intent_id: UUID,
body: SettlementLineCreate,
*,
actor: CurrentUser | None,
):
return await self.service.add_line(tenant_id, intent_id, body, actor=actor)

View File

@ -0,0 +1,84 @@
"""Tracking command handlers — Phase 10.7."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.tracking import (
CustomerTrackingTokenCreate,
ProofOfDeliveryCreate,
ProofOfDeliveryUpdate,
TrackingPointCreate,
TrackingSessionCreate,
TrackingSessionUpdate,
)
from app.services.tracking import (
CustomerTrackingTokenService,
ProofOfDeliveryService,
TrackingSessionService,
)
from shared.security import CurrentUser
class TrackingCommands:
def __init__(self, session: AsyncSession) -> None:
self.sessions = TrackingSessionService(session)
self.pod = ProofOfDeliveryService(session)
self.tokens = CustomerTrackingTokenService(session)
async def start_session(
self, tenant_id: UUID, body: TrackingSessionCreate, *, actor: CurrentUser | None
):
return await self.sessions.start(tenant_id, body, actor=actor)
async def update_session(
self,
tenant_id: UUID,
session_id: UUID,
body: TrackingSessionUpdate,
*,
actor: CurrentUser | None,
):
return await self.sessions.update(tenant_id, session_id, body, actor=actor)
async def record_point(
self,
tenant_id: UUID,
session_id: UUID,
body: TrackingPointCreate,
*,
actor: CurrentUser | None,
):
return await self.sessions.record_point(
tenant_id, session_id, body, actor=actor
)
async def capture_pod(
self, tenant_id: UUID, body: ProofOfDeliveryCreate, *, actor: CurrentUser | None
):
return await self.pod.capture(tenant_id, body, actor=actor)
async def update_pod(
self,
tenant_id: UUID,
pod_id: UUID,
body: ProofOfDeliveryUpdate,
*,
actor: CurrentUser | None,
):
return await self.pod.update(tenant_id, pod_id, body, actor=actor)
async def create_token(
self,
tenant_id: UUID,
body: CustomerTrackingTokenCreate,
*,
actor: CurrentUser | None,
):
return await self.tokens.create(tenant_id, body, actor=actor)
async def revoke_token(
self, tenant_id: UUID, token_id: UUID, *, actor: CurrentUser | None
):
return await self.tokens.revoke(tenant_id, token_id, actor=actor)

View File

@ -0,0 +1,75 @@
"""Delivery Service settings."""
from __future__ import annotations
from functools import lru_cache
from typing import Literal
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
environment: Literal["development", "staging", "production", "test"] = "development"
debug: bool = True
log_level: str = "INFO"
service_name: str = Field(
default="delivery-service", validation_alias="DELIVERY_SERVICE_NAME"
)
api_v1_prefix: str = "/api/v1"
database_url: str = Field(
default="postgresql+asyncpg://superapp:superapp_password@localhost:5432/delivery_db",
validation_alias="DELIVERY_DATABASE_URL",
)
database_url_sync: str = Field(
default="postgresql+psycopg://superapp:superapp_password@localhost:5432/delivery_db",
validation_alias="DELIVERY_DATABASE_URL_SYNC",
)
core_service_url: str = Field(
default="http://localhost:8000", validation_alias="CORE_SERVICE_URL"
)
keycloak_enabled: bool = True
keycloak_server_url: str = Field(
default="http://localhost:8080", validation_alias="KEYCLOAK_SERVER_URL"
)
keycloak_public_url: str = Field(default="", validation_alias="KEYCLOAK_PUBLIC_URL")
keycloak_realm: str = Field(default="superapp", validation_alias="KEYCLOAK_REALM")
jwt_algorithm: str = "RS256"
jwt_audience: str = "account"
jwt_verify_signature: bool = True
auth_required: bool = True
cors_origins: str = Field(
default="http://localhost:3000,http://127.0.0.1:3000",
validation_alias="CORS_ORIGINS",
)
@property
def keycloak_public_base(self) -> str:
return (self.keycloak_public_url or self.keycloak_server_url).rstrip("/")
@property
def keycloak_public_realm_url(self) -> str:
return f"{self.keycloak_public_base}/realms/{self.keycloak_realm}"
@property
def cors_origin_list(self) -> list[str]:
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
@lru_cache
def get_settings() -> Settings:
return Settings()
settings = get_settings()

View File

@ -0,0 +1,27 @@
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.pool import StaticPool
from app.core.config import settings
class Base(DeclarativeBase):
pass
_engine_kwargs: dict = {"pool_pre_ping": True, "future": True}
if settings.database_url.startswith("sqlite"):
_engine_kwargs["poolclass"] = StaticPool
_engine_kwargs["connect_args"] = {"check_same_thread": False}
engine = create_async_engine(settings.database_url, **_engine_kwargs)
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
except Exception:
await session.rollback()
raise

View File

@ -0,0 +1,14 @@
import logging
import sys
def configure_logging(level: str = "INFO") -> None:
logging.basicConfig(
level=getattr(logging, level.upper(), logging.INFO),
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
stream=sys.stdout,
)
def get_logger(name: str) -> logging.Logger:
return logging.getLogger(name)

View File

@ -0,0 +1,49 @@
"""JWT authentication dependencies for Delivery service."""
from __future__ import annotations
from functools import lru_cache
from fastapi import Depends
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from app.core.config import settings
from shared.auth import JWTSettings, JWTValidator
from shared.exceptions import UnauthorizedError
from shared.security import CurrentUser
_bearer = HTTPBearer(auto_error=False)
@lru_cache
def get_jwt_validator() -> JWTValidator:
return JWTValidator(
JWTSettings(
keycloak_enabled=settings.keycloak_enabled,
keycloak_server_url=settings.keycloak_server_url,
keycloak_realm=settings.keycloak_realm,
jwt_algorithm=settings.jwt_algorithm,
jwt_audience=settings.jwt_audience,
jwt_verify_signature=settings.jwt_verify_signature,
jwt_issuer=settings.keycloak_public_realm_url,
)
)
async def get_current_user(
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
) -> CurrentUser:
if not settings.auth_required:
return CurrentUser(user_id="test-user", username="test", roles=["tenant_admin"])
if credentials is None or not credentials.credentials:
raise UnauthorizedError("توکن احراز هویت ارائه نشده است")
return await get_jwt_validator().validate(credentials.credentials)
async def get_optional_user(
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
) -> CurrentUser | None:
if not settings.auth_required:
return CurrentUser(user_id="test-user", username="test", roles=["tenant_admin"])
if credentials is None or not credentials.credentials:
return None
return await get_jwt_validator().validate(credentials.credentials)

View File

@ -0,0 +1,121 @@
"""Delivery event publisher — in-memory + transactional outbox (ADR-006)."""
from __future__ import annotations
from typing import Any, Protocol
from uuid import UUID, uuid4
from sqlalchemy.ext.asyncio import AsyncSession
from shared.events import EventEnvelope, EventStatus
from app.core.config import settings
from app.events.types import DeliveryEventType
from app.models.outbox import OutboxEvent
class EventPublisher(Protocol):
def publish(
self,
*,
event_type: DeliveryEventType,
aggregate_type: str,
aggregate_id: UUID,
tenant_id: UUID,
payload: dict[str, Any] | None = None,
) -> EventEnvelope: ...
class InMemoryEventPublisher:
"""Records published envelopes for tests and local verification."""
def __init__(self) -> None:
self.published: list[EventEnvelope] = []
def publish(
self,
*,
event_type: DeliveryEventType,
aggregate_type: str,
aggregate_id: UUID,
tenant_id: UUID,
payload: dict[str, Any] | None = None,
) -> EventEnvelope:
envelope = EventEnvelope(
event_id=uuid4(),
event_type=event_type.value,
aggregate_type=aggregate_type,
aggregate_id=str(aggregate_id),
tenant_id=tenant_id,
source_service=settings.service_name,
payload=payload or {},
)
self.published.append(envelope)
return envelope
def record(self, envelope: EventEnvelope) -> EventEnvelope:
self.published.append(envelope)
return envelope
class TransactionalEventPublisher:
"""Persist outbox row in the current transaction; mirror to memory in tests."""
def __init__(
self, session: AsyncSession, memory: InMemoryEventPublisher | None = None
) -> None:
self.session = session
if memory is not None:
self.memory = memory
elif settings.environment == "test":
self.memory = get_event_publisher()
else:
self.memory = None
async def publish(
self,
*,
event_type: DeliveryEventType,
aggregate_type: str,
aggregate_id: UUID,
tenant_id: UUID,
payload: dict[str, Any] | None = None,
) -> EventEnvelope:
envelope = EventEnvelope(
event_id=uuid4(),
event_type=event_type.value,
aggregate_type=aggregate_type,
aggregate_id=str(aggregate_id),
tenant_id=tenant_id,
source_service=settings.service_name,
payload=payload or {},
)
row = OutboxEvent(
tenant_id=tenant_id,
event_type=envelope.event_type,
aggregate_type=aggregate_type,
aggregate_id=str(aggregate_id),
payload={
"event_id": str(envelope.event_id),
"source_service": settings.service_name,
**(payload or {}),
},
status=EventStatus.PENDING,
)
self.session.add(row)
await self.session.flush()
if self.memory is not None:
self.memory.record(envelope)
return envelope
_default_publisher = InMemoryEventPublisher()
def get_event_publisher() -> InMemoryEventPublisher:
return _default_publisher
def reset_event_publisher() -> InMemoryEventPublisher:
global _default_publisher
_default_publisher = InMemoryEventPublisher()
return _default_publisher

View File

@ -0,0 +1,105 @@
"""Delivery event type contracts (publish-only)."""
from __future__ import annotations
import enum
class DeliveryEventType(str, enum.Enum):
ORGANIZATION_CREATED = "delivery.organization.created"
ORGANIZATION_UPDATED = "delivery.organization.updated"
HUB_CREATED = "delivery.hub.created"
HUB_UPDATED = "delivery.hub.updated"
EXTERNAL_PROVIDER_REGISTERED = "delivery.external_provider.registered"
EXTERNAL_PROVIDER_UPDATED = "delivery.external_provider.updated"
ROUTING_ENGINE_REGISTERED = "delivery.routing_engine.registered"
ROUTING_ENGINE_UPDATED = "delivery.routing_engine.updated"
CONFIGURATION_CREATED = "delivery.configuration.created"
CONFIGURATION_UPDATED = "delivery.configuration.updated"
SETTING_UPSERTED = "delivery.setting.upserted"
# Phase 10.1 — Driver Management
DRIVER_CREATED = "delivery.driver.created"
DRIVER_UPDATED = "delivery.driver.updated"
DRIVER_ACTIVATED = "delivery.driver.activated"
DRIVER_SUSPENDED = "delivery.driver.suspended"
DRIVER_RESUMED = "delivery.driver.resumed"
DRIVER_DEACTIVATED = "delivery.driver.deactivated"
DRIVER_BLOCKED = "delivery.driver.blocked"
DRIVER_UNBLOCKED = "delivery.driver.unblocked"
DRIVER_ARCHIVED = "delivery.driver.archived"
DRIVER_DELETED = "delivery.driver.deleted"
DRIVER_CREDENTIAL_ADDED = "delivery.driver.credential_added"
DRIVER_DOCUMENT_ATTACHED = "delivery.driver.document_attached"
DRIVER_STATUS_CHANGED = "delivery.driver.status_changed"
# Phase 10.2 — Fleet & Vehicle Types
FLEET_CREATED = "delivery.fleet.created"
FLEET_UPDATED = "delivery.fleet.updated"
FLEET_DELETED = "delivery.fleet.deleted"
VEHICLE_TYPE_CREATED = "delivery.vehicle_type.created"
VEHICLE_TYPE_UPDATED = "delivery.vehicle_type.updated"
VEHICLE_TYPE_DELETED = "delivery.vehicle_type.deleted"
VEHICLE_CREATED = "delivery.vehicle.created"
VEHICLE_UPDATED = "delivery.vehicle.updated"
VEHICLE_DELETED = "delivery.vehicle.deleted"
VEHICLE_ASSIGNMENT_CREATED = "delivery.vehicle_assignment.created"
VEHICLE_ASSIGNMENT_UPDATED = "delivery.vehicle_assignment.updated"
VEHICLE_ASSIGNMENT_ENDED = "delivery.vehicle_assignment.ended"
# Phase 10.3 — Availability, Shifts & Working Zones
DRIVER_AVAILABILITY_UPDATED = "delivery.driver_availability.updated"
SHIFT_CREATED = "delivery.shift.created"
SHIFT_UPDATED = "delivery.shift.updated"
SHIFT_DELETED = "delivery.shift.deleted"
SHIFT_ASSIGNMENT_CREATED = "delivery.shift_assignment.created"
SHIFT_ASSIGNMENT_UPDATED = "delivery.shift_assignment.updated"
WORKING_ZONE_CREATED = "delivery.working_zone.created"
WORKING_ZONE_UPDATED = "delivery.working_zone.updated"
WORKING_ZONE_DELETED = "delivery.working_zone.deleted"
# Phase 10.4 — Pricing, Capabilities & Bundles
PRICING_RULE_CREATED = "delivery.pricing_rule.created"
PRICING_RULE_UPDATED = "delivery.pricing_rule.updated"
PRICING_RULE_DELETED = "delivery.pricing_rule.deleted"
CAPABILITY_CREATED = "delivery.capability.created"
CAPABILITY_UPDATED = "delivery.capability.updated"
CAPABILITY_DELETED = "delivery.capability.deleted"
BUNDLE_CREATED = "delivery.bundle.created"
BUNDLE_UPDATED = "delivery.bundle.updated"
BUNDLE_DELETED = "delivery.bundle.deleted"
# Phase 10.5 — Dispatch Engine
DISPATCH_JOB_CREATED = "delivery.dispatch_job.created"
DISPATCH_JOB_UPDATED = "delivery.dispatch_job.updated"
DISPATCH_JOB_STATUS_CHANGED = "delivery.dispatch_job.status_changed"
DISPATCH_JOB_DELETED = "delivery.dispatch_job.deleted"
JOB_ASSIGNMENT_CREATED = "delivery.job_assignment.created"
JOB_ASSIGNMENT_UPDATED = "delivery.job_assignment.updated"
JOB_ASSIGNMENT_STATUS_CHANGED = "delivery.job_assignment.status_changed"
# Phase 10.6 — Routing & Optimization
ROUTE_PLAN_CREATED = "delivery.route_plan.created"
ROUTE_PLAN_UPDATED = "delivery.route_plan.updated"
ROUTE_PLAN_DELETED = "delivery.route_plan.deleted"
ROUTE_STOP_ADDED = "delivery.route_stop.added"
OPTIMIZATION_RUN_STARTED = "delivery.optimization_run.started"
OPTIMIZATION_RUN_COMPLETED = "delivery.optimization_run.completed"
OPTIMIZATION_RUN_FAILED = "delivery.optimization_run.failed"
# Phase 10.7 — Tracking & Proof of Delivery
TRACKING_SESSION_STARTED = "delivery.tracking_session.started"
TRACKING_SESSION_UPDATED = "delivery.tracking_session.updated"
TRACKING_SESSION_ENDED = "delivery.tracking_session.ended"
TRACKING_POINT_RECORDED = "delivery.tracking_point.recorded"
PROOF_OF_DELIVERY_CAPTURED = "delivery.proof_of_delivery.captured"
PROOF_OF_DELIVERY_UPDATED = "delivery.proof_of_delivery.updated"
CUSTOMER_TRACKING_TOKEN_CREATED = "delivery.customer_tracking_token.created"
CUSTOMER_TRACKING_TOKEN_REVOKED = "delivery.customer_tracking_token.revoked"
# Phase 10.8 — Settlement
SETTLEMENT_INTENT_CREATED = "delivery.settlement_intent.created"
SETTLEMENT_INTENT_UPDATED = "delivery.settlement_intent.updated"
SETTLEMENT_INTENT_SUBMITTED = "delivery.settlement_intent.submitted"
SETTLEMENT_INTENT_SETTLED = "delivery.settlement_intent.settled"
SETTLEMENT_INTENT_FAILED = "delivery.settlement_intent.failed"
SETTLEMENT_LINE_ADDED = "delivery.settlement_line.added"

View File

@ -0,0 +1,69 @@
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from app import __version__
from app.api.v1 import api_router
from app.api.v1 import health
from app.core.config import settings
from app.core.logging import configure_logging, get_logger
from app.middlewares.tenant import TenantHeaderMiddleware
from shared.exceptions import AppError
from shared.responses import ErrorDetail, ErrorResponse
configure_logging(settings.log_level)
logger = get_logger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info("service_starting", extra={"service": settings.service_name})
yield
def create_app() -> FastAPI:
app = FastAPI(
title="Delivery & Fleet Platform",
version=__version__,
description="Torbat Driver — Delivery & Fleet Platform (Phase 10.1 Driver Management)",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origin_list,
allow_origin_regex=r"https?://([a-z0-9-]+\.)*torbatyar\.ir",
allow_credentials=False,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_middleware(TenantHeaderMiddleware)
@app.exception_handler(AppError)
async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
return JSONResponse(
status_code=exc.status_code,
content=ErrorResponse(
error=ErrorDetail(
code=exc.error_code, message=exc.message, details=exc.details
)
).model_dump(),
)
@app.exception_handler(Exception)
async def unhandled_handler(request: Request, exc: Exception) -> JSONResponse:
logger.error("unhandled_exception", extra={"error": str(exc)}, exc_info=exc)
return JSONResponse(
status_code=500,
content=ErrorResponse(
error=ErrorDetail(code="internal_error", message="خطای داخلی سرور")
).model_dump(),
)
app.include_router(health.router)
app.include_router(api_router, prefix=settings.api_v1_prefix)
return app
app = create_app()

View File

@ -0,0 +1,24 @@
"""Tenant header resolution middleware."""
from __future__ import annotations
from uuid import UUID
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import Response
from shared.tenant import HEADER_TENANT_ID, STATE_TENANT_ID
class TenantHeaderMiddleware(BaseHTTPMiddleware):
"""Resolve tenant from X-Tenant-ID header only (microservice pattern)."""
async def dispatch(self, request: Request, call_next) -> Response:
setattr(request.state, STATE_TENANT_ID, None)
raw = request.headers.get(HEADER_TENANT_ID)
if raw:
try:
setattr(request.state, STATE_TENANT_ID, UUID(raw))
except ValueError:
pass
return await call_next(request)

View File

@ -0,0 +1,40 @@
"""Import all models for Alembic metadata discovery."""
from app.models.availability import ( # noqa: F401
DriverAvailability,
Shift,
ShiftAssignment,
WorkingZone,
)
from app.models.dispatch import DispatchJob, JobAssignment # noqa: F401
from app.models.drivers import ( # noqa: F401
Driver,
DriverCredential,
DriverDocument,
DriverLifecycleEvent,
)
from app.models.fleet import Fleet, Vehicle, VehicleAssignment, VehicleType # noqa: F401
from app.models.foundation import ( # noqa: F401
DeliveryAuditLog,
DeliveryConfiguration,
DeliveryHub,
DeliveryOrganization,
DeliveryPermission,
DeliveryRole,
DeliverySetting,
ExternalProviderConfig,
RoutingEngineRegistration,
)
from app.models.outbox import OutboxEvent # noqa: F401
from app.models.pricing import ( # noqa: F401
CapabilityBundle,
CapabilityDefinition,
PricingRule,
)
from app.models.routing import OptimizationRun, RoutePlan, RouteStop # noqa: F401
from app.models.settlement import SettlementIntent, SettlementLine # noqa: F401
from app.models.tracking import ( # noqa: F401
CustomerTrackingToken,
ProofOfDelivery,
TrackingPoint,
TrackingSession,
)

View File

@ -0,0 +1,142 @@
"""Availability, shifts & working zones — Phase 10.3."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Index, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.core.database import Base
from app.models.base import (
ActorAuditMixin,
OptimisticLockMixin,
SoftDeleteMixin,
TenantMixin,
TimestampMixin,
UUIDPrimaryKeyMixin,
)
from app.models.types import (
DriverAvailabilityStatus,
GUID,
ShiftAssignmentStatus,
ShiftStatus,
WorkingZoneStatus,
)
class DriverAvailability(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "driver_availabilities"
driver_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
status: Mapped[DriverAvailabilityStatus] = mapped_column(
default=DriverAvailabilityStatus.OFFLINE, nullable=False
)
effective_from: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
effective_until: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
Index("ix_driver_availabilities_driver", "tenant_id", "driver_id"),
Index("ix_driver_availabilities_tenant_deleted", "tenant_id", "is_deleted"),
)
class Shift(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "shifts"
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
hub_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[ShiftStatus] = mapped_column(
default=ShiftStatus.DRAFT, nullable=False
)
starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
ends_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_shifts_tenant_code"),
Index("ix_shifts_tenant_status", "tenant_id", "status"),
Index("ix_shifts_tenant_deleted", "tenant_id", "is_deleted"),
)
class ShiftAssignment(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
):
__tablename__ = "shift_assignments"
shift_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
driver_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
status: Mapped[ShiftAssignmentStatus] = mapped_column(
default=ShiftAssignmentStatus.ASSIGNED, nullable=False
)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id",
"shift_id",
"driver_id",
name="uq_shift_assignments_tenant_shift_driver",
),
Index("ix_shift_assignments_shift", "tenant_id", "shift_id"),
Index("ix_shift_assignments_driver", "tenant_id", "driver_id"),
)
class WorkingZone(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "working_zones"
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[WorkingZoneStatus] = mapped_column(
default=WorkingZoneStatus.ACTIVE, nullable=False
)
geo_boundary_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_working_zones_tenant_code"),
Index("ix_working_zones_tenant_status", "tenant_id", "status"),
Index("ix_working_zones_tenant_deleted", "tenant_id", "is_deleted"),
)

View File

@ -0,0 +1,51 @@
"""Shared model mixins."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import Boolean, DateTime, Integer, String, func
from sqlalchemy.orm import Mapped, mapped_column
from app.models.types import GUID
class UUIDPrimaryKeyMixin:
id: Mapped[uuid.UUID] = mapped_column(
GUID(), 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,
)
class TenantMixin:
"""Row-level tenancy (ADR-003). Tenant comes from request context, never hardcoded."""
tenant_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
class SoftDeleteMixin:
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
deleted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
deleted_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
class ActorAuditMixin:
created_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
updated_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
class OptimisticLockMixin:
version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)

View File

@ -0,0 +1,89 @@
"""Dispatch engine aggregates — Phase 10.5."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Index, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.core.database import Base
from app.models.base import (
ActorAuditMixin,
OptimisticLockMixin,
SoftDeleteMixin,
TenantMixin,
TimestampMixin,
UUIDPrimaryKeyMixin,
)
from app.models.types import DispatchJobStatus, GUID, JobAssignmentStatus
class DispatchJob(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""Vertical job refs only — no deep order ownership."""
__tablename__ = "dispatch_jobs"
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
hub_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
code: Mapped[str] = mapped_column(String(50), nullable=False)
status: Mapped[DispatchJobStatus] = mapped_column(
default=DispatchJobStatus.PENDING, nullable=False
)
external_order_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
external_source: Mapped[str | None] = mapped_column(String(100), nullable=True)
priority: Mapped[int] = mapped_column(default=0, nullable=False)
pickup_address_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
dropoff_address_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
scheduled_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
status_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_dispatch_jobs_tenant_code"),
Index("ix_dispatch_jobs_tenant_status", "tenant_id", "status"),
Index("ix_dispatch_jobs_external_ref", "tenant_id", "external_order_ref"),
Index("ix_dispatch_jobs_tenant_deleted", "tenant_id", "is_deleted"),
)
class JobAssignment(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "job_assignments"
dispatch_job_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
driver_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
vehicle_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
status: Mapped[JobAssignmentStatus] = mapped_column(
default=JobAssignmentStatus.PENDING, nullable=False
)
assigned_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
Index("ix_job_assignments_job", "tenant_id", "dispatch_job_id"),
Index("ix_job_assignments_driver", "tenant_id", "driver_id"),
Index("ix_job_assignments_tenant_status", "tenant_id", "status"),
Index("ix_job_assignments_tenant_deleted", "tenant_id", "is_deleted"),
)

View File

@ -0,0 +1,167 @@
"""Driver management aggregates — Phase 10.1.
UUID refs only within delivery_db. No fleet/dispatch ownership.
Identity/CRM/Storage are external refs only.
"""
from __future__ import annotations
import uuid
from datetime import date, datetime
from sqlalchemy import (
Boolean,
Date,
DateTime,
Index,
String,
Text,
UniqueConstraint,
)
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.core.database import Base
from app.models.base import (
ActorAuditMixin,
OptimisticLockMixin,
SoftDeleteMixin,
TenantMixin,
TimestampMixin,
UUIDPrimaryKeyMixin,
)
from app.models.types import (
DriverCredentialKind,
DriverDocumentKind,
DriverLifecycleAction,
DriverStatus,
GUID,
)
class Driver(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""Courier/driver profile owned by Delivery Platform."""
__tablename__ = "drivers"
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
hub_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
code: Mapped[str] = mapped_column(String(50), nullable=False)
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
first_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
last_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
mobile: Mapped[str | None] = mapped_column(String(32), nullable=True)
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
status: Mapped[DriverStatus] = mapped_column(
default=DriverStatus.PENDING, nullable=False
)
external_user_ref: Mapped[str | None] = mapped_column(String(100), nullable=True)
external_crm_contact_ref: Mapped[str | None] = mapped_column(
String(100), nullable=True
)
hire_date: Mapped[date | None] = mapped_column(Date, nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
capability_tags: Mapped[list | None] = mapped_column(JSON, nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
status_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
status_changed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_drivers_tenant_code"),
Index("ix_drivers_tenant_status", "tenant_id", "status"),
Index("ix_drivers_org", "tenant_id", "organization_id"),
Index("ix_drivers_hub", "tenant_id", "hub_id"),
Index("ix_drivers_mobile", "tenant_id", "mobile"),
Index("ix_drivers_tenant_deleted", "tenant_id", "is_deleted"),
Index("ix_drivers_display_name", "tenant_id", "display_name"),
)
class DriverCredential(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
):
"""Credential / compliance refs for a driver (binaries via Storage refs)."""
__tablename__ = "driver_credentials"
driver_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
kind: Mapped[DriverCredentialKind] = mapped_column(
default=DriverCredentialKind.LICENSE, nullable=False
)
number: Mapped[str] = mapped_column(String(100), nullable=False)
issued_at: Mapped[date | None] = mapped_column(Date, nullable=True)
expires_at: Mapped[date | None] = mapped_column(Date, nullable=True)
is_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
document_file_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id",
"driver_id",
"kind",
"number",
name="uq_driver_credentials_tenant_kind_number",
),
Index("ix_driver_credentials_driver", "tenant_id", "driver_id"),
Index("ix_driver_credentials_tenant_deleted", "tenant_id", "is_deleted"),
)
class DriverDocument(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
):
"""Driver document metadata — Storage owns file blobs."""
__tablename__ = "driver_documents"
driver_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
kind: Mapped[DriverDocumentKind] = mapped_column(
default=DriverDocumentKind.OTHER, nullable=False
)
title: Mapped[str] = mapped_column(String(255), nullable=False)
storage_file_ref: Mapped[str] = mapped_column(String(255), nullable=False)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
__table_args__ = (
Index("ix_driver_documents_driver", "tenant_id", "driver_id"),
Index("ix_driver_documents_tenant_deleted", "tenant_id", "is_deleted"),
)
class DriverLifecycleEvent(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
"""Append-only driver lifecycle history."""
__tablename__ = "driver_lifecycle_events"
driver_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
action: Mapped[DriverLifecycleAction] = mapped_column(nullable=False)
from_status: Mapped[DriverStatus] = mapped_column(nullable=False)
to_status: Mapped[DriverStatus] = mapped_column(nullable=False)
reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
actor_user_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
Index("ix_driver_lifecycle_driver", "tenant_id", "driver_id"),
Index("ix_driver_lifecycle_created", "tenant_id", "created_at"),
)

View File

@ -0,0 +1,126 @@
"""Fleet & Vehicle aggregates — Phase 10.2."""
from __future__ import annotations
import uuid
from sqlalchemy import Index, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.core.database import Base
from app.models.base import (
ActorAuditMixin,
OptimisticLockMixin,
SoftDeleteMixin,
TenantMixin,
TimestampMixin,
UUIDPrimaryKeyMixin,
)
from app.models.types import FleetStatus, GUID, VehicleAssignmentStatus, VehicleStatus
class Fleet(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "fleets"
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
hub_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[FleetStatus] = mapped_column(default=FleetStatus.DRAFT, nullable=False)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_fleets_tenant_code"),
Index("ix_fleets_tenant_status", "tenant_id", "status"),
Index("ix_fleets_org", "tenant_id", "organization_id"),
Index("ix_fleets_tenant_deleted", "tenant_id", "is_deleted"),
)
class VehicleType(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "vehicle_types"
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
capacity_kg: Mapped[float | None] = mapped_column(nullable=True)
capacity_volume: Mapped[float | None] = mapped_column(nullable=True)
capability_tags: Mapped[list | None] = mapped_column(JSON, nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_vehicle_types_tenant_code"),
Index("ix_vehicle_types_tenant_deleted", "tenant_id", "is_deleted"),
)
class Vehicle(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "vehicles"
fleet_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
vehicle_type_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
code: Mapped[str] = mapped_column(String(50), nullable=False)
plate_number: Mapped[str | None] = mapped_column(String(32), nullable=True)
status: Mapped[VehicleStatus] = mapped_column(
default=VehicleStatus.AVAILABLE, nullable=False
)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_vehicles_tenant_code"),
Index("ix_vehicles_fleet", "tenant_id", "fleet_id"),
Index("ix_vehicles_tenant_status", "tenant_id", "status"),
Index("ix_vehicles_tenant_deleted", "tenant_id", "is_deleted"),
)
class VehicleAssignment(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
):
"""Shell assignment linking vehicles to drivers — no dispatch engine."""
__tablename__ = "vehicle_assignments"
vehicle_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
driver_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
status: Mapped[VehicleAssignmentStatus] = mapped_column(
default=VehicleAssignmentStatus.ACTIVE, nullable=False
)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
Index("ix_vehicle_assignments_vehicle", "tenant_id", "vehicle_id"),
Index("ix_vehicle_assignments_driver", "tenant_id", "driver_id"),
Index("ix_vehicle_assignments_tenant_deleted", "tenant_id", "is_deleted"),
)

View File

@ -0,0 +1,303 @@
"""Delivery foundation aggregates — Phase 10.0.
Independent aggregates use UUID references within delivery_db only.
No SQLAlchemy relationship graphs between aggregates.
No dispatch/routing/tracking engines shells and contracts only.
"""
from __future__ import annotations
import uuid
from sqlalchemy import Index, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.core.database import Base
from app.models.base import (
ActorAuditMixin,
OptimisticLockMixin,
SoftDeleteMixin,
TenantMixin,
TimestampMixin,
UUIDPrimaryKeyMixin,
)
from app.models.types import (
AuditAction,
GUID,
LifecycleStatus,
ProviderKind,
ProviderStatus,
RoutingEngineStatus,
)
class DeliveryOrganization(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""Root delivery organization / fleet operator shell for a tenant (Torbat Driver)."""
__tablename__ = "delivery_organizations"
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[LifecycleStatus] = mapped_column(
default=LifecycleStatus.DRAFT, nullable=False
)
legal_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
timezone: Mapped[str] = mapped_column(String(64), default="Asia/Tehran", nullable=False)
language: Mapped[str] = mapped_column(String(16), default="fa", nullable=False)
currency_code: Mapped[str] = mapped_column(String(3), default="IRR", nullable=False)
settings: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_delivery_organizations_tenant_code"),
Index("ix_delivery_organizations_tenant_status", "tenant_id", "status"),
Index("ix_delivery_organizations_tenant_deleted", "tenant_id", "is_deleted"),
)
class DeliveryHub(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
):
"""Operational hub / depot under a delivery organization."""
__tablename__ = "delivery_hubs"
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[LifecycleStatus] = mapped_column(
default=LifecycleStatus.ACTIVE, nullable=False
)
address: Mapped[dict | None] = mapped_column(JSON, nullable=True)
timezone: Mapped[str | None] = mapped_column(String(64), nullable=True)
working_hours: Mapped[dict | None] = mapped_column(JSON, nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id", "organization_id", "code", name="uq_delivery_hubs_tenant_code"
),
Index("ix_delivery_hubs_org", "tenant_id", "organization_id"),
Index("ix_delivery_hubs_tenant_deleted", "tenant_id", "is_deleted"),
)
class DeliveryRole(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
):
"""Local delivery RBAC role catalog shell."""
__tablename__ = "delivery_roles"
organization_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[LifecycleStatus] = mapped_column(
default=LifecycleStatus.ACTIVE, nullable=False
)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_delivery_roles_tenant_code"),
Index("ix_delivery_roles_tenant_deleted", "tenant_id", "is_deleted"),
)
class DeliveryPermission(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
):
"""Local delivery permission catalog shell (complements platform delivery.* tree)."""
__tablename__ = "delivery_permissions"
role_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
code: Mapped[str] = mapped_column(String(100), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_delivery_permissions_tenant_code"),
Index("ix_delivery_permissions_tenant_deleted", "tenant_id", "is_deleted"),
)
class ExternalProviderConfig(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""External fleet/courier/maps provider registration shell — credentials stay here."""
__tablename__ = "external_provider_configs"
organization_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
provider_kind: Mapped[ProviderKind] = mapped_column(
default=ProviderKind.CUSTOM, nullable=False
)
adapter_key: Mapped[str] = mapped_column(String(100), nullable=False)
status: Mapped[ProviderStatus] = mapped_column(
default=ProviderStatus.REGISTERED, nullable=False
)
priority: Mapped[int] = mapped_column(default=100, nullable=False)
credentials_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
config: Mapped[dict | None] = mapped_column(JSON, nullable=True)
capabilities: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id", "code", name="uq_external_provider_configs_tenant_code"
),
Index("ix_external_provider_configs_kind", "tenant_id", "provider_kind"),
Index("ix_external_provider_configs_tenant_deleted", "tenant_id", "is_deleted"),
)
class RoutingEngineRegistration(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""Future routing engine registration — adapter ready, no engine implementation."""
__tablename__ = "routing_engine_registrations"
organization_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
adapter_key: Mapped[str] = mapped_column(String(100), nullable=False)
status: Mapped[RoutingEngineStatus] = mapped_column(
default=RoutingEngineStatus.REGISTERED, nullable=False
)
is_default: Mapped[bool] = mapped_column(default=False, nullable=False)
config: Mapped[dict | None] = mapped_column(JSON, nullable=True)
capabilities: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id", "code", name="uq_routing_engine_registrations_tenant_code"
),
Index("ix_routing_engine_registrations_tenant_deleted", "tenant_id", "is_deleted"),
)
class DeliveryConfiguration(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""Tenant delivery configuration — hours, policies, locale shells."""
__tablename__ = "delivery_configurations"
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
hub_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
code: Mapped[str] = mapped_column(String(50), nullable=False)
working_hours: Mapped[dict | None] = mapped_column(JSON, nullable=True)
dispatch_policies: Mapped[dict | None] = mapped_column(JSON, nullable=True)
tracking_policies: Mapped[dict | None] = mapped_column(JSON, nullable=True)
settlement_policies: Mapped[dict | None] = mapped_column(JSON, nullable=True)
custom_fields: Mapped[dict | None] = mapped_column(JSON, nullable=True)
timezone: Mapped[str] = mapped_column(String(64), default="Asia/Tehran", nullable=False)
language: Mapped[str] = mapped_column(String(16), default="fa", nullable=False)
currency_code: Mapped[str] = mapped_column(String(3), default="IRR", nullable=False)
__table_args__ = (
UniqueConstraint(
"tenant_id",
"organization_id",
"code",
name="uq_delivery_configurations_tenant_code",
),
Index("ix_delivery_configurations_org", "tenant_id", "organization_id"),
Index("ix_delivery_configurations_tenant_deleted", "tenant_id", "is_deleted"),
)
class DeliverySetting(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
):
"""Key/value delivery settings shell."""
__tablename__ = "delivery_settings"
organization_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
hub_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
key: Mapped[str] = mapped_column(String(100), nullable=False)
value: Mapped[dict | None] = mapped_column(JSON, nullable=True)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id",
"organization_id",
"hub_id",
"key",
name="uq_delivery_settings_tenant_key",
),
Index("ix_delivery_settings_tenant_deleted", "tenant_id", "is_deleted"),
)
class DeliveryAuditLog(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
"""Append-only delivery audit trail."""
__tablename__ = "delivery_audit_logs"
entity_type: Mapped[str] = mapped_column(String(50), nullable=False)
entity_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
action: Mapped[AuditAction] = mapped_column(nullable=False)
actor_user_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
changes: Mapped[dict | None] = mapped_column(JSON, nullable=True)
message: Mapped[str | None] = mapped_column(Text, nullable=True)
__table_args__ = (
Index(
"ix_delivery_audit_entity",
"tenant_id",
"entity_type",
"entity_id",
),
)

View File

@ -0,0 +1,44 @@
"""Transactional outbox model — ADR-006."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Index, Integer, String, func
from sqlalchemy import Enum as SAEnum
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.core.database import Base
from app.models.base import UUIDPrimaryKeyMixin
from app.models.types import GUID
from shared.events import EventStatus
class OutboxEvent(UUIDPrimaryKeyMixin, Base):
"""Pending domain events written in the same DB transaction as mutations."""
__tablename__ = "outbox_events"
tenant_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
event_type: Mapped[str] = mapped_column(String(150), nullable=False)
aggregate_type: Mapped[str] = mapped_column(String(100), nullable=False)
aggregate_id: Mapped[str] = mapped_column(String(100), nullable=False)
payload: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
status: Mapped[EventStatus] = mapped_column(
SAEnum(EventStatus, name="delivery_event_status", native_enum=False),
default=EventStatus.PENDING,
nullable=False,
)
retry_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), server_default=func.now(), nullable=False
)
processed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
__table_args__ = (
Index("ix_delivery_outbox_status", "status"),
Index("ix_delivery_outbox_tenant", "tenant_id"),
)

View File

@ -0,0 +1,108 @@
"""Pricing, capabilities & bundles — Phase 10.4."""
from __future__ import annotations
import uuid
from decimal import Decimal
from sqlalchemy import Index, Numeric, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.core.database import Base
from app.models.base import (
ActorAuditMixin,
OptimisticLockMixin,
SoftDeleteMixin,
TenantMixin,
TimestampMixin,
UUIDPrimaryKeyMixin,
)
from app.models.types import (
CapabilityBundleStatus,
CapabilityStatus,
GUID,
PricingRuleStatus,
)
class PricingRule(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "pricing_rules"
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[PricingRuleStatus] = mapped_column(
default=PricingRuleStatus.DRAFT, nullable=False
)
base_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
currency_code: Mapped[str] = mapped_column(String(3), nullable=False, default="IRR")
rule_config: Mapped[dict | None] = mapped_column(JSON, nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_pricing_rules_tenant_code"),
Index("ix_pricing_rules_tenant_status", "tenant_id", "status"),
Index("ix_pricing_rules_tenant_deleted", "tenant_id", "is_deleted"),
)
class CapabilityDefinition(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "capability_definitions"
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[CapabilityStatus] = mapped_column(
default=CapabilityStatus.ACTIVE, nullable=False
)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id", "code", name="uq_capability_definitions_tenant_code"
),
Index("ix_capability_definitions_tenant_deleted", "tenant_id", "is_deleted"),
)
class CapabilityBundle(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "capability_bundles"
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[CapabilityBundleStatus] = mapped_column(
default=CapabilityBundleStatus.DRAFT, nullable=False
)
capability_codes: Mapped[list | None] = mapped_column(JSON, nullable=True)
pricing_rule_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_capability_bundles_tenant_code"),
Index("ix_capability_bundles_tenant_status", "tenant_id", "status"),
Index("ix_capability_bundles_tenant_deleted", "tenant_id", "is_deleted"),
)

View File

@ -0,0 +1,110 @@
"""Routing & optimization aggregates — Phase 10.6."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.core.database import Base
from app.models.base import (
ActorAuditMixin,
OptimisticLockMixin,
SoftDeleteMixin,
TenantMixin,
TimestampMixin,
UUIDPrimaryKeyMixin,
)
from app.models.types import GUID, OptimizationRunStatus, RoutePlanStatus, RouteStopStatus
class RoutePlan(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "route_plans"
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
code: Mapped[str] = mapped_column(String(50), nullable=False)
status: Mapped[RoutePlanStatus] = mapped_column(
default=RoutePlanStatus.DRAFT, nullable=False
)
driver_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
dispatch_job_ids: Mapped[list | None] = mapped_column(JSON, nullable=True)
routing_engine_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
planned_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_route_plans_tenant_code"),
Index("ix_route_plans_tenant_status", "tenant_id", "status"),
Index("ix_route_plans_tenant_deleted", "tenant_id", "is_deleted"),
)
class RouteStop(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
):
__tablename__ = "route_stops"
route_plan_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
status: Mapped[RouteStopStatus] = mapped_column(
default=RouteStopStatus.PENDING, nullable=False
)
address_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
external_order_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
Index("ix_route_stops_plan", "tenant_id", "route_plan_id"),
Index("ix_route_stops_tenant_deleted", "tenant_id", "is_deleted"),
)
class OptimizationRun(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
):
"""External routing via RoutingEngineProvider protocol refs only."""
__tablename__ = "optimization_runs"
route_plan_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
status: Mapped[OptimizationRunStatus] = mapped_column(
default=OptimizationRunStatus.PENDING, nullable=False
)
routing_engine_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
request_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
result_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
started_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
__table_args__ = (
Index("ix_optimization_runs_tenant_status", "tenant_id", "status"),
Index("ix_optimization_runs_plan", "tenant_id", "route_plan_id"),
)

View File

@ -0,0 +1,78 @@
"""Settlement aggregates — Phase 10.8.
Uses AccountingProvider protocol refs only NO journal entries in delivery_db.
"""
from __future__ import annotations
import uuid
from decimal import Decimal
from sqlalchemy import Index, Numeric, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.core.database import Base
from app.models.base import (
ActorAuditMixin,
OptimisticLockMixin,
SoftDeleteMixin,
TenantMixin,
TimestampMixin,
UUIDPrimaryKeyMixin,
)
from app.models.types import GUID, SettlementIntentStatus, SettlementLineKind
class SettlementIntent(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "settlement_intents"
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
code: Mapped[str] = mapped_column(String(50), nullable=False)
status: Mapped[SettlementIntentStatus] = mapped_column(
default=SettlementIntentStatus.DRAFT, nullable=False
)
driver_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
dispatch_job_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
external_settlement_ref: Mapped[str | None] = mapped_column(
String(255), nullable=True
)
total_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
currency_code: Mapped[str] = mapped_column(String(3), nullable=False, default="IRR")
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_settlement_intents_tenant_code"),
Index("ix_settlement_intents_tenant_status", "tenant_id", "status"),
Index("ix_settlement_intents_tenant_deleted", "tenant_id", "is_deleted"),
)
class SettlementLine(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
):
__tablename__ = "settlement_lines"
settlement_intent_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
kind: Mapped[SettlementLineKind] = mapped_column(nullable=False)
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
currency_code: Mapped[str] = mapped_column(String(3), nullable=False, default="IRR")
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
Index("ix_settlement_lines_intent", "tenant_id", "settlement_intent_id"),
)

View File

@ -0,0 +1,138 @@
"""Tracking & proof of delivery — Phase 10.7."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Float, Index, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.core.database import Base
from app.models.base import (
ActorAuditMixin,
OptimisticLockMixin,
SoftDeleteMixin,
TenantMixin,
TimestampMixin,
UUIDPrimaryKeyMixin,
)
from app.models.types import (
CustomerTrackingTokenStatus,
GUID,
ProofOfDeliveryStatus,
TrackingSessionStatus,
)
class TrackingSession(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "tracking_sessions"
dispatch_job_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
driver_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
status: Mapped[TrackingSessionStatus] = mapped_column(
default=TrackingSessionStatus.ACTIVE, nullable=False
)
started_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
ended_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
Index("ix_tracking_sessions_tenant_status", "tenant_id", "status"),
Index("ix_tracking_sessions_job", "tenant_id", "dispatch_job_id"),
Index("ix_tracking_sessions_tenant_deleted", "tenant_id", "is_deleted"),
)
class TrackingPoint(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
):
__tablename__ = "tracking_points"
tracking_session_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
latitude: Mapped[float] = mapped_column(Float, nullable=False)
longitude: Mapped[float] = mapped_column(Float, nullable=False)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
Index("ix_tracking_points_session", "tenant_id", "tracking_session_id"),
Index("ix_tracking_points_recorded", "tenant_id", "recorded_at"),
)
class ProofOfDelivery(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""Storage file refs for POD only — no blob ownership."""
__tablename__ = "proof_of_delivery"
dispatch_job_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
status: Mapped[ProofOfDeliveryStatus] = mapped_column(
default=ProofOfDeliveryStatus.PENDING, nullable=False
)
signature_file_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
photo_file_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
delivery_code: Mapped[str | None] = mapped_column(String(50), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
captured_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
Index("ix_proof_of_delivery_job", "tenant_id", "dispatch_job_id"),
Index("ix_proof_of_delivery_tenant_status", "tenant_id", "status"),
Index("ix_proof_of_delivery_tenant_deleted", "tenant_id", "is_deleted"),
)
class CustomerTrackingToken(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
):
__tablename__ = "customer_tracking_tokens"
dispatch_job_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
token: Mapped[str] = mapped_column(String(128), nullable=False)
status: Mapped[CustomerTrackingTokenStatus] = mapped_column(
default=CustomerTrackingTokenStatus.ACTIVE, nullable=False
)
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "token", name="uq_customer_tracking_tokens_token"),
Index("ix_customer_tracking_tokens_job", "tenant_id", "dispatch_job_id"),
Index("ix_customer_tracking_tokens_tenant_status", "tenant_id", "status"),
)

View File

@ -0,0 +1,277 @@
"""Delivery domain enums and dialect-safe GUID type."""
from __future__ import annotations
import enum
import uuid
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.types import CHAR, TypeDecorator
class GUID(TypeDecorator):
impl = CHAR
cache_ok = True
def load_dialect_impl(self, dialect):
if dialect.name == "postgresql":
return dialect.type_descriptor(PG_UUID(as_uuid=True))
return dialect.type_descriptor(CHAR(36))
def process_bind_param(self, value, dialect):
if value is None:
return value
if dialect.name == "postgresql":
return value if isinstance(value, uuid.UUID) else uuid.UUID(str(value))
return str(value)
def process_result_value(self, value, dialect):
if value is None:
return value
if isinstance(value, uuid.UUID):
return value
return uuid.UUID(str(value))
class LifecycleStatus(str, enum.Enum):
DRAFT = "draft"
ACTIVE = "active"
INACTIVE = "inactive"
SUSPENDED = "suspended"
ARCHIVED = "archived"
class ProviderKind(str, enum.Enum):
ROUTING_ENGINE = "routing_engine"
FLEET = "fleet"
COURIER = "courier"
MAPS = "maps"
CUSTOM = "custom"
class ProviderStatus(str, enum.Enum):
REGISTERED = "registered"
ACTIVE = "active"
INACTIVE = "inactive"
FAILED = "failed"
RETIRED = "retired"
class RoutingEngineStatus(str, enum.Enum):
REGISTERED = "registered"
READY = "ready"
ACTIVE = "active"
DISABLED = "disabled"
class AuditAction(str, enum.Enum):
CREATE = "create"
UPDATE = "update"
DELETE = "delete"
RESTORE = "restore"
STATUS_CHANGE = "status_change"
REGISTER = "register"
ENABLE = "enable"
DISABLE = "disable"
ACTIVATE = "activate"
SUSPEND = "suspend"
RESUME = "resume"
DEACTIVATE = "deactivate"
BLOCK = "block"
UNBLOCK = "unblock"
ARCHIVE = "archive"
class DriverStatus(str, enum.Enum):
"""Operational status for Delivery drivers (Torbat Driver)."""
PENDING = "pending"
ACTIVE = "active"
SUSPENDED = "suspended"
INACTIVE = "inactive"
BLOCKED = "blocked"
ARCHIVED = "archived"
class DriverLifecycleAction(str, enum.Enum):
ACTIVATE = "activate"
SUSPEND = "suspend"
RESUME = "resume"
DEACTIVATE = "deactivate"
BLOCK = "block"
UNBLOCK = "unblock"
ARCHIVE = "archive"
class DriverCredentialKind(str, enum.Enum):
LICENSE = "license"
NATIONAL_ID = "national_id"
HEALTH_CARD = "health_card"
BACKGROUND_CHECK = "background_check"
OTHER = "other"
class DriverDocumentKind(str, enum.Enum):
LICENSE_SCAN = "license_scan"
ID_SCAN = "id_scan"
CONTRACT = "contract"
PHOTO = "photo"
OTHER = "other"
# Phase 10.2 — Fleet & Vehicle Types
class FleetStatus(str, enum.Enum):
DRAFT = "draft"
ACTIVE = "active"
INACTIVE = "inactive"
ARCHIVED = "archived"
class VehicleStatus(str, enum.Enum):
AVAILABLE = "available"
ASSIGNED = "assigned"
MAINTENANCE = "maintenance"
INACTIVE = "inactive"
ARCHIVED = "archived"
class VehicleAssignmentStatus(str, enum.Enum):
ACTIVE = "active"
ENDED = "ended"
CANCELLED = "cancelled"
# Phase 10.3 — Availability, Shifts & Working Zones
class DriverAvailabilityStatus(str, enum.Enum):
ONLINE = "online"
OFFLINE = "offline"
BUSY = "busy"
BREAK = "break"
class ShiftStatus(str, enum.Enum):
DRAFT = "draft"
SCHEDULED = "scheduled"
ACTIVE = "active"
COMPLETED = "completed"
CANCELLED = "cancelled"
class ShiftAssignmentStatus(str, enum.Enum):
ASSIGNED = "assigned"
CHECKED_IN = "checked_in"
COMPLETED = "completed"
CANCELLED = "cancelled"
class WorkingZoneStatus(str, enum.Enum):
ACTIVE = "active"
INACTIVE = "inactive"
# Phase 10.4 — Pricing, Capabilities & Bundles
class PricingRuleStatus(str, enum.Enum):
DRAFT = "draft"
ACTIVE = "active"
INACTIVE = "inactive"
ARCHIVED = "archived"
class CapabilityStatus(str, enum.Enum):
ACTIVE = "active"
INACTIVE = "inactive"
class CapabilityBundleStatus(str, enum.Enum):
DRAFT = "draft"
ACTIVE = "active"
INACTIVE = "inactive"
ARCHIVED = "archived"
# Phase 10.5 — Dispatch Engine
class DispatchJobStatus(str, enum.Enum):
PENDING = "pending"
ASSIGNED = "assigned"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
CANCELLED = "cancelled"
FAILED = "failed"
class DispatchJobAction(str, enum.Enum):
ASSIGN = "assign"
START = "start"
COMPLETE = "complete"
CANCEL = "cancel"
FAIL = "fail"
class JobAssignmentStatus(str, enum.Enum):
PENDING = "pending"
ACCEPTED = "accepted"
REJECTED = "rejected"
ACTIVE = "active"
COMPLETED = "completed"
CANCELLED = "cancelled"
# Phase 10.6 — Routing & Optimization
class RoutePlanStatus(str, enum.Enum):
DRAFT = "draft"
PLANNED = "planned"
ACTIVE = "active"
COMPLETED = "completed"
CANCELLED = "cancelled"
class RouteStopStatus(str, enum.Enum):
PENDING = "pending"
ARRIVED = "arrived"
COMPLETED = "completed"
SKIPPED = "skipped"
class OptimizationRunStatus(str, enum.Enum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
# Phase 10.7 — Tracking & Proof of Delivery
class TrackingSessionStatus(str, enum.Enum):
ACTIVE = "active"
PAUSED = "paused"
COMPLETED = "completed"
CANCELLED = "cancelled"
class ProofOfDeliveryStatus(str, enum.Enum):
PENDING = "pending"
CAPTURED = "captured"
VERIFIED = "verified"
REJECTED = "rejected"
class CustomerTrackingTokenStatus(str, enum.Enum):
ACTIVE = "active"
EXPIRED = "expired"
REVOKED = "revoked"
# Phase 10.8 — Settlement
class SettlementIntentStatus(str, enum.Enum):
DRAFT = "draft"
PENDING = "pending"
SUBMITTED = "submitted"
SETTLED = "settled"
FAILED = "failed"
CANCELLED = "cancelled"
class SettlementLineKind(str, enum.Enum):
DELIVERY_FEE = "delivery_fee"
TIP = "tip"
SURCHARGE = "surcharge"
ADJUSTMENT = "adjustment"
OTHER = "other"

View File

@ -0,0 +1,365 @@
"""Delivery permission definitions — Phase 10.010.8."""
from __future__ import annotations
DELIVERY_VIEW = "delivery.view"
DELIVERY_MANAGE = "delivery.manage"
ORGANIZATIONS_VIEW = "delivery.organizations.view"
ORGANIZATIONS_CREATE = "delivery.organizations.create"
ORGANIZATIONS_UPDATE = "delivery.organizations.update"
ORGANIZATIONS_DELETE = "delivery.organizations.delete"
ORGANIZATIONS_MANAGE = "delivery.organizations.manage"
HUBS_VIEW = "delivery.hubs.view"
HUBS_CREATE = "delivery.hubs.create"
HUBS_UPDATE = "delivery.hubs.update"
HUBS_DELETE = "delivery.hubs.delete"
HUBS_MANAGE = "delivery.hubs.manage"
EXTERNAL_PROVIDERS_VIEW = "delivery.external_providers.view"
EXTERNAL_PROVIDERS_CREATE = "delivery.external_providers.create"
EXTERNAL_PROVIDERS_UPDATE = "delivery.external_providers.update"
EXTERNAL_PROVIDERS_DELETE = "delivery.external_providers.delete"
EXTERNAL_PROVIDERS_MANAGE = "delivery.external_providers.manage"
ROUTING_ENGINES_VIEW = "delivery.routing_engines.view"
ROUTING_ENGINES_CREATE = "delivery.routing_engines.create"
ROUTING_ENGINES_UPDATE = "delivery.routing_engines.update"
ROUTING_ENGINES_DELETE = "delivery.routing_engines.delete"
ROUTING_ENGINES_MANAGE = "delivery.routing_engines.manage"
CONFIGURATIONS_VIEW = "delivery.configurations.view"
CONFIGURATIONS_CREATE = "delivery.configurations.create"
CONFIGURATIONS_UPDATE = "delivery.configurations.update"
CONFIGURATIONS_DELETE = "delivery.configurations.delete"
CONFIGURATIONS_MANAGE = "delivery.configurations.manage"
SETTINGS_VIEW = "delivery.settings.view"
SETTINGS_MANAGE = "delivery.settings.manage"
AUDIT_VIEW = "delivery.audit.view"
# Phase 10.1 — Driver Management
DRIVERS_VIEW = "delivery.drivers.view"
DRIVERS_CREATE = "delivery.drivers.create"
DRIVERS_UPDATE = "delivery.drivers.update"
DRIVERS_DELETE = "delivery.drivers.delete"
DRIVERS_ACTIVATE = "delivery.drivers.activate"
DRIVERS_SUSPEND = "delivery.drivers.suspend"
DRIVERS_RESUME = "delivery.drivers.resume"
DRIVERS_DEACTIVATE = "delivery.drivers.deactivate"
DRIVERS_BLOCK = "delivery.drivers.block"
DRIVERS_UNBLOCK = "delivery.drivers.unblock"
DRIVERS_ARCHIVE = "delivery.drivers.archive"
DRIVERS_LIFECYCLE_VIEW = "delivery.drivers.lifecycle.view"
DRIVERS_CREDENTIALS_VIEW = "delivery.drivers.credentials.view"
DRIVERS_CREDENTIALS_MANAGE = "delivery.drivers.credentials.manage"
DRIVERS_DOCUMENTS_VIEW = "delivery.drivers.documents.view"
DRIVERS_DOCUMENTS_MANAGE = "delivery.drivers.documents.manage"
DRIVERS_MANAGE = "delivery.drivers.manage"
# Phase 10.2 — Fleet & Vehicles
FLEETS_VIEW = "delivery.fleets.view"
FLEETS_CREATE = "delivery.fleets.create"
FLEETS_UPDATE = "delivery.fleets.update"
FLEETS_DELETE = "delivery.fleets.delete"
FLEETS_MANAGE = "delivery.fleets.manage"
VEHICLE_TYPES_VIEW = "delivery.vehicle_types.view"
VEHICLE_TYPES_CREATE = "delivery.vehicle_types.create"
VEHICLE_TYPES_UPDATE = "delivery.vehicle_types.update"
VEHICLE_TYPES_DELETE = "delivery.vehicle_types.delete"
VEHICLE_TYPES_MANAGE = "delivery.vehicle_types.manage"
VEHICLES_VIEW = "delivery.vehicles.view"
VEHICLES_CREATE = "delivery.vehicles.create"
VEHICLES_UPDATE = "delivery.vehicles.update"
VEHICLES_DELETE = "delivery.vehicles.delete"
VEHICLES_ASSIGNMENTS_VIEW = "delivery.vehicles.assignments.view"
VEHICLES_ASSIGNMENTS_MANAGE = "delivery.vehicles.assignments.manage"
VEHICLES_MANAGE = "delivery.vehicles.manage"
# Phase 10.3 — Availability, Shifts & Working Zones
AVAILABILITY_VIEW = "delivery.availability.view"
AVAILABILITY_MANAGE = "delivery.availability.manage"
SHIFTS_VIEW = "delivery.shifts.view"
SHIFTS_CREATE = "delivery.shifts.create"
SHIFTS_UPDATE = "delivery.shifts.update"
SHIFTS_DELETE = "delivery.shifts.delete"
SHIFTS_ASSIGNMENTS_VIEW = "delivery.shifts.assignments.view"
SHIFTS_ASSIGNMENTS_MANAGE = "delivery.shifts.assignments.manage"
SHIFTS_MANAGE = "delivery.shifts.manage"
WORKING_ZONES_VIEW = "delivery.working_zones.view"
WORKING_ZONES_CREATE = "delivery.working_zones.create"
WORKING_ZONES_UPDATE = "delivery.working_zones.update"
WORKING_ZONES_DELETE = "delivery.working_zones.delete"
WORKING_ZONES_MANAGE = "delivery.working_zones.manage"
# Phase 10.4 — Pricing, Capabilities & Bundles
PRICING_RULES_VIEW = "delivery.pricing_rules.view"
PRICING_RULES_CREATE = "delivery.pricing_rules.create"
PRICING_RULES_UPDATE = "delivery.pricing_rules.update"
PRICING_RULES_DELETE = "delivery.pricing_rules.delete"
PRICING_RULES_MANAGE = "delivery.pricing_rules.manage"
CAPABILITIES_VIEW = "delivery.capabilities.view"
CAPABILITIES_CREATE = "delivery.capabilities.create"
CAPABILITIES_UPDATE = "delivery.capabilities.update"
CAPABILITIES_DELETE = "delivery.capabilities.delete"
CAPABILITIES_MANAGE = "delivery.capabilities.manage"
BUNDLES_VIEW = "delivery.bundles.view"
BUNDLES_CREATE = "delivery.bundles.create"
BUNDLES_UPDATE = "delivery.bundles.update"
BUNDLES_DELETE = "delivery.bundles.delete"
BUNDLES_MANAGE = "delivery.bundles.manage"
# Phase 10.5 — Dispatch Engine
DISPATCH_JOBS_VIEW = "delivery.dispatch.jobs.view"
DISPATCH_JOBS_CREATE = "delivery.dispatch.jobs.create"
DISPATCH_JOBS_UPDATE = "delivery.dispatch.jobs.update"
DISPATCH_JOBS_DELETE = "delivery.dispatch.jobs.delete"
DISPATCH_JOBS_STATUS = "delivery.dispatch.jobs.status"
DISPATCH_JOBS_MANAGE = "delivery.dispatch.jobs.manage"
DISPATCH_ASSIGNMENTS_VIEW = "delivery.dispatch.assignments.view"
DISPATCH_ASSIGNMENTS_CREATE = "delivery.dispatch.assignments.create"
DISPATCH_ASSIGNMENTS_UPDATE = "delivery.dispatch.assignments.update"
DISPATCH_ASSIGNMENTS_MANAGE = "delivery.dispatch.assignments.manage"
DISPATCH_VIEW = "delivery.dispatch.view"
DISPATCH_MANAGE = "delivery.dispatch.manage"
# Phase 10.6 — Routing & Optimization
ROUTES_VIEW = "delivery.routes.view"
ROUTES_CREATE = "delivery.routes.create"
ROUTES_UPDATE = "delivery.routes.update"
ROUTES_DELETE = "delivery.routes.delete"
ROUTES_STOPS_VIEW = "delivery.routes.stops.view"
ROUTES_STOPS_MANAGE = "delivery.routes.stops.manage"
ROUTES_MANAGE = "delivery.routes.manage"
OPTIMIZATION_RUNS_VIEW = "delivery.optimization.runs.view"
OPTIMIZATION_RUNS_CREATE = "delivery.optimization.runs.create"
OPTIMIZATION_RUNS_MANAGE = "delivery.optimization.runs.manage"
ROUTING_VIEW = "delivery.routing.view"
ROUTING_MANAGE = "delivery.routing.manage"
# Phase 10.7 — Tracking & Proof of Delivery
TRACKING_SESSIONS_VIEW = "delivery.tracking.sessions.view"
TRACKING_SESSIONS_CREATE = "delivery.tracking.sessions.create"
TRACKING_SESSIONS_UPDATE = "delivery.tracking.sessions.update"
TRACKING_SESSIONS_MANAGE = "delivery.tracking.sessions.manage"
TRACKING_POINTS_VIEW = "delivery.tracking.points.view"
TRACKING_POINTS_MANAGE = "delivery.tracking.points.manage"
PROOF_OF_DELIVERY_VIEW = "delivery.proof_of_delivery.view"
PROOF_OF_DELIVERY_CREATE = "delivery.proof_of_delivery.create"
PROOF_OF_DELIVERY_UPDATE = "delivery.proof_of_delivery.update"
PROOF_OF_DELIVERY_MANAGE = "delivery.proof_of_delivery.manage"
CUSTOMER_TRACKING_VIEW = "delivery.customer_tracking.view"
CUSTOMER_TRACKING_CREATE = "delivery.customer_tracking.create"
CUSTOMER_TRACKING_REVOKE = "delivery.customer_tracking.revoke"
CUSTOMER_TRACKING_MANAGE = "delivery.customer_tracking.manage"
TRACKING_VIEW = "delivery.tracking.view"
TRACKING_MANAGE = "delivery.tracking.manage"
# Phase 10.8 — Settlement
SETTLEMENTS_VIEW = "delivery.settlements.view"
SETTLEMENTS_CREATE = "delivery.settlements.create"
SETTLEMENTS_UPDATE = "delivery.settlements.update"
SETTLEMENTS_STATUS = "delivery.settlements.status"
SETTLEMENTS_LINES_VIEW = "delivery.settlements.lines.view"
SETTLEMENTS_LINES_MANAGE = "delivery.settlements.lines.manage"
SETTLEMENTS_MANAGE = "delivery.settlements.manage"
SETTLEMENT_VIEW = "delivery.settlement.view"
SETTLEMENT_MANAGE = "delivery.settlement.manage"
# Legacy aliases
FLEET_VIEW = FLEETS_VIEW
FLEET_MANAGE = FLEETS_MANAGE
ALL_PERMISSIONS: list[str] = [
DELIVERY_VIEW,
DELIVERY_MANAGE,
ORGANIZATIONS_VIEW,
ORGANIZATIONS_CREATE,
ORGANIZATIONS_UPDATE,
ORGANIZATIONS_DELETE,
ORGANIZATIONS_MANAGE,
HUBS_VIEW,
HUBS_CREATE,
HUBS_UPDATE,
HUBS_DELETE,
HUBS_MANAGE,
EXTERNAL_PROVIDERS_VIEW,
EXTERNAL_PROVIDERS_CREATE,
EXTERNAL_PROVIDERS_UPDATE,
EXTERNAL_PROVIDERS_DELETE,
EXTERNAL_PROVIDERS_MANAGE,
ROUTING_ENGINES_VIEW,
ROUTING_ENGINES_CREATE,
ROUTING_ENGINES_UPDATE,
ROUTING_ENGINES_DELETE,
ROUTING_ENGINES_MANAGE,
CONFIGURATIONS_VIEW,
CONFIGURATIONS_CREATE,
CONFIGURATIONS_UPDATE,
CONFIGURATIONS_DELETE,
CONFIGURATIONS_MANAGE,
SETTINGS_VIEW,
SETTINGS_MANAGE,
AUDIT_VIEW,
DRIVERS_VIEW,
DRIVERS_CREATE,
DRIVERS_UPDATE,
DRIVERS_DELETE,
DRIVERS_ACTIVATE,
DRIVERS_SUSPEND,
DRIVERS_RESUME,
DRIVERS_DEACTIVATE,
DRIVERS_BLOCK,
DRIVERS_UNBLOCK,
DRIVERS_ARCHIVE,
DRIVERS_LIFECYCLE_VIEW,
DRIVERS_CREDENTIALS_VIEW,
DRIVERS_CREDENTIALS_MANAGE,
DRIVERS_DOCUMENTS_VIEW,
DRIVERS_DOCUMENTS_MANAGE,
DRIVERS_MANAGE,
FLEETS_VIEW,
FLEETS_CREATE,
FLEETS_UPDATE,
FLEETS_DELETE,
FLEETS_MANAGE,
VEHICLE_TYPES_VIEW,
VEHICLE_TYPES_CREATE,
VEHICLE_TYPES_UPDATE,
VEHICLE_TYPES_DELETE,
VEHICLE_TYPES_MANAGE,
VEHICLES_VIEW,
VEHICLES_CREATE,
VEHICLES_UPDATE,
VEHICLES_DELETE,
VEHICLES_ASSIGNMENTS_VIEW,
VEHICLES_ASSIGNMENTS_MANAGE,
VEHICLES_MANAGE,
AVAILABILITY_VIEW,
AVAILABILITY_MANAGE,
SHIFTS_VIEW,
SHIFTS_CREATE,
SHIFTS_UPDATE,
SHIFTS_DELETE,
SHIFTS_ASSIGNMENTS_VIEW,
SHIFTS_ASSIGNMENTS_MANAGE,
SHIFTS_MANAGE,
WORKING_ZONES_VIEW,
WORKING_ZONES_CREATE,
WORKING_ZONES_UPDATE,
WORKING_ZONES_DELETE,
WORKING_ZONES_MANAGE,
PRICING_RULES_VIEW,
PRICING_RULES_CREATE,
PRICING_RULES_UPDATE,
PRICING_RULES_DELETE,
PRICING_RULES_MANAGE,
CAPABILITIES_VIEW,
CAPABILITIES_CREATE,
CAPABILITIES_UPDATE,
CAPABILITIES_DELETE,
CAPABILITIES_MANAGE,
BUNDLES_VIEW,
BUNDLES_CREATE,
BUNDLES_UPDATE,
BUNDLES_DELETE,
BUNDLES_MANAGE,
DISPATCH_JOBS_VIEW,
DISPATCH_JOBS_CREATE,
DISPATCH_JOBS_UPDATE,
DISPATCH_JOBS_DELETE,
DISPATCH_JOBS_STATUS,
DISPATCH_JOBS_MANAGE,
DISPATCH_ASSIGNMENTS_VIEW,
DISPATCH_ASSIGNMENTS_CREATE,
DISPATCH_ASSIGNMENTS_UPDATE,
DISPATCH_ASSIGNMENTS_MANAGE,
DISPATCH_VIEW,
DISPATCH_MANAGE,
ROUTES_VIEW,
ROUTES_CREATE,
ROUTES_UPDATE,
ROUTES_DELETE,
ROUTES_STOPS_VIEW,
ROUTES_STOPS_MANAGE,
ROUTES_MANAGE,
OPTIMIZATION_RUNS_VIEW,
OPTIMIZATION_RUNS_CREATE,
OPTIMIZATION_RUNS_MANAGE,
ROUTING_VIEW,
ROUTING_MANAGE,
TRACKING_SESSIONS_VIEW,
TRACKING_SESSIONS_CREATE,
TRACKING_SESSIONS_UPDATE,
TRACKING_SESSIONS_MANAGE,
TRACKING_POINTS_VIEW,
TRACKING_POINTS_MANAGE,
PROOF_OF_DELIVERY_VIEW,
PROOF_OF_DELIVERY_CREATE,
PROOF_OF_DELIVERY_UPDATE,
PROOF_OF_DELIVERY_MANAGE,
CUSTOMER_TRACKING_VIEW,
CUSTOMER_TRACKING_CREATE,
CUSTOMER_TRACKING_REVOKE,
CUSTOMER_TRACKING_MANAGE,
TRACKING_VIEW,
TRACKING_MANAGE,
SETTLEMENTS_VIEW,
SETTLEMENTS_CREATE,
SETTLEMENTS_UPDATE,
SETTLEMENTS_STATUS,
SETTLEMENTS_LINES_VIEW,
SETTLEMENTS_LINES_MANAGE,
SETTLEMENTS_MANAGE,
SETTLEMENT_VIEW,
SETTLEMENT_MANAGE,
FLEET_VIEW,
FLEET_MANAGE,
]
PERMISSION_PREFIXES: tuple[str, ...] = (
"delivery.",
"delivery.organizations.",
"delivery.hubs.",
"delivery.external_providers.",
"delivery.routing_engines.",
"delivery.configurations.",
"delivery.settings.",
"delivery.audit.",
"delivery.drivers.",
"delivery.fleets.",
"delivery.vehicle_types.",
"delivery.vehicles.",
"delivery.availability.",
"delivery.shifts.",
"delivery.working_zones.",
"delivery.pricing_rules.",
"delivery.capabilities.",
"delivery.bundles.",
"delivery.dispatch.",
"delivery.routes.",
"delivery.optimization.",
"delivery.tracking.",
"delivery.proof_of_delivery.",
"delivery.customer_tracking.",
"delivery.settlements.",
"delivery.fleet.",
"delivery.routing.",
"delivery.settlement.",
)

View File

@ -0,0 +1,4 @@
"""Driver domain policies package."""
from app.policies.drivers import DriverLifecyclePolicy
__all__ = ["DriverLifecyclePolicy"]

View File

@ -0,0 +1,28 @@
"""Dispatch domain policies — Phase 10.5."""
from __future__ import annotations
from app.models.types import DispatchJobAction, DispatchJobStatus
from app.validators.dispatch import (
ensure_dispatch_job_transition,
target_status_for,
validate_dispatch_reason,
)
class DispatchJobPolicy:
REQUIRES_REASON = frozenset(
{DispatchJobAction.CANCEL, DispatchJobAction.FAIL}
)
def assert_transition(
self,
*,
action: DispatchJobAction,
current: DispatchJobStatus,
reason: str | None = None,
) -> tuple[DispatchJobStatus, str | None]:
ensure_dispatch_job_transition(action=action, current=current)
cleaned = validate_dispatch_reason(
reason, required=action in self.REQUIRES_REASON
)
return target_status_for(action), cleaned

View File

@ -0,0 +1,35 @@
"""Driver domain policies — Phase 10.1."""
from __future__ import annotations
from app.models.types import DriverLifecycleAction, DriverStatus
from app.validators.drivers import (
ensure_driver_lifecycle_transition,
target_status_for,
validate_reason,
)
class DriverLifecyclePolicy:
"""Enforces legal driver status transitions and reason rules."""
REQUIRES_REASON = frozenset(
{
DriverLifecycleAction.SUSPEND,
DriverLifecycleAction.BLOCK,
DriverLifecycleAction.DEACTIVATE,
DriverLifecycleAction.ARCHIVE,
}
)
def assert_transition(
self,
*,
action: DriverLifecycleAction,
current: DriverStatus,
reason: str | None = None,
) -> tuple[DriverStatus, str | None]:
ensure_driver_lifecycle_transition(action=action, current=current)
cleaned = validate_reason(
reason, required=action in self.REQUIRES_REASON
)
return target_status_for(action), cleaned

View File

@ -0,0 +1,26 @@
"""Settlement domain policies — Phase 10.8."""
from __future__ import annotations
from app.models.types import SettlementIntentStatus
from app.validators.dispatch import validate_dispatch_reason
from app.validators.settlement import (
ensure_settlement_transition,
target_settlement_status,
)
class SettlementIntentPolicy:
REQUIRES_REASON = frozenset({"fail", "cancel"})
def assert_transition(
self,
*,
action: str,
current: SettlementIntentStatus,
reason: str | None = None,
) -> tuple[SettlementIntentStatus, str | None]:
ensure_settlement_transition(action=action, current=current)
cleaned = validate_dispatch_reason(
reason, required=action in self.REQUIRES_REASON
)
return target_settlement_status(action), cleaned

View File

@ -0,0 +1,13 @@
"""Platform provider contracts package."""
from app.providers.contracts import ( # noqa: F401
AIProvider,
AccountingProvider,
CRMProvider,
CommunicationProvider,
FleetProvider,
IdentityProvider,
LoyaltyProvider,
NotificationProvider,
RoutingEngineProvider,
StorageProvider,
)

View File

@ -0,0 +1,96 @@
"""Platform capability contracts — interfaces only; no implementations in Delivery."""
from __future__ import annotations
from typing import Any, Protocol
from uuid import UUID
class AccountingProvider(Protocol):
"""Contract for Accounting. Delivery must not post journals."""
async def create_settlement_ref(
self, *, tenant_id: UUID, reference: str, payload: dict[str, Any]
) -> dict[str, Any]: ...
class CRMProvider(Protocol):
"""Contract for CRM. Delivery stores external contact refs only."""
async def resolve_contact(
self, *, tenant_id: UUID, contact_ref: str
) -> dict[str, Any]: ...
class LoyaltyProvider(Protocol):
"""Contract for Loyalty. Delivery must not own points/ledger."""
async def resolve_member(
self, *, tenant_id: UUID, member_ref: str
) -> dict[str, Any]: ...
class CommunicationProvider(Protocol):
"""Contract for Communication Platform. Delivery must not own SMS providers."""
async def send(
self,
*,
tenant_id: UUID,
channel: str,
template_key: str,
to: str,
payload: dict[str, Any],
) -> None: ...
class NotificationProvider(Protocol):
"""Contract for Notification fanout. Delivery must not own message delivery."""
async def notify(
self,
*,
tenant_id: UUID,
channel: str,
template_key: str,
payload: dict[str, Any],
) -> None: ...
class StorageProvider(Protocol):
"""Contract for File Storage. Delivery stores file refs only (e.g. POD)."""
async def resolve_file(
self, *, tenant_id: UUID, file_ref: str
) -> dict[str, Any]: ...
class AIProvider(Protocol):
"""Contract for AI Platform. Delivery must not own model inference."""
async def suggest(
self, *, tenant_id: UUID, task: str, context: dict[str, Any]
) -> dict[str, Any]: ...
class IdentityProvider(Protocol):
"""Contract for Identity. Delivery stores external_user_ref only."""
async def resolve_user(
self, *, tenant_id: UUID, user_ref: str
) -> dict[str, Any]: ...
class RoutingEngineProvider(Protocol):
"""Contract for pluggable routing engines — adapters live in Delivery."""
async def plan_route(
self, *, tenant_id: UUID, request: dict[str, Any]
) -> dict[str, Any]: ...
class FleetProvider(Protocol):
"""Contract for external fleet/courier providers — adapters live in Delivery."""
async def request_fulfillment(
self, *, tenant_id: UUID, request: dict[str, Any]
) -> dict[str, Any]: ...

View File

@ -0,0 +1,4 @@
"""Driver queries package."""
from app.queries.drivers import DriverQueries
__all__ = ["DriverQueries"]

View File

@ -0,0 +1,38 @@
"""Availability query handlers — Phase 10.3."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.availability import AvailabilityService, ShiftService, WorkingZoneService
from app.specifications.availability import ShiftListSpec, WorkingZoneListSpec
class AvailabilityQueries:
def __init__(self, session: AsyncSession) -> None:
self.availability = AvailabilityService(session)
self.shifts = ShiftService(session)
self.zones = WorkingZoneService(session)
async def get_availability(self, tenant_id: UUID, driver_id: UUID):
return await self.availability.get_for_driver(tenant_id, driver_id)
async def get_shift(self, tenant_id: UUID, shift_id: UUID):
return await self.shifts.get(tenant_id, shift_id)
async def list_shifts(
self, tenant_id: UUID, *, offset: int, limit: int, spec: ShiftListSpec | None
):
return await self.shifts.list(tenant_id, offset=offset, limit=limit, spec=spec)
async def list_shift_assignments(self, tenant_id: UUID, shift_id: UUID):
return await self.shifts.list_assignments(tenant_id, shift_id)
async def get_zone(self, tenant_id: UUID, zone_id: UUID):
return await self.zones.get(tenant_id, zone_id)
async def list_zones(
self, tenant_id: UUID, *, offset: int, limit: int, spec: WorkingZoneListSpec | None
):
return await self.zones.list(tenant_id, offset=offset, limit=limit, spec=spec)

View File

@ -0,0 +1,38 @@
"""Dispatch query handlers — Phase 10.5."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.dispatch import DispatchJobService, JobAssignmentService
from app.specifications.dispatch import DispatchJobListSpec, JobAssignmentListSpec
class DispatchQueries:
def __init__(self, session: AsyncSession) -> None:
self.jobs = DispatchJobService(session)
self.assignments = JobAssignmentService(session)
async def get_job(self, tenant_id: UUID, job_id: UUID):
return await self.jobs.get(tenant_id, job_id)
async def list_jobs(
self, tenant_id: UUID, *, offset: int, limit: int, spec: DispatchJobListSpec | None
):
return await self.jobs.list(tenant_id, offset=offset, limit=limit, spec=spec)
async def get_assignment(self, tenant_id: UUID, assignment_id: UUID):
return await self.assignments.get(tenant_id, assignment_id)
async def list_assignments(
self,
tenant_id: UUID,
*,
offset: int,
limit: int,
spec: JobAssignmentListSpec | None,
):
return await self.assignments.list(
tenant_id, offset=offset, limit=limit, spec=spec
)

View File

@ -0,0 +1,38 @@
"""Driver query handlers — Phase 10.1."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.drivers import DriverService
from app.specifications.drivers import DriverListSpec
class DriverQueries:
def __init__(self, session: AsyncSession) -> None:
self.service = DriverService(session)
async def get(self, tenant_id: UUID, driver_id: UUID):
return await self.service.get(tenant_id, driver_id)
async def list(
self,
tenant_id: UUID,
*,
offset: int,
limit: int,
spec: DriverListSpec | None,
):
return await self.service.list(
tenant_id, offset=offset, limit=limit, spec=spec
)
async def lifecycle(self, tenant_id: UUID, driver_id: UUID, *, limit: int = 100):
return await self.service.list_lifecycle(tenant_id, driver_id, limit=limit)
async def credentials(self, tenant_id: UUID, driver_id: UUID):
return await self.service.list_credentials(tenant_id, driver_id)
async def documents(self, tenant_id: UUID, driver_id: UUID):
return await self.service.list_documents(tenant_id, driver_id)

View File

@ -0,0 +1,43 @@
"""Fleet query handlers — Phase 10.2."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.fleet import FleetService, VehicleService, VehicleTypeService
from app.specifications.fleet import FleetListSpec, VehicleListSpec, VehicleTypeListSpec
class FleetQueries:
def __init__(self, session: AsyncSession) -> None:
self.fleets = FleetService(session)
self.types = VehicleTypeService(session)
self.vehicles = VehicleService(session)
async def get_fleet(self, tenant_id: UUID, fleet_id: UUID):
return await self.fleets.get(tenant_id, fleet_id)
async def list_fleets(
self, tenant_id: UUID, *, offset: int, limit: int, spec: FleetListSpec | None
):
return await self.fleets.list(tenant_id, offset=offset, limit=limit, spec=spec)
async def get_vehicle_type(self, tenant_id: UUID, type_id: UUID):
return await self.types.get(tenant_id, type_id)
async def list_vehicle_types(
self, tenant_id: UUID, *, offset: int, limit: int, spec: VehicleTypeListSpec | None
):
return await self.types.list(tenant_id, offset=offset, limit=limit, spec=spec)
async def get_vehicle(self, tenant_id: UUID, vehicle_id: UUID):
return await self.vehicles.get(tenant_id, vehicle_id)
async def list_vehicles(
self, tenant_id: UUID, *, offset: int, limit: int, spec: VehicleListSpec | None
):
return await self.vehicles.list(tenant_id, offset=offset, limit=limit, spec=spec)
async def list_assignments(self, tenant_id: UUID, vehicle_id: UUID):
return await self.vehicles.list_assignments(tenant_id, vehicle_id)

View File

@ -0,0 +1,44 @@
"""Pricing query handlers — Phase 10.4."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.pricing import BundleService, CapabilityService, PricingRuleService
from app.specifications.pricing import (
BundleListSpec,
CapabilityListSpec,
PricingRuleListSpec,
)
class PricingQueries:
def __init__(self, session: AsyncSession) -> None:
self.rules = PricingRuleService(session)
self.capabilities = CapabilityService(session)
self.bundles = BundleService(session)
async def get_rule(self, tenant_id: UUID, rule_id: UUID):
return await self.rules.get(tenant_id, rule_id)
async def list_rules(
self, tenant_id: UUID, *, offset: int, limit: int, spec: PricingRuleListSpec | None
):
return await self.rules.list(tenant_id, offset=offset, limit=limit, spec=spec)
async def get_capability(self, tenant_id: UUID, cap_id: UUID):
return await self.capabilities.get(tenant_id, cap_id)
async def list_capabilities(
self, tenant_id: UUID, *, offset: int, limit: int, spec: CapabilityListSpec | None
):
return await self.capabilities.list(tenant_id, offset=offset, limit=limit, spec=spec)
async def get_bundle(self, tenant_id: UUID, bundle_id: UUID):
return await self.bundles.get(tenant_id, bundle_id)
async def list_bundles(
self, tenant_id: UUID, *, offset: int, limit: int, spec: BundleListSpec | None
):
return await self.bundles.list(tenant_id, offset=offset, limit=limit, spec=spec)

View File

@ -0,0 +1,41 @@
"""Routing query handlers — Phase 10.6."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.routing import OptimizationRunService, RoutePlanService
from app.specifications.routing import OptimizationRunListSpec, RoutePlanListSpec
class RoutingQueries:
def __init__(self, session: AsyncSession) -> None:
self.routes = RoutePlanService(session)
self.optimization = OptimizationRunService(session)
async def get_route(self, tenant_id: UUID, plan_id: UUID):
return await self.routes.get(tenant_id, plan_id)
async def list_routes(
self, tenant_id: UUID, *, offset: int, limit: int, spec: RoutePlanListSpec | None
):
return await self.routes.list(tenant_id, offset=offset, limit=limit, spec=spec)
async def list_stops(self, tenant_id: UUID, plan_id: UUID):
return await self.routes.list_stops(tenant_id, plan_id)
async def get_optimization_run(self, tenant_id: UUID, run_id: UUID):
return await self.optimization.get(tenant_id, run_id)
async def list_optimization_runs(
self,
tenant_id: UUID,
*,
offset: int,
limit: int,
spec: OptimizationRunListSpec | None,
):
return await self.optimization.list(
tenant_id, offset=offset, limit=limit, spec=spec
)

View File

@ -0,0 +1,32 @@
"""Settlement query handlers — Phase 10.8."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.settlement import SettlementService
from app.specifications.settlement import SettlementIntentListSpec
class SettlementQueries:
def __init__(self, session: AsyncSession) -> None:
self.service = SettlementService(session)
async def get(self, tenant_id: UUID, intent_id: UUID):
return await self.service.get(tenant_id, intent_id)
async def list(
self,
tenant_id: UUID,
*,
offset: int,
limit: int,
spec: SettlementIntentListSpec | None,
):
return await self.service.list(
tenant_id, offset=offset, limit=limit, spec=spec
)
async def lines(self, tenant_id: UUID, intent_id: UUID):
return await self.service.list_lines(tenant_id, intent_id)

View File

@ -0,0 +1,68 @@
"""Tracking query handlers — Phase 10.7."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.tracking import (
CustomerTrackingTokenService,
ProofOfDeliveryService,
TrackingSessionService,
)
from app.specifications.tracking import (
CustomerTrackingTokenListSpec,
ProofOfDeliveryListSpec,
TrackingSessionListSpec,
)
class TrackingQueries:
def __init__(self, session: AsyncSession) -> None:
self.sessions = TrackingSessionService(session)
self.pod = ProofOfDeliveryService(session)
self.tokens = CustomerTrackingTokenService(session)
async def get_session(self, tenant_id: UUID, session_id: UUID):
return await self.sessions.get(tenant_id, session_id)
async def list_sessions(
self,
tenant_id: UUID,
*,
offset: int,
limit: int,
spec: TrackingSessionListSpec | None,
):
return await self.sessions.list(
tenant_id, offset=offset, limit=limit, spec=spec
)
async def list_points(self, tenant_id: UUID, session_id: UUID, *, limit: int = 500):
return await self.sessions.list_points(tenant_id, session_id, limit=limit)
async def get_pod(self, tenant_id: UUID, pod_id: UUID):
return await self.pod.get(tenant_id, pod_id)
async def list_pods(
self,
tenant_id: UUID,
*,
offset: int,
limit: int,
spec: ProofOfDeliveryListSpec | None,
):
return await self.pod.list(tenant_id, offset=offset, limit=limit, spec=spec)
async def get_token(self, tenant_id: UUID, token_id: UUID):
return await self.tokens.get(tenant_id, token_id)
async def list_tokens(
self,
tenant_id: UUID,
*,
offset: int,
limit: int,
spec: CustomerTrackingTokenListSpec | None,
):
return await self.tokens.list(tenant_id, offset=offset, limit=limit, spec=spec)

View File

@ -0,0 +1,145 @@
"""Availability repositories — Phase 10.3."""
from __future__ import annotations
from typing import Sequence
from uuid import UUID
from sqlalchemy import func, select
from app.models.availability import (
DriverAvailability,
Shift,
ShiftAssignment,
WorkingZone,
)
from app.repositories.base import TenantBaseRepository
from app.specifications.availability import ShiftListSpec, WorkingZoneListSpec
class DriverAvailabilityRepository(TenantBaseRepository[DriverAvailability]):
model = DriverAvailability
async def get_for_driver(
self, tenant_id: UUID, driver_id: UUID
) -> DriverAvailability | None:
stmt = (
select(self.model)
.where(
self.model.tenant_id == tenant_id,
self.model.driver_id == driver_id,
self.model.is_deleted.is_(False),
)
.order_by(self.model.updated_at.desc())
.limit(1)
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
class ShiftRepository(TenantBaseRepository[Shift]):
model = Shift
async def get_by_code(self, tenant_id: UUID, code: str) -> Shift | None:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.code == code,
self.model.is_deleted.is_(False),
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def list_filtered(
self,
tenant_id: UUID,
*,
offset: int = 0,
limit: int = 20,
spec: ShiftListSpec | None = None,
) -> Sequence[Shift]:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.is_deleted.is_(False),
)
if spec is not None:
stmt = spec.apply(stmt)
else:
stmt = stmt.order_by(self.model.starts_at.desc())
stmt = stmt.offset(offset).limit(limit)
result = await self.session.execute(stmt)
return result.scalars().all()
async def count_filtered(
self, tenant_id: UUID, *, spec: ShiftListSpec | None = None
) -> int:
stmt = select(func.count()).select_from(self.model).where(
self.model.tenant_id == tenant_id,
self.model.is_deleted.is_(False),
)
if spec is not None:
clauses = spec.filter_clauses()
if clauses:
stmt = stmt.where(*clauses)
result = await self.session.execute(stmt)
return int(result.scalar_one())
class ShiftAssignmentRepository(TenantBaseRepository[ShiftAssignment]):
model = ShiftAssignment
async def list_for_shift(
self, tenant_id: UUID, shift_id: UUID
) -> Sequence[ShiftAssignment]:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.shift_id == shift_id,
self.model.is_deleted.is_(False),
)
result = await self.session.execute(stmt)
return result.scalars().all()
class WorkingZoneRepository(TenantBaseRepository[WorkingZone]):
model = WorkingZone
async def get_by_code(self, tenant_id: UUID, code: str) -> WorkingZone | None:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.code == code,
self.model.is_deleted.is_(False),
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def list_filtered(
self,
tenant_id: UUID,
*,
offset: int = 0,
limit: int = 20,
spec: WorkingZoneListSpec | None = None,
) -> Sequence[WorkingZone]:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.is_deleted.is_(False),
)
if spec is not None:
stmt = spec.apply(stmt)
else:
stmt = stmt.order_by(self.model.created_at.desc())
stmt = stmt.offset(offset).limit(limit)
result = await self.session.execute(stmt)
return result.scalars().all()
async def count_filtered(
self, tenant_id: UUID, *, spec: WorkingZoneListSpec | None = None
) -> int:
stmt = select(func.count()).select_from(self.model).where(
self.model.tenant_id == tenant_id,
self.model.is_deleted.is_(False),
)
if spec is not None:
clauses = spec.filter_clauses()
if clauses:
stmt = stmt.where(*clauses)
result = await self.session.execute(stmt)
return int(result.scalar_one())

View File

@ -0,0 +1,78 @@
"""Tenant-aware base repository with soft-delete helpers."""
from __future__ import annotations
from datetime import datetime, timezone
from typing import Generic, Sequence, TypeVar
from uuid import UUID
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import Base
ModelT = TypeVar("ModelT", bound=Base)
class TenantBaseRepository(Generic[ModelT]):
model: type[ModelT]
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def get(self, tenant_id: UUID, entity_id: UUID) -> ModelT | None:
clauses = [
self.model.tenant_id == tenant_id, # type: ignore[attr-defined]
self.model.id == entity_id, # type: ignore[attr-defined]
]
if hasattr(self.model, "is_deleted"):
clauses.append(self.model.is_deleted.is_(False)) # type: ignore[attr-defined]
stmt = select(self.model).where(*clauses)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def get_including_deleted(
self, tenant_id: UUID, entity_id: UUID
) -> ModelT | None:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id, # type: ignore[attr-defined]
self.model.id == entity_id, # type: ignore[attr-defined]
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def add(self, entity: ModelT) -> ModelT:
self.session.add(entity)
await self.session.flush()
return entity
async def soft_delete(self, entity: ModelT, *, deleted_by: str | None = None) -> None:
entity.is_deleted = True # type: ignore[attr-defined]
entity.deleted_at = datetime.now(timezone.utc) # type: ignore[attr-defined]
if deleted_by is not None and hasattr(entity, "deleted_by"):
entity.deleted_by = deleted_by # type: ignore[attr-defined]
await self.session.flush()
async def restore(self, entity: ModelT) -> None:
entity.is_deleted = False # type: ignore[attr-defined]
entity.deleted_at = None # type: ignore[attr-defined]
if hasattr(entity, "deleted_by"):
entity.deleted_by = None # type: ignore[attr-defined]
await self.session.flush()
async def list_by_tenant(
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20
) -> Sequence[ModelT]:
clauses = [self.model.tenant_id == tenant_id] # type: ignore[attr-defined]
if hasattr(self.model, "is_deleted"):
clauses.append(self.model.is_deleted.is_(False)) # type: ignore[attr-defined]
stmt = select(self.model).where(*clauses).offset(offset).limit(limit)
result = await self.session.execute(stmt)
return result.scalars().all()
async def count_by_tenant(self, tenant_id: UUID) -> int:
clauses = [self.model.tenant_id == tenant_id] # type: ignore[attr-defined]
if hasattr(self.model, "is_deleted"):
clauses.append(self.model.is_deleted.is_(False)) # type: ignore[attr-defined]
stmt = select(func.count()).select_from(self.model).where(*clauses)
result = await self.session.execute(stmt)
return int(result.scalar_one())

View File

@ -0,0 +1,107 @@
"""Dispatch repositories — Phase 10.5."""
from __future__ import annotations
from typing import Sequence
from uuid import UUID
from sqlalchemy import func, select
from app.models.dispatch import DispatchJob, JobAssignment
from app.repositories.base import TenantBaseRepository
from app.specifications.dispatch import DispatchJobListSpec, JobAssignmentListSpec
class DispatchJobRepository(TenantBaseRepository[DispatchJob]):
model = DispatchJob
async def get_by_code(self, tenant_id: UUID, code: str) -> DispatchJob | None:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.code == code,
self.model.is_deleted.is_(False),
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def list_filtered(
self,
tenant_id: UUID,
*,
offset: int = 0,
limit: int = 20,
spec: DispatchJobListSpec | None = None,
) -> Sequence[DispatchJob]:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.is_deleted.is_(False),
)
if spec is not None:
stmt = spec.apply(stmt)
else:
stmt = stmt.order_by(self.model.created_at.desc())
stmt = stmt.offset(offset).limit(limit)
result = await self.session.execute(stmt)
return result.scalars().all()
async def count_filtered(
self, tenant_id: UUID, *, spec: DispatchJobListSpec | None = None
) -> int:
stmt = select(func.count()).select_from(self.model).where(
self.model.tenant_id == tenant_id,
self.model.is_deleted.is_(False),
)
if spec is not None:
clauses = spec.filter_clauses()
if clauses:
stmt = stmt.where(*clauses)
result = await self.session.execute(stmt)
return int(result.scalar_one())
class JobAssignmentRepository(TenantBaseRepository[JobAssignment]):
model = JobAssignment
async def list_filtered(
self,
tenant_id: UUID,
*,
offset: int = 0,
limit: int = 20,
spec: JobAssignmentListSpec | None = None,
) -> Sequence[JobAssignment]:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.is_deleted.is_(False),
)
if spec is not None:
stmt = spec.apply(stmt)
else:
stmt = stmt.order_by(self.model.created_at.desc())
stmt = stmt.offset(offset).limit(limit)
result = await self.session.execute(stmt)
return result.scalars().all()
async def count_filtered(
self, tenant_id: UUID, *, spec: JobAssignmentListSpec | None = None
) -> int:
stmt = select(func.count()).select_from(self.model).where(
self.model.tenant_id == tenant_id,
self.model.is_deleted.is_(False),
)
if spec is not None:
clauses = spec.filter_clauses()
if clauses:
stmt = stmt.where(*clauses)
result = await self.session.execute(stmt)
return int(result.scalar_one())
async def list_for_job(
self, tenant_id: UUID, dispatch_job_id: UUID
) -> Sequence[JobAssignment]:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.dispatch_job_id == dispatch_job_id,
self.model.is_deleted.is_(False),
)
result = await self.session.execute(stmt)
return result.scalars().all()

View File

@ -0,0 +1,112 @@
"""Driver repositories — Phase 10.1."""
from __future__ import annotations
from typing import Sequence
from uuid import UUID
from sqlalchemy import func, select
from app.models.drivers import (
Driver,
DriverCredential,
DriverDocument,
DriverLifecycleEvent,
)
from app.repositories.base import TenantBaseRepository
from app.specifications.drivers import DriverListSpec
class DriverRepository(TenantBaseRepository[Driver]):
model = Driver
async def get_by_code(self, tenant_id: UUID, code: str) -> Driver | None:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.code == code,
self.model.is_deleted.is_(False),
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def list_filtered(
self,
tenant_id: UUID,
*,
offset: int = 0,
limit: int = 20,
spec: DriverListSpec | None = None,
) -> Sequence[Driver]:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.is_deleted.is_(False),
)
if spec is not None:
stmt = spec.apply(stmt)
else:
stmt = stmt.order_by(self.model.created_at.desc())
stmt = stmt.offset(offset).limit(limit)
result = await self.session.execute(stmt)
return result.scalars().all()
async def count_filtered(
self, tenant_id: UUID, *, spec: DriverListSpec | None = None
) -> int:
stmt = select(func.count()).select_from(self.model).where(
self.model.tenant_id == tenant_id,
self.model.is_deleted.is_(False),
)
if spec is not None:
clauses = spec.filter_clauses()
if clauses:
stmt = stmt.where(*clauses)
result = await self.session.execute(stmt)
return int(result.scalar_one())
class DriverCredentialRepository(TenantBaseRepository[DriverCredential]):
model = DriverCredential
async def list_for_driver(
self, tenant_id: UUID, driver_id: UUID
) -> Sequence[DriverCredential]:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.driver_id == driver_id,
self.model.is_deleted.is_(False),
)
result = await self.session.execute(stmt)
return result.scalars().all()
class DriverDocumentRepository(TenantBaseRepository[DriverDocument]):
model = DriverDocument
async def list_for_driver(
self, tenant_id: UUID, driver_id: UUID
) -> Sequence[DriverDocument]:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.driver_id == driver_id,
self.model.is_deleted.is_(False),
)
result = await self.session.execute(stmt)
return result.scalars().all()
class DriverLifecycleEventRepository(TenantBaseRepository[DriverLifecycleEvent]):
model = DriverLifecycleEvent
async def list_for_driver(
self, tenant_id: UUID, driver_id: UUID, *, limit: int = 100
) -> Sequence[DriverLifecycleEvent]:
stmt = (
select(self.model)
.where(
self.model.tenant_id == tenant_id,
self.model.driver_id == driver_id,
)
.order_by(self.model.created_at.desc())
.limit(limit)
)
result = await self.session.execute(stmt)
return result.scalars().all()

View File

@ -0,0 +1,184 @@
"""Fleet repositories — Phase 10.2."""
from __future__ import annotations
from typing import Sequence
from uuid import UUID
from sqlalchemy import func, select
from app.models.fleet import Fleet, Vehicle, VehicleAssignment, VehicleType
from app.models.types import VehicleAssignmentStatus
from app.repositories.base import TenantBaseRepository
from app.specifications.fleet import FleetListSpec, VehicleListSpec, VehicleTypeListSpec
class FleetRepository(TenantBaseRepository[Fleet]):
model = Fleet
async def get_by_code(self, tenant_id: UUID, code: str) -> Fleet | None:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.code == code,
self.model.is_deleted.is_(False),
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def list_filtered(
self,
tenant_id: UUID,
*,
offset: int = 0,
limit: int = 20,
spec: FleetListSpec | None = None,
) -> Sequence[Fleet]:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.is_deleted.is_(False),
)
if spec is not None:
stmt = spec.apply(stmt)
else:
stmt = stmt.order_by(self.model.created_at.desc())
stmt = stmt.offset(offset).limit(limit)
result = await self.session.execute(stmt)
return result.scalars().all()
async def count_filtered(
self, tenant_id: UUID, *, spec: FleetListSpec | None = None
) -> int:
stmt = select(func.count()).select_from(self.model).where(
self.model.tenant_id == tenant_id,
self.model.is_deleted.is_(False),
)
if spec is not None:
clauses = spec.filter_clauses()
if clauses:
stmt = stmt.where(*clauses)
result = await self.session.execute(stmt)
return int(result.scalar_one())
class VehicleTypeRepository(TenantBaseRepository[VehicleType]):
model = VehicleType
async def get_by_code(self, tenant_id: UUID, code: str) -> VehicleType | None:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.code == code,
self.model.is_deleted.is_(False),
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def list_filtered(
self,
tenant_id: UUID,
*,
offset: int = 0,
limit: int = 20,
spec: VehicleTypeListSpec | None = None,
) -> Sequence[VehicleType]:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.is_deleted.is_(False),
)
if spec is not None:
stmt = spec.apply(stmt)
else:
stmt = stmt.order_by(self.model.created_at.desc())
stmt = stmt.offset(offset).limit(limit)
result = await self.session.execute(stmt)
return result.scalars().all()
async def count_filtered(
self, tenant_id: UUID, *, spec: VehicleTypeListSpec | None = None
) -> int:
stmt = select(func.count()).select_from(self.model).where(
self.model.tenant_id == tenant_id,
self.model.is_deleted.is_(False),
)
if spec is not None:
clauses = spec.filter_clauses()
if clauses:
stmt = stmt.where(*clauses)
result = await self.session.execute(stmt)
return int(result.scalar_one())
class VehicleRepository(TenantBaseRepository[Vehicle]):
model = Vehicle
async def get_by_code(self, tenant_id: UUID, code: str) -> Vehicle | None:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.code == code,
self.model.is_deleted.is_(False),
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def list_filtered(
self,
tenant_id: UUID,
*,
offset: int = 0,
limit: int = 20,
spec: VehicleListSpec | None = None,
) -> Sequence[Vehicle]:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.is_deleted.is_(False),
)
if spec is not None:
stmt = spec.apply(stmt)
else:
stmt = stmt.order_by(self.model.created_at.desc())
stmt = stmt.offset(offset).limit(limit)
result = await self.session.execute(stmt)
return result.scalars().all()
async def count_filtered(
self, tenant_id: UUID, *, spec: VehicleListSpec | None = None
) -> int:
stmt = select(func.count()).select_from(self.model).where(
self.model.tenant_id == tenant_id,
self.model.is_deleted.is_(False),
)
if spec is not None:
clauses = spec.filter_clauses()
if clauses:
stmt = stmt.where(*clauses)
result = await self.session.execute(stmt)
return int(result.scalar_one())
class VehicleAssignmentRepository(TenantBaseRepository[VehicleAssignment]):
model = VehicleAssignment
async def list_for_vehicle(
self, tenant_id: UUID, vehicle_id: UUID
) -> Sequence[VehicleAssignment]:
stmt = (
select(self.model)
.where(
self.model.tenant_id == tenant_id,
self.model.vehicle_id == vehicle_id,
self.model.is_deleted.is_(False),
)
.order_by(self.model.created_at.desc())
)
result = await self.session.execute(stmt)
return result.scalars().all()
async def get_active_for_vehicle(
self, tenant_id: UUID, vehicle_id: UUID
) -> VehicleAssignment | None:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.vehicle_id == vehicle_id,
self.model.status == VehicleAssignmentStatus.ACTIVE,
self.model.is_deleted.is_(False),
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()

View File

@ -0,0 +1,142 @@
"""Delivery foundation repositories."""
from __future__ import annotations
from typing import Sequence
from uuid import UUID
from sqlalchemy import select
from app.models.foundation import (
DeliveryAuditLog,
DeliveryConfiguration,
DeliveryHub,
DeliveryOrganization,
DeliveryPermission,
DeliveryRole,
DeliverySetting,
ExternalProviderConfig,
RoutingEngineRegistration,
)
from app.repositories.base import TenantBaseRepository
class DeliveryOrganizationRepository(TenantBaseRepository[DeliveryOrganization]):
model = DeliveryOrganization
async def get_by_code(
self, tenant_id: UUID, code: str
) -> DeliveryOrganization | None:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.code == code,
self.model.is_deleted.is_(False),
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
class DeliveryHubRepository(TenantBaseRepository[DeliveryHub]):
model = DeliveryHub
async def list_by_organization(
self, tenant_id: UUID, organization_id: UUID, *, offset: int = 0, limit: int = 20
) -> Sequence[DeliveryHub]:
stmt = (
select(self.model)
.where(
self.model.tenant_id == tenant_id,
self.model.organization_id == organization_id,
self.model.is_deleted.is_(False),
)
.offset(offset)
.limit(limit)
)
result = await self.session.execute(stmt)
return result.scalars().all()
class DeliveryRoleRepository(TenantBaseRepository[DeliveryRole]):
model = DeliveryRole
class DeliveryPermissionRepository(TenantBaseRepository[DeliveryPermission]):
model = DeliveryPermission
class ExternalProviderConfigRepository(TenantBaseRepository[ExternalProviderConfig]):
model = ExternalProviderConfig
async def get_by_code(
self, tenant_id: UUID, code: str
) -> ExternalProviderConfig | None:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.code == code,
self.model.is_deleted.is_(False),
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
class RoutingEngineRegistrationRepository(
TenantBaseRepository[RoutingEngineRegistration]
):
model = RoutingEngineRegistration
class DeliveryConfigurationRepository(TenantBaseRepository[DeliveryConfiguration]):
model = DeliveryConfiguration
class DeliverySettingRepository(TenantBaseRepository[DeliverySetting]):
model = DeliverySetting
async def get_by_key(
self,
tenant_id: UUID,
key: str,
*,
organization_id: UUID | None = None,
hub_id: UUID | None = None,
) -> DeliverySetting | None:
clauses = [
self.model.tenant_id == tenant_id,
self.model.key == key,
self.model.is_deleted.is_(False),
]
if organization_id is None:
clauses.append(self.model.organization_id.is_(None))
else:
clauses.append(self.model.organization_id == organization_id)
if hub_id is None:
clauses.append(self.model.hub_id.is_(None))
else:
clauses.append(self.model.hub_id == hub_id)
stmt = select(self.model).where(*clauses)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
class DeliveryAuditLogRepository(TenantBaseRepository[DeliveryAuditLog]):
model = DeliveryAuditLog
async def list_for_entity(
self,
tenant_id: UUID,
entity_type: str,
entity_id: UUID,
*,
limit: int = 100,
) -> Sequence[DeliveryAuditLog]:
stmt = (
select(self.model)
.where(
self.model.tenant_id == tenant_id,
self.model.entity_type == entity_type,
self.model.entity_id == entity_id,
)
.order_by(self.model.created_at.desc())
.limit(limit)
)
result = await self.session.execute(stmt)
return result.scalars().all()

Some files were not shown because too many files have changed in this diff Show More