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_REALM=superapp
|
||||||
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=superapp-frontend
|
NEXT_PUBLIC_KEYCLOAK_CLIENT_ID=superapp-frontend
|
||||||
NEXT_PUBLIC_ACCOUNTING_API_URL=http://localhost:8002
|
NEXT_PUBLIC_ACCOUNTING_API_URL=http://localhost:8002
|
||||||
|
NEXT_PUBLIC_CRM_API_URL=http://localhost:8003
|
||||||
|
NEXT_PUBLIC_LOYALTY_API_URL=http://localhost:8004
|
||||||
|
NEXT_PUBLIC_COMMUNICATION_API_URL=http://localhost:8005
|
||||||
|
NEXT_PUBLIC_SPORTS_CENTER_API_URL=http://localhost:8006
|
||||||
|
NEXT_PUBLIC_DELIVERY_API_URL=http://localhost:8007
|
||||||
|
NEXT_PUBLIC_EXPERIENCE_API_URL=http://localhost:8008
|
||||||
|
NEXT_PUBLIC_HOSPITALITY_API_URL=http://localhost:8009
|
||||||
|
NEXT_PUBLIC_HEALTHCARE_API_URL=http://localhost:8010
|
||||||
|
NEXT_PUBLIC_BEAUTY_BUSINESS_API_URL=http://localhost:8011
|
||||||
|
|
||||||
INTERNAL_TOKEN_SECRET=change-me-internal-secret
|
INTERNAL_TOKEN_SECRET=change-me-internal-secret
|
||||||
|
|
||||||
|
|||||||
@ -15,7 +15,7 @@
|
|||||||
| [`docs/reference/services-contracts.md`](./docs/reference/services-contracts.md) | Service contracts |
|
| [`docs/reference/services-contracts.md`](./docs/reference/services-contracts.md) | Service contracts |
|
||||||
| [`docs/development/developer-guide.md`](./docs/development/developer-guide.md) | Developer guide |
|
| [`docs/development/developer-guide.md`](./docs/development/developer-guide.md) | Developer guide |
|
||||||
| [`docs/module-registry.md`](./docs/module-registry.md) | Module registry |
|
| [`docs/module-registry.md`](./docs/module-registry.md) | Module registry |
|
||||||
| [`docs/ai-framework/`](./docs/ai-framework/) | AI Development Framework (implementation lifecycle) |
|
| [`docs/ai-framework/`](./docs/ai-framework/) | Enterprise / AI Development Framework (implementation lifecycle; production-ready by default) |
|
||||||
| [`docs/provider-registry.md`](./docs/provider-registry.md) | Provider registry |
|
| [`docs/provider-registry.md`](./docs/provider-registry.md) | Provider registry |
|
||||||
| [`docs/glossary.md`](./docs/glossary.md) | Glossary |
|
| [`docs/glossary.md`](./docs/glossary.md) | Glossary |
|
||||||
| [`docs/progress.md`](./docs/progress.md) | Completed work |
|
| [`docs/progress.md`](./docs/progress.md) | Completed work |
|
||||||
@ -62,6 +62,7 @@ docker compose up -d --build
|
|||||||
- **Loyalty API:** http://localhost:8004/docs
|
- **Loyalty API:** http://localhost:8004/docs
|
||||||
- **Communication API:** http://localhost:8005/docs
|
- **Communication API:** http://localhost:8005/docs
|
||||||
- **Sports Center API:** http://localhost:8006/docs
|
- **Sports Center API:** http://localhost:8006/docs
|
||||||
|
- **Hospitality API:** http://localhost:8009/docs
|
||||||
- **Keycloak:** http://localhost:8080
|
- **Keycloak:** http://localhost:8080
|
||||||
|
|
||||||
Details: [`docs/development/developer-guide.md`](./docs/development/developer-guide.md).
|
Details: [`docs/development/developer-guide.md`](./docs/development/developer-guide.md).
|
||||||
|
|||||||
@ -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
|
# Communication Service
|
||||||
|
|
||||||
Independent **Enterprise Communication Platform** for TorbatYar SuperApp.
|
Independent **Enterprise Communication Platform** (**Torbat Communication**) for TorbatYar SuperApp.
|
||||||
|
|
||||||
- Database: `communication_db` (sole owner)
|
- Database: `communication_db` (sole owner)
|
||||||
- Port: `8005`
|
- Port: `8005`
|
||||||
- Version: `0.8.10.1`
|
- Version: `0.8.10.1`
|
||||||
- Permission prefix: `communication.*`
|
- Permission prefix: `communication.*`
|
||||||
|
- Status: **Production Ready (SMS MVP)**
|
||||||
|
|
||||||
## Boundaries
|
## Boundaries
|
||||||
|
|
||||||
@ -15,10 +16,14 @@ Consumers interact only via HTTP APIs and events. No module may call external SM
|
|||||||
|
|
||||||
## Channels
|
## Channels
|
||||||
|
|
||||||
Designed for: SMS, Email, Push, WhatsApp, Telegram, Rubika, Voice, Future.
|
| Channel | Status |
|
||||||
|
| --- | --- |
|
||||||
**Initially active:** SMS (mock + Payamak adapters).
|
| SMS (mock + Payamak) | **Production Ready** |
|
||||||
|
| Email, Push, WhatsApp, Telegram, Rubika, Voice | Architecture-ready stubs — see [communication-roadmap.md](../../../docs/communication-roadmap.md) |
|
||||||
|
|
||||||
## Docs
|
## Docs
|
||||||
|
|
||||||
See `docs/communication-phase-8.md`.
|
- [communication-phase-8.md](../../../docs/communication-phase-8.md)
|
||||||
|
- [communication-roadmap.md](../../../docs/communication-roadmap.md)
|
||||||
|
- [phase-handover/phase-8.md](../../../docs/phase-handover/phase-8.md)
|
||||||
|
- [service-snapshots/communication.yaml](../../../docs/service-snapshots/communication.yaml)
|
||||||
|
|||||||
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