Compare commits
15 Commits
master
...
cursor/com
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
091b33a638 | ||
|
|
ef048219cd | ||
|
|
0eec9f729b | ||
|
|
72077908f1 | ||
|
|
4451b32a33 | ||
|
|
9fac160258 | ||
|
|
047cd17afd | ||
|
|
fb05937790 | ||
|
|
57ea594334 | ||
|
|
5c6a2e78cf | ||
|
|
7978970783 | ||
|
|
203671a7bf | ||
|
|
625275e55b | ||
|
|
f89ca52e02 | ||
|
|
10c3c43a75 |
@ -164,6 +164,15 @@ NEXT_PUBLIC_KEYCLOAK_URL=https://auth.torbatyar.ir
|
||||
NEXT_PUBLIC_KEYCLOAK_REALM=superapp
|
||||
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=superapp-frontend
|
||||
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
|
||||
|
||||
|
||||
@ -15,7 +15,7 @@
|
||||
| [`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/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/glossary.md`](./docs/glossary.md) | Glossary |
|
||||
| [`docs/progress.md`](./docs/progress.md) | Completed work |
|
||||
@ -62,6 +62,7 @@ docker compose up -d --build
|
||||
- **Loyalty API:** http://localhost:8004/docs
|
||||
- **Communication API:** http://localhost:8005/docs
|
||||
- **Sports Center API:** http://localhost:8006/docs
|
||||
- **Hospitality API:** http://localhost:8009/docs
|
||||
- **Keycloak:** http://localhost:8080
|
||||
|
||||
Details: [`docs/development/developer-guide.md`](./docs/development/developer-guide.md).
|
||||
|
||||
@ -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})")
|
||||
175
backend/core-service/scripts/seed_platform_catalog.py
Normal file
175
backend/core-service/scripts/seed_platform_catalog.py
Normal 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())
|
||||
@ -1,11 +1,12 @@
|
||||
# Communication Service
|
||||
|
||||
Independent **Enterprise Communication Platform** for TorbatYar SuperApp.
|
||||
Independent **Enterprise Communication Platform** (**Torbat Communication**) for TorbatYar SuperApp.
|
||||
|
||||
- Database: `communication_db` (sole owner)
|
||||
- Port: `8005`
|
||||
- Version: `0.8.10.1`
|
||||
- Permission prefix: `communication.*`
|
||||
- Status: **Production Ready (SMS MVP)**
|
||||
|
||||
## Boundaries
|
||||
|
||||
@ -15,10 +16,14 @@ Consumers interact only via HTTP APIs and events. No module may call external SM
|
||||
|
||||
## Channels
|
||||
|
||||
Designed for: SMS, Email, Push, WhatsApp, Telegram, Rubika, Voice, Future.
|
||||
|
||||
**Initially active:** SMS (mock + Payamak adapters).
|
||||
| Channel | Status |
|
||||
| --- | --- |
|
||||
| SMS (mock + Payamak) | **Production Ready** |
|
||||
| Email, Push, WhatsApp, Telegram, Rubika, Voice | Architecture-ready stubs — see [communication-roadmap.md](../../../docs/communication-roadmap.md) |
|
||||
|
||||
## 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)
|
||||
|
||||
22
backend/services/delivery/Dockerfile.dev
Normal file
22
backend/services/delivery/Dockerfile.dev
Normal 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
|
||||
63
backend/services/delivery/README.md
Normal file
63
backend/services/delivery/README.md
Normal 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
|
||||
```
|
||||
41
backend/services/delivery/alembic.ini
Normal file
41
backend/services/delivery/alembic.ini
Normal 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
|
||||
42
backend/services/delivery/alembic/env.py
Normal file
42
backend/services/delivery/alembic/env.py
Normal 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()
|
||||
19
backend/services/delivery/alembic/versions/0001_initial.py
Normal file
19
backend/services/delivery/alembic/versions/0001_initial.py
Normal 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)
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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)
|
||||
1
backend/services/delivery/app/__init__.py
Normal file
1
backend/services/delivery/app/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
__version__ = "0.10.8.0"
|
||||
39
backend/services/delivery/app/api/deps.py
Normal file
39
backend/services/delivery/app/api/deps.py
Normal 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
|
||||
48
backend/services/delivery/app/api/permissions.py
Normal file
48
backend/services/delivery/app/api/permissions.py
Normal 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
|
||||
98
backend/services/delivery/app/api/v1/__init__.py
Normal file
98
backend/services/delivery/app/api/v1/__init__.py
Normal 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"]
|
||||
)
|
||||
30
backend/services/delivery/app/api/v1/audit.py
Normal file
30
backend/services/delivery/app/api/v1/audit.py
Normal 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
|
||||
)
|
||||
40
backend/services/delivery/app/api/v1/availability.py
Normal file
40
backend/services/delivery/app/api/v1/availability.py
Normal 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
|
||||
)
|
||||
100
backend/services/delivery/app/api/v1/bundles.py
Normal file
100
backend/services/delivery/app/api/v1/bundles.py
Normal 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)
|
||||
100
backend/services/delivery/app/api/v1/capabilities.py
Normal file
100
backend/services/delivery/app/api/v1/capabilities.py
Normal 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)
|
||||
83
backend/services/delivery/app/api/v1/configurations.py
Normal file
83
backend/services/delivery/app/api/v1/configurations.py
Normal 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
|
||||
)
|
||||
88
backend/services/delivery/app/api/v1/customer_tracking.py
Normal file
88
backend/services/delivery/app/api/v1/customer_tracking.py
Normal 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)
|
||||
94
backend/services/delivery/app/api/v1/dispatch_assignments.py
Normal file
94
backend/services/delivery/app/api/v1/dispatch_assignments.py
Normal 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
|
||||
)
|
||||
120
backend/services/delivery/app/api/v1/dispatch_jobs.py
Normal file
120
backend/services/delivery/app/api/v1/dispatch_jobs.py
Normal 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)
|
||||
273
backend/services/delivery/app/api/v1/drivers.py
Normal file
273
backend/services/delivery/app/api/v1/drivers.py
Normal 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)
|
||||
83
backend/services/delivery/app/api/v1/external_providers.py
Normal file
83
backend/services/delivery/app/api/v1/external_providers.py
Normal 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
|
||||
)
|
||||
102
backend/services/delivery/app/api/v1/fleets.py
Normal file
102
backend/services/delivery/app/api/v1/fleets.py
Normal 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)
|
||||
100
backend/services/delivery/app/api/v1/health.py
Normal file
100
backend/services/delivery/app/api/v1/health.py
Normal 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",
|
||||
}
|
||||
70
backend/services/delivery/app/api/v1/hubs.py
Normal file
70
backend/services/delivery/app/api/v1/hubs.py
Normal 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)
|
||||
74
backend/services/delivery/app/api/v1/optimization.py
Normal file
74
backend/services/delivery/app/api/v1/optimization.py
Normal 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)
|
||||
83
backend/services/delivery/app/api/v1/organizations.py
Normal file
83
backend/services/delivery/app/api/v1/organizations.py
Normal 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
|
||||
)
|
||||
27
backend/services/delivery/app/api/v1/permissions.py
Normal file
27
backend/services/delivery/app/api/v1/permissions.py
Normal 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),
|
||||
}
|
||||
103
backend/services/delivery/app/api/v1/pricing_rules.py
Normal file
103
backend/services/delivery/app/api/v1/pricing_rules.py
Normal 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)
|
||||
90
backend/services/delivery/app/api/v1/proof_of_delivery.py
Normal file
90
backend/services/delivery/app/api/v1/proof_of_delivery.py
Normal 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)
|
||||
134
backend/services/delivery/app/api/v1/routes.py
Normal file
134
backend/services/delivery/app/api/v1/routes.py
Normal 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)
|
||||
83
backend/services/delivery/app/api/v1/routing_engines.py
Normal file
83
backend/services/delivery/app/api/v1/routing_engines.py
Normal 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
|
||||
)
|
||||
39
backend/services/delivery/app/api/v1/settings.py
Normal file
39
backend/services/delivery/app/api/v1/settings.py
Normal 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
|
||||
)
|
||||
169
backend/services/delivery/app/api/v1/settlements.py
Normal file
169
backend/services/delivery/app/api/v1/settlements.py
Normal 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)
|
||||
156
backend/services/delivery/app/api/v1/shifts.py
Normal file
156
backend/services/delivery/app/api/v1/shifts.py
Normal 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
|
||||
)
|
||||
126
backend/services/delivery/app/api/v1/tracking.py
Normal file
126
backend/services/delivery/app/api/v1/tracking.py
Normal 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)
|
||||
96
backend/services/delivery/app/api/v1/vehicle_types.py
Normal file
96
backend/services/delivery/app/api/v1/vehicle_types.py
Normal 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)
|
||||
154
backend/services/delivery/app/api/v1/vehicles.py
Normal file
154
backend/services/delivery/app/api/v1/vehicles.py
Normal 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
|
||||
)
|
||||
105
backend/services/delivery/app/api/v1/working_zones.py
Normal file
105
backend/services/delivery/app/api/v1/working_zones.py
Normal 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)
|
||||
4
backend/services/delivery/app/commands/__init__.py
Normal file
4
backend/services/delivery/app/commands/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
"""Driver commands package."""
|
||||
from app.commands.drivers import DriverCommands
|
||||
|
||||
__all__ = ["DriverCommands"]
|
||||
90
backend/services/delivery/app/commands/availability.py
Normal file
90
backend/services/delivery/app/commands/availability.py
Normal 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)
|
||||
62
backend/services/delivery/app/commands/dispatch.py
Normal file
62
backend/services/delivery/app/commands/dispatch.py
Normal 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)
|
||||
148
backend/services/delivery/app/commands/drivers.py
Normal file
148
backend/services/delivery/app/commands/drivers.py
Normal 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)
|
||||
92
backend/services/delivery/app/commands/fleet.py
Normal file
92
backend/services/delivery/app/commands/fleet.py
Normal 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
|
||||
)
|
||||
69
backend/services/delivery/app/commands/pricing.py
Normal file
69
backend/services/delivery/app/commands/pricing.py
Normal 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)
|
||||
51
backend/services/delivery/app/commands/routing.py
Normal file
51
backend/services/delivery/app/commands/routing.py
Normal 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)
|
||||
85
backend/services/delivery/app/commands/settlement.py
Normal file
85
backend/services/delivery/app/commands/settlement.py
Normal 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)
|
||||
84
backend/services/delivery/app/commands/tracking.py
Normal file
84
backend/services/delivery/app/commands/tracking.py
Normal 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)
|
||||
75
backend/services/delivery/app/core/config.py
Normal file
75
backend/services/delivery/app/core/config.py
Normal 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()
|
||||
27
backend/services/delivery/app/core/database.py
Normal file
27
backend/services/delivery/app/core/database.py
Normal 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
|
||||
14
backend/services/delivery/app/core/logging.py
Normal file
14
backend/services/delivery/app/core/logging.py
Normal 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)
|
||||
49
backend/services/delivery/app/core/security.py
Normal file
49
backend/services/delivery/app/core/security.py
Normal 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)
|
||||
121
backend/services/delivery/app/events/publisher.py
Normal file
121
backend/services/delivery/app/events/publisher.py
Normal 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
|
||||
105
backend/services/delivery/app/events/types.py
Normal file
105
backend/services/delivery/app/events/types.py
Normal 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"
|
||||
69
backend/services/delivery/app/main.py
Normal file
69
backend/services/delivery/app/main.py
Normal 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()
|
||||
24
backend/services/delivery/app/middlewares/tenant.py
Normal file
24
backend/services/delivery/app/middlewares/tenant.py
Normal 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)
|
||||
40
backend/services/delivery/app/models/__init__.py
Normal file
40
backend/services/delivery/app/models/__init__.py
Normal 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,
|
||||
)
|
||||
142
backend/services/delivery/app/models/availability.py
Normal file
142
backend/services/delivery/app/models/availability.py
Normal 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"),
|
||||
)
|
||||
51
backend/services/delivery/app/models/base.py
Normal file
51
backend/services/delivery/app/models/base.py
Normal 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)
|
||||
89
backend/services/delivery/app/models/dispatch.py
Normal file
89
backend/services/delivery/app/models/dispatch.py
Normal 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"),
|
||||
)
|
||||
167
backend/services/delivery/app/models/drivers.py
Normal file
167
backend/services/delivery/app/models/drivers.py
Normal 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"),
|
||||
)
|
||||
126
backend/services/delivery/app/models/fleet.py
Normal file
126
backend/services/delivery/app/models/fleet.py
Normal 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"),
|
||||
)
|
||||
303
backend/services/delivery/app/models/foundation.py
Normal file
303
backend/services/delivery/app/models/foundation.py
Normal 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",
|
||||
),
|
||||
)
|
||||
44
backend/services/delivery/app/models/outbox.py
Normal file
44
backend/services/delivery/app/models/outbox.py
Normal 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"),
|
||||
)
|
||||
108
backend/services/delivery/app/models/pricing.py
Normal file
108
backend/services/delivery/app/models/pricing.py
Normal 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"),
|
||||
)
|
||||
110
backend/services/delivery/app/models/routing.py
Normal file
110
backend/services/delivery/app/models/routing.py
Normal 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"),
|
||||
)
|
||||
78
backend/services/delivery/app/models/settlement.py
Normal file
78
backend/services/delivery/app/models/settlement.py
Normal 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"),
|
||||
)
|
||||
138
backend/services/delivery/app/models/tracking.py
Normal file
138
backend/services/delivery/app/models/tracking.py
Normal 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"),
|
||||
)
|
||||
277
backend/services/delivery/app/models/types.py
Normal file
277
backend/services/delivery/app/models/types.py
Normal 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"
|
||||
365
backend/services/delivery/app/permissions/definitions.py
Normal file
365
backend/services/delivery/app/permissions/definitions.py
Normal file
@ -0,0 +1,365 @@
|
||||
"""Delivery permission definitions — Phase 10.0–10.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.",
|
||||
)
|
||||
4
backend/services/delivery/app/policies/__init__.py
Normal file
4
backend/services/delivery/app/policies/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
"""Driver domain policies package."""
|
||||
from app.policies.drivers import DriverLifecyclePolicy
|
||||
|
||||
__all__ = ["DriverLifecyclePolicy"]
|
||||
28
backend/services/delivery/app/policies/dispatch.py
Normal file
28
backend/services/delivery/app/policies/dispatch.py
Normal 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
|
||||
35
backend/services/delivery/app/policies/drivers.py
Normal file
35
backend/services/delivery/app/policies/drivers.py
Normal 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
|
||||
26
backend/services/delivery/app/policies/settlement.py
Normal file
26
backend/services/delivery/app/policies/settlement.py
Normal 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
|
||||
13
backend/services/delivery/app/providers/__init__.py
Normal file
13
backend/services/delivery/app/providers/__init__.py
Normal 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,
|
||||
)
|
||||
96
backend/services/delivery/app/providers/contracts.py
Normal file
96
backend/services/delivery/app/providers/contracts.py
Normal 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]: ...
|
||||
4
backend/services/delivery/app/queries/__init__.py
Normal file
4
backend/services/delivery/app/queries/__init__.py
Normal file
@ -0,0 +1,4 @@
|
||||
"""Driver queries package."""
|
||||
from app.queries.drivers import DriverQueries
|
||||
|
||||
__all__ = ["DriverQueries"]
|
||||
38
backend/services/delivery/app/queries/availability.py
Normal file
38
backend/services/delivery/app/queries/availability.py
Normal 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)
|
||||
38
backend/services/delivery/app/queries/dispatch.py
Normal file
38
backend/services/delivery/app/queries/dispatch.py
Normal 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
|
||||
)
|
||||
38
backend/services/delivery/app/queries/drivers.py
Normal file
38
backend/services/delivery/app/queries/drivers.py
Normal 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)
|
||||
43
backend/services/delivery/app/queries/fleet.py
Normal file
43
backend/services/delivery/app/queries/fleet.py
Normal 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)
|
||||
44
backend/services/delivery/app/queries/pricing.py
Normal file
44
backend/services/delivery/app/queries/pricing.py
Normal 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)
|
||||
41
backend/services/delivery/app/queries/routing.py
Normal file
41
backend/services/delivery/app/queries/routing.py
Normal 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
|
||||
)
|
||||
32
backend/services/delivery/app/queries/settlement.py
Normal file
32
backend/services/delivery/app/queries/settlement.py
Normal 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)
|
||||
68
backend/services/delivery/app/queries/tracking.py
Normal file
68
backend/services/delivery/app/queries/tracking.py
Normal 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)
|
||||
145
backend/services/delivery/app/repositories/availability.py
Normal file
145
backend/services/delivery/app/repositories/availability.py
Normal 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())
|
||||
78
backend/services/delivery/app/repositories/base.py
Normal file
78
backend/services/delivery/app/repositories/base.py
Normal 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())
|
||||
107
backend/services/delivery/app/repositories/dispatch.py
Normal file
107
backend/services/delivery/app/repositories/dispatch.py
Normal 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()
|
||||
112
backend/services/delivery/app/repositories/drivers.py
Normal file
112
backend/services/delivery/app/repositories/drivers.py
Normal 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()
|
||||
184
backend/services/delivery/app/repositories/fleet.py
Normal file
184
backend/services/delivery/app/repositories/fleet.py
Normal 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()
|
||||
142
backend/services/delivery/app/repositories/foundation.py
Normal file
142
backend/services/delivery/app/repositories/foundation.py
Normal 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
Loading…
Reference in New Issue
Block a user