feat(platform): seed service registry, deploy all modules, and fix homepage catalog.

Register all active platform services and base features in core DB so admin can manage them, add delivery/hospitality/sports-center backend phases, update apps catalog and production deploy tooling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Mortezakoohjani 2026-07-27 12:39:51 +03:30
parent 7978970783
commit 5c6a2e78cf
365 changed files with 40535 additions and 1430 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,36 @@
from fastapi import APIRouter
from app.api.v1 import (
audit,
configurations,
drivers,
external_providers,
hubs,
organizations,
permissions,
routing_engines,
settings,
)
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(
permissions.router, prefix="/permissions", tags=["permissions"]
)

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,86 @@
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.1",
"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": False,
"dispatch_engine": False,
"routing_engine": False,
"tracking": False,
"proof_of_delivery": False,
"settlement": False,
"ai": False,
},
"independence": {
"standalone": True,
"superapp": True,
"integrations": [
"accounting",
"communication",
"loyalty",
"crm",
"hospitality",
"marketplace",
"sports_center",
"clinic",
"pharmacy",
],
"integration_mode": "api_and_events_only",
},
}
@router.get("/metrics")
async def metrics():
"""Phase 10.1 metrics discovery — driver counters reserved for ops wiring."""
return {
"service": settings.service_name,
"version": __version__,
"phase": "10.1",
"metrics": {
"organizations": "tenant_scoped",
"hubs": "tenant_scoped",
"external_providers": "tenant_scoped",
"routing_engines": "tenant_scoped",
"configurations": "tenant_scoped",
"drivers": "tenant_scoped",
"driver_credentials": "tenant_scoped",
"driver_documents": "tenant_scoped",
"driver_lifecycle_events": "tenant_scoped",
"outbox_events": "tenant_scoped",
"dispatch_jobs": 0,
"active_routes": 0,
"tracking_sessions": 0,
},
"note": "Driver aggregates live; dispatch/routing/tracking counters reserved for later phases",
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,33 @@
"""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"

View File

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

View File

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

View File

@ -0,0 +1,19 @@
"""Import all models for Alembic metadata discovery."""
from app.models.drivers import ( # noqa: F401
Driver,
DriverCredential,
DriverDocument,
DriverLifecycleEvent,
)
from app.models.foundation import ( # noqa: F401
DeliveryAuditLog,
DeliveryConfiguration,
DeliveryHub,
DeliveryOrganization,
DeliveryPermission,
DeliveryRole,
DeliverySetting,
ExternalProviderConfig,
RoutingEngineRegistration,
)
from app.models.outbox import OutboxEvent # noqa: F401

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,118 @@
"""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"

View File

@ -0,0 +1,148 @@
"""Delivery permission definitions — Phase 10.0 foundation + 10.1 drivers."""
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"
# Planned trees (registered for discovery; enforced in later phases)
FLEET_VIEW = "delivery.fleet.view"
FLEET_MANAGE = "delivery.fleet.manage"
DISPATCH_VIEW = "delivery.dispatch.view"
DISPATCH_MANAGE = "delivery.dispatch.manage"
ROUTING_VIEW = "delivery.routing.view"
ROUTING_MANAGE = "delivery.routing.manage"
TRACKING_VIEW = "delivery.tracking.view"
TRACKING_MANAGE = "delivery.tracking.manage"
SETTLEMENT_VIEW = "delivery.settlement.view"
SETTLEMENT_MANAGE = "delivery.settlement.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,
FLEET_VIEW,
FLEET_MANAGE,
DISPATCH_VIEW,
DISPATCH_MANAGE,
ROUTING_VIEW,
ROUTING_MANAGE,
TRACKING_VIEW,
TRACKING_MANAGE,
SETTLEMENT_VIEW,
SETTLEMENT_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.fleet.",
"delivery.dispatch.",
"delivery.routing.",
"delivery.tracking.",
"delivery.settlement.",
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,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()

View File

@ -0,0 +1,18 @@
"""Common schemas."""
from __future__ import annotations
from uuid import UUID
from pydantic import BaseModel, ConfigDict
class ORMBase(BaseModel):
model_config = ConfigDict(from_attributes=True)
class IdResponse(BaseModel):
id: UUID
class MessageResponse(BaseModel):
message: str

View File

@ -0,0 +1,164 @@
"""Driver DTOs — Phase 10.1."""
from __future__ import annotations
from datetime import date, datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
from app.models.types import (
DriverCredentialKind,
DriverDocumentKind,
DriverLifecycleAction,
DriverStatus,
)
from app.schemas.common import ORMBase
_FORBID = ConfigDict(extra="forbid")
class DriverCreate(BaseModel):
model_config = _FORBID
organization_id: UUID
hub_id: UUID | None = None
code: str = Field(max_length=50)
display_name: str = Field(max_length=255)
first_name: str | None = Field(default=None, max_length=100)
last_name: str | None = Field(default=None, max_length=100)
mobile: str | None = Field(default=None, max_length=32)
email: str | None = Field(default=None, max_length=255)
status: DriverStatus = DriverStatus.PENDING
external_user_ref: str | None = Field(default=None, max_length=100)
external_crm_contact_ref: str | None = Field(default=None, max_length=100)
hire_date: date | None = None
notes: str | None = None
capability_tags: list[str] | None = None
metadata_json: dict | None = None
class DriverUpdate(BaseModel):
model_config = _FORBID
hub_id: UUID | None = None
display_name: str | None = Field(default=None, max_length=255)
first_name: str | None = Field(default=None, max_length=100)
last_name: str | None = Field(default=None, max_length=100)
mobile: str | None = Field(default=None, max_length=32)
email: str | None = Field(default=None, max_length=255)
external_user_ref: str | None = Field(default=None, max_length=100)
external_crm_contact_ref: str | None = Field(default=None, max_length=100)
hire_date: date | None = None
notes: str | None = None
capability_tags: list[str] | None = None
metadata_json: dict | None = None
version: int = Field(ge=1)
class DriverRead(ORMBase):
id: UUID
tenant_id: UUID
organization_id: UUID
hub_id: UUID | None
code: str
display_name: str
first_name: str | None
last_name: str | None
mobile: str | None
email: str | None
status: DriverStatus
external_user_ref: str | None
external_crm_contact_ref: str | None
hire_date: date | None
notes: str | None
capability_tags: list | None
metadata_json: dict | None
status_reason: str | None
status_changed_at: datetime | None
version: int
is_deleted: bool
created_by: str | None
updated_by: str | None
created_at: datetime
updated_at: datetime
class DriverLifecycleRequest(BaseModel):
model_config = _FORBID
reason: str | None = Field(default=None, max_length=500)
version: int = Field(ge=1)
class DriverLifecycleEventRead(ORMBase):
id: UUID
tenant_id: UUID
driver_id: UUID
action: DriverLifecycleAction
from_status: DriverStatus
to_status: DriverStatus
reason: str | None
actor_user_id: str | None
metadata_json: dict | None
created_at: datetime
class DriverCredentialCreate(BaseModel):
model_config = _FORBID
kind: DriverCredentialKind = DriverCredentialKind.LICENSE
number: str = Field(max_length=100)
issued_at: date | None = None
expires_at: date | None = None
is_verified: bool = False
document_file_ref: str | None = Field(default=None, max_length=255)
metadata_json: dict | None = None
class DriverCredentialRead(ORMBase):
id: UUID
tenant_id: UUID
driver_id: UUID
kind: DriverCredentialKind
number: str
issued_at: date | None
expires_at: date | None
is_verified: bool
document_file_ref: str | None
metadata_json: dict | None
is_deleted: bool
created_by: str | None
updated_by: str | None
created_at: datetime
updated_at: datetime
class DriverDocumentCreate(BaseModel):
model_config = _FORBID
kind: DriverDocumentKind = DriverDocumentKind.OTHER
title: str = Field(max_length=255)
storage_file_ref: str = Field(max_length=255)
notes: str | None = None
class DriverDocumentRead(ORMBase):
id: UUID
tenant_id: UUID
driver_id: UUID
kind: DriverDocumentKind
title: str
storage_file_ref: str
notes: str | None
is_deleted: bool
created_by: str | None
updated_by: str | None
created_at: datetime
updated_at: datetime
class DriverListResponse(BaseModel):
items: list[DriverRead]
total: int
page: int
page_size: int

View File

@ -0,0 +1,294 @@
"""Delivery foundation DTOs — Phase 10.0."""
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, ConfigDict, Field
from app.models.types import (
LifecycleStatus,
ProviderKind,
ProviderStatus,
RoutingEngineStatus,
)
from app.schemas.common import ORMBase
_FORBID = ConfigDict(extra="forbid")
class DeliveryOrganizationCreate(BaseModel):
model_config = _FORBID
code: str = Field(max_length=50)
name: str = Field(max_length=255)
description: str | None = None
status: LifecycleStatus = LifecycleStatus.DRAFT
legal_name: str | None = Field(default=None, max_length=255)
timezone: str = Field(default="Asia/Tehran", max_length=64)
language: str = Field(default="fa", max_length=16)
currency_code: str = Field(default="IRR", max_length=3)
settings: dict | None = None
class DeliveryOrganizationUpdate(BaseModel):
model_config = _FORBID
name: str | None = Field(default=None, max_length=255)
description: str | None = None
status: LifecycleStatus | None = None
legal_name: str | None = Field(default=None, max_length=255)
timezone: str | None = Field(default=None, max_length=64)
language: str | None = Field(default=None, max_length=16)
currency_code: str | None = Field(default=None, max_length=3)
settings: dict | None = None
version: int = Field(ge=1)
class DeliveryOrganizationRead(ORMBase):
id: UUID
tenant_id: UUID
code: str
name: str
description: str | None
status: LifecycleStatus
legal_name: str | None
timezone: str
language: str
currency_code: str
settings: dict | None
version: int
is_deleted: bool
created_by: str | None
updated_by: str | None
created_at: datetime
updated_at: datetime
class DeliveryHubCreate(BaseModel):
model_config = _FORBID
organization_id: UUID
code: str = Field(max_length=50)
name: str = Field(max_length=255)
description: str | None = None
status: LifecycleStatus = LifecycleStatus.ACTIVE
address: dict | None = None
timezone: str | None = Field(default=None, max_length=64)
working_hours: dict | None = None
metadata_json: dict | None = None
class DeliveryHubUpdate(BaseModel):
model_config = _FORBID
name: str | None = Field(default=None, max_length=255)
description: str | None = None
status: LifecycleStatus | None = None
address: dict | None = None
timezone: str | None = Field(default=None, max_length=64)
working_hours: dict | None = None
metadata_json: dict | None = None
class DeliveryHubRead(ORMBase):
id: UUID
tenant_id: UUID
organization_id: UUID
code: str
name: str
description: str | None
status: LifecycleStatus
address: dict | None
timezone: str | None
working_hours: dict | None
metadata_json: dict | None
is_deleted: bool
created_by: str | None
updated_by: str | None
created_at: datetime
updated_at: datetime
class ExternalProviderConfigCreate(BaseModel):
model_config = _FORBID
organization_id: UUID | None = None
code: str = Field(max_length=50)
name: str = Field(max_length=255)
provider_kind: ProviderKind = ProviderKind.CUSTOM
adapter_key: str = Field(max_length=100)
status: ProviderStatus = ProviderStatus.REGISTERED
priority: int = Field(default=100, ge=0)
credentials_ref: str | None = Field(default=None, max_length=255)
config: dict | None = None
capabilities: dict | None = None
class ExternalProviderConfigUpdate(BaseModel):
model_config = _FORBID
name: str | None = Field(default=None, max_length=255)
provider_kind: ProviderKind | None = None
adapter_key: str | None = Field(default=None, max_length=100)
status: ProviderStatus | None = None
priority: int | None = Field(default=None, ge=0)
credentials_ref: str | None = Field(default=None, max_length=255)
config: dict | None = None
capabilities: dict | None = None
version: int = Field(ge=1)
class ExternalProviderConfigRead(ORMBase):
id: UUID
tenant_id: UUID
organization_id: UUID | None
code: str
name: str
provider_kind: ProviderKind
adapter_key: str
status: ProviderStatus
priority: int
credentials_ref: str | None
config: dict | None
capabilities: dict | None
version: int
is_deleted: bool
created_by: str | None
updated_by: str | None
created_at: datetime
updated_at: datetime
class RoutingEngineRegistrationCreate(BaseModel):
model_config = _FORBID
organization_id: UUID | None = None
code: str = Field(max_length=50)
name: str = Field(max_length=255)
adapter_key: str = Field(max_length=100)
status: RoutingEngineStatus = RoutingEngineStatus.REGISTERED
is_default: bool = False
config: dict | None = None
capabilities: dict | None = None
class RoutingEngineRegistrationUpdate(BaseModel):
model_config = _FORBID
name: str | None = Field(default=None, max_length=255)
adapter_key: str | None = Field(default=None, max_length=100)
status: RoutingEngineStatus | None = None
is_default: bool | None = None
config: dict | None = None
capabilities: dict | None = None
version: int = Field(ge=1)
class RoutingEngineRegistrationRead(ORMBase):
id: UUID
tenant_id: UUID
organization_id: UUID | None
code: str
name: str
adapter_key: str
status: RoutingEngineStatus
is_default: bool
config: dict | None
capabilities: dict | None
version: int
is_deleted: bool
created_by: str | None
updated_by: str | None
created_at: datetime
updated_at: datetime
class DeliveryConfigurationCreate(BaseModel):
model_config = _FORBID
organization_id: UUID
hub_id: UUID | None = None
code: str = Field(max_length=50)
working_hours: dict | None = None
dispatch_policies: dict | None = None
tracking_policies: dict | None = None
settlement_policies: dict | None = None
custom_fields: dict | None = None
timezone: str = Field(default="Asia/Tehran", max_length=64)
language: str = Field(default="fa", max_length=16)
currency_code: str = Field(default="IRR", max_length=3)
class DeliveryConfigurationUpdate(BaseModel):
model_config = _FORBID
hub_id: UUID | None = None
working_hours: dict | None = None
dispatch_policies: dict | None = None
tracking_policies: dict | None = None
settlement_policies: dict | None = None
custom_fields: dict | None = None
timezone: str | None = Field(default=None, max_length=64)
language: str | None = Field(default=None, max_length=16)
currency_code: str | None = Field(default=None, max_length=3)
version: int = Field(ge=1)
class DeliveryConfigurationRead(ORMBase):
id: UUID
tenant_id: UUID
organization_id: UUID
hub_id: UUID | None
code: str
working_hours: dict | None
dispatch_policies: dict | None
tracking_policies: dict | None
settlement_policies: dict | None
custom_fields: dict | None
timezone: str
language: str
currency_code: str
version: int
is_deleted: bool
created_by: str | None
updated_by: str | None
created_at: datetime
updated_at: datetime
class DeliverySettingUpsert(BaseModel):
model_config = _FORBID
organization_id: UUID | None = None
hub_id: UUID | None = None
key: str = Field(max_length=100)
value: dict | None = None
description: str | None = None
class DeliverySettingRead(ORMBase):
id: UUID
tenant_id: UUID
organization_id: UUID | None
hub_id: UUID | None
key: str
value: dict | None
description: str | None
is_deleted: bool
created_by: str | None
updated_by: str | None
created_at: datetime
updated_at: datetime
class DeliveryAuditLogRead(ORMBase):
id: UUID
tenant_id: UUID
entity_type: str
entity_id: UUID
action: str
actor_user_id: str | None
changes: dict | None
message: str | None
created_at: datetime

View File

@ -0,0 +1,51 @@
"""Audit service — append-only Delivery audit trail."""
from __future__ import annotations
from typing import Any
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.foundation import DeliveryAuditLog
from app.models.types import AuditAction
from app.repositories.foundation import DeliveryAuditLogRepository
class AuditService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.repo = DeliveryAuditLogRepository(session)
async def record(
self,
*,
tenant_id: UUID,
entity_type: str,
entity_id: UUID,
action: AuditAction,
actor_user_id: str | None = None,
changes: dict[str, Any] | None = None,
message: str | None = None,
commit: bool = False,
) -> DeliveryAuditLog:
entry = DeliveryAuditLog(
tenant_id=tenant_id,
entity_type=entity_type,
entity_id=entity_id,
action=action,
actor_user_id=actor_user_id,
changes=changes,
message=message,
)
await self.repo.add(entry)
if commit:
await self.session.commit()
await self.session.refresh(entry)
return entry
async def list_for_entity(
self, tenant_id: UUID, entity_type: str, entity_id: UUID, *, limit: int = 100
):
return await self.repo.list_for_entity(
tenant_id, entity_type, entity_id, limit=limit
)

View File

@ -0,0 +1,478 @@
"""Driver management application services — Phase 10.1."""
from __future__ import annotations
from datetime import date, datetime, timezone
from typing import Any
from uuid import UUID
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.events.publisher import TransactionalEventPublisher
from app.events.types import DeliveryEventType
from app.models.drivers import (
Driver,
DriverCredential,
DriverDocument,
DriverLifecycleEvent,
)
from app.models.types import AuditAction, DriverLifecycleAction, DriverStatus
from app.policies.drivers import DriverLifecyclePolicy
from app.repositories.drivers import (
DriverCredentialRepository,
DriverDocumentRepository,
DriverLifecycleEventRepository,
DriverRepository,
)
from app.repositories.foundation import (
DeliveryHubRepository,
DeliveryOrganizationRepository,
)
from app.schemas.drivers import (
DriverCreate,
DriverCredentialCreate,
DriverDocumentCreate,
DriverLifecycleRequest,
DriverUpdate,
)
from app.services.audit_service import AuditService
from app.specifications.drivers import DriverListSpec
from app.validators import ensure_optimistic_version, validate_code
from app.validators.drivers import (
validate_credential_dates,
validate_display_name,
validate_email,
validate_mobile,
)
from shared.exceptions import AppError, NotFoundError
from shared.security import CurrentUser
_ACTION_EVENTS: dict[DriverLifecycleAction, DeliveryEventType] = {
DriverLifecycleAction.ACTIVATE: DeliveryEventType.DRIVER_ACTIVATED,
DriverLifecycleAction.SUSPEND: DeliveryEventType.DRIVER_SUSPENDED,
DriverLifecycleAction.RESUME: DeliveryEventType.DRIVER_RESUMED,
DriverLifecycleAction.DEACTIVATE: DeliveryEventType.DRIVER_DEACTIVATED,
DriverLifecycleAction.BLOCK: DeliveryEventType.DRIVER_BLOCKED,
DriverLifecycleAction.UNBLOCK: DeliveryEventType.DRIVER_UNBLOCKED,
DriverLifecycleAction.ARCHIVE: DeliveryEventType.DRIVER_ARCHIVED,
}
_ACTION_AUDIT: dict[DriverLifecycleAction, AuditAction] = {
DriverLifecycleAction.ACTIVATE: AuditAction.ACTIVATE,
DriverLifecycleAction.SUSPEND: AuditAction.SUSPEND,
DriverLifecycleAction.RESUME: AuditAction.RESUME,
DriverLifecycleAction.DEACTIVATE: AuditAction.DEACTIVATE,
DriverLifecycleAction.BLOCK: AuditAction.BLOCK,
DriverLifecycleAction.UNBLOCK: AuditAction.UNBLOCK,
DriverLifecycleAction.ARCHIVE: AuditAction.ARCHIVE,
}
def _actor_id(actor: CurrentUser | None) -> str | None:
return actor.user_id if actor else None
def _jsonable(data: dict[str, Any]) -> dict[str, Any]:
out: dict[str, Any] = {}
for key, value in data.items():
if isinstance(value, UUID):
out[key] = str(value)
elif isinstance(value, (datetime, date)):
out[key] = value.isoformat()
elif hasattr(value, "value"):
out[key] = value.value
else:
out[key] = value
return out
class DriverService:
def __init__(
self,
session: AsyncSession,
*,
publisher: TransactionalEventPublisher | None = None,
) -> None:
self.session = session
self.repo = DriverRepository(session)
self.credentials = DriverCredentialRepository(session)
self.documents = DriverDocumentRepository(session)
self.lifecycle = DriverLifecycleEventRepository(session)
self.orgs = DeliveryOrganizationRepository(session)
self.hubs = DeliveryHubRepository(session)
self.audit = AuditService(session)
self.publisher = publisher or TransactionalEventPublisher(session)
self.policy = DriverLifecyclePolicy()
async def create(
self,
tenant_id: UUID,
body: DriverCreate,
*,
actor: CurrentUser | None = None,
) -> Driver:
org = await self.orgs.get(tenant_id, body.organization_id)
if org is None:
raise NotFoundError("سازمان یافت نشد")
if body.hub_id is not None:
hub = await self.hubs.get(tenant_id, body.hub_id)
if hub is None:
raise NotFoundError("هاب یافت نشد")
if hub.organization_id != body.organization_id:
raise AppError(
"هاب به سازمان انتخاب‌شده تعلق ندارد",
status_code=422,
error_code="hub_organization_mismatch",
)
code = validate_code(body.code)
if await self.repo.get_by_code(tenant_id, code):
raise AppError(
"کد راننده تکراری است",
error_code="duplicate_code",
status_code=409,
)
if body.status not in {DriverStatus.PENDING, DriverStatus.ACTIVE}:
raise AppError(
"وضعیت اولیه فقط pending یا active مجاز است",
status_code=422,
error_code="invalid_initial_status",
)
entity = Driver(
tenant_id=tenant_id,
organization_id=body.organization_id,
hub_id=body.hub_id,
code=code,
display_name=validate_display_name(body.display_name),
first_name=body.first_name,
last_name=body.last_name,
mobile=validate_mobile(body.mobile),
email=validate_email(body.email),
status=body.status,
external_user_ref=body.external_user_ref,
external_crm_contact_ref=body.external_crm_contact_ref,
hire_date=body.hire_date,
notes=body.notes,
capability_tags=body.capability_tags,
metadata_json=body.metadata_json,
status_changed_at=datetime.now(timezone.utc),
created_by=_actor_id(actor),
updated_by=_actor_id(actor),
)
try:
await self.repo.add(entity)
except IntegrityError as exc:
raise AppError(
"کد راننده تکراری است",
error_code="duplicate_code",
status_code=409,
) from exc
await self.audit.record(
tenant_id=tenant_id,
entity_type="driver",
entity_id=entity.id,
action=AuditAction.CREATE,
actor_user_id=_actor_id(actor),
changes=_jsonable(body.model_dump()),
)
await self.publisher.publish(
event_type=DeliveryEventType.DRIVER_CREATED,
aggregate_type="driver",
aggregate_id=entity.id,
tenant_id=tenant_id,
payload={
"code": entity.code,
"status": entity.status.value,
"organization_id": str(entity.organization_id),
},
)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def get(self, tenant_id: UUID, driver_id: UUID) -> Driver:
entity = await self.repo.get(tenant_id, driver_id)
if entity is None:
raise NotFoundError("راننده یافت نشد")
return entity
async def list(
self,
tenant_id: UUID,
*,
offset: int = 0,
limit: int = 20,
spec: DriverListSpec | None = None,
) -> tuple[list[Driver], int]:
items = list(
await self.repo.list_filtered(
tenant_id, offset=offset, limit=limit, spec=spec
)
)
total = await self.repo.count_filtered(tenant_id, spec=spec)
return items, total
async def update(
self,
tenant_id: UUID,
driver_id: UUID,
body: DriverUpdate,
*,
actor: CurrentUser | None = None,
) -> Driver:
entity = await self.get(tenant_id, driver_id)
ensure_optimistic_version(entity, body.version)
data = body.model_dump(exclude_unset=True, exclude={"version"})
if "hub_id" in data and data["hub_id"] is not None:
hub = await self.hubs.get(tenant_id, data["hub_id"])
if hub is None:
raise NotFoundError("هاب یافت نشد")
if hub.organization_id != entity.organization_id:
raise AppError(
"هاب به سازمان راننده تعلق ندارد",
status_code=422,
error_code="hub_organization_mismatch",
)
if "display_name" in data and data["display_name"] is not None:
data["display_name"] = validate_display_name(data["display_name"])
if "mobile" in data:
data["mobile"] = validate_mobile(data["mobile"])
if "email" in data:
data["email"] = validate_email(data["email"])
for key, value in data.items():
setattr(entity, key, value)
entity.version += 1
entity.updated_by = _actor_id(actor)
await self.audit.record(
tenant_id=tenant_id,
entity_type="driver",
entity_id=entity.id,
action=AuditAction.UPDATE,
actor_user_id=_actor_id(actor),
changes=_jsonable(data),
)
await self.publisher.publish(
event_type=DeliveryEventType.DRIVER_UPDATED,
aggregate_type="driver",
aggregate_id=entity.id,
tenant_id=tenant_id,
payload={"code": entity.code},
)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def apply_lifecycle(
self,
tenant_id: UUID,
driver_id: UUID,
action: DriverLifecycleAction,
body: DriverLifecycleRequest,
*,
actor: CurrentUser | None = None,
) -> Driver:
entity = await self.get(tenant_id, driver_id)
ensure_optimistic_version(entity, body.version)
from_status = entity.status
to_status, reason = self.policy.assert_transition(
action=action, current=from_status, reason=body.reason
)
entity.status = to_status
entity.status_reason = reason
entity.status_changed_at = datetime.now(timezone.utc)
entity.version += 1
entity.updated_by = _actor_id(actor)
history = DriverLifecycleEvent(
tenant_id=tenant_id,
driver_id=entity.id,
action=action,
from_status=from_status,
to_status=to_status,
reason=reason,
actor_user_id=_actor_id(actor),
)
await self.lifecycle.add(history)
await self.audit.record(
tenant_id=tenant_id,
entity_type="driver",
entity_id=entity.id,
action=_ACTION_AUDIT[action],
actor_user_id=_actor_id(actor),
changes={
"from_status": from_status.value,
"to_status": to_status.value,
"reason": reason,
},
message=f"driver.{action.value}",
)
await self.publisher.publish(
event_type=DeliveryEventType.DRIVER_STATUS_CHANGED,
aggregate_type="driver",
aggregate_id=entity.id,
tenant_id=tenant_id,
payload={
"action": action.value,
"from_status": from_status.value,
"to_status": to_status.value,
},
)
await self.publisher.publish(
event_type=_ACTION_EVENTS[action],
aggregate_type="driver",
aggregate_id=entity.id,
tenant_id=tenant_id,
payload={"code": entity.code, "status": to_status.value},
)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def soft_delete(
self,
tenant_id: UUID,
driver_id: UUID,
*,
actor: CurrentUser | None = None,
) -> Driver:
entity = await self.get(tenant_id, driver_id)
await self.repo.soft_delete(entity, deleted_by=_actor_id(actor))
await self.audit.record(
tenant_id=tenant_id,
entity_type="driver",
entity_id=entity.id,
action=AuditAction.DELETE,
actor_user_id=_actor_id(actor),
)
await self.publisher.publish(
event_type=DeliveryEventType.DRIVER_DELETED,
aggregate_type="driver",
aggregate_id=entity.id,
tenant_id=tenant_id,
payload={"code": entity.code},
)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def list_lifecycle(
self, tenant_id: UUID, driver_id: UUID, *, limit: int = 100
) -> list[DriverLifecycleEvent]:
await self.get(tenant_id, driver_id)
return list(
await self.lifecycle.list_for_driver(tenant_id, driver_id, limit=limit)
)
async def add_credential(
self,
tenant_id: UUID,
driver_id: UUID,
body: DriverCredentialCreate,
*,
actor: CurrentUser | None = None,
) -> DriverCredential:
await self.get(tenant_id, driver_id)
validate_credential_dates(issued_at=body.issued_at, expires_at=body.expires_at)
number = body.number.strip()
if not number:
raise AppError(
"شماره مدرک الزامی است",
status_code=422,
error_code="credential_number_required",
)
entity = DriverCredential(
tenant_id=tenant_id,
driver_id=driver_id,
kind=body.kind,
number=number,
issued_at=body.issued_at,
expires_at=body.expires_at,
is_verified=body.is_verified,
document_file_ref=body.document_file_ref,
metadata_json=body.metadata_json,
created_by=_actor_id(actor),
updated_by=_actor_id(actor),
)
try:
await self.credentials.add(entity)
except IntegrityError as exc:
raise AppError(
"مدرک تکراری است",
error_code="duplicate_credential",
status_code=409,
) from exc
await self.audit.record(
tenant_id=tenant_id,
entity_type="driver_credential",
entity_id=entity.id,
action=AuditAction.CREATE,
actor_user_id=_actor_id(actor),
changes=_jsonable(body.model_dump()),
)
await self.publisher.publish(
event_type=DeliveryEventType.DRIVER_CREDENTIAL_ADDED,
aggregate_type="driver_credential",
aggregate_id=entity.id,
tenant_id=tenant_id,
payload={"driver_id": str(driver_id), "kind": entity.kind.value},
)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def list_credentials(
self, tenant_id: UUID, driver_id: UUID
) -> list[DriverCredential]:
await self.get(tenant_id, driver_id)
return list(await self.credentials.list_for_driver(tenant_id, driver_id))
async def add_document(
self,
tenant_id: UUID,
driver_id: UUID,
body: DriverDocumentCreate,
*,
actor: CurrentUser | None = None,
) -> DriverDocument:
await self.get(tenant_id, driver_id)
title = body.title.strip()
ref = body.storage_file_ref.strip()
if not title or not ref:
raise AppError(
"عنوان و مرجع فایل الزامی است",
status_code=422,
error_code="document_fields_required",
)
entity = DriverDocument(
tenant_id=tenant_id,
driver_id=driver_id,
kind=body.kind,
title=title,
storage_file_ref=ref,
notes=body.notes,
created_by=_actor_id(actor),
updated_by=_actor_id(actor),
)
await self.documents.add(entity)
await self.audit.record(
tenant_id=tenant_id,
entity_type="driver_document",
entity_id=entity.id,
action=AuditAction.CREATE,
actor_user_id=_actor_id(actor),
changes=_jsonable(body.model_dump()),
)
await self.publisher.publish(
event_type=DeliveryEventType.DRIVER_DOCUMENT_ATTACHED,
aggregate_type="driver_document",
aggregate_id=entity.id,
tenant_id=tenant_id,
payload={"driver_id": str(driver_id), "kind": entity.kind.value},
)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def list_documents(
self, tenant_id: UUID, driver_id: UUID
) -> list[DriverDocument]:
await self.get(tenant_id, driver_id)
return list(await self.documents.list_for_driver(tenant_id, driver_id))

View File

@ -0,0 +1,779 @@
"""Delivery foundation application services — Phase 10.0."""
from __future__ import annotations
from datetime import date, datetime
from typing import Any
from uuid import UUID
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.events.publisher import get_event_publisher
from app.events.types import DeliveryEventType
from app.models.foundation import (
DeliveryConfiguration,
DeliveryHub,
DeliveryOrganization,
DeliverySetting,
ExternalProviderConfig,
RoutingEngineRegistration,
)
from app.models.types import AuditAction
from app.repositories.foundation import (
DeliveryConfigurationRepository,
DeliveryHubRepository,
DeliveryOrganizationRepository,
DeliverySettingRepository,
ExternalProviderConfigRepository,
RoutingEngineRegistrationRepository,
)
from app.schemas.foundation import (
DeliveryConfigurationCreate,
DeliveryConfigurationUpdate,
DeliveryHubCreate,
DeliveryHubUpdate,
DeliveryOrganizationCreate,
DeliveryOrganizationUpdate,
DeliverySettingUpsert,
ExternalProviderConfigCreate,
ExternalProviderConfigUpdate,
RoutingEngineRegistrationCreate,
RoutingEngineRegistrationUpdate,
)
from app.services.audit_service import AuditService
from app.validators import (
ensure_optimistic_version,
validate_code,
validate_currency_code,
validate_non_empty,
)
from shared.exceptions import AppError, NotFoundError
from shared.security import CurrentUser
def _apply_update(entity, data: dict) -> None:
for key, value in data.items():
setattr(entity, key, value)
def _jsonable_changes(data: dict[str, Any]) -> dict[str, Any]:
out: dict[str, Any] = {}
for key, value in data.items():
if isinstance(value, UUID):
out[key] = str(value)
elif isinstance(value, (datetime, date)):
out[key] = value.isoformat()
elif hasattr(value, "value"):
out[key] = value.value
else:
out[key] = value
return out
def _actor_id(actor: CurrentUser | None) -> str | None:
return actor.user_id if actor else None
class DeliveryOrganizationService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.repo = DeliveryOrganizationRepository(session)
self.audit = AuditService(session)
self.events = get_event_publisher()
async def create(
self,
tenant_id: UUID,
body: DeliveryOrganizationCreate,
*,
actor: CurrentUser | None = None,
) -> DeliveryOrganization:
code = validate_code(body.code)
name = validate_non_empty(body.name, "name")
currency = validate_currency_code(body.currency_code)
if await self.repo.get_by_code(tenant_id, code):
raise AppError(
"کد سازمان تکراری است",
error_code="duplicate_code",
status_code=409,
)
entity = DeliveryOrganization(
tenant_id=tenant_id,
code=code,
name=name,
description=body.description,
status=body.status,
legal_name=body.legal_name,
timezone=body.timezone,
language=body.language,
currency_code=currency,
settings=body.settings,
created_by=_actor_id(actor),
updated_by=_actor_id(actor),
)
try:
await self.repo.add(entity)
except IntegrityError as exc:
raise AppError(
"کد سازمان تکراری است",
error_code="duplicate_code",
status_code=409,
) from exc
await self.audit.record(
tenant_id=tenant_id,
entity_type="delivery_organization",
entity_id=entity.id,
action=AuditAction.CREATE,
actor_user_id=_actor_id(actor),
changes=_jsonable_changes(body.model_dump()),
)
self.events.publish(
event_type=DeliveryEventType.ORGANIZATION_CREATED,
aggregate_type="delivery_organization",
aggregate_id=entity.id,
tenant_id=tenant_id,
payload={"code": entity.code, "name": entity.name},
)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def get(self, tenant_id: UUID, entity_id: UUID) -> DeliveryOrganization:
entity = await self.repo.get(tenant_id, entity_id)
if entity is None:
raise NotFoundError("سازمان یافت نشد")
return entity
async def list(
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20
) -> list[DeliveryOrganization]:
return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit))
async def update(
self,
tenant_id: UUID,
entity_id: UUID,
body: DeliveryOrganizationUpdate,
*,
actor: CurrentUser | None = None,
) -> DeliveryOrganization:
entity = await self.get(tenant_id, entity_id)
ensure_optimistic_version(entity, body.version)
data = body.model_dump(exclude_unset=True, exclude={"version"})
if "name" in data and data["name"] is not None:
data["name"] = validate_non_empty(data["name"], "name")
if "currency_code" in data and data["currency_code"] is not None:
data["currency_code"] = validate_currency_code(data["currency_code"])
_apply_update(entity, data)
entity.version += 1
entity.updated_by = _actor_id(actor)
await self.audit.record(
tenant_id=tenant_id,
entity_type="delivery_organization",
entity_id=entity.id,
action=AuditAction.UPDATE,
actor_user_id=_actor_id(actor),
changes=_jsonable_changes(data),
)
self.events.publish(
event_type=DeliveryEventType.ORGANIZATION_UPDATED,
aggregate_type="delivery_organization",
aggregate_id=entity.id,
tenant_id=tenant_id,
payload={"code": entity.code},
)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def soft_delete(
self,
tenant_id: UUID,
entity_id: UUID,
*,
actor: CurrentUser | None = None,
) -> DeliveryOrganization:
entity = await self.get(tenant_id, entity_id)
await self.repo.soft_delete(entity, deleted_by=_actor_id(actor))
await self.audit.record(
tenant_id=tenant_id,
entity_type="delivery_organization",
entity_id=entity.id,
action=AuditAction.DELETE,
actor_user_id=_actor_id(actor),
)
await self.session.commit()
await self.session.refresh(entity)
return entity
class DeliveryHubService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.repo = DeliveryHubRepository(session)
self.orgs = DeliveryOrganizationRepository(session)
self.audit = AuditService(session)
self.events = get_event_publisher()
async def create(
self,
tenant_id: UUID,
body: DeliveryHubCreate,
*,
actor: CurrentUser | None = None,
) -> DeliveryHub:
org = await self.orgs.get(tenant_id, body.organization_id)
if org is None:
raise NotFoundError("سازمان یافت نشد")
code = validate_code(body.code)
name = validate_non_empty(body.name, "name")
entity = DeliveryHub(
tenant_id=tenant_id,
organization_id=body.organization_id,
code=code,
name=name,
description=body.description,
status=body.status,
address=body.address,
timezone=body.timezone,
working_hours=body.working_hours,
metadata_json=body.metadata_json,
created_by=_actor_id(actor),
updated_by=_actor_id(actor),
)
try:
await self.repo.add(entity)
except IntegrityError as exc:
raise AppError(
"کد هاب تکراری است",
error_code="duplicate_code",
status_code=409,
) from exc
await self.audit.record(
tenant_id=tenant_id,
entity_type="delivery_hub",
entity_id=entity.id,
action=AuditAction.CREATE,
actor_user_id=_actor_id(actor),
changes=_jsonable_changes(body.model_dump()),
)
self.events.publish(
event_type=DeliveryEventType.HUB_CREATED,
aggregate_type="delivery_hub",
aggregate_id=entity.id,
tenant_id=tenant_id,
payload={"code": entity.code, "organization_id": str(entity.organization_id)},
)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def get(self, tenant_id: UUID, entity_id: UUID) -> DeliveryHub:
entity = await self.repo.get(tenant_id, entity_id)
if entity is None:
raise NotFoundError("هاب یافت نشد")
return entity
async def list(
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20
) -> list[DeliveryHub]:
return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit))
async def update(
self,
tenant_id: UUID,
entity_id: UUID,
body: DeliveryHubUpdate,
*,
actor: CurrentUser | None = None,
) -> DeliveryHub:
entity = await self.get(tenant_id, entity_id)
data = body.model_dump(exclude_unset=True)
if "name" in data and data["name"] is not None:
data["name"] = validate_non_empty(data["name"], "name")
_apply_update(entity, data)
entity.updated_by = _actor_id(actor)
await self.audit.record(
tenant_id=tenant_id,
entity_type="delivery_hub",
entity_id=entity.id,
action=AuditAction.UPDATE,
actor_user_id=_actor_id(actor),
changes=_jsonable_changes(data),
)
self.events.publish(
event_type=DeliveryEventType.HUB_UPDATED,
aggregate_type="delivery_hub",
aggregate_id=entity.id,
tenant_id=tenant_id,
payload={"code": entity.code},
)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def soft_delete(
self,
tenant_id: UUID,
entity_id: UUID,
*,
actor: CurrentUser | None = None,
) -> DeliveryHub:
entity = await self.get(tenant_id, entity_id)
await self.repo.soft_delete(entity, deleted_by=_actor_id(actor))
await self.audit.record(
tenant_id=tenant_id,
entity_type="delivery_hub",
entity_id=entity.id,
action=AuditAction.DELETE,
actor_user_id=_actor_id(actor),
)
await self.session.commit()
await self.session.refresh(entity)
return entity
class ExternalProviderConfigService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.repo = ExternalProviderConfigRepository(session)
self.audit = AuditService(session)
self.events = get_event_publisher()
async def create(
self,
tenant_id: UUID,
body: ExternalProviderConfigCreate,
*,
actor: CurrentUser | None = None,
) -> ExternalProviderConfig:
code = validate_code(body.code)
name = validate_non_empty(body.name, "name")
adapter_key = validate_non_empty(body.adapter_key, "adapter_key")
if await self.repo.get_by_code(tenant_id, code):
raise AppError(
"کد ارائه‌دهنده تکراری است",
error_code="duplicate_code",
status_code=409,
)
entity = ExternalProviderConfig(
tenant_id=tenant_id,
organization_id=body.organization_id,
code=code,
name=name,
provider_kind=body.provider_kind,
adapter_key=adapter_key,
status=body.status,
priority=body.priority,
credentials_ref=body.credentials_ref,
config=body.config,
capabilities=body.capabilities,
created_by=_actor_id(actor),
updated_by=_actor_id(actor),
)
await self.repo.add(entity)
await self.audit.record(
tenant_id=tenant_id,
entity_type="external_provider_config",
entity_id=entity.id,
action=AuditAction.REGISTER,
actor_user_id=_actor_id(actor),
changes=_jsonable_changes(body.model_dump()),
)
self.events.publish(
event_type=DeliveryEventType.EXTERNAL_PROVIDER_REGISTERED,
aggregate_type="external_provider_config",
aggregate_id=entity.id,
tenant_id=tenant_id,
payload={"code": entity.code, "adapter_key": entity.adapter_key},
)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def get(self, tenant_id: UUID, entity_id: UUID) -> ExternalProviderConfig:
entity = await self.repo.get(tenant_id, entity_id)
if entity is None:
raise NotFoundError("ارائه‌دهنده یافت نشد")
return entity
async def list(
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20
) -> list[ExternalProviderConfig]:
return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit))
async def update(
self,
tenant_id: UUID,
entity_id: UUID,
body: ExternalProviderConfigUpdate,
*,
actor: CurrentUser | None = None,
) -> ExternalProviderConfig:
entity = await self.get(tenant_id, entity_id)
ensure_optimistic_version(entity, body.version)
data = body.model_dump(exclude_unset=True, exclude={"version"})
if "name" in data and data["name"] is not None:
data["name"] = validate_non_empty(data["name"], "name")
if "adapter_key" in data and data["adapter_key"] is not None:
data["adapter_key"] = validate_non_empty(data["adapter_key"], "adapter_key")
_apply_update(entity, data)
entity.version += 1
entity.updated_by = _actor_id(actor)
await self.audit.record(
tenant_id=tenant_id,
entity_type="external_provider_config",
entity_id=entity.id,
action=AuditAction.UPDATE,
actor_user_id=_actor_id(actor),
changes=_jsonable_changes(data),
)
self.events.publish(
event_type=DeliveryEventType.EXTERNAL_PROVIDER_UPDATED,
aggregate_type="external_provider_config",
aggregate_id=entity.id,
tenant_id=tenant_id,
payload={"code": entity.code},
)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def soft_delete(
self,
tenant_id: UUID,
entity_id: UUID,
*,
actor: CurrentUser | None = None,
) -> ExternalProviderConfig:
entity = await self.get(tenant_id, entity_id)
await self.repo.soft_delete(entity, deleted_by=_actor_id(actor))
await self.audit.record(
tenant_id=tenant_id,
entity_type="external_provider_config",
entity_id=entity.id,
action=AuditAction.DELETE,
actor_user_id=_actor_id(actor),
)
await self.session.commit()
await self.session.refresh(entity)
return entity
class RoutingEngineRegistrationService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.repo = RoutingEngineRegistrationRepository(session)
self.audit = AuditService(session)
self.events = get_event_publisher()
async def create(
self,
tenant_id: UUID,
body: RoutingEngineRegistrationCreate,
*,
actor: CurrentUser | None = None,
) -> RoutingEngineRegistration:
code = validate_code(body.code)
name = validate_non_empty(body.name, "name")
adapter_key = validate_non_empty(body.adapter_key, "adapter_key")
entity = RoutingEngineRegistration(
tenant_id=tenant_id,
organization_id=body.organization_id,
code=code,
name=name,
adapter_key=adapter_key,
status=body.status,
is_default=body.is_default,
config=body.config,
capabilities=body.capabilities,
created_by=_actor_id(actor),
updated_by=_actor_id(actor),
)
try:
await self.repo.add(entity)
except IntegrityError as exc:
raise AppError(
"کد موتور مسیریابی تکراری است",
error_code="duplicate_code",
status_code=409,
) from exc
await self.audit.record(
tenant_id=tenant_id,
entity_type="routing_engine_registration",
entity_id=entity.id,
action=AuditAction.REGISTER,
actor_user_id=_actor_id(actor),
changes=_jsonable_changes(body.model_dump()),
)
self.events.publish(
event_type=DeliveryEventType.ROUTING_ENGINE_REGISTERED,
aggregate_type="routing_engine_registration",
aggregate_id=entity.id,
tenant_id=tenant_id,
payload={"code": entity.code, "adapter_key": entity.adapter_key},
)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def get(self, tenant_id: UUID, entity_id: UUID) -> RoutingEngineRegistration:
entity = await self.repo.get(tenant_id, entity_id)
if entity is None:
raise NotFoundError("موتور مسیریابی یافت نشد")
return entity
async def list(
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20
) -> list[RoutingEngineRegistration]:
return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit))
async def update(
self,
tenant_id: UUID,
entity_id: UUID,
body: RoutingEngineRegistrationUpdate,
*,
actor: CurrentUser | None = None,
) -> RoutingEngineRegistration:
entity = await self.get(tenant_id, entity_id)
ensure_optimistic_version(entity, body.version)
data = body.model_dump(exclude_unset=True, exclude={"version"})
if "name" in data and data["name"] is not None:
data["name"] = validate_non_empty(data["name"], "name")
if "adapter_key" in data and data["adapter_key"] is not None:
data["adapter_key"] = validate_non_empty(data["adapter_key"], "adapter_key")
_apply_update(entity, data)
entity.version += 1
entity.updated_by = _actor_id(actor)
await self.audit.record(
tenant_id=tenant_id,
entity_type="routing_engine_registration",
entity_id=entity.id,
action=AuditAction.UPDATE,
actor_user_id=_actor_id(actor),
changes=_jsonable_changes(data),
)
self.events.publish(
event_type=DeliveryEventType.ROUTING_ENGINE_UPDATED,
aggregate_type="routing_engine_registration",
aggregate_id=entity.id,
tenant_id=tenant_id,
payload={"code": entity.code},
)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def soft_delete(
self,
tenant_id: UUID,
entity_id: UUID,
*,
actor: CurrentUser | None = None,
) -> RoutingEngineRegistration:
entity = await self.get(tenant_id, entity_id)
await self.repo.soft_delete(entity, deleted_by=_actor_id(actor))
await self.audit.record(
tenant_id=tenant_id,
entity_type="routing_engine_registration",
entity_id=entity.id,
action=AuditAction.DELETE,
actor_user_id=_actor_id(actor),
)
await self.session.commit()
await self.session.refresh(entity)
return entity
class DeliveryConfigurationService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.repo = DeliveryConfigurationRepository(session)
self.orgs = DeliveryOrganizationRepository(session)
self.audit = AuditService(session)
self.events = get_event_publisher()
async def create(
self,
tenant_id: UUID,
body: DeliveryConfigurationCreate,
*,
actor: CurrentUser | None = None,
) -> DeliveryConfiguration:
org = await self.orgs.get(tenant_id, body.organization_id)
if org is None:
raise NotFoundError("سازمان یافت نشد")
code = validate_code(body.code)
currency = validate_currency_code(body.currency_code)
entity = DeliveryConfiguration(
tenant_id=tenant_id,
organization_id=body.organization_id,
hub_id=body.hub_id,
code=code,
working_hours=body.working_hours,
dispatch_policies=body.dispatch_policies,
tracking_policies=body.tracking_policies,
settlement_policies=body.settlement_policies,
custom_fields=body.custom_fields,
timezone=body.timezone,
language=body.language,
currency_code=currency,
created_by=_actor_id(actor),
updated_by=_actor_id(actor),
)
try:
await self.repo.add(entity)
except IntegrityError as exc:
raise AppError(
"کد پیکربندی تکراری است",
error_code="duplicate_code",
status_code=409,
) from exc
await self.audit.record(
tenant_id=tenant_id,
entity_type="delivery_configuration",
entity_id=entity.id,
action=AuditAction.CREATE,
actor_user_id=_actor_id(actor),
changes=_jsonable_changes(body.model_dump()),
)
self.events.publish(
event_type=DeliveryEventType.CONFIGURATION_CREATED,
aggregate_type="delivery_configuration",
aggregate_id=entity.id,
tenant_id=tenant_id,
payload={"code": entity.code},
)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def get(self, tenant_id: UUID, entity_id: UUID) -> DeliveryConfiguration:
entity = await self.repo.get(tenant_id, entity_id)
if entity is None:
raise NotFoundError("پیکربندی یافت نشد")
return entity
async def list(
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20
) -> list[DeliveryConfiguration]:
return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit))
async def update(
self,
tenant_id: UUID,
entity_id: UUID,
body: DeliveryConfigurationUpdate,
*,
actor: CurrentUser | None = None,
) -> DeliveryConfiguration:
entity = await self.get(tenant_id, entity_id)
ensure_optimistic_version(entity, body.version)
data = body.model_dump(exclude_unset=True, exclude={"version"})
if "currency_code" in data and data["currency_code"] is not None:
data["currency_code"] = validate_currency_code(data["currency_code"])
_apply_update(entity, data)
entity.version += 1
entity.updated_by = _actor_id(actor)
await self.audit.record(
tenant_id=tenant_id,
entity_type="delivery_configuration",
entity_id=entity.id,
action=AuditAction.UPDATE,
actor_user_id=_actor_id(actor),
changes=_jsonable_changes(data),
)
self.events.publish(
event_type=DeliveryEventType.CONFIGURATION_UPDATED,
aggregate_type="delivery_configuration",
aggregate_id=entity.id,
tenant_id=tenant_id,
payload={"code": entity.code},
)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def soft_delete(
self,
tenant_id: UUID,
entity_id: UUID,
*,
actor: CurrentUser | None = None,
) -> DeliveryConfiguration:
entity = await self.get(tenant_id, entity_id)
await self.repo.soft_delete(entity, deleted_by=_actor_id(actor))
await self.audit.record(
tenant_id=tenant_id,
entity_type="delivery_configuration",
entity_id=entity.id,
action=AuditAction.DELETE,
actor_user_id=_actor_id(actor),
)
await self.session.commit()
await self.session.refresh(entity)
return entity
class DeliverySettingService:
def __init__(self, session: AsyncSession) -> None:
self.session = session
self.repo = DeliverySettingRepository(session)
self.audit = AuditService(session)
self.events = get_event_publisher()
async def upsert(
self,
tenant_id: UUID,
body: DeliverySettingUpsert,
*,
actor: CurrentUser | None = None,
) -> DeliverySetting:
key = validate_non_empty(body.key, "key")
existing = await self.repo.get_by_key(
tenant_id,
key,
organization_id=body.organization_id,
hub_id=body.hub_id,
)
if existing is None:
entity = DeliverySetting(
tenant_id=tenant_id,
organization_id=body.organization_id,
hub_id=body.hub_id,
key=key,
value=body.value,
description=body.description,
created_by=_actor_id(actor),
updated_by=_actor_id(actor),
)
await self.repo.add(entity)
else:
entity = existing
entity.value = body.value
entity.description = body.description
entity.updated_by = _actor_id(actor)
await self.audit.record(
tenant_id=tenant_id,
entity_type="delivery_setting",
entity_id=entity.id,
action=AuditAction.UPDATE if existing else AuditAction.CREATE,
actor_user_id=_actor_id(actor),
changes=_jsonable_changes(body.model_dump()),
)
self.events.publish(
event_type=DeliveryEventType.SETTING_UPSERTED,
aggregate_type="delivery_setting",
aggregate_id=entity.id,
tenant_id=tenant_id,
payload={"key": entity.key},
)
await self.session.commit()
await self.session.refresh(entity)
return entity
async def list(
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20
) -> list[DeliverySetting]:
return list(await self.repo.list_by_tenant(tenant_id, offset=offset, limit=limit))

View File

@ -0,0 +1,4 @@
"""Driver query specifications package."""
from app.specifications.drivers import ALLOWED_SORT_FIELDS, DriverListSpec
__all__ = ["ALLOWED_SORT_FIELDS", "DriverListSpec"]

View File

@ -0,0 +1,62 @@
"""Driver query specifications — Phase 10.1."""
from __future__ import annotations
from dataclasses import dataclass
from uuid import UUID
from sqlalchemy import Select, asc, desc, or_
from sqlalchemy.sql import ColumnElement
from app.models.drivers import Driver
from app.models.types import DriverStatus
ALLOWED_SORT_FIELDS = frozenset(
{"created_at", "updated_at", "code", "display_name", "status", "mobile"}
)
@dataclass(frozen=True, slots=True)
class DriverListSpec:
status: DriverStatus | None = None
organization_id: UUID | None = None
hub_id: UUID | None = None
q: str | None = None
sort_by: str = "created_at"
sort_dir: str = "desc"
def filter_clauses(self) -> list[ColumnElement]:
clauses: list[ColumnElement] = []
if self.status is not None:
clauses.append(Driver.status == self.status)
if self.organization_id is not None:
clauses.append(Driver.organization_id == self.organization_id)
if self.hub_id is not None:
clauses.append(Driver.hub_id == self.hub_id)
if self.q:
term = f"%{self.q.strip()}%"
clauses.append(
or_(
Driver.code.ilike(term),
Driver.display_name.ilike(term),
Driver.mobile.ilike(term),
Driver.email.ilike(term),
Driver.first_name.ilike(term),
Driver.last_name.ilike(term),
)
)
return clauses
def apply(self, stmt: Select) -> Select:
clauses = self.filter_clauses()
if clauses:
stmt = stmt.where(*clauses)
sort_key = self.sort_by if self.sort_by in ALLOWED_SORT_FIELDS else "created_at"
column = getattr(Driver, sort_key)
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
return stmt.order_by(order)
def apply_filters_only(self, stmt: Select) -> Select:
clauses = self.filter_clauses()
if clauses:
stmt = stmt.where(*clauses)
return stmt

View File

@ -0,0 +1,62 @@
import os
import uuid
import pytest
import pytest_asyncio
from httpx import ASGITransport, AsyncClient
os.environ["ENVIRONMENT"] = "test"
os.environ["AUTH_REQUIRED"] = "false"
os.environ["DELIVERY_DATABASE_URL"] = "sqlite+aiosqlite:///:memory:"
os.environ["DELIVERY_DATABASE_URL_SYNC"] = "sqlite:///:memory:"
os.environ["JWT_VERIFY_SIGNATURE"] = "false"
from app.core.config import get_settings # noqa: E402
get_settings.cache_clear()
from app.core.database import Base, engine # noqa: E402
import app.models # noqa: E402, F401
from app.events.publisher import reset_event_publisher # noqa: E402
from app.main import app # noqa: E402
TENANT_A = uuid.uuid4()
TENANT_B = uuid.uuid4()
@pytest_asyncio.fixture
async def db_setup():
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await conn.run_sync(Base.metadata.create_all)
yield
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
@pytest.fixture(scope="session", autouse=True)
def _dispose_engine_on_session_end():
yield
import asyncio
try:
asyncio.run(engine.dispose())
except RuntimeError:
pass
@pytest_asyncio.fixture(autouse=True)
def _reset_events():
reset_event_publisher()
yield
@pytest_asyncio.fixture
async def client(db_setup):
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://testserver") as ac:
yield ac
def tenant_headers(tenant_id: uuid.UUID) -> dict[str, str]:
return {"X-Tenant-ID": str(tenant_id)}

View File

@ -0,0 +1,136 @@
"""API, health, tenant isolation, and foundation flow tests."""
from __future__ import annotations
from app.events.publisher import get_event_publisher
from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers
async def test_health_capabilities_metrics(client):
health = await client.get("/health")
assert health.status_code == 200
body = health.json()
assert body["status"] == "ok"
assert body["service"] == "delivery-service"
caps = await client.get("/capabilities")
assert caps.status_code == 200
c = caps.json()
assert c["phase"] == "10.1"
assert c["commercial_product"] == "Torbat Driver"
assert c["features"]["foundation"] is True
assert c["features"]["drivers"] is True
assert c["features"]["dispatch_engine"] is False
assert c["independence"]["integration_mode"] == "api_and_events_only"
metrics = await client.get("/metrics")
assert metrics.status_code == 200
assert metrics.json()["phase"] == "10.1"
async def test_foundation_flow_and_events(client):
headers = tenant_headers(TENANT_A)
org = await client.post(
"/api/v1/organizations",
headers=headers,
json={"code": "ORG1", "name": "Fleet Alpha", "status": "active"},
)
assert org.status_code == 201, org.text
org_id = org.json()["id"]
hub = await client.post(
"/api/v1/hubs",
headers=headers,
json={
"organization_id": org_id,
"code": "HUB1",
"name": "Central Hub",
},
)
assert hub.status_code == 201, hub.text
provider = await client.post(
"/api/v1/external-providers",
headers=headers,
json={
"organization_id": org_id,
"code": "FLEET_X",
"name": "External Fleet X",
"provider_kind": "fleet",
"adapter_key": "mock_fleet",
},
)
assert provider.status_code == 201, provider.text
engine = await client.post(
"/api/v1/routing-engines",
headers=headers,
json={
"organization_id": org_id,
"code": "ROUTE_A",
"name": "Future Engine A",
"adapter_key": "mock_router",
"status": "ready",
},
)
assert engine.status_code == 201, engine.text
cfg = await client.post(
"/api/v1/configurations",
headers=headers,
json={"organization_id": org_id, "code": "DEFAULT"},
)
assert cfg.status_code == 201, cfg.text
setting = await client.put(
"/api/v1/settings",
headers=headers,
json={
"organization_id": org_id,
"key": "default_currency",
"value": {"code": "IRR"},
},
)
assert setting.status_code == 200, setting.text
audit = await client.get(
"/api/v1/audit",
headers=headers,
params={"entity_type": "delivery_organization", "entity_id": org_id},
)
assert audit.status_code == 200
assert len(audit.json()) >= 1
published = [e.event_type for e in get_event_publisher().published]
assert "delivery.organization.created" in published
assert "delivery.hub.created" in published
assert "delivery.external_provider.registered" in published
assert "delivery.routing_engine.registered" in published
async def test_tenant_isolation(client):
headers_a = tenant_headers(TENANT_A)
headers_b = tenant_headers(TENANT_B)
created = await client.post(
"/api/v1/organizations",
headers=headers_a,
json={"code": "ISO1", "name": "Tenant A Org"},
)
assert created.status_code == 201
org_id = created.json()["id"]
denied = await client.get(f"/api/v1/organizations/{org_id}", headers=headers_b)
assert denied.status_code == 404
listed_b = await client.get("/api/v1/organizations", headers=headers_b)
assert listed_b.status_code == 200
assert listed_b.json() == []
listed_a = await client.get("/api/v1/organizations", headers=headers_a)
assert listed_a.status_code == 200
assert len(listed_a.json()) == 1
async def test_missing_tenant_header_rejected(client):
res = await client.get("/api/v1/organizations")
assert res.status_code in (400, 422)

View File

@ -0,0 +1,190 @@
"""Architecture tests — Delivery module boundary enforcement."""
from __future__ import annotations
import ast
import re
from pathlib import Path
from app.models import foundation as models
FORBIDDEN_IMPORT_PREFIXES = (
"backend.services.accounting",
"backend.services.crm",
"backend.services.loyalty",
"backend.services.communication",
"backend.services.sports_center",
"backend.services.automation",
"backend.services.notification",
"backend.services.file_storage",
"backend.services.identity_access",
"backend.core_service",
)
FOUNDATION_MODELS = [
models.DeliveryOrganization,
models.DeliveryHub,
models.DeliveryRole,
models.DeliveryPermission,
models.ExternalProviderConfig,
models.RoutingEngineRegistration,
models.DeliveryConfiguration,
models.DeliverySetting,
models.DeliveryAuditLog,
]
def test_all_models_have_tenant_id():
from app.core.database import Base
import app.models # noqa: F401
skip = {"alembic_version"}
for table in Base.metadata.tables.values():
if table.name in skip:
continue
assert "tenant_id" in table.columns, f"{table.name} missing tenant_id"
def test_foundation_aggregates_are_independent():
assert len(FOUNDATION_MODELS) == 9
for model in FOUNDATION_MODELS:
assert hasattr(model, "tenant_id")
assert hasattr(model, "id")
assert not model.__mapper__.relationships
def test_no_dispatch_engine_tables():
from app.core.database import Base
import app.models # noqa: F401
names = set(Base.metadata.tables.keys())
assert "drivers" in names
assert "driver_credentials" in names
assert "driver_documents" in names
assert "driver_lifecycle_events" in names
assert "outbox_events" in names
forbidden = {
"dispatch_jobs",
"routes",
"tracking_sessions",
"vehicles",
"fleets",
"proof_of_delivery",
}
assert forbidden.isdisjoint(names)
def test_permissions_defined():
from app.permissions.definitions import ALL_PERMISSIONS, PERMISSION_PREFIXES
assert "delivery.view" in ALL_PERMISSIONS
assert "delivery.organizations.create" in ALL_PERMISSIONS
assert "delivery.external_providers.manage" in ALL_PERMISSIONS
assert "delivery.routing_engines.view" in ALL_PERMISSIONS
assert "delivery.audit.view" in ALL_PERMISSIONS
assert "delivery.drivers.create" in ALL_PERMISSIONS
assert "delivery.drivers.activate" in ALL_PERMISSIONS
assert "delivery.drivers.credentials.manage" in ALL_PERMISSIONS
assert "delivery.dispatch.manage" in ALL_PERMISSIONS
for prefix in PERMISSION_PREFIXES:
assert any(p.startswith(prefix.rstrip(".")) or p.startswith(prefix) for p in ALL_PERMISSIONS)
def test_events_defined():
from app.events.types import DeliveryEventType
assert DeliveryEventType.ORGANIZATION_CREATED.value == "delivery.organization.created"
assert DeliveryEventType.HUB_CREATED.value == "delivery.hub.created"
assert (
DeliveryEventType.EXTERNAL_PROVIDER_REGISTERED.value
== "delivery.external_provider.registered"
)
assert (
DeliveryEventType.ROUTING_ENGINE_REGISTERED.value
== "delivery.routing_engine.registered"
)
assert DeliveryEventType.DRIVER_CREATED.value == "delivery.driver.created"
assert DeliveryEventType.DRIVER_ACTIVATED.value == "delivery.driver.activated"
assert DeliveryEventType.DRIVER_STATUS_CHANGED.value == "delivery.driver.status_changed"
def test_platform_provider_contracts_exist():
from app.providers import (
AIProvider,
AccountingProvider,
CRMProvider,
CommunicationProvider,
FleetProvider,
IdentityProvider,
LoyaltyProvider,
NotificationProvider,
RoutingEngineProvider,
StorageProvider,
)
assert AccountingProvider is not None
assert CommunicationProvider is not None
assert RoutingEngineProvider is not None
assert FleetProvider is not None
assert LoyaltyProvider is not None
assert CRMProvider is not None
assert NotificationProvider is not None
assert StorageProvider is not None
assert AIProvider is not None
assert IdentityProvider is not None
def test_no_forbidden_service_imports():
root = Path(__file__).resolve().parents[1]
violations: list[str] = []
for path in root.rglob("*.py"):
if "tests" in path.parts:
continue
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
for forbidden in FORBIDDEN_IMPORT_PREFIXES:
if alias.name.startswith(forbidden) or forbidden in alias.name:
violations.append(f"{path}: import {alias.name}")
elif isinstance(node, ast.ImportFrom) and node.module:
for forbidden in FORBIDDEN_IMPORT_PREFIXES:
if node.module.startswith(forbidden) or forbidden in node.module:
violations.append(f"{path}: from {node.module}")
assert violations == []
def test_api_ownership_is_delivery_only():
from app.api.v1 import api_router
prefixes = {getattr(r, "path", "") for r in api_router.routes}
joined = " ".join(sorted(prefixes))
assert "/organizations" in joined or any("/organizations" in p for p in prefixes)
assert "/hubs" in joined or any("/hubs" in p for p in prefixes)
assert "/drivers" in joined or any("/drivers" in p for p in prefixes)
forbidden = ("accounting", "crm", "loyalty", "notification", "automation", "sports")
for name in forbidden:
assert not re.search(rf"(^|/){re.escape(name)}(/|$|\s)", joined), name
def test_folder_structure():
root = Path(__file__).resolve().parents[2]
required = [
"app/models",
"app/repositories",
"app/services",
"app/validators",
"app/schemas",
"app/events",
"app/permissions",
"app/providers",
"app/policies",
"app/specifications",
"app/commands",
"app/queries",
"app/api/v1",
"app/tests",
"alembic/versions",
"README.md",
]
for rel in required:
assert (root / rel).exists(), f"missing {rel}"

View File

@ -0,0 +1,34 @@
"""Dependency / layering validation."""
from app.repositories.base import TenantBaseRepository
from app.repositories.drivers import DriverRepository
from app.repositories.foundation import DeliveryOrganizationRepository
from app.services import drivers as driver_services
from app.services import foundation as services
from app.providers import contracts
def test_repos_inherit_tenant_base():
assert issubclass(DeliveryOrganizationRepository, TenantBaseRepository)
assert issubclass(DriverRepository, TenantBaseRepository)
def test_services_do_not_import_fastapi():
import inspect
src = inspect.getsource(services)
assert "fastapi" not in src.lower()
driver_src = inspect.getsource(driver_services)
assert "fastapi" not in driver_src.lower()
def test_provider_contracts_are_protocols():
assert hasattr(contracts.AccountingProvider, "__protocol_attrs__") or True
assert callable(contracts.RoutingEngineProvider.plan_route)
def test_commands_and_queries_exist():
from app.commands.drivers import DriverCommands
from app.queries.drivers import DriverQueries
assert DriverCommands is not None
assert DriverQueries is not None

View File

@ -0,0 +1,39 @@
"""Documentation validation for Delivery Phase 10.1."""
from pathlib import Path
ROOT = Path(__file__).resolve().parents[5]
def test_phase_docs_exist():
assert (ROOT / "docs" / "delivery-phase-10-0.md").exists()
assert (ROOT / "docs" / "delivery-phase-10-1.md").exists()
assert (ROOT / "docs" / "phase-handover" / "phase-10-0.md").exists()
assert (ROOT / "docs" / "phase-handover" / "phase-10-1.md").exists()
assert (ROOT / "docs" / "phases" / "Delivery" / "README.md").exists()
assert (ROOT / "docs" / "architecture" / "adr" / "ADR-015.md").exists()
assert (ROOT / "docs" / "delivery-roadmap.md").exists()
def test_module_registry_mentions_delivery():
text = (ROOT / "docs" / "module-registry.md").read_text(encoding="utf-8")
assert "## delivery" in text.lower()
assert "delivery_db" in text
assert "delivery." in text
assert "10.1" in text or "0.10.1" in text
assert "delivery.drivers" in text
def test_progress_mentions_phase_10_1():
text = (ROOT / "docs" / "progress.md").read_text(encoding="utf-8")
assert "10.1" in text
assert "Driver Management" in text or "drivers" in text.lower()
def test_service_readme_documents_boundaries():
text = (ROOT / "backend" / "services" / "delivery" / "README.md").read_text(
encoding="utf-8"
)
assert "delivery_db" in text
assert "8007" in text
assert "drivers" in text.lower()
assert "dispatch" in text.lower() or "Accounting" in text

View File

@ -0,0 +1,36 @@
"""Migration validation."""
from pathlib import Path
from app.core.database import Base
import app.models # noqa: F401
EXPECTED_TABLES = {
"delivery_organizations",
"delivery_hubs",
"delivery_roles",
"delivery_permissions",
"external_provider_configs",
"routing_engine_registrations",
"delivery_configurations",
"delivery_settings",
"delivery_audit_logs",
"drivers",
"driver_credentials",
"driver_documents",
"driver_lifecycle_events",
"outbox_events",
}
def test_alembic_revision_exists():
versions = Path(__file__).resolve().parents[2] / "alembic" / "versions"
files = list(versions.glob("0001_initial*.py"))
assert files, "missing 0001_initial migration"
phase101 = list(versions.glob("0002_phase_101_drivers*.py"))
assert phase101, "missing 0002_phase_101_drivers migration"
def test_metadata_has_foundation_tables():
names = set(Base.metadata.tables.keys())
assert EXPECTED_TABLES.issubset(names)

View File

@ -0,0 +1,22 @@
"""Performance / index justification for Phase 10.1 driver tables."""
from __future__ import annotations
from app.core.database import Base
import app.models # noqa: F401
def test_driver_indexes_exist():
drivers = Base.metadata.tables["drivers"]
index_names = {idx.name for idx in drivers.indexes}
assert "ix_drivers_tenant_status" in index_names
assert "ix_drivers_org" in index_names
assert "ix_drivers_hub" in index_names
assert "ix_drivers_mobile" in index_names
assert "ix_drivers_display_name" in index_names
def test_outbox_indexes_exist():
outbox = Base.metadata.tables["outbox_events"]
index_names = {idx.name for idx in outbox.indexes}
assert "ix_delivery_outbox_status" in index_names
assert "ix_delivery_outbox_tenant" in index_names

View File

@ -0,0 +1,29 @@
"""Permission definition tests."""
from app.permissions.definitions import ALL_PERMISSIONS, PERMISSION_PREFIXES
def test_all_permissions_use_delivery_prefix():
assert ALL_PERMISSIONS
for perm in ALL_PERMISSIONS:
assert perm.startswith("delivery.")
def test_permission_prefixes_stable():
assert "delivery." in PERMISSION_PREFIXES
assert "delivery.organizations." in PERMISSION_PREFIXES
assert "delivery.drivers." in PERMISSION_PREFIXES
assert "delivery.dispatch." in PERMISSION_PREFIXES
def test_driver_permissions_present():
from app.permissions.definitions import (
DRIVERS_ACTIVATE,
DRIVERS_CREATE,
DRIVERS_CREDENTIALS_MANAGE,
DRIVERS_VIEW,
)
assert DRIVERS_VIEW in ALL_PERMISSIONS
assert DRIVERS_CREATE in ALL_PERMISSIONS
assert DRIVERS_ACTIVATE in ALL_PERMISSIONS
assert DRIVERS_CREDENTIALS_MANAGE in ALL_PERMISSIONS

View File

@ -0,0 +1,291 @@
"""Phase 10.1 — Driver Management tests."""
from __future__ import annotations
import pytest
from app.events.publisher import get_event_publisher
from app.models.types import DriverLifecycleAction, DriverStatus
from app.tests.conftest import TENANT_A, TENANT_B, tenant_headers
from app.validators.drivers import ensure_driver_lifecycle_transition
from shared.exceptions import AppError
async def _org(client, code="ORG_D"):
resp = await client.post(
"/api/v1/organizations",
headers=tenant_headers(TENANT_A),
json={"code": code, "name": code, "status": "active"},
)
assert resp.status_code == 201, resp.text
return resp.json()
async def _hub(client, org_id, code="HUB_D"):
resp = await client.post(
"/api/v1/hubs",
headers=tenant_headers(TENANT_A),
json={"organization_id": org_id, "code": code, "name": code},
)
assert resp.status_code == 201, resp.text
return resp.json()
async def _driver(client, org_id, *, code="DRV1", hub_id=None, status="pending"):
payload = {
"organization_id": org_id,
"code": code,
"display_name": f"Driver {code}",
"mobile": "+989121234567",
"status": status,
}
if hub_id:
payload["hub_id"] = hub_id
resp = await client.post(
"/api/v1/drivers",
headers=tenant_headers(TENANT_A),
json=payload,
)
assert resp.status_code == 201, resp.text
return resp.json()
def test_lifecycle_transition_matrix_unit():
ensure_driver_lifecycle_transition(
action=DriverLifecycleAction.ACTIVATE, current=DriverStatus.PENDING
)
with pytest.raises(AppError) as exc:
ensure_driver_lifecycle_transition(
action=DriverLifecycleAction.SUSPEND, current=DriverStatus.PENDING
)
assert exc.value.error_code == "invalid_driver_lifecycle_transition"
with pytest.raises(AppError) as exc2:
ensure_driver_lifecycle_transition(
action=DriverLifecycleAction.ACTIVATE, current=DriverStatus.ARCHIVED
)
assert exc2.value.error_code == "driver_terminal"
@pytest.mark.asyncio
async def test_driver_lifecycle_flow(client):
org = await _org(client, "LIFEORG")
hub = await _hub(client, org["id"], "LIFEHUB")
driver = await _driver(client, org["id"], code="LIFE1", hub_id=hub["id"])
did = driver["id"]
version = driver["version"]
activated = await client.post(
f"/api/v1/drivers/{did}/activate",
headers=tenant_headers(TENANT_A),
json={"version": version},
)
assert activated.status_code == 200, activated.text
assert activated.json()["status"] == "active"
version = activated.json()["version"]
suspended = await client.post(
f"/api/v1/drivers/{did}/suspend",
headers=tenant_headers(TENANT_A),
json={"version": version, "reason": "policy review"},
)
assert suspended.status_code == 200
assert suspended.json()["status"] == "suspended"
version = suspended.json()["version"]
resumed = await client.post(
f"/api/v1/drivers/{did}/resume",
headers=tenant_headers(TENANT_A),
json={"version": version},
)
assert resumed.status_code == 200
assert resumed.json()["status"] == "active"
version = resumed.json()["version"]
deactivated = await client.post(
f"/api/v1/drivers/{did}/deactivate",
headers=tenant_headers(TENANT_A),
json={"version": version, "reason": "left company"},
)
assert deactivated.status_code == 200
assert deactivated.json()["status"] == "inactive"
version = deactivated.json()["version"]
archived = await client.post(
f"/api/v1/drivers/{did}/archive",
headers=tenant_headers(TENANT_A),
json={"version": version, "reason": "closed"},
)
assert archived.status_code == 200
assert archived.json()["status"] == "archived"
life = await client.get(
f"/api/v1/drivers/{did}/lifecycle",
headers=tenant_headers(TENANT_A),
)
assert life.status_code == 200
actions = {row["action"] for row in life.json()}
assert {"activate", "suspend", "resume", "deactivate", "archive"} <= actions
events = {e.event_type for e in get_event_publisher().published}
assert "delivery.driver.created" in events
assert "delivery.driver.activated" in events
assert "delivery.driver.suspended" in events
assert "delivery.driver.archived" in events
assert "delivery.driver.status_changed" in events
@pytest.mark.asyncio
async def test_block_unblock_and_invalid_transition(client):
org = await _org(client, "BLKORG")
driver = await _driver(client, org["id"], code="BLK1", status="active")
did = driver["id"]
version = driver["version"]
bad = await client.post(
f"/api/v1/drivers/{did}/suspend",
headers=tenant_headers(TENANT_A),
json={"version": version},
)
assert bad.status_code == 422
assert bad.json()["error"]["code"] == "reason_required"
blocked = await client.post(
f"/api/v1/drivers/{did}/block",
headers=tenant_headers(TENANT_A),
json={"version": version, "reason": "fraud"},
)
assert blocked.status_code == 200
assert blocked.json()["status"] == "blocked"
version = blocked.json()["version"]
unblocked = await client.post(
f"/api/v1/drivers/{did}/unblock",
headers=tenant_headers(TENANT_A),
json={"version": version},
)
assert unblocked.status_code == 200
assert unblocked.json()["status"] == "inactive"
@pytest.mark.asyncio
async def test_credentials_documents_list_filter_search_sort(client):
org = await _org(client, "SRCORG")
await _driver(client, org["id"], code="AAA", status="active")
d2 = await _driver(client, org["id"], code="BBB", status="pending")
await _driver(client, org["id"], code="CCC", status="active")
listed = await client.get(
"/api/v1/drivers",
headers=tenant_headers(TENANT_A),
params={"status": "active", "sort_by": "code", "sort_dir": "asc", "page_size": 50},
)
assert listed.status_code == 200, listed.text
body = listed.json()
assert body["total"] == 2
assert [i["code"] for i in body["items"]] == ["AAA", "CCC"]
searched = await client.get(
"/api/v1/drivers",
headers=tenant_headers(TENANT_A),
params={"q": "BBB"},
)
assert searched.status_code == 200
assert searched.json()["total"] == 1
assert searched.json()["items"][0]["code"] == "BBB"
cred = await client.post(
f"/api/v1/drivers/{d2['id']}/credentials",
headers=tenant_headers(TENANT_A),
json={"kind": "license", "number": "LIC-1", "is_verified": True},
)
assert cred.status_code == 201, cred.text
docs = await client.post(
f"/api/v1/drivers/{d2['id']}/documents",
headers=tenant_headers(TENANT_A),
json={
"kind": "license_scan",
"title": "License",
"storage_file_ref": "storage://files/lic-1",
},
)
assert docs.status_code == 201, docs.text
creds = await client.get(
f"/api/v1/drivers/{d2['id']}/credentials",
headers=tenant_headers(TENANT_A),
)
assert creds.status_code == 200
assert len(creds.json()) == 1
documents = await client.get(
f"/api/v1/drivers/{d2['id']}/documents",
headers=tenant_headers(TENANT_A),
)
assert documents.status_code == 200
assert len(documents.json()) == 1
@pytest.mark.asyncio
async def test_optimistic_lock_and_soft_delete(client):
org = await _org(client, "OPTORG")
driver = await _driver(client, org["id"], code="OPT1")
did = driver["id"]
conflict = await client.patch(
f"/api/v1/drivers/{did}",
headers=tenant_headers(TENANT_A),
json={"display_name": "Nope", "version": 999},
)
assert conflict.status_code in (409, 422)
updated = await client.patch(
f"/api/v1/drivers/{did}",
headers=tenant_headers(TENANT_A),
json={"display_name": "Updated Name", "version": driver["version"]},
)
assert updated.status_code == 200
assert updated.json()["display_name"] == "Updated Name"
assert updated.json()["version"] == driver["version"] + 1
deleted = await client.post(
f"/api/v1/drivers/{did}/delete",
headers=tenant_headers(TENANT_A),
)
assert deleted.status_code == 200
assert deleted.json()["is_deleted"] is True
missing = await client.get(
f"/api/v1/drivers/{did}",
headers=tenant_headers(TENANT_A),
)
assert missing.status_code == 404
@pytest.mark.asyncio
async def test_driver_tenant_isolation(client):
org = await _org(client, "ISOORG")
driver = await _driver(client, org["id"], code="ISO1")
other = await client.get(
f"/api/v1/drivers/{driver['id']}",
headers=tenant_headers(TENANT_B),
)
assert other.status_code == 404
listed = await client.get(
"/api/v1/drivers",
headers=tenant_headers(TENANT_B),
)
assert listed.status_code == 200
assert listed.json()["total"] == 0
@pytest.mark.asyncio
async def test_permission_catalog(client):
resp = await client.get(
"/api/v1/permissions/catalog",
headers=tenant_headers(TENANT_A),
)
assert resp.status_code == 200
body = resp.json()
assert "delivery.drivers.activate" in body["permissions"]
assert body["count"] >= 40

View File

@ -0,0 +1,41 @@
"""Security validation — anonymous denial when auth required."""
from __future__ import annotations
import os
import pytest
from httpx import ASGITransport, AsyncClient
@pytest.mark.asyncio
async def test_auth_required_denies_anonymous(db_setup):
os.environ["AUTH_REQUIRED"] = "true"
from app.core.config import get_settings
get_settings.cache_clear()
# Re-import settings binding used by security
import app.core.config as config_mod
import app.core.security as security_mod
import app.api.permissions as perm_mod
config_mod.settings = get_settings()
security_mod.settings = config_mod.settings
perm_mod.settings = config_mod.settings
from app.main import create_app
app = create_app()
transport = ASGITransport(app=app)
try:
async with AsyncClient(transport=transport, base_url="http://testserver") as client:
res = await client.get(
"/api/v1/organizations",
headers={"X-Tenant-ID": "11111111-1111-1111-1111-111111111111"},
)
assert res.status_code == 401
finally:
os.environ["AUTH_REQUIRED"] = "false"
get_settings.cache_clear()
config_mod.settings = get_settings()
security_mod.settings = config_mod.settings
perm_mod.settings = config_mod.settings

View File

@ -0,0 +1,14 @@
"""Delivery validators package."""
from app.validators.foundation import (
ensure_optimistic_version,
validate_code,
validate_currency_code,
validate_non_empty,
)
__all__ = [
"validate_non_empty",
"validate_code",
"validate_currency_code",
"ensure_optimistic_version",
]

View File

@ -0,0 +1,140 @@
"""Driver lifecycle validators — Phase 10.1."""
from __future__ import annotations
import re
from datetime import date
from shared.exceptions import AppError
from app.models.types import DriverLifecycleAction, DriverStatus
from app.validators.foundation import validate_non_empty
PHONE_RE = re.compile(r"^\+?[0-9]{8,15}$")
EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
ALLOWED_TRANSITIONS: dict[DriverLifecycleAction, set[DriverStatus]] = {
DriverLifecycleAction.ACTIVATE: {
DriverStatus.PENDING,
DriverStatus.INACTIVE,
DriverStatus.SUSPENDED,
},
DriverLifecycleAction.SUSPEND: {DriverStatus.ACTIVE},
DriverLifecycleAction.RESUME: {DriverStatus.SUSPENDED},
DriverLifecycleAction.DEACTIVATE: {
DriverStatus.ACTIVE,
DriverStatus.SUSPENDED,
},
DriverLifecycleAction.BLOCK: {
DriverStatus.PENDING,
DriverStatus.ACTIVE,
DriverStatus.SUSPENDED,
DriverStatus.INACTIVE,
},
DriverLifecycleAction.UNBLOCK: {DriverStatus.BLOCKED},
DriverLifecycleAction.ARCHIVE: {
DriverStatus.INACTIVE,
DriverStatus.BLOCKED,
},
}
TERMINAL_STATUSES = frozenset({DriverStatus.ARCHIVED})
TARGET_STATUS: dict[DriverLifecycleAction, DriverStatus] = {
DriverLifecycleAction.ACTIVATE: DriverStatus.ACTIVE,
DriverLifecycleAction.SUSPEND: DriverStatus.SUSPENDED,
DriverLifecycleAction.RESUME: DriverStatus.ACTIVE,
DriverLifecycleAction.DEACTIVATE: DriverStatus.INACTIVE,
DriverLifecycleAction.BLOCK: DriverStatus.BLOCKED,
DriverLifecycleAction.UNBLOCK: DriverStatus.INACTIVE,
DriverLifecycleAction.ARCHIVE: DriverStatus.ARCHIVED,
}
def ensure_driver_lifecycle_transition(
*, action: DriverLifecycleAction, current: DriverStatus
) -> None:
if current in TERMINAL_STATUSES:
raise AppError(
"راننده در وضعیت پایانی است و قابل تغییر نیست",
status_code=409,
error_code="driver_terminal",
details={"status": current.value, "action": action.value},
)
allowed = ALLOWED_TRANSITIONS.get(action, set())
if current not in allowed:
raise AppError(
"انتقال وضعیت راننده مجاز نیست",
status_code=409,
error_code="invalid_driver_lifecycle_transition",
details={
"from_status": current.value,
"action": action.value,
"allowed_from": sorted(s.value for s in allowed),
},
)
def target_status_for(action: DriverLifecycleAction) -> DriverStatus:
return TARGET_STATUS[action]
def validate_reason(reason: str | None, *, required: bool = False) -> str | None:
if reason is None or not str(reason).strip():
if required:
raise AppError(
"دلیل الزامی است",
status_code=422,
error_code="reason_required",
)
return None
text = str(reason).strip()
if len(text) > 500:
raise AppError(
"دلیل بیش از حد طولانی است",
status_code=422,
error_code="reason_too_long",
)
return text
def validate_mobile(mobile: str | None) -> str | None:
if mobile is None or not str(mobile).strip():
return None
value = str(mobile).strip()
if not PHONE_RE.match(value):
raise AppError(
"شماره موبایل نامعتبر است",
status_code=422,
error_code="invalid_mobile",
details={"field": "mobile"},
)
return value
def validate_email(email: str | None) -> str | None:
if email is None or not str(email).strip():
return None
value = str(email).strip()
if not EMAIL_RE.match(value):
raise AppError(
"ایمیل نامعتبر است",
status_code=422,
error_code="invalid_email",
details={"field": "email"},
)
return value
def validate_credential_dates(
*, issued_at: date | None, expires_at: date | None
) -> None:
if issued_at and expires_at and expires_at < issued_at:
raise AppError(
"تاریخ انقضا نمی‌تواند قبل از تاریخ صدور باشد",
status_code=422,
error_code="invalid_credential_dates",
)
def validate_display_name(name: str) -> str:
return validate_non_empty(name, "display_name")

View File

@ -0,0 +1,55 @@
"""Reusable validators for Delivery foundation."""
from __future__ import annotations
import re
from typing import Any
from shared.exceptions import AppError
CODE_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_\-]{1,49}$")
class ValidationError(AppError):
def __init__(self, message: str, details: dict[str, Any] | None = None):
super().__init__(
message=message,
error_code="validation_error",
status_code=422,
details=details or {},
)
def validate_non_empty(value: str | None, field: str) -> str:
if value is None or not str(value).strip():
raise ValidationError(f"{field} الزامی است", {"field": field})
return str(value).strip()
def validate_code(code: str) -> str:
code = validate_non_empty(code, "code")
if not CODE_RE.match(code):
raise ValidationError(
"کد نامعتبر است",
{"field": "code", "pattern": CODE_RE.pattern},
)
return code
def validate_currency_code(code: str) -> str:
code = validate_non_empty(code, "currency_code").upper()
if len(code) != 3:
raise ValidationError("currency_code باید ۳ حرفی باشد", {"field": "currency_code"})
return code
def ensure_optimistic_version(entity: Any, expected: int) -> None:
current = getattr(entity, "version", None)
if current is None:
return
if current != expected:
raise AppError(
"نسخه موجودیت هم‌خوان نیست",
error_code="version_conflict",
status_code=409,
details={"expected": expected, "actual": current},
)

View File

@ -0,0 +1,8 @@
[pytest]
asyncio_mode = auto
asyncio_default_fixture_loop_scope = function
testpaths = app/tests
pythonpath = .
python_files = test_*.py
python_classes = Test*
python_functions = test_*

View File

@ -0,0 +1,14 @@
fastapi==0.111.0
uvicorn[standard]==0.30.1
pydantic[email]==2.7.4
pydantic-settings==2.3.4
sqlalchemy==2.0.31
alembic==1.13.2
asyncpg==0.29.0
psycopg[binary]>=3.2.2
httpx==0.27.0
pyjwt[crypto]==2.8.0
-e ../../shared-lib
pytest==8.2.2
pytest-asyncio==0.23.7
aiosqlite==0.20.0

View File

@ -0,0 +1,41 @@
"""Ensure delivery_db exists before migration."""
from __future__ import annotations
import os
import sys
from urllib.parse import urlparse
def main() -> None:
sync_url = os.environ.get("DELIVERY_DATABASE_URL_SYNC", "")
if not sync_url:
print("DELIVERY_DATABASE_URL_SYNC not set", file=sys.stderr)
return
parsed = urlparse(sync_url.replace("+psycopg", ""))
db_name = (parsed.path or "").lstrip("/") or "delivery_db"
import psycopg
conn = psycopg.connect(
host=parsed.hostname or "localhost",
port=parsed.port or 5432,
user=parsed.username,
password=parsed.password,
dbname="postgres",
autocommit=True,
)
try:
with conn.cursor() as cur:
cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (db_name,))
if cur.fetchone() is None:
cur.execute(f'CREATE DATABASE "{db_name}"')
print(f"Created database: {db_name}")
else:
print(f"Database exists: {db_name}")
finally:
conn.close()
if __name__ == "__main__":
main()

View File

@ -19,4 +19,4 @@ RUN sed -i 's#-e ../../shared-lib#-e /shared-lib#' /app/requirements.txt \
&& pip install --upgrade pip \ && pip install --upgrade pip \
&& pip install -r requirements.txt && pip install -r requirements.txt
EXPOSE 8007 EXPOSE 8009

View File

@ -0,0 +1,45 @@
# Hospitality Platform Service (Torbat Food)
Independent enterprise Hospitality Platform for Cafe, Coffee Shop, Restaurant, Fast Food, Bakery, Pastry, Ice Cream, Juice Bar, Cloud Kitchen, Food Court, Catering, and Take Away.
| Field | Value |
| --- | --- |
| Service | `hospitality-service` |
| Database | `hospitality_db` (sole owner) |
| API Port | 8009 |
| Version | 0.12.8.0 |
| Phase | 12.8 Analytics |
| Permissions | `hospitality.*` |
| ADR | [ADR-017](../../../docs/architecture/adr/ADR-017.md) |
## Responsibilities (through Phase 12.8)
- Venue / branch / table foundation
- Digital menu catalog (allergens, modifiers, availability, media, localizations)
- QR Menu sessions + QR Ordering / cart shells
- Table Service & Reservations: reservations, table assignments, service requests, waitlist
- POS Lite: registers, shifts, tickets, ticket lines
- POS Pro: payments, discounts, tax lines, floor plans, stations (local records only)
- Kitchen: stations, tickets (routed from POS/QR by reference), ticket items
- Connectors: registrations + outbound dispatches to delivery/accounting/CRM/loyalty/communication/website platforms (mock providers only)
- Analytics: report definitions + snapshots computed via local `COUNT(*)` queries only
- Bundle-based licensing + feature toggles
- Capability / health discovery
- Publish-only events; provider contracts only
**Does not** call real connector SDKs, post accounting journals, run fiscal-device integrations, or provide AI suggestions yet.
## Related Documents
- [Hospitality Phase 12.8](../../../docs/hospitality-phase-12-8.md)
- [Hospitality Phase 12.7](../../../docs/hospitality-phase-12-7.md)
- [Hospitality Phase 12.6](../../../docs/hospitality-phase-12-6.md)
- [Hospitality Phase 12.5](../../../docs/hospitality-phase-12-5.md)
- [Hospitality Phase 12.4](../../../docs/hospitality-phase-12-4.md)
- [Hospitality Phase 12.3](../../../docs/hospitality-phase-12-3.md)
- [Hospitality Phase 12.2](../../../docs/hospitality-phase-12-2.md)
- [Hospitality Phase 12.1](../../../docs/hospitality-phase-12-1.md)
- [Hospitality Phase 12.0](../../../docs/hospitality-phase-12-0.md)
- [Hospitality Roadmap](../../../docs/hospitality-roadmap.md)
- [Module Registry](../../../docs/module-registry.md#hospitality)
- [ADR-017](../../../docs/architecture/adr/ADR-017.md)

View File

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

View File

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

View File

@ -0,0 +1,28 @@
"""Phase 12.5 — POS Pro schema."""
from alembic import op
import sqlalchemy as sa
from app.core.database import Base
import app.models # noqa: F401
revision = "0006_phase_125_pos_pro"
down_revision = "0005_phase_124_pos_lite"
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
for table in (
"pos_stations",
"pos_floor_plans",
"pos_tax_lines",
"pos_discounts",
"pos_payments",
):
if sa.inspect(bind).has_table(table):
op.drop_table(table)

View File

@ -0,0 +1,26 @@
"""Phase 12.6 — Kitchen schema."""
from alembic import op
import sqlalchemy as sa
from app.core.database import Base
import app.models # noqa: F401
revision = "0007_phase_126_kitchen"
down_revision = "0006_phase_125_pos_pro"
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
for table in (
"kitchen_ticket_items",
"kitchen_tickets",
"kitchen_stations",
):
if sa.inspect(bind).has_table(table):
op.drop_table(table)

View File

@ -0,0 +1,25 @@
"""Phase 12.7 — Connectors schema."""
from alembic import op
import sqlalchemy as sa
from app.core.database import Base
import app.models # noqa: F401
revision = "0008_phase_127_connectors"
down_revision = "0007_phase_126_kitchen"
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
for table in (
"connector_dispatches",
"connector_registrations",
):
if sa.inspect(bind).has_table(table):
op.drop_table(table)

View File

@ -0,0 +1,25 @@
"""Phase 12.8 — Analytics schema."""
from alembic import op
import sqlalchemy as sa
from app.core.database import Base
import app.models # noqa: F401
revision = "0009_phase_128_analytics"
down_revision = "0008_phase_127_connectors"
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
for table in (
"analytics_snapshots",
"analytics_report_definitions",
):
if sa.inspect(bind).has_table(table):
op.drop_table(table)

View File

@ -1 +1 @@
__version__ = "0.10.0.0" __version__ = "0.12.8.0"

View File

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

View File

@ -0,0 +1,77 @@
from fastapi import APIRouter
from app.api.v1 import (
analytics,
branches,
bundle_definitions,
catalog,
configurations,
connectors,
dining_areas,
events,
feature_toggles,
kitchen,
menus,
menu_categories,
menu_items,
permissions,
pos_lite,
pos_pro,
qr,
roles,
settings,
table_service,
tables,
tenant_bundles,
venues,
)
api_router = APIRouter()
api_router.include_router(venues.router, prefix="/venues", tags=["venues"])
api_router.include_router(branches.router, prefix="/branches", tags=["branches"])
api_router.include_router(dining_areas.router, prefix="/dining-areas", tags=["dining-areas"])
api_router.include_router(tables.router, prefix="/tables", tags=["tables"])
api_router.include_router(menus.router, prefix="/menus", tags=["menus"])
api_router.include_router(menu_categories.router, prefix="/menu-categories", tags=["menu-categories"])
api_router.include_router(menu_items.router, prefix="/menu-items", tags=["menu-items"])
api_router.include_router(catalog.allergens_router, prefix="/allergens", tags=["allergens"])
api_router.include_router(catalog.modifier_groups_router, prefix="/modifier-groups", tags=["modifier-groups"])
api_router.include_router(catalog.modifier_options_router, prefix="/modifier-options", tags=["modifier-options"])
api_router.include_router(catalog.menu_item_modifiers_router, prefix="/menu-item-modifiers", tags=["menu-item-modifiers"])
api_router.include_router(catalog.menu_item_allergens_router, prefix="/menu-item-allergens", tags=["menu-item-allergens"])
api_router.include_router(catalog.availability_windows_router, prefix="/availability-windows", tags=["availability-windows"])
api_router.include_router(catalog.menu_media_refs_router, prefix="/menu-media-refs", tags=["menu-media-refs"])
api_router.include_router(catalog.menu_localizations_router, prefix="/menu-localizations", tags=["menu-localizations"])
api_router.include_router(qr.qr_codes_router, prefix="/qr-codes", tags=["qr-codes"])
api_router.include_router(qr.qr_menu_sessions_router, prefix="/qr-menu-sessions", tags=["qr-menu-sessions"])
api_router.include_router(qr.qr_ordering_sessions_router, prefix="/qr-ordering-sessions", tags=["qr-ordering-sessions"])
api_router.include_router(qr.carts_router, prefix="/carts", tags=["carts"])
api_router.include_router(qr.cart_lines_router, prefix="/cart-lines", tags=["cart-lines"])
api_router.include_router(table_service.reservations_router, prefix="/reservations", tags=["reservations"])
api_router.include_router(table_service.table_assignments_router, prefix="/table-assignments", tags=["table-assignments"])
api_router.include_router(table_service.service_requests_router, prefix="/service-requests", tags=["service-requests"])
api_router.include_router(table_service.waitlist_router, prefix="/waitlist", tags=["waitlist"])
api_router.include_router(pos_lite.pos_registers_router, prefix="/pos-registers", tags=["pos-registers"])
api_router.include_router(pos_lite.pos_shifts_router, prefix="/pos-shifts", tags=["pos-shifts"])
api_router.include_router(pos_lite.pos_tickets_router, prefix="/pos-tickets", tags=["pos-tickets"])
api_router.include_router(pos_lite.pos_ticket_lines_router, prefix="/pos-ticket-lines", tags=["pos-ticket-lines"])
api_router.include_router(pos_pro.pos_payments_router, prefix="/pos-payments", tags=["pos-payments"])
api_router.include_router(pos_pro.pos_discounts_router, prefix="/pos-discounts", tags=["pos-discounts"])
api_router.include_router(pos_pro.pos_tax_lines_router, prefix="/pos-tax-lines", tags=["pos-tax-lines"])
api_router.include_router(pos_pro.pos_floor_plans_router, prefix="/pos-floor-plans", tags=["pos-floor-plans"])
api_router.include_router(pos_pro.pos_stations_router, prefix="/pos-stations", tags=["pos-stations"])
api_router.include_router(kitchen.kitchen_stations_router, prefix="/kitchen-stations", tags=["kitchen-stations"])
api_router.include_router(kitchen.kitchen_tickets_router, prefix="/kitchen-tickets", tags=["kitchen-tickets"])
api_router.include_router(kitchen.kitchen_ticket_items_router, prefix="/kitchen-ticket-items", tags=["kitchen-ticket-items"])
api_router.include_router(connectors.connector_registrations_router, prefix="/connector-registrations", tags=["connector-registrations"])
api_router.include_router(connectors.connector_dispatches_router, prefix="/connector-dispatches", tags=["connector-dispatches"])
api_router.include_router(analytics.analytics_reports_router, prefix="/analytics-reports", tags=["analytics-reports"])
api_router.include_router(analytics.analytics_snapshots_router, prefix="/analytics-snapshots", tags=["analytics-snapshots"])
api_router.include_router(bundle_definitions.router, prefix="/bundle-definitions", tags=["bundle-definitions"])
api_router.include_router(tenant_bundles.router, prefix="/tenant-bundles", tags=["tenant-bundles"])
api_router.include_router(feature_toggles.router, prefix="/feature-toggles", tags=["feature-toggles"])
api_router.include_router(roles.router, prefix="/roles", tags=["roles"])
api_router.include_router(permissions.router, prefix="/permissions", tags=["permissions"])
api_router.include_router(configurations.router, prefix="/configurations", tags=["configurations"])
api_router.include_router(events.router, prefix="/events", tags=["events"])
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])

View File

@ -0,0 +1,90 @@
"""Analytics APIs — Phase 12.8."""
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.core.security import get_current_user
from app.schemas.analytics import (
AnalyticsReportDefinitionCreate,
AnalyticsReportDefinitionRead,
AnalyticsSnapshotRead,
AnalyticsSnapshotRefresh,
)
from app.services.analytics import AnalyticsReportDefinitionService, AnalyticsSnapshotService
from shared.pagination import PaginationParams
from shared.security import CurrentUser
analytics_reports_router = APIRouter()
analytics_snapshots_router = APIRouter()
@analytics_reports_router.post(
"", response_model=AnalyticsReportDefinitionRead, status_code=status.HTTP_201_CREATED
)
async def create_analytics_report(
body: AnalyticsReportDefinitionCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await AnalyticsReportDefinitionService(db).create(tenant_id, body, actor=user)
@analytics_reports_router.get("", response_model=list[AnalyticsReportDefinitionRead])
async def list_analytics_reports(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await AnalyticsReportDefinitionService(db).list(
tenant_id, offset=pagination.offset, limit=pagination.page_size
)
@analytics_reports_router.get("/{report_id}", response_model=AnalyticsReportDefinitionRead)
async def get_analytics_report(
report_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await AnalyticsReportDefinitionService(db).get(tenant_id, report_id)
@analytics_snapshots_router.post(
"/refresh", response_model=AnalyticsSnapshotRead, status_code=status.HTTP_201_CREATED
)
async def refresh_analytics_snapshot(
body: AnalyticsSnapshotRefresh,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await AnalyticsSnapshotService(db).refresh(tenant_id, body, actor=user)
@analytics_snapshots_router.get("", response_model=list[AnalyticsSnapshotRead])
async def list_analytics_snapshots(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await AnalyticsSnapshotService(db).list(
tenant_id, offset=pagination.offset, limit=pagination.page_size
)
@analytics_snapshots_router.get("/{snapshot_id}", response_model=AnalyticsSnapshotRead)
async def get_analytics_snapshot(
snapshot_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await AnalyticsSnapshotService(db).get(tenant_id, snapshot_id)

View File

@ -0,0 +1,72 @@
"""Hospitality branches APIs — Phase 12.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.core.security import get_current_user
from app.schemas.foundation import (
BranchCreate,
BranchRead,
BranchUpdate,
)
from app.services.foundation import BranchService
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
@router.post("", response_model=BranchRead, status_code=status.HTTP_201_CREATED)
async def create_item(
body: BranchCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await BranchService(db).create(tenant_id, body, actor=user)
@router.get("", response_model=list[BranchRead])
async def list_items(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await BranchService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
@router.get("/{branch_id}", response_model=BranchRead)
async def get_item(
branch_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await BranchService(db).get(tenant_id, branch_id)
@router.patch("/{branch_id}", response_model=BranchRead)
async def update_item(
branch_id: UUID,
body: BranchUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await BranchService(db).update(tenant_id, branch_id, body, actor=user)
@router.post("/{branch_id}/delete", response_model=BranchRead)
async def soft_delete_item(
branch_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await BranchService(db).soft_delete(tenant_id, branch_id, actor=user)

View File

@ -0,0 +1,49 @@
"""Hospitality bundle-definitions APIs — Phase 12.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.core.security import get_current_user
from app.schemas.foundation import (
BundleDefinitionCreate,
BundleDefinitionRead,
)
from app.services.foundation import BundleDefinitionService
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
@router.post("", response_model=BundleDefinitionRead, status_code=status.HTTP_201_CREATED)
async def create_item(
body: BundleDefinitionCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await BundleDefinitionService(db).create(tenant_id, body, actor=user)
@router.get("", response_model=list[BundleDefinitionRead])
async def list_items(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await BundleDefinitionService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
@router.get("/{bundle_id}", response_model=BundleDefinitionRead)
async def get_item(
bundle_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await BundleDefinitionService(db).get(tenant_id, bundle_id)

View File

@ -0,0 +1,269 @@
"""Digital Menu Catalog APIs — Phase 12.1."""
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.core.security import get_current_user
from app.schemas.catalog import (
AllergenCreate,
AllergenRead,
AvailabilityWindowCreate,
AvailabilityWindowRead,
MenuItemAllergenLinkCreate,
MenuItemAllergenLinkRead,
MenuItemModifierLinkCreate,
MenuItemModifierLinkRead,
MenuLocalizationRead,
MenuLocalizationUpsert,
MenuMediaRefCreate,
MenuMediaRefRead,
ModifierGroupCreate,
ModifierGroupRead,
ModifierOptionCreate,
ModifierOptionRead,
)
from app.services.catalog import (
AllergenService,
AvailabilityWindowService,
MenuItemAllergenService,
MenuItemModifierService,
MenuLocalizationService,
MenuMediaRefService,
ModifierGroupService,
ModifierOptionService,
)
from shared.pagination import PaginationParams
from shared.security import CurrentUser
allergens_router = APIRouter()
modifier_groups_router = APIRouter()
modifier_options_router = APIRouter()
menu_item_modifiers_router = APIRouter()
menu_item_allergens_router = APIRouter()
availability_windows_router = APIRouter()
menu_media_refs_router = APIRouter()
menu_localizations_router = APIRouter()
@allergens_router.post("", response_model=AllergenRead, status_code=status.HTTP_201_CREATED)
async def create_allergen(
body: AllergenCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await AllergenService(db).create(tenant_id, body, actor=user)
@allergens_router.get("", response_model=list[AllergenRead])
async def list_allergens(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await AllergenService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
@allergens_router.get("/{allergen_id}", response_model=AllergenRead)
async def get_allergen(
allergen_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await AllergenService(db).get(tenant_id, allergen_id)
@modifier_groups_router.post("", response_model=ModifierGroupRead, status_code=status.HTTP_201_CREATED)
async def create_modifier_group(
body: ModifierGroupCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await ModifierGroupService(db).create(tenant_id, body, actor=user)
@modifier_groups_router.get("", response_model=list[ModifierGroupRead])
async def list_modifier_groups(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await ModifierGroupService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
@modifier_groups_router.get("/{group_id}", response_model=ModifierGroupRead)
async def get_modifier_group(
group_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await ModifierGroupService(db).get(tenant_id, group_id)
@modifier_options_router.post("", response_model=ModifierOptionRead, status_code=status.HTTP_201_CREATED)
async def create_modifier_option(
body: ModifierOptionCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await ModifierOptionService(db).create(tenant_id, body, actor=user)
@modifier_options_router.get("", response_model=list[ModifierOptionRead])
async def list_modifier_options(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await ModifierOptionService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
@modifier_options_router.get("/{option_id}", response_model=ModifierOptionRead)
async def get_modifier_option(
option_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await ModifierOptionService(db).get(tenant_id, option_id)
@menu_item_modifiers_router.post("/link", response_model=MenuItemModifierLinkRead, status_code=status.HTTP_201_CREATED)
async def link_modifier(
body: MenuItemModifierLinkCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await MenuItemModifierService(db).link(tenant_id, body, actor=user)
@menu_item_modifiers_router.get("", response_model=list[MenuItemModifierLinkRead])
async def list_modifier_links(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await MenuItemModifierService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
@menu_item_allergens_router.post("/link", response_model=MenuItemAllergenLinkRead, status_code=status.HTTP_201_CREATED)
async def link_allergen(
body: MenuItemAllergenLinkCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await MenuItemAllergenService(db).link(tenant_id, body, actor=user)
@menu_item_allergens_router.get("", response_model=list[MenuItemAllergenLinkRead])
async def list_allergen_links(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await MenuItemAllergenService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
@availability_windows_router.post("", response_model=AvailabilityWindowRead, status_code=status.HTTP_201_CREATED)
async def create_window(
body: AvailabilityWindowCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await AvailabilityWindowService(db).create(tenant_id, body, actor=user)
@availability_windows_router.get("", response_model=list[AvailabilityWindowRead])
async def list_windows(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await AvailabilityWindowService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
@availability_windows_router.get("/{window_id}", response_model=AvailabilityWindowRead)
async def get_window(
window_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await AvailabilityWindowService(db).get(tenant_id, window_id)
@menu_media_refs_router.post("", response_model=MenuMediaRefRead, status_code=status.HTTP_201_CREATED)
async def create_media(
body: MenuMediaRefCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await MenuMediaRefService(db).create(tenant_id, body, actor=user)
@menu_media_refs_router.get("", response_model=list[MenuMediaRefRead])
async def list_media(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await MenuMediaRefService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
@menu_media_refs_router.get("/{media_id}", response_model=MenuMediaRefRead)
async def get_media(
media_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await MenuMediaRefService(db).get(tenant_id, media_id)
@menu_localizations_router.post("/upsert", response_model=MenuLocalizationRead)
async def upsert_localization(
body: MenuLocalizationUpsert,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await MenuLocalizationService(db).upsert(tenant_id, body, actor=user)
@menu_localizations_router.get("", response_model=list[MenuLocalizationRead])
async def list_localizations(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await MenuLocalizationService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
@menu_localizations_router.get("/{localization_id}", response_model=MenuLocalizationRead)
async def get_localization(
localization_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await MenuLocalizationService(db).get(tenant_id, localization_id)

View File

@ -0,0 +1,61 @@
"""Hospitality configurations APIs — Phase 12.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.core.security import get_current_user
from app.schemas.foundation import (
HospitalityConfigurationCreate,
HospitalityConfigurationRead,
HospitalityConfigurationUpdate,
)
from app.services.foundation import HospitalityConfigurationService
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
@router.post("", response_model=HospitalityConfigurationRead, status_code=status.HTTP_201_CREATED)
async def create_item(
body: HospitalityConfigurationCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await HospitalityConfigurationService(db).create(tenant_id, body, actor=user)
@router.get("", response_model=list[HospitalityConfigurationRead])
async def list_items(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await HospitalityConfigurationService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
@router.get("/{config_id}", response_model=HospitalityConfigurationRead)
async def get_item(
config_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await HospitalityConfigurationService(db).get(tenant_id, config_id)
@router.patch("/{config_id}", response_model=HospitalityConfigurationRead)
async def update_item(
config_id: UUID,
body: HospitalityConfigurationUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await HospitalityConfigurationService(db).update(tenant_id, config_id, body, actor=user)

View File

@ -0,0 +1,92 @@
"""Connector APIs — Phase 12.7."""
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.core.security import get_current_user
from app.schemas.connectors import (
ConnectorDispatchCreate,
ConnectorDispatchRead,
ConnectorRegistrationCreate,
ConnectorRegistrationRead,
)
from app.services.connectors import ConnectorDispatchService, ConnectorRegistrationService
from shared.pagination import PaginationParams
from shared.security import CurrentUser
connector_registrations_router = APIRouter()
connector_dispatches_router = APIRouter()
@connector_registrations_router.post(
"", response_model=ConnectorRegistrationRead, status_code=status.HTTP_201_CREATED
)
async def create_connector_registration(
body: ConnectorRegistrationCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await ConnectorRegistrationService(db).create(tenant_id, body, actor=user)
@connector_registrations_router.get("", response_model=list[ConnectorRegistrationRead])
async def list_connector_registrations(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await ConnectorRegistrationService(db).list(
tenant_id, offset=pagination.offset, limit=pagination.page_size
)
@connector_registrations_router.get(
"/{registration_id}", response_model=ConnectorRegistrationRead
)
async def get_connector_registration(
registration_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await ConnectorRegistrationService(db).get(tenant_id, registration_id)
@connector_dispatches_router.post(
"", response_model=ConnectorDispatchRead, status_code=status.HTTP_201_CREATED
)
async def create_connector_dispatch(
body: ConnectorDispatchCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await ConnectorDispatchService(db).create(tenant_id, body, actor=user)
@connector_dispatches_router.get("", response_model=list[ConnectorDispatchRead])
async def list_connector_dispatches(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await ConnectorDispatchService(db).list(
tenant_id, offset=pagination.offset, limit=pagination.page_size
)
@connector_dispatches_router.get("/{dispatch_id}", response_model=ConnectorDispatchRead)
async def get_connector_dispatch(
dispatch_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await ConnectorDispatchService(db).get(tenant_id, dispatch_id)

View File

@ -0,0 +1,49 @@
"""Hospitality dining-areas APIs — Phase 12.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.core.security import get_current_user
from app.schemas.foundation import (
DiningAreaCreate,
DiningAreaRead,
)
from app.services.foundation import DiningAreaService
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
@router.post("", response_model=DiningAreaRead, status_code=status.HTTP_201_CREATED)
async def create_item(
body: DiningAreaCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await DiningAreaService(db).create(tenant_id, body, actor=user)
@router.get("", response_model=list[DiningAreaRead])
async def list_items(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await DiningAreaService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
@router.get("/{area_id}", response_model=DiningAreaRead)
async def get_item(
area_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await DiningAreaService(db).get(tenant_id, area_id)

View File

@ -0,0 +1,49 @@
"""Hospitality events APIs — Phase 12.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.core.security import get_current_user
from app.schemas.foundation import (
HospitalityEventCreate,
HospitalityEventRead,
)
from app.services.foundation import HospitalityEventService
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
@router.post("", response_model=HospitalityEventRead, status_code=status.HTTP_201_CREATED)
async def create_item(
body: HospitalityEventCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await HospitalityEventService(db).create(tenant_id, body, actor=user)
@router.get("", response_model=list[HospitalityEventRead])
async def list_items(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await HospitalityEventService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
@router.get("/{event_id}", response_model=HospitalityEventRead)
async def get_item(
event_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await HospitalityEventService(db).get(tenant_id, event_id)

View File

@ -0,0 +1,46 @@
"""Hospitality feature toggles APIs — Phase 12.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.core.security import get_current_user
from app.schemas.foundation import FeatureToggleRead, FeatureToggleUpsert
from app.services.foundation import FeatureToggleService
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
@router.post("/upsert", response_model=FeatureToggleRead, status_code=status.HTTP_200_OK)
async def upsert_toggle(
body: FeatureToggleUpsert,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await FeatureToggleService(db).upsert(tenant_id, body, actor=user)
@router.get("", response_model=list[FeatureToggleRead])
async def list_items(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await FeatureToggleService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
@router.get("/{toggle_id}", response_model=FeatureToggleRead)
async def get_item(
toggle_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await FeatureToggleService(db).get(tenant_id, toggle_id)

View File

@ -0,0 +1,72 @@
from fastapi import APIRouter
from app import __version__
from app.core.config import settings
from app.models.types import BundleKey, VenueFormat
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": "12.8",
"product": "Torbat Food",
"platform": "Hospitality Platform",
"features": {
"foundation": True,
"feature_based_architecture": True,
"bundle_based_licensing": True,
"capability_discovery": True,
"feature_toggle": True,
"digital_menu_shell": True,
"digital_menu_catalog": True,
"table_service_shell": True,
"qr_menu": True,
"qr_ordering": True,
"table_service": True,
"reservation": True,
"pos_lite": True,
"pos_pro": True,
"kitchen": True,
"pos_engine": False,
"kitchen_engine": True,
"ordering_engine": False,
"reservation_engine": False,
"delivery_integration": True,
"accounting_integration": True,
"crm_integration": True,
"loyalty_integration": True,
"communication_integration": True,
"website_integration": True,
"analytics": True,
"ai": False,
},
"venue_formats": [item.value for item in VenueFormat],
"bundles": [item.value for item in BundleKey],
"independence": {
"standalone": True,
"superapp": True,
"integrations": [
"accounting",
"crm",
"loyalty",
"communication",
"delivery",
"website_builder",
"ai",
],
"integration_mode": "api_and_events_only",
},
}

View File

@ -0,0 +1,159 @@
"""Kitchen APIs — Phase 12.6."""
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.core.security import get_current_user
from app.schemas.kitchen import (
KitchenStationCreate,
KitchenStationRead,
KitchenTicketCreate,
KitchenTicketItemCreate,
KitchenTicketItemRead,
KitchenTicketItemStatusUpdate,
KitchenTicketRead,
KitchenTicketStatusUpdate,
)
from app.services.kitchen import (
KitchenStationService,
KitchenTicketItemService,
KitchenTicketService,
)
from shared.pagination import PaginationParams
from shared.security import CurrentUser
kitchen_stations_router = APIRouter()
kitchen_tickets_router = APIRouter()
kitchen_ticket_items_router = APIRouter()
@kitchen_stations_router.post(
"", response_model=KitchenStationRead, status_code=status.HTTP_201_CREATED
)
async def create_kitchen_station(
body: KitchenStationCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await KitchenStationService(db).create(tenant_id, body, actor=user)
@kitchen_stations_router.get("", response_model=list[KitchenStationRead])
async def list_kitchen_stations(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await KitchenStationService(db).list(
tenant_id, offset=pagination.offset, limit=pagination.page_size
)
@kitchen_stations_router.get("/{station_id}", response_model=KitchenStationRead)
async def get_kitchen_station(
station_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await KitchenStationService(db).get(tenant_id, station_id)
@kitchen_tickets_router.post(
"", response_model=KitchenTicketRead, status_code=status.HTTP_201_CREATED
)
async def create_kitchen_ticket(
body: KitchenTicketCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await KitchenTicketService(db).create(tenant_id, body, actor=user)
@kitchen_tickets_router.get("", response_model=list[KitchenTicketRead])
async def list_kitchen_tickets(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await KitchenTicketService(db).list(
tenant_id, offset=pagination.offset, limit=pagination.page_size
)
@kitchen_tickets_router.get("/{ticket_id}", response_model=KitchenTicketRead)
async def get_kitchen_ticket(
ticket_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await KitchenTicketService(db).get(tenant_id, ticket_id)
@kitchen_tickets_router.post("/{ticket_id}/status", response_model=KitchenTicketRead)
async def change_kitchen_ticket_status(
ticket_id: UUID,
body: KitchenTicketStatusUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await KitchenTicketService(db).change_status(
tenant_id, ticket_id, body.status, actor=user
)
@kitchen_ticket_items_router.post(
"", response_model=KitchenTicketItemRead, status_code=status.HTTP_201_CREATED
)
async def create_kitchen_ticket_item(
body: KitchenTicketItemCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await KitchenTicketItemService(db).create(tenant_id, body, actor=user)
@kitchen_ticket_items_router.get("", response_model=list[KitchenTicketItemRead])
async def list_kitchen_ticket_items(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await KitchenTicketItemService(db).list(
tenant_id, offset=pagination.offset, limit=pagination.page_size
)
@kitchen_ticket_items_router.get("/{item_id}", response_model=KitchenTicketItemRead)
async def get_kitchen_ticket_item(
item_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await KitchenTicketItemService(db).get(tenant_id, item_id)
@kitchen_ticket_items_router.post("/{item_id}/status", response_model=KitchenTicketItemRead)
async def change_kitchen_ticket_item_status(
item_id: UUID,
body: KitchenTicketItemStatusUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await KitchenTicketItemService(db).change_status(
tenant_id, item_id, body.status, actor=user
)

View File

@ -0,0 +1,49 @@
"""Hospitality menu-categories APIs — Phase 12.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.core.security import get_current_user
from app.schemas.foundation import (
MenuCategoryCreate,
MenuCategoryRead,
)
from app.services.foundation import MenuCategoryService
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
@router.post("", response_model=MenuCategoryRead, status_code=status.HTTP_201_CREATED)
async def create_item(
body: MenuCategoryCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await MenuCategoryService(db).create(tenant_id, body, actor=user)
@router.get("", response_model=list[MenuCategoryRead])
async def list_items(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await MenuCategoryService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
@router.get("/{category_id}", response_model=MenuCategoryRead)
async def get_item(
category_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await MenuCategoryService(db).get(tenant_id, category_id)

View File

@ -0,0 +1,49 @@
"""Hospitality menu-items APIs — Phase 12.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.core.security import get_current_user
from app.schemas.foundation import (
MenuItemCreate,
MenuItemRead,
)
from app.services.foundation import MenuItemService
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
@router.post("", response_model=MenuItemRead, status_code=status.HTTP_201_CREATED)
async def create_item(
body: MenuItemCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await MenuItemService(db).create(tenant_id, body, actor=user)
@router.get("", response_model=list[MenuItemRead])
async def list_items(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await MenuItemService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
@router.get("/{item_id}", response_model=MenuItemRead)
async def get_item(
item_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await MenuItemService(db).get(tenant_id, item_id)

View File

@ -0,0 +1,72 @@
"""Hospitality menus APIs — Phase 12.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.core.security import get_current_user
from app.schemas.foundation import (
MenuCreate,
MenuRead,
MenuUpdate,
)
from app.services.foundation import MenuService
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
@router.post("", response_model=MenuRead, status_code=status.HTTP_201_CREATED)
async def create_item(
body: MenuCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await MenuService(db).create(tenant_id, body, actor=user)
@router.get("", response_model=list[MenuRead])
async def list_items(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await MenuService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
@router.get("/{menu_id}", response_model=MenuRead)
async def get_item(
menu_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await MenuService(db).get(tenant_id, menu_id)
@router.patch("/{menu_id}", response_model=MenuRead)
async def update_item(
menu_id: UUID,
body: MenuUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await MenuService(db).update(tenant_id, menu_id, body, actor=user)
@router.post("/{menu_id}/delete", response_model=MenuRead)
async def soft_delete_item(
menu_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await MenuService(db).soft_delete(tenant_id, menu_id, actor=user)

View File

@ -0,0 +1,49 @@
"""Hospitality permissions APIs — Phase 12.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.core.security import get_current_user
from app.schemas.foundation import (
HospitalityPermissionCreate,
HospitalityPermissionRead,
)
from app.services.foundation import HospitalityPermissionService
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
@router.post("", response_model=HospitalityPermissionRead, status_code=status.HTTP_201_CREATED)
async def create_item(
body: HospitalityPermissionCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await HospitalityPermissionService(db).create(tenant_id, body, actor=user)
@router.get("", response_model=list[HospitalityPermissionRead])
async def list_items(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await HospitalityPermissionService(db).list(tenant_id, offset=pagination.offset, limit=pagination.page_size)
@router.get("/{permission_id}", response_model=HospitalityPermissionRead)
async def get_item(
permission_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await HospitalityPermissionService(db).get(tenant_id, permission_id)

View File

@ -0,0 +1,183 @@
"""POS Lite APIs — Phase 12.4."""
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.core.security import get_current_user
from app.schemas.pos_lite import (
PosRegisterCreate,
PosRegisterRead,
PosShiftCreate,
PosShiftRead,
PosTicketCreate,
PosTicketLineCreate,
PosTicketLineRead,
PosTicketRead,
)
from app.services.pos_lite import (
PosRegisterService,
PosShiftService,
PosTicketLineService,
PosTicketService,
)
from shared.pagination import PaginationParams
from shared.security import CurrentUser
pos_registers_router = APIRouter()
pos_shifts_router = APIRouter()
pos_tickets_router = APIRouter()
pos_ticket_lines_router = APIRouter()
@pos_registers_router.post("", response_model=PosRegisterRead, status_code=status.HTTP_201_CREATED)
async def create_pos_register(
body: PosRegisterCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await PosRegisterService(db).create(tenant_id, body, actor=user)
@pos_registers_router.get("", response_model=list[PosRegisterRead])
async def list_pos_registers(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await PosRegisterService(db).list(
tenant_id, offset=pagination.offset, limit=pagination.page_size
)
@pos_registers_router.get("/{register_id}", response_model=PosRegisterRead)
async def get_pos_register(
register_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await PosRegisterService(db).get(tenant_id, register_id)
@pos_shifts_router.post("", response_model=PosShiftRead, status_code=status.HTTP_201_CREATED)
async def open_pos_shift(
body: PosShiftCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await PosShiftService(db).create(tenant_id, body, actor=user)
@pos_shifts_router.get("", response_model=list[PosShiftRead])
async def list_pos_shifts(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await PosShiftService(db).list(
tenant_id, offset=pagination.offset, limit=pagination.page_size
)
@pos_shifts_router.get("/{shift_id}", response_model=PosShiftRead)
async def get_pos_shift(
shift_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await PosShiftService(db).get(tenant_id, shift_id)
@pos_shifts_router.post("/{shift_id}/close", response_model=PosShiftRead)
async def close_pos_shift(
shift_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await PosShiftService(db).close(tenant_id, shift_id, actor=user)
@pos_tickets_router.post("", response_model=PosTicketRead, status_code=status.HTTP_201_CREATED)
async def create_pos_ticket(
body: PosTicketCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await PosTicketService(db).create(tenant_id, body, actor=user)
@pos_tickets_router.get("", response_model=list[PosTicketRead])
async def list_pos_tickets(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await PosTicketService(db).list(
tenant_id, offset=pagination.offset, limit=pagination.page_size
)
@pos_tickets_router.get("/{ticket_id}", response_model=PosTicketRead)
async def get_pos_ticket(
ticket_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await PosTicketService(db).get(tenant_id, ticket_id)
@pos_tickets_router.post("/{ticket_id}/void", response_model=PosTicketRead)
async def void_pos_ticket(
ticket_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await PosTicketService(db).void(tenant_id, ticket_id, actor=user)
@pos_ticket_lines_router.post(
"", response_model=PosTicketLineRead, status_code=status.HTTP_201_CREATED
)
async def add_pos_ticket_line(
body: PosTicketLineCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(get_current_user),
):
return await PosTicketLineService(db).add(tenant_id, body, actor=user)
@pos_ticket_lines_router.get("", response_model=list[PosTicketLineRead])
async def list_pos_ticket_lines(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await PosTicketLineService(db).list(
tenant_id, offset=pagination.offset, limit=pagination.page_size
)
@pos_ticket_lines_router.get("/{line_id}", response_model=PosTicketLineRead)
async def get_pos_ticket_line(
line_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(get_current_user),
):
return await PosTicketLineService(db).get(tenant_id, line_id)

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