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:
parent
7978970783
commit
5c6a2e78cf
@ -15,7 +15,7 @@
|
|||||||
| [`docs/reference/services-contracts.md`](./docs/reference/services-contracts.md) | Service contracts |
|
| [`docs/reference/services-contracts.md`](./docs/reference/services-contracts.md) | Service contracts |
|
||||||
| [`docs/development/developer-guide.md`](./docs/development/developer-guide.md) | Developer guide |
|
| [`docs/development/developer-guide.md`](./docs/development/developer-guide.md) | Developer guide |
|
||||||
| [`docs/module-registry.md`](./docs/module-registry.md) | Module registry |
|
| [`docs/module-registry.md`](./docs/module-registry.md) | Module registry |
|
||||||
| [`docs/ai-framework/`](./docs/ai-framework/) | AI Development Framework (implementation lifecycle) |
|
| [`docs/ai-framework/`](./docs/ai-framework/) | Enterprise / AI Development Framework (implementation lifecycle; production-ready by default) |
|
||||||
| [`docs/provider-registry.md`](./docs/provider-registry.md) | Provider registry |
|
| [`docs/provider-registry.md`](./docs/provider-registry.md) | Provider registry |
|
||||||
| [`docs/glossary.md`](./docs/glossary.md) | Glossary |
|
| [`docs/glossary.md`](./docs/glossary.md) | Glossary |
|
||||||
| [`docs/progress.md`](./docs/progress.md) | Completed work |
|
| [`docs/progress.md`](./docs/progress.md) | Completed work |
|
||||||
@ -62,6 +62,7 @@ docker compose up -d --build
|
|||||||
- **Loyalty API:** http://localhost:8004/docs
|
- **Loyalty API:** http://localhost:8004/docs
|
||||||
- **Communication API:** http://localhost:8005/docs
|
- **Communication API:** http://localhost:8005/docs
|
||||||
- **Sports Center API:** http://localhost:8006/docs
|
- **Sports Center API:** http://localhost:8006/docs
|
||||||
|
- **Hospitality API:** http://localhost:8009/docs
|
||||||
- **Keycloak:** http://localhost:8080
|
- **Keycloak:** http://localhost:8080
|
||||||
|
|
||||||
Details: [`docs/development/developer-guide.md`](./docs/development/developer-guide.md).
|
Details: [`docs/development/developer-guide.md`](./docs/development/developer-guide.md).
|
||||||
|
|||||||
@ -0,0 +1,103 @@
|
|||||||
|
"""seed platform service registry and base features
|
||||||
|
|
||||||
|
Revision ID: 0006_platform_catalog_seed
|
||||||
|
Revises: 0005_tenant_onboarding
|
||||||
|
Create Date: 2026-07-27
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "0006_platform_catalog_seed"
|
||||||
|
down_revision: Union[str, None] = "0005_tenant_onboarding"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
SERVICES = [
|
||||||
|
("accounting", "حسابداری آنلاین", "http://accounting-service:8002"),
|
||||||
|
("healthcare", "تربت هلث", "http://healthcare-service:8010"),
|
||||||
|
("beauty", "تربت بیوتی", "http://beauty-business-service:8011"),
|
||||||
|
("crm", "سیستم CRM", "http://crm-service:8003"),
|
||||||
|
("loyalty", "تربت وفاداری", "http://loyalty-service:8004"),
|
||||||
|
("communication", "ارتباطات و پیامک", "http://communication-service:8005"),
|
||||||
|
("sports_center", "مرکز ورزشی", "http://sports-center-service:8006"),
|
||||||
|
("delivery", "تربت درایور", "http://delivery-service:8007"),
|
||||||
|
("experience", "پلتفرم تجربه", "http://experience-service:8008"),
|
||||||
|
("hospitality", "تربت فود", "http://hospitality-service:8009"),
|
||||||
|
("identity", "هویت و دسترسی", "http://identity-access-service:8001"),
|
||||||
|
]
|
||||||
|
|
||||||
|
FEATURES = [
|
||||||
|
("accounting.access", "دسترسی حسابداری", "accounting"),
|
||||||
|
("healthcare.access", "دسترسی سلامت", "healthcare"),
|
||||||
|
("beauty.access", "دسترسی زیبایی", "beauty"),
|
||||||
|
("crm.access", "دسترسی CRM", "crm"),
|
||||||
|
("loyalty.access", "دسترسی وفاداری", "loyalty"),
|
||||||
|
("communication.access", "دسترسی ارتباطات", "communication"),
|
||||||
|
("sports_center.access", "دسترسی مرکز ورزشی", "sports_center"),
|
||||||
|
("delivery.access", "دسترسی لجستیک", "delivery"),
|
||||||
|
("experience.access", "دسترسی تجربه", "experience"),
|
||||||
|
("hospitality.access", "دسترسی رستوران", "hospitality"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
for service_key, name, base_url in SERVICES:
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
INSERT INTO service_registry (id, service_key, name, base_url, health_check_url, status)
|
||||||
|
VALUES (CAST(:id AS uuid), :service_key, :name, :base_url, :health_url, 'active')
|
||||||
|
ON CONFLICT (service_key) DO NOTHING
|
||||||
|
"""
|
||||||
|
).bindparams(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
service_key=service_key,
|
||||||
|
name=name,
|
||||||
|
base_url=base_url,
|
||||||
|
health_url=f"{base_url}/health",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for feature_key, name, service_key in FEATURES:
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
INSERT INTO features (id, feature_key, name, description, service_key, is_active)
|
||||||
|
VALUES (CAST(:id AS uuid), :feature_key, :name, :description, :service_key, true)
|
||||||
|
ON CONFLICT (feature_key) DO NOTHING
|
||||||
|
"""
|
||||||
|
).bindparams(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
feature_key=feature_key,
|
||||||
|
name=name,
|
||||||
|
description=f"دسترسی پایه به سرویس {service_key}",
|
||||||
|
service_key=service_key,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for feature_key, _, _ in FEATURES:
|
||||||
|
op.execute(
|
||||||
|
sa.text(
|
||||||
|
"""
|
||||||
|
INSERT INTO plan_features (id, plan_id, feature_id, is_enabled)
|
||||||
|
SELECT CAST(:id AS uuid), p.id, f.id, true
|
||||||
|
FROM plans p
|
||||||
|
CROSS JOIN features f
|
||||||
|
WHERE p.code = 'FREE' AND f.feature_key = :feature_key
|
||||||
|
ON CONFLICT (plan_id, feature_id) DO NOTHING
|
||||||
|
"""
|
||||||
|
).bindparams(id=str(uuid.uuid4()), feature_key=feature_key)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
feature_keys = ", ".join(f"'{key}'" for key, _, _ in FEATURES)
|
||||||
|
service_keys = ", ".join(f"'{key}'" for key, _, _ in SERVICES)
|
||||||
|
op.execute(f"DELETE FROM plan_features WHERE feature_id IN (SELECT id FROM features WHERE feature_key IN ({feature_keys}))")
|
||||||
|
op.execute(f"DELETE FROM features WHERE feature_key IN ({feature_keys})")
|
||||||
|
op.execute(f"DELETE FROM service_registry WHERE service_key IN ({service_keys})")
|
||||||
175
backend/core-service/scripts/seed_platform_catalog.py
Normal file
175
backend/core-service/scripts/seed_platform_catalog.py
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Idempotent seed for service_registry and base platform features."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.models.enums import ServiceStatus
|
||||||
|
from app.models.plan import Feature, Plan, PlanFeature
|
||||||
|
from app.models.registry import ServiceRegistry
|
||||||
|
|
||||||
|
PLATFORM_SERVICES: list[dict[str, str | None]] = [
|
||||||
|
{
|
||||||
|
"service_key": "accounting",
|
||||||
|
"name": "حسابداری آنلاین",
|
||||||
|
"base_url": "http://accounting-service:8002",
|
||||||
|
"health_check_url": "http://accounting-service:8002/health",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"service_key": "healthcare",
|
||||||
|
"name": "تربت هلث",
|
||||||
|
"base_url": "http://healthcare-service:8010",
|
||||||
|
"health_check_url": "http://healthcare-service:8010/health",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"service_key": "beauty",
|
||||||
|
"name": "تربت بیوتی",
|
||||||
|
"base_url": "http://beauty-business-service:8011",
|
||||||
|
"health_check_url": "http://beauty-business-service:8011/health",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"service_key": "crm",
|
||||||
|
"name": "سیستم CRM",
|
||||||
|
"base_url": "http://crm-service:8003",
|
||||||
|
"health_check_url": "http://crm-service:8003/health",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"service_key": "loyalty",
|
||||||
|
"name": "تربت وفاداری",
|
||||||
|
"base_url": "http://loyalty-service:8004",
|
||||||
|
"health_check_url": "http://loyalty-service:8004/health",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"service_key": "communication",
|
||||||
|
"name": "ارتباطات و پیامک",
|
||||||
|
"base_url": "http://communication-service:8005",
|
||||||
|
"health_check_url": "http://communication-service:8005/health",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"service_key": "sports_center",
|
||||||
|
"name": "مرکز ورزشی",
|
||||||
|
"base_url": "http://sports-center-service:8006",
|
||||||
|
"health_check_url": "http://sports-center-service:8006/health",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"service_key": "delivery",
|
||||||
|
"name": "تربت درایور",
|
||||||
|
"base_url": "http://delivery-service:8007",
|
||||||
|
"health_check_url": "http://delivery-service:8007/health",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"service_key": "experience",
|
||||||
|
"name": "پلتفرم تجربه",
|
||||||
|
"base_url": "http://experience-service:8008",
|
||||||
|
"health_check_url": "http://experience-service:8008/health",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"service_key": "hospitality",
|
||||||
|
"name": "تربت فود",
|
||||||
|
"base_url": "http://hospitality-service:8009",
|
||||||
|
"health_check_url": "http://hospitality-service:8009/health",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"service_key": "identity",
|
||||||
|
"name": "هویت و دسترسی",
|
||||||
|
"base_url": "http://identity-access-service:8001",
|
||||||
|
"health_check_url": "http://identity-access-service:8001/health",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
PLATFORM_FEATURES: list[dict[str, str]] = [
|
||||||
|
{"feature_key": "accounting.access", "name": "دسترسی حسابداری", "service_key": "accounting"},
|
||||||
|
{"feature_key": "healthcare.access", "name": "دسترسی سلامت", "service_key": "healthcare"},
|
||||||
|
{"feature_key": "beauty.access", "name": "دسترسی زیبایی", "service_key": "beauty"},
|
||||||
|
{"feature_key": "crm.access", "name": "دسترسی CRM", "service_key": "crm"},
|
||||||
|
{"feature_key": "loyalty.access", "name": "دسترسی وفاداری", "service_key": "loyalty"},
|
||||||
|
{"feature_key": "communication.access", "name": "دسترسی ارتباطات", "service_key": "communication"},
|
||||||
|
{"feature_key": "sports_center.access", "name": "دسترسی مرکز ورزشی", "service_key": "sports_center"},
|
||||||
|
{"feature_key": "delivery.access", "name": "دسترسی لجستیک", "service_key": "delivery"},
|
||||||
|
{"feature_key": "experience.access", "name": "دسترسی تجربه", "service_key": "experience"},
|
||||||
|
{"feature_key": "hospitality.access", "name": "دسترسی رستوران", "service_key": "hospitality"},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def seed(session: AsyncSession) -> tuple[int, int]:
|
||||||
|
services_added = 0
|
||||||
|
features_added = 0
|
||||||
|
|
||||||
|
for row in PLATFORM_SERVICES:
|
||||||
|
existing = await session.scalar(
|
||||||
|
select(ServiceRegistry).where(ServiceRegistry.service_key == row["service_key"])
|
||||||
|
)
|
||||||
|
if existing is None:
|
||||||
|
session.add(
|
||||||
|
ServiceRegistry(
|
||||||
|
service_key=str(row["service_key"]),
|
||||||
|
name=str(row["name"]),
|
||||||
|
base_url=row["base_url"],
|
||||||
|
health_check_url=row["health_check_url"],
|
||||||
|
status=ServiceStatus.ACTIVE,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
services_added += 1
|
||||||
|
|
||||||
|
for row in PLATFORM_FEATURES:
|
||||||
|
existing = await session.scalar(
|
||||||
|
select(Feature).where(Feature.feature_key == row["feature_key"])
|
||||||
|
)
|
||||||
|
if existing is None:
|
||||||
|
session.add(
|
||||||
|
Feature(
|
||||||
|
feature_key=row["feature_key"],
|
||||||
|
name=row["name"],
|
||||||
|
description=f"دسترسی پایه به سرویس {row['service_key']}",
|
||||||
|
service_key=row["service_key"],
|
||||||
|
is_active=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
features_added += 1
|
||||||
|
|
||||||
|
await session.flush()
|
||||||
|
|
||||||
|
free_plan = await session.scalar(select(Plan).where(Plan.code == "FREE"))
|
||||||
|
if free_plan is not None:
|
||||||
|
for row in PLATFORM_FEATURES:
|
||||||
|
feature = await session.scalar(
|
||||||
|
select(Feature).where(Feature.feature_key == row["feature_key"])
|
||||||
|
)
|
||||||
|
if feature is None:
|
||||||
|
continue
|
||||||
|
link = await session.scalar(
|
||||||
|
select(PlanFeature).where(
|
||||||
|
PlanFeature.plan_id == free_plan.id,
|
||||||
|
PlanFeature.feature_id == feature.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if link is None:
|
||||||
|
session.add(
|
||||||
|
PlanFeature(
|
||||||
|
plan_id=free_plan.id,
|
||||||
|
feature_id=feature.id,
|
||||||
|
is_enabled=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
await session.commit()
|
||||||
|
return services_added, features_added
|
||||||
|
|
||||||
|
|
||||||
|
async def main() -> None:
|
||||||
|
engine = create_async_engine(settings.database_url, echo=False)
|
||||||
|
session_factory = async_sessionmaker(engine, expire_on_commit=False)
|
||||||
|
async with session_factory() as session:
|
||||||
|
services_added, features_added = await seed(session)
|
||||||
|
await engine.dispose()
|
||||||
|
print(
|
||||||
|
f"seed_platform_catalog: services_added={services_added}, features_added={features_added}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
22
backend/services/delivery/Dockerfile.dev
Normal file
22
backend/services/delivery/Dockerfile.dev
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
# Dev image: dependencies only — code mounted with uvicorn --reload
|
||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1 \
|
||||||
|
PYTHONPATH=/app
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apt-get update \
|
||||||
|
&& apt-get install -y --no-install-recommends build-essential libpq-dev \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY backend/shared-lib/ /shared-lib/
|
||||||
|
COPY backend/services/delivery/requirements.txt /app/requirements.txt
|
||||||
|
|
||||||
|
RUN sed -i 's#-e ../../shared-lib#-e /shared-lib#' /app/requirements.txt \
|
||||||
|
&& pip install --upgrade pip \
|
||||||
|
&& pip install -r requirements.txt
|
||||||
|
|
||||||
|
EXPOSE 8007
|
||||||
63
backend/services/delivery/README.md
Normal file
63
backend/services/delivery/README.md
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
# Delivery & Fleet Platform (Torbat Driver)
|
||||||
|
|
||||||
|
| Field | Value |
|
||||||
|
| --- | --- |
|
||||||
|
| Service | `delivery-service` |
|
||||||
|
| Database | `delivery_db` |
|
||||||
|
| Port | 8007 |
|
||||||
|
| Permission prefix | `delivery.*` |
|
||||||
|
| Version | 0.10.1.0 |
|
||||||
|
| Phase | 10.1 Driver Management |
|
||||||
|
| ADR | [ADR-015](../../../docs/architecture/adr/ADR-015.md) |
|
||||||
|
|
||||||
|
## Owns
|
||||||
|
|
||||||
|
- Delivery organizations, hubs, configuration/settings shells
|
||||||
|
- External provider / routing-engine **registrations** (adapters later)
|
||||||
|
- **Drivers**: profiles, lifecycle, credential/document refs, lifecycle history
|
||||||
|
- Delivery audit log + transactional outbox for driver events
|
||||||
|
- Health / capabilities / metrics discovery
|
||||||
|
- Publish-only `delivery.*` events
|
||||||
|
|
||||||
|
## Does not own
|
||||||
|
|
||||||
|
- Accounting journals / Posting Engine
|
||||||
|
- Communication SMS/email providers or message delivery timeline
|
||||||
|
- Loyalty ledger
|
||||||
|
- Vertical order aggregates (Hospitality, Marketplace, Store, Pharmacy, Clinic, Sports Center)
|
||||||
|
- Fleet / vehicles / dispatch / routing / tracking **engines** (later phases)
|
||||||
|
- Identity user admin / CRM contact master / Storage blobs
|
||||||
|
- Driver App / Dispatcher Panel UI (frontend)
|
||||||
|
|
||||||
|
## APIs
|
||||||
|
|
||||||
|
| Method | Path |
|
||||||
|
| --- | --- |
|
||||||
|
| GET | `/health` |
|
||||||
|
| GET | `/capabilities` |
|
||||||
|
| GET | `/metrics` |
|
||||||
|
| CRUD | `/api/v1/organizations` |
|
||||||
|
| CRUD | `/api/v1/hubs` |
|
||||||
|
| CRUD | `/api/v1/external-providers` |
|
||||||
|
| CRUD | `/api/v1/routing-engines` |
|
||||||
|
| CRUD | `/api/v1/configurations` |
|
||||||
|
| PUT/GET | `/api/v1/settings` |
|
||||||
|
| GET | `/api/v1/audit` |
|
||||||
|
| CRUD + lifecycle | `/api/v1/drivers` |
|
||||||
|
| GET | `/api/v1/permissions/catalog` |
|
||||||
|
|
||||||
|
## Run (dev)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export DELIVERY_DATABASE_URL=postgresql+asyncpg://...
|
||||||
|
export DELIVERY_DATABASE_URL_SYNC=postgresql+psycopg://...
|
||||||
|
python scripts/ensure_db.py
|
||||||
|
alembic upgrade head
|
||||||
|
uvicorn app.main:app --host 0.0.0.0 --port 8007 --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pytest
|
||||||
|
```
|
||||||
41
backend/services/delivery/alembic.ini
Normal file
41
backend/services/delivery/alembic.ini
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
prepend_sys_path = .
|
||||||
|
path_separator = os
|
||||||
|
version_path_separator = os
|
||||||
|
|
||||||
|
sqlalchemy.url = driver://user:pass@localhost/dbname
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
42
backend/services/delivery/alembic/env.py
Normal file
42
backend/services/delivery/alembic/env.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import Base
|
||||||
|
import app.models # noqa: F401
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
config.set_main_option("sqlalchemy.url", settings.database_url_sync)
|
||||||
|
if config.config_file_name:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline():
|
||||||
|
context.configure(
|
||||||
|
url=settings.database_url_sync,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online():
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
19
backend/services/delivery/alembic/versions/0001_initial.py
Normal file
19
backend/services/delivery/alembic/versions/0001_initial.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
"""Initial Delivery schema — Phase 10.0 foundation."""
|
||||||
|
from alembic import op
|
||||||
|
from app.core.database import Base
|
||||||
|
import app.models # noqa: F401
|
||||||
|
|
||||||
|
revision = "0001_initial"
|
||||||
|
down_revision = None
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
Base.metadata.create_all(bind=bind)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
Base.metadata.drop_all(bind=bind)
|
||||||
@ -0,0 +1,30 @@
|
|||||||
|
"""Phase 10.1 — Driver Management schema (additive)."""
|
||||||
|
from alembic import op
|
||||||
|
from app.core.database import Base
|
||||||
|
import app.models # noqa: F401
|
||||||
|
|
||||||
|
revision = "0002_phase_101_drivers"
|
||||||
|
down_revision = "0001_initial"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_NEW_TABLES = (
|
||||||
|
"drivers",
|
||||||
|
"driver_credentials",
|
||||||
|
"driver_documents",
|
||||||
|
"driver_lifecycle_events",
|
||||||
|
"outbox_events",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
Base.metadata.create_all(bind=bind)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
for name in reversed(_NEW_TABLES):
|
||||||
|
table = Base.metadata.tables.get(name)
|
||||||
|
if table is not None:
|
||||||
|
table.drop(bind=bind, checkfirst=True)
|
||||||
1
backend/services/delivery/app/__init__.py
Normal file
1
backend/services/delivery/app/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
__version__ = "0.10.1.0"
|
||||||
39
backend/services/delivery/app/api/deps.py
Normal file
39
backend/services/delivery/app/api/deps.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
"""Common API dependencies."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import Depends, Query, Request
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from shared.exceptions import TenantNotResolvedError
|
||||||
|
from shared.pagination import PaginationParams
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
from shared.tenant import STATE_TENANT_ID
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"get_db",
|
||||||
|
"get_pagination",
|
||||||
|
"require_tenant",
|
||||||
|
"get_current_user",
|
||||||
|
"AsyncSession",
|
||||||
|
"CurrentUser",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_pagination(
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=20, ge=1, le=500),
|
||||||
|
) -> PaginationParams:
|
||||||
|
return PaginationParams(page=page, page_size=page_size)
|
||||||
|
|
||||||
|
|
||||||
|
def require_tenant(request: Request) -> UUID:
|
||||||
|
tenant_id = getattr(request.state, STATE_TENANT_ID, None)
|
||||||
|
if tenant_id is None:
|
||||||
|
raise TenantNotResolvedError(
|
||||||
|
"tenant قابل تشخیص نبود. هدر X-Tenant-ID را ارسال کنید."
|
||||||
|
)
|
||||||
|
return tenant_id
|
||||||
48
backend/services/delivery/app/api/permissions.py
Normal file
48
backend/services/delivery/app/api/permissions.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
"""Permission enforcement helpers for Delivery APIs."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from shared.exceptions import ForbiddenError
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
_ADMIN_ROLES = frozenset({"platform_admin", "tenant_owner", "tenant_admin"})
|
||||||
|
|
||||||
|
|
||||||
|
def user_has_permission(user: CurrentUser, permission: str) -> bool:
|
||||||
|
if any(role in _ADMIN_ROLES for role in user.roles):
|
||||||
|
return True
|
||||||
|
roles = set(user.roles)
|
||||||
|
if "delivery.manage" in roles or permission in roles:
|
||||||
|
return True
|
||||||
|
if "delivery.view" in roles and permission.endswith(".view"):
|
||||||
|
return True
|
||||||
|
parts = permission.split(".")
|
||||||
|
if len(parts) >= 3:
|
||||||
|
manage = f"{parts[0]}.{parts[1]}.manage"
|
||||||
|
if manage in roles:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def require_permissions(*permissions: str) -> Callable:
|
||||||
|
"""Deny unless AUTH is off, user is admin, or any listed permission is present."""
|
||||||
|
|
||||||
|
async def _dependency(
|
||||||
|
user: CurrentUser = Depends(get_current_user),
|
||||||
|
) -> CurrentUser:
|
||||||
|
if not settings.auth_required:
|
||||||
|
return user
|
||||||
|
if any(user_has_permission(user, perm) for perm in permissions):
|
||||||
|
return user
|
||||||
|
raise ForbiddenError(
|
||||||
|
"دسترسی مجاز نیست",
|
||||||
|
error_code="permission_denied",
|
||||||
|
details={"required": list(permissions)},
|
||||||
|
)
|
||||||
|
|
||||||
|
return _dependency
|
||||||
36
backend/services/delivery/app/api/v1/__init__.py
Normal file
36
backend/services/delivery/app/api/v1/__init__.py
Normal 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"]
|
||||||
|
)
|
||||||
30
backend/services/delivery/app/api/v1/audit.py
Normal file
30
backend/services/delivery/app/api/v1/audit.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
"""Delivery audit APIs — Phase 10.0."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, require_tenant
|
||||||
|
from app.api.permissions import require_permissions
|
||||||
|
from app.permissions.definitions import AUDIT_VIEW
|
||||||
|
from app.schemas.foundation import DeliveryAuditLogRead
|
||||||
|
from app.services.audit_service import AuditService
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[DeliveryAuditLogRead])
|
||||||
|
async def list_audit(
|
||||||
|
entity_type: str = Query(...),
|
||||||
|
entity_id: UUID = Query(...),
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(AUDIT_VIEW)),
|
||||||
|
):
|
||||||
|
return await AuditService(db).list_for_entity(
|
||||||
|
tenant_id, entity_type, entity_id, limit=limit
|
||||||
|
)
|
||||||
83
backend/services/delivery/app/api/v1/configurations.py
Normal file
83
backend/services/delivery/app/api/v1/configurations.py
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
"""Delivery configuration APIs — Phase 10.0."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, get_pagination, require_tenant
|
||||||
|
from app.api.permissions import require_permissions
|
||||||
|
from app.permissions.definitions import (
|
||||||
|
CONFIGURATIONS_CREATE,
|
||||||
|
CONFIGURATIONS_DELETE,
|
||||||
|
CONFIGURATIONS_UPDATE,
|
||||||
|
CONFIGURATIONS_VIEW,
|
||||||
|
)
|
||||||
|
from app.schemas.foundation import (
|
||||||
|
DeliveryConfigurationCreate,
|
||||||
|
DeliveryConfigurationRead,
|
||||||
|
DeliveryConfigurationUpdate,
|
||||||
|
)
|
||||||
|
from app.services.foundation import DeliveryConfigurationService
|
||||||
|
from shared.pagination import PaginationParams
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=DeliveryConfigurationRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_configuration(
|
||||||
|
body: DeliveryConfigurationCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_CREATE)),
|
||||||
|
):
|
||||||
|
return await DeliveryConfigurationService(db).create(tenant_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[DeliveryConfigurationRead])
|
||||||
|
async def list_configurations(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
pagination: PaginationParams = Depends(get_pagination),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_VIEW)),
|
||||||
|
):
|
||||||
|
return await DeliveryConfigurationService(db).list(
|
||||||
|
tenant_id, offset=pagination.offset, limit=pagination.page_size
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{configuration_id}", response_model=DeliveryConfigurationRead)
|
||||||
|
async def get_configuration(
|
||||||
|
configuration_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_VIEW)),
|
||||||
|
):
|
||||||
|
return await DeliveryConfigurationService(db).get(tenant_id, configuration_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{configuration_id}", response_model=DeliveryConfigurationRead)
|
||||||
|
async def update_configuration(
|
||||||
|
configuration_id: UUID,
|
||||||
|
body: DeliveryConfigurationUpdate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_UPDATE)),
|
||||||
|
):
|
||||||
|
return await DeliveryConfigurationService(db).update(
|
||||||
|
tenant_id, configuration_id, body, actor=user
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{configuration_id}/delete", response_model=DeliveryConfigurationRead)
|
||||||
|
async def soft_delete_configuration(
|
||||||
|
configuration_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(CONFIGURATIONS_DELETE)),
|
||||||
|
):
|
||||||
|
return await DeliveryConfigurationService(db).soft_delete(
|
||||||
|
tenant_id, configuration_id, actor=user
|
||||||
|
)
|
||||||
273
backend/services/delivery/app/api/v1/drivers.py
Normal file
273
backend/services/delivery/app/api/v1/drivers.py
Normal file
@ -0,0 +1,273 @@
|
|||||||
|
"""Driver management APIs — Phase 10.1."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, get_pagination, require_tenant
|
||||||
|
from app.api.permissions import require_permissions
|
||||||
|
from app.commands.drivers import DriverCommands
|
||||||
|
from app.models.types import DriverStatus
|
||||||
|
from app.permissions.definitions import (
|
||||||
|
DRIVERS_ACTIVATE,
|
||||||
|
DRIVERS_ARCHIVE,
|
||||||
|
DRIVERS_BLOCK,
|
||||||
|
DRIVERS_CREATE,
|
||||||
|
DRIVERS_CREDENTIALS_MANAGE,
|
||||||
|
DRIVERS_CREDENTIALS_VIEW,
|
||||||
|
DRIVERS_DEACTIVATE,
|
||||||
|
DRIVERS_DELETE,
|
||||||
|
DRIVERS_DOCUMENTS_MANAGE,
|
||||||
|
DRIVERS_DOCUMENTS_VIEW,
|
||||||
|
DRIVERS_LIFECYCLE_VIEW,
|
||||||
|
DRIVERS_RESUME,
|
||||||
|
DRIVERS_SUSPEND,
|
||||||
|
DRIVERS_UNBLOCK,
|
||||||
|
DRIVERS_UPDATE,
|
||||||
|
DRIVERS_VIEW,
|
||||||
|
)
|
||||||
|
from app.queries.drivers import DriverQueries
|
||||||
|
from app.schemas.drivers import (
|
||||||
|
DriverCreate,
|
||||||
|
DriverCredentialCreate,
|
||||||
|
DriverCredentialRead,
|
||||||
|
DriverDocumentCreate,
|
||||||
|
DriverDocumentRead,
|
||||||
|
DriverLifecycleEventRead,
|
||||||
|
DriverLifecycleRequest,
|
||||||
|
DriverListResponse,
|
||||||
|
DriverRead,
|
||||||
|
DriverUpdate,
|
||||||
|
)
|
||||||
|
from app.specifications.drivers import ALLOWED_SORT_FIELDS, DriverListSpec
|
||||||
|
from shared.pagination import PaginationParams
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _list_spec(
|
||||||
|
status_filter: DriverStatus | None = Query(default=None, alias="status"),
|
||||||
|
organization_id: UUID | None = Query(default=None),
|
||||||
|
hub_id: UUID | None = Query(default=None),
|
||||||
|
q: str | None = Query(default=None, max_length=100),
|
||||||
|
sort_by: str = Query(default="created_at"),
|
||||||
|
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
||||||
|
) -> DriverListSpec:
|
||||||
|
if sort_by not in ALLOWED_SORT_FIELDS:
|
||||||
|
sort_by = "created_at"
|
||||||
|
return DriverListSpec(
|
||||||
|
status=status_filter,
|
||||||
|
organization_id=organization_id,
|
||||||
|
hub_id=hub_id,
|
||||||
|
q=q,
|
||||||
|
sort_by=sort_by,
|
||||||
|
sort_dir=sort_dir.lower(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=DriverRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_driver(
|
||||||
|
body: DriverCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(DRIVERS_CREATE)),
|
||||||
|
):
|
||||||
|
return await DriverCommands(db).create(tenant_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=DriverListResponse)
|
||||||
|
async def list_drivers(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
pagination: PaginationParams = Depends(get_pagination),
|
||||||
|
spec: DriverListSpec = Depends(_list_spec),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(DRIVERS_VIEW)),
|
||||||
|
):
|
||||||
|
items, total = await DriverQueries(db).list(
|
||||||
|
tenant_id,
|
||||||
|
offset=pagination.offset,
|
||||||
|
limit=pagination.page_size,
|
||||||
|
spec=spec,
|
||||||
|
)
|
||||||
|
return DriverListResponse(
|
||||||
|
items=items,
|
||||||
|
total=total,
|
||||||
|
page=pagination.page,
|
||||||
|
page_size=pagination.page_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{driver_id}", response_model=DriverRead)
|
||||||
|
async def get_driver(
|
||||||
|
driver_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(DRIVERS_VIEW)),
|
||||||
|
):
|
||||||
|
return await DriverQueries(db).get(tenant_id, driver_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{driver_id}", response_model=DriverRead)
|
||||||
|
async def update_driver(
|
||||||
|
driver_id: UUID,
|
||||||
|
body: DriverUpdate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(DRIVERS_UPDATE)),
|
||||||
|
):
|
||||||
|
return await DriverCommands(db).update(tenant_id, driver_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{driver_id}/activate", response_model=DriverRead)
|
||||||
|
async def activate_driver(
|
||||||
|
driver_id: UUID,
|
||||||
|
body: DriverLifecycleRequest,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(DRIVERS_ACTIVATE)),
|
||||||
|
):
|
||||||
|
return await DriverCommands(db).activate(tenant_id, driver_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{driver_id}/suspend", response_model=DriverRead)
|
||||||
|
async def suspend_driver(
|
||||||
|
driver_id: UUID,
|
||||||
|
body: DriverLifecycleRequest,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(DRIVERS_SUSPEND)),
|
||||||
|
):
|
||||||
|
return await DriverCommands(db).suspend(tenant_id, driver_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{driver_id}/resume", response_model=DriverRead)
|
||||||
|
async def resume_driver(
|
||||||
|
driver_id: UUID,
|
||||||
|
body: DriverLifecycleRequest,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(DRIVERS_RESUME)),
|
||||||
|
):
|
||||||
|
return await DriverCommands(db).resume(tenant_id, driver_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{driver_id}/deactivate", response_model=DriverRead)
|
||||||
|
async def deactivate_driver(
|
||||||
|
driver_id: UUID,
|
||||||
|
body: DriverLifecycleRequest,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(DRIVERS_DEACTIVATE)),
|
||||||
|
):
|
||||||
|
return await DriverCommands(db).deactivate(tenant_id, driver_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{driver_id}/block", response_model=DriverRead)
|
||||||
|
async def block_driver(
|
||||||
|
driver_id: UUID,
|
||||||
|
body: DriverLifecycleRequest,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(DRIVERS_BLOCK)),
|
||||||
|
):
|
||||||
|
return await DriverCommands(db).block(tenant_id, driver_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{driver_id}/unblock", response_model=DriverRead)
|
||||||
|
async def unblock_driver(
|
||||||
|
driver_id: UUID,
|
||||||
|
body: DriverLifecycleRequest,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(DRIVERS_UNBLOCK)),
|
||||||
|
):
|
||||||
|
return await DriverCommands(db).unblock(tenant_id, driver_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{driver_id}/archive", response_model=DriverRead)
|
||||||
|
async def archive_driver(
|
||||||
|
driver_id: UUID,
|
||||||
|
body: DriverLifecycleRequest,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(DRIVERS_ARCHIVE)),
|
||||||
|
):
|
||||||
|
return await DriverCommands(db).archive(tenant_id, driver_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{driver_id}/lifecycle", response_model=list[DriverLifecycleEventRead])
|
||||||
|
async def list_lifecycle(
|
||||||
|
driver_id: UUID,
|
||||||
|
limit: int = Query(default=100, ge=1, le=500),
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(DRIVERS_LIFECYCLE_VIEW)),
|
||||||
|
):
|
||||||
|
return await DriverQueries(db).lifecycle(tenant_id, driver_id, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{driver_id}/credentials",
|
||||||
|
response_model=DriverCredentialRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def add_credential(
|
||||||
|
driver_id: UUID,
|
||||||
|
body: DriverCredentialCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(DRIVERS_CREDENTIALS_MANAGE)),
|
||||||
|
):
|
||||||
|
return await DriverCommands(db).add_credential(
|
||||||
|
tenant_id, driver_id, body, actor=user
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{driver_id}/credentials", response_model=list[DriverCredentialRead])
|
||||||
|
async def list_credentials(
|
||||||
|
driver_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(DRIVERS_CREDENTIALS_VIEW)),
|
||||||
|
):
|
||||||
|
return await DriverQueries(db).credentials(tenant_id, driver_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{driver_id}/documents",
|
||||||
|
response_model=DriverDocumentRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def add_document(
|
||||||
|
driver_id: UUID,
|
||||||
|
body: DriverDocumentCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(DRIVERS_DOCUMENTS_MANAGE)),
|
||||||
|
):
|
||||||
|
return await DriverCommands(db).add_document(
|
||||||
|
tenant_id, driver_id, body, actor=user
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{driver_id}/documents", response_model=list[DriverDocumentRead])
|
||||||
|
async def list_documents(
|
||||||
|
driver_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(DRIVERS_DOCUMENTS_VIEW)),
|
||||||
|
):
|
||||||
|
return await DriverQueries(db).documents(tenant_id, driver_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{driver_id}/delete", response_model=DriverRead)
|
||||||
|
async def soft_delete_driver(
|
||||||
|
driver_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(DRIVERS_DELETE)),
|
||||||
|
):
|
||||||
|
return await DriverCommands(db).soft_delete(tenant_id, driver_id, actor=user)
|
||||||
83
backend/services/delivery/app/api/v1/external_providers.py
Normal file
83
backend/services/delivery/app/api/v1/external_providers.py
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
"""External provider config APIs — Phase 10.0."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, get_pagination, require_tenant
|
||||||
|
from app.api.permissions import require_permissions
|
||||||
|
from app.permissions.definitions import (
|
||||||
|
EXTERNAL_PROVIDERS_CREATE,
|
||||||
|
EXTERNAL_PROVIDERS_DELETE,
|
||||||
|
EXTERNAL_PROVIDERS_UPDATE,
|
||||||
|
EXTERNAL_PROVIDERS_VIEW,
|
||||||
|
)
|
||||||
|
from app.schemas.foundation import (
|
||||||
|
ExternalProviderConfigCreate,
|
||||||
|
ExternalProviderConfigRead,
|
||||||
|
ExternalProviderConfigUpdate,
|
||||||
|
)
|
||||||
|
from app.services.foundation import ExternalProviderConfigService
|
||||||
|
from shared.pagination import PaginationParams
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ExternalProviderConfigRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_provider(
|
||||||
|
body: ExternalProviderConfigCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_CREATE)),
|
||||||
|
):
|
||||||
|
return await ExternalProviderConfigService(db).create(tenant_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[ExternalProviderConfigRead])
|
||||||
|
async def list_providers(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
pagination: PaginationParams = Depends(get_pagination),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_VIEW)),
|
||||||
|
):
|
||||||
|
return await ExternalProviderConfigService(db).list(
|
||||||
|
tenant_id, offset=pagination.offset, limit=pagination.page_size
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{provider_id}", response_model=ExternalProviderConfigRead)
|
||||||
|
async def get_provider(
|
||||||
|
provider_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_VIEW)),
|
||||||
|
):
|
||||||
|
return await ExternalProviderConfigService(db).get(tenant_id, provider_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{provider_id}", response_model=ExternalProviderConfigRead)
|
||||||
|
async def update_provider(
|
||||||
|
provider_id: UUID,
|
||||||
|
body: ExternalProviderConfigUpdate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_UPDATE)),
|
||||||
|
):
|
||||||
|
return await ExternalProviderConfigService(db).update(
|
||||||
|
tenant_id, provider_id, body, actor=user
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{provider_id}/delete", response_model=ExternalProviderConfigRead)
|
||||||
|
async def soft_delete_provider(
|
||||||
|
provider_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(EXTERNAL_PROVIDERS_DELETE)),
|
||||||
|
):
|
||||||
|
return await ExternalProviderConfigService(db).soft_delete(
|
||||||
|
tenant_id, provider_id, actor=user
|
||||||
|
)
|
||||||
86
backend/services/delivery/app/api/v1/health.py
Normal file
86
backend/services/delivery/app/api/v1/health.py
Normal 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",
|
||||||
|
}
|
||||||
70
backend/services/delivery/app/api/v1/hubs.py
Normal file
70
backend/services/delivery/app/api/v1/hubs.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
"""Delivery hub APIs — Phase 10.0."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, get_pagination, require_tenant
|
||||||
|
from app.api.permissions import require_permissions
|
||||||
|
from app.permissions.definitions import HUBS_CREATE, HUBS_DELETE, HUBS_UPDATE, HUBS_VIEW
|
||||||
|
from app.schemas.foundation import DeliveryHubCreate, DeliveryHubRead, DeliveryHubUpdate
|
||||||
|
from app.services.foundation import DeliveryHubService
|
||||||
|
from shared.pagination import PaginationParams
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=DeliveryHubRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_hub(
|
||||||
|
body: DeliveryHubCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(HUBS_CREATE)),
|
||||||
|
):
|
||||||
|
return await DeliveryHubService(db).create(tenant_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[DeliveryHubRead])
|
||||||
|
async def list_hubs(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
pagination: PaginationParams = Depends(get_pagination),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(HUBS_VIEW)),
|
||||||
|
):
|
||||||
|
return await DeliveryHubService(db).list(
|
||||||
|
tenant_id, offset=pagination.offset, limit=pagination.page_size
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{hub_id}", response_model=DeliveryHubRead)
|
||||||
|
async def get_hub(
|
||||||
|
hub_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(HUBS_VIEW)),
|
||||||
|
):
|
||||||
|
return await DeliveryHubService(db).get(tenant_id, hub_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{hub_id}", response_model=DeliveryHubRead)
|
||||||
|
async def update_hub(
|
||||||
|
hub_id: UUID,
|
||||||
|
body: DeliveryHubUpdate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(HUBS_UPDATE)),
|
||||||
|
):
|
||||||
|
return await DeliveryHubService(db).update(tenant_id, hub_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{hub_id}/delete", response_model=DeliveryHubRead)
|
||||||
|
async def soft_delete_hub(
|
||||||
|
hub_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(HUBS_DELETE)),
|
||||||
|
):
|
||||||
|
return await DeliveryHubService(db).soft_delete(tenant_id, hub_id, actor=user)
|
||||||
83
backend/services/delivery/app/api/v1/organizations.py
Normal file
83
backend/services/delivery/app/api/v1/organizations.py
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
"""Delivery organization APIs — Phase 10.0."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, get_pagination, require_tenant
|
||||||
|
from app.api.permissions import require_permissions
|
||||||
|
from app.permissions.definitions import (
|
||||||
|
ORGANIZATIONS_CREATE,
|
||||||
|
ORGANIZATIONS_DELETE,
|
||||||
|
ORGANIZATIONS_UPDATE,
|
||||||
|
ORGANIZATIONS_VIEW,
|
||||||
|
)
|
||||||
|
from app.schemas.foundation import (
|
||||||
|
DeliveryOrganizationCreate,
|
||||||
|
DeliveryOrganizationRead,
|
||||||
|
DeliveryOrganizationUpdate,
|
||||||
|
)
|
||||||
|
from app.services.foundation import DeliveryOrganizationService
|
||||||
|
from shared.pagination import PaginationParams
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=DeliveryOrganizationRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_organization(
|
||||||
|
body: DeliveryOrganizationCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(ORGANIZATIONS_CREATE)),
|
||||||
|
):
|
||||||
|
return await DeliveryOrganizationService(db).create(tenant_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[DeliveryOrganizationRead])
|
||||||
|
async def list_organizations(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
pagination: PaginationParams = Depends(get_pagination),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(ORGANIZATIONS_VIEW)),
|
||||||
|
):
|
||||||
|
return await DeliveryOrganizationService(db).list(
|
||||||
|
tenant_id, offset=pagination.offset, limit=pagination.page_size
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{organization_id}", response_model=DeliveryOrganizationRead)
|
||||||
|
async def get_organization(
|
||||||
|
organization_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(ORGANIZATIONS_VIEW)),
|
||||||
|
):
|
||||||
|
return await DeliveryOrganizationService(db).get(tenant_id, organization_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{organization_id}", response_model=DeliveryOrganizationRead)
|
||||||
|
async def update_organization(
|
||||||
|
organization_id: UUID,
|
||||||
|
body: DeliveryOrganizationUpdate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(ORGANIZATIONS_UPDATE)),
|
||||||
|
):
|
||||||
|
return await DeliveryOrganizationService(db).update(
|
||||||
|
tenant_id, organization_id, body, actor=user
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{organization_id}/delete", response_model=DeliveryOrganizationRead)
|
||||||
|
async def soft_delete_organization(
|
||||||
|
organization_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(ORGANIZATIONS_DELETE)),
|
||||||
|
):
|
||||||
|
return await DeliveryOrganizationService(db).soft_delete(
|
||||||
|
tenant_id, organization_id, actor=user
|
||||||
|
)
|
||||||
27
backend/services/delivery/app/api/v1/permissions.py
Normal file
27
backend/services/delivery/app/api/v1/permissions.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
"""Permission catalog discovery API — Phase 10.1."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends
|
||||||
|
|
||||||
|
from app.api.permissions import require_permissions
|
||||||
|
from app.permissions.definitions import (
|
||||||
|
ALL_PERMISSIONS,
|
||||||
|
DELIVERY_VIEW,
|
||||||
|
PERMISSION_PREFIXES,
|
||||||
|
)
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/catalog")
|
||||||
|
async def permission_catalog(
|
||||||
|
_user: CurrentUser = Depends(require_permissions(DELIVERY_VIEW)),
|
||||||
|
):
|
||||||
|
"""Static permission discovery for Identity / admin consoles."""
|
||||||
|
return {
|
||||||
|
"prefix": "delivery.",
|
||||||
|
"prefixes": list(PERMISSION_PREFIXES),
|
||||||
|
"permissions": list(ALL_PERMISSIONS),
|
||||||
|
"count": len(ALL_PERMISSIONS),
|
||||||
|
}
|
||||||
83
backend/services/delivery/app/api/v1/routing_engines.py
Normal file
83
backend/services/delivery/app/api/v1/routing_engines.py
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
"""Routing engine registration APIs — Phase 10.0."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, get_pagination, require_tenant
|
||||||
|
from app.api.permissions import require_permissions
|
||||||
|
from app.permissions.definitions import (
|
||||||
|
ROUTING_ENGINES_CREATE,
|
||||||
|
ROUTING_ENGINES_DELETE,
|
||||||
|
ROUTING_ENGINES_UPDATE,
|
||||||
|
ROUTING_ENGINES_VIEW,
|
||||||
|
)
|
||||||
|
from app.schemas.foundation import (
|
||||||
|
RoutingEngineRegistrationCreate,
|
||||||
|
RoutingEngineRegistrationRead,
|
||||||
|
RoutingEngineRegistrationUpdate,
|
||||||
|
)
|
||||||
|
from app.services.foundation import RoutingEngineRegistrationService
|
||||||
|
from shared.pagination import PaginationParams
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=RoutingEngineRegistrationRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_engine(
|
||||||
|
body: RoutingEngineRegistrationCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(ROUTING_ENGINES_CREATE)),
|
||||||
|
):
|
||||||
|
return await RoutingEngineRegistrationService(db).create(tenant_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[RoutingEngineRegistrationRead])
|
||||||
|
async def list_engines(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
pagination: PaginationParams = Depends(get_pagination),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(ROUTING_ENGINES_VIEW)),
|
||||||
|
):
|
||||||
|
return await RoutingEngineRegistrationService(db).list(
|
||||||
|
tenant_id, offset=pagination.offset, limit=pagination.page_size
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{engine_id}", response_model=RoutingEngineRegistrationRead)
|
||||||
|
async def get_engine(
|
||||||
|
engine_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(ROUTING_ENGINES_VIEW)),
|
||||||
|
):
|
||||||
|
return await RoutingEngineRegistrationService(db).get(tenant_id, engine_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{engine_id}", response_model=RoutingEngineRegistrationRead)
|
||||||
|
async def update_engine(
|
||||||
|
engine_id: UUID,
|
||||||
|
body: RoutingEngineRegistrationUpdate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(ROUTING_ENGINES_UPDATE)),
|
||||||
|
):
|
||||||
|
return await RoutingEngineRegistrationService(db).update(
|
||||||
|
tenant_id, engine_id, body, actor=user
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{engine_id}/delete", response_model=RoutingEngineRegistrationRead)
|
||||||
|
async def soft_delete_engine(
|
||||||
|
engine_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(ROUTING_ENGINES_DELETE)),
|
||||||
|
):
|
||||||
|
return await RoutingEngineRegistrationService(db).soft_delete(
|
||||||
|
tenant_id, engine_id, actor=user
|
||||||
|
)
|
||||||
39
backend/services/delivery/app/api/v1/settings.py
Normal file
39
backend/services/delivery/app/api/v1/settings.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
"""Delivery settings APIs — Phase 10.0."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, get_pagination, require_tenant
|
||||||
|
from app.api.permissions import require_permissions
|
||||||
|
from app.permissions.definitions import SETTINGS_MANAGE, SETTINGS_VIEW
|
||||||
|
from app.schemas.foundation import DeliverySettingRead, DeliverySettingUpsert
|
||||||
|
from app.services.foundation import DeliverySettingService
|
||||||
|
from shared.pagination import PaginationParams
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("", response_model=DeliverySettingRead, status_code=status.HTTP_200_OK)
|
||||||
|
async def upsert_setting(
|
||||||
|
body: DeliverySettingUpsert,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(SETTINGS_MANAGE)),
|
||||||
|
):
|
||||||
|
return await DeliverySettingService(db).upsert(tenant_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=list[DeliverySettingRead])
|
||||||
|
async def list_settings(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
pagination: PaginationParams = Depends(get_pagination),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(SETTINGS_VIEW)),
|
||||||
|
):
|
||||||
|
return await DeliverySettingService(db).list(
|
||||||
|
tenant_id, offset=pagination.offset, limit=pagination.page_size
|
||||||
|
)
|
||||||
4
backend/services/delivery/app/commands/__init__.py
Normal file
4
backend/services/delivery/app/commands/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
"""Driver commands package."""
|
||||||
|
from app.commands.drivers import DriverCommands
|
||||||
|
|
||||||
|
__all__ = ["DriverCommands"]
|
||||||
148
backend/services/delivery/app/commands/drivers.py
Normal file
148
backend/services/delivery/app/commands/drivers.py
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
"""Driver command handlers — Phase 10.1."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.models.types import DriverLifecycleAction
|
||||||
|
from app.schemas.drivers import (
|
||||||
|
DriverCreate,
|
||||||
|
DriverCredentialCreate,
|
||||||
|
DriverDocumentCreate,
|
||||||
|
DriverLifecycleRequest,
|
||||||
|
DriverUpdate,
|
||||||
|
)
|
||||||
|
from app.services.drivers import DriverService
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
|
||||||
|
class DriverCommands:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.service = DriverService(session)
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self, tenant_id: UUID, body: DriverCreate, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.service.create(tenant_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def update(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
driver_id: UUID,
|
||||||
|
body: DriverUpdate,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None,
|
||||||
|
):
|
||||||
|
return await self.service.update(tenant_id, driver_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def activate(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
driver_id: UUID,
|
||||||
|
body: DriverLifecycleRequest,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None,
|
||||||
|
):
|
||||||
|
return await self.service.apply_lifecycle(
|
||||||
|
tenant_id, driver_id, DriverLifecycleAction.ACTIVATE, body, actor=actor
|
||||||
|
)
|
||||||
|
|
||||||
|
async def suspend(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
driver_id: UUID,
|
||||||
|
body: DriverLifecycleRequest,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None,
|
||||||
|
):
|
||||||
|
return await self.service.apply_lifecycle(
|
||||||
|
tenant_id, driver_id, DriverLifecycleAction.SUSPEND, body, actor=actor
|
||||||
|
)
|
||||||
|
|
||||||
|
async def resume(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
driver_id: UUID,
|
||||||
|
body: DriverLifecycleRequest,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None,
|
||||||
|
):
|
||||||
|
return await self.service.apply_lifecycle(
|
||||||
|
tenant_id, driver_id, DriverLifecycleAction.RESUME, body, actor=actor
|
||||||
|
)
|
||||||
|
|
||||||
|
async def deactivate(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
driver_id: UUID,
|
||||||
|
body: DriverLifecycleRequest,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None,
|
||||||
|
):
|
||||||
|
return await self.service.apply_lifecycle(
|
||||||
|
tenant_id, driver_id, DriverLifecycleAction.DEACTIVATE, body, actor=actor
|
||||||
|
)
|
||||||
|
|
||||||
|
async def block(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
driver_id: UUID,
|
||||||
|
body: DriverLifecycleRequest,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None,
|
||||||
|
):
|
||||||
|
return await self.service.apply_lifecycle(
|
||||||
|
tenant_id, driver_id, DriverLifecycleAction.BLOCK, body, actor=actor
|
||||||
|
)
|
||||||
|
|
||||||
|
async def unblock(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
driver_id: UUID,
|
||||||
|
body: DriverLifecycleRequest,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None,
|
||||||
|
):
|
||||||
|
return await self.service.apply_lifecycle(
|
||||||
|
tenant_id, driver_id, DriverLifecycleAction.UNBLOCK, body, actor=actor
|
||||||
|
)
|
||||||
|
|
||||||
|
async def archive(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
driver_id: UUID,
|
||||||
|
body: DriverLifecycleRequest,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None,
|
||||||
|
):
|
||||||
|
return await self.service.apply_lifecycle(
|
||||||
|
tenant_id, driver_id, DriverLifecycleAction.ARCHIVE, body, actor=actor
|
||||||
|
)
|
||||||
|
|
||||||
|
async def soft_delete(
|
||||||
|
self, tenant_id: UUID, driver_id: UUID, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.service.soft_delete(tenant_id, driver_id, actor=actor)
|
||||||
|
|
||||||
|
async def add_credential(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
driver_id: UUID,
|
||||||
|
body: DriverCredentialCreate,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None,
|
||||||
|
):
|
||||||
|
return await self.service.add_credential(
|
||||||
|
tenant_id, driver_id, body, actor=actor
|
||||||
|
)
|
||||||
|
|
||||||
|
async def add_document(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
driver_id: UUID,
|
||||||
|
body: DriverDocumentCreate,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None,
|
||||||
|
):
|
||||||
|
return await self.service.add_document(tenant_id, driver_id, body, actor=actor)
|
||||||
75
backend/services/delivery/app/core/config.py
Normal file
75
backend/services/delivery/app/core/config.py
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
"""Delivery Service settings."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import Field
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(
|
||||||
|
env_file=".env",
|
||||||
|
env_file_encoding="utf-8",
|
||||||
|
case_sensitive=False,
|
||||||
|
extra="ignore",
|
||||||
|
)
|
||||||
|
|
||||||
|
environment: Literal["development", "staging", "production", "test"] = "development"
|
||||||
|
debug: bool = True
|
||||||
|
log_level: str = "INFO"
|
||||||
|
service_name: str = Field(
|
||||||
|
default="delivery-service", validation_alias="DELIVERY_SERVICE_NAME"
|
||||||
|
)
|
||||||
|
api_v1_prefix: str = "/api/v1"
|
||||||
|
|
||||||
|
database_url: str = Field(
|
||||||
|
default="postgresql+asyncpg://superapp:superapp_password@localhost:5432/delivery_db",
|
||||||
|
validation_alias="DELIVERY_DATABASE_URL",
|
||||||
|
)
|
||||||
|
database_url_sync: str = Field(
|
||||||
|
default="postgresql+psycopg://superapp:superapp_password@localhost:5432/delivery_db",
|
||||||
|
validation_alias="DELIVERY_DATABASE_URL_SYNC",
|
||||||
|
)
|
||||||
|
|
||||||
|
core_service_url: str = Field(
|
||||||
|
default="http://localhost:8000", validation_alias="CORE_SERVICE_URL"
|
||||||
|
)
|
||||||
|
|
||||||
|
keycloak_enabled: bool = True
|
||||||
|
keycloak_server_url: str = Field(
|
||||||
|
default="http://localhost:8080", validation_alias="KEYCLOAK_SERVER_URL"
|
||||||
|
)
|
||||||
|
keycloak_public_url: str = Field(default="", validation_alias="KEYCLOAK_PUBLIC_URL")
|
||||||
|
keycloak_realm: str = Field(default="superapp", validation_alias="KEYCLOAK_REALM")
|
||||||
|
|
||||||
|
jwt_algorithm: str = "RS256"
|
||||||
|
jwt_audience: str = "account"
|
||||||
|
jwt_verify_signature: bool = True
|
||||||
|
auth_required: bool = True
|
||||||
|
|
||||||
|
cors_origins: str = Field(
|
||||||
|
default="http://localhost:3000,http://127.0.0.1:3000",
|
||||||
|
validation_alias="CORS_ORIGINS",
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def keycloak_public_base(self) -> str:
|
||||||
|
return (self.keycloak_public_url or self.keycloak_server_url).rstrip("/")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def keycloak_public_realm_url(self) -> str:
|
||||||
|
return f"{self.keycloak_public_base}/realms/{self.keycloak_realm}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cors_origin_list(self) -> list[str]:
|
||||||
|
return [o.strip() for o in self.cors_origins.split(",") if o.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_settings() -> Settings:
|
||||||
|
return Settings()
|
||||||
|
|
||||||
|
|
||||||
|
settings = get_settings()
|
||||||
27
backend/services/delivery/app/core/database.py
Normal file
27
backend/services/delivery/app/core/database.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||||
|
from sqlalchemy.orm import DeclarativeBase
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
class Base(DeclarativeBase):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_engine_kwargs: dict = {"pool_pre_ping": True, "future": True}
|
||||||
|
if settings.database_url.startswith("sqlite"):
|
||||||
|
_engine_kwargs["poolclass"] = StaticPool
|
||||||
|
_engine_kwargs["connect_args"] = {"check_same_thread": False}
|
||||||
|
|
||||||
|
engine = create_async_engine(settings.database_url, **_engine_kwargs)
|
||||||
|
AsyncSessionLocal = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_db():
|
||||||
|
async with AsyncSessionLocal() as session:
|
||||||
|
try:
|
||||||
|
yield session
|
||||||
|
except Exception:
|
||||||
|
await session.rollback()
|
||||||
|
raise
|
||||||
14
backend/services/delivery/app/core/logging.py
Normal file
14
backend/services/delivery/app/core/logging.py
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging(level: str = "INFO") -> None:
|
||||||
|
logging.basicConfig(
|
||||||
|
level=getattr(logging, level.upper(), logging.INFO),
|
||||||
|
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
||||||
|
stream=sys.stdout,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_logger(name: str) -> logging.Logger:
|
||||||
|
return logging.getLogger(name)
|
||||||
49
backend/services/delivery/app/core/security.py
Normal file
49
backend/services/delivery/app/core/security.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
"""JWT authentication dependencies for Delivery service."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
|
|
||||||
|
from fastapi import Depends
|
||||||
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from shared.auth import JWTSettings, JWTValidator
|
||||||
|
from shared.exceptions import UnauthorizedError
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
_bearer = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache
|
||||||
|
def get_jwt_validator() -> JWTValidator:
|
||||||
|
return JWTValidator(
|
||||||
|
JWTSettings(
|
||||||
|
keycloak_enabled=settings.keycloak_enabled,
|
||||||
|
keycloak_server_url=settings.keycloak_server_url,
|
||||||
|
keycloak_realm=settings.keycloak_realm,
|
||||||
|
jwt_algorithm=settings.jwt_algorithm,
|
||||||
|
jwt_audience=settings.jwt_audience,
|
||||||
|
jwt_verify_signature=settings.jwt_verify_signature,
|
||||||
|
jwt_issuer=settings.keycloak_public_realm_url,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_current_user(
|
||||||
|
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
||||||
|
) -> CurrentUser:
|
||||||
|
if not settings.auth_required:
|
||||||
|
return CurrentUser(user_id="test-user", username="test", roles=["tenant_admin"])
|
||||||
|
if credentials is None or not credentials.credentials:
|
||||||
|
raise UnauthorizedError("توکن احراز هویت ارائه نشده است")
|
||||||
|
return await get_jwt_validator().validate(credentials.credentials)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_optional_user(
|
||||||
|
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer),
|
||||||
|
) -> CurrentUser | None:
|
||||||
|
if not settings.auth_required:
|
||||||
|
return CurrentUser(user_id="test-user", username="test", roles=["tenant_admin"])
|
||||||
|
if credentials is None or not credentials.credentials:
|
||||||
|
return None
|
||||||
|
return await get_jwt_validator().validate(credentials.credentials)
|
||||||
121
backend/services/delivery/app/events/publisher.py
Normal file
121
backend/services/delivery/app/events/publisher.py
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
"""Delivery event publisher — in-memory + transactional outbox (ADR-006)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Protocol
|
||||||
|
from uuid import UUID, uuid4
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from shared.events import EventEnvelope, EventStatus
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.events.types import DeliveryEventType
|
||||||
|
from app.models.outbox import OutboxEvent
|
||||||
|
|
||||||
|
|
||||||
|
class EventPublisher(Protocol):
|
||||||
|
def publish(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
event_type: DeliveryEventType,
|
||||||
|
aggregate_type: str,
|
||||||
|
aggregate_id: UUID,
|
||||||
|
tenant_id: UUID,
|
||||||
|
payload: dict[str, Any] | None = None,
|
||||||
|
) -> EventEnvelope: ...
|
||||||
|
|
||||||
|
|
||||||
|
class InMemoryEventPublisher:
|
||||||
|
"""Records published envelopes for tests and local verification."""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.published: list[EventEnvelope] = []
|
||||||
|
|
||||||
|
def publish(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
event_type: DeliveryEventType,
|
||||||
|
aggregate_type: str,
|
||||||
|
aggregate_id: UUID,
|
||||||
|
tenant_id: UUID,
|
||||||
|
payload: dict[str, Any] | None = None,
|
||||||
|
) -> EventEnvelope:
|
||||||
|
envelope = EventEnvelope(
|
||||||
|
event_id=uuid4(),
|
||||||
|
event_type=event_type.value,
|
||||||
|
aggregate_type=aggregate_type,
|
||||||
|
aggregate_id=str(aggregate_id),
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source_service=settings.service_name,
|
||||||
|
payload=payload or {},
|
||||||
|
)
|
||||||
|
self.published.append(envelope)
|
||||||
|
return envelope
|
||||||
|
|
||||||
|
def record(self, envelope: EventEnvelope) -> EventEnvelope:
|
||||||
|
self.published.append(envelope)
|
||||||
|
return envelope
|
||||||
|
|
||||||
|
|
||||||
|
class TransactionalEventPublisher:
|
||||||
|
"""Persist outbox row in the current transaction; mirror to memory in tests."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, session: AsyncSession, memory: InMemoryEventPublisher | None = None
|
||||||
|
) -> None:
|
||||||
|
self.session = session
|
||||||
|
if memory is not None:
|
||||||
|
self.memory = memory
|
||||||
|
elif settings.environment == "test":
|
||||||
|
self.memory = get_event_publisher()
|
||||||
|
else:
|
||||||
|
self.memory = None
|
||||||
|
|
||||||
|
async def publish(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
event_type: DeliveryEventType,
|
||||||
|
aggregate_type: str,
|
||||||
|
aggregate_id: UUID,
|
||||||
|
tenant_id: UUID,
|
||||||
|
payload: dict[str, Any] | None = None,
|
||||||
|
) -> EventEnvelope:
|
||||||
|
envelope = EventEnvelope(
|
||||||
|
event_id=uuid4(),
|
||||||
|
event_type=event_type.value,
|
||||||
|
aggregate_type=aggregate_type,
|
||||||
|
aggregate_id=str(aggregate_id),
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
source_service=settings.service_name,
|
||||||
|
payload=payload or {},
|
||||||
|
)
|
||||||
|
row = OutboxEvent(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
event_type=envelope.event_type,
|
||||||
|
aggregate_type=aggregate_type,
|
||||||
|
aggregate_id=str(aggregate_id),
|
||||||
|
payload={
|
||||||
|
"event_id": str(envelope.event_id),
|
||||||
|
"source_service": settings.service_name,
|
||||||
|
**(payload or {}),
|
||||||
|
},
|
||||||
|
status=EventStatus.PENDING,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
if self.memory is not None:
|
||||||
|
self.memory.record(envelope)
|
||||||
|
return envelope
|
||||||
|
|
||||||
|
|
||||||
|
_default_publisher = InMemoryEventPublisher()
|
||||||
|
|
||||||
|
|
||||||
|
def get_event_publisher() -> InMemoryEventPublisher:
|
||||||
|
return _default_publisher
|
||||||
|
|
||||||
|
|
||||||
|
def reset_event_publisher() -> InMemoryEventPublisher:
|
||||||
|
global _default_publisher
|
||||||
|
_default_publisher = InMemoryEventPublisher()
|
||||||
|
return _default_publisher
|
||||||
33
backend/services/delivery/app/events/types.py
Normal file
33
backend/services/delivery/app/events/types.py
Normal 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"
|
||||||
69
backend/services/delivery/app/main.py
Normal file
69
backend/services/delivery/app/main.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
|
||||||
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
|
|
||||||
|
from app import __version__
|
||||||
|
from app.api.v1 import api_router
|
||||||
|
from app.api.v1 import health
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.logging import configure_logging, get_logger
|
||||||
|
from app.middlewares.tenant import TenantHeaderMiddleware
|
||||||
|
from shared.exceptions import AppError
|
||||||
|
from shared.responses import ErrorDetail, ErrorResponse
|
||||||
|
|
||||||
|
configure_logging(settings.log_level)
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
logger.info("service_starting", extra={"service": settings.service_name})
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
def create_app() -> FastAPI:
|
||||||
|
app = FastAPI(
|
||||||
|
title="Delivery & Fleet Platform",
|
||||||
|
version=__version__,
|
||||||
|
description="Torbat Driver — Delivery & Fleet Platform (Phase 10.1 Driver Management)",
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.cors_origin_list,
|
||||||
|
allow_origin_regex=r"https?://([a-z0-9-]+\.)*torbatyar\.ir",
|
||||||
|
allow_credentials=False,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
app.add_middleware(TenantHeaderMiddleware)
|
||||||
|
|
||||||
|
@app.exception_handler(AppError)
|
||||||
|
async def app_error_handler(request: Request, exc: AppError) -> JSONResponse:
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=exc.status_code,
|
||||||
|
content=ErrorResponse(
|
||||||
|
error=ErrorDetail(
|
||||||
|
code=exc.error_code, message=exc.message, details=exc.details
|
||||||
|
)
|
||||||
|
).model_dump(),
|
||||||
|
)
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def unhandled_handler(request: Request, exc: Exception) -> JSONResponse:
|
||||||
|
logger.error("unhandled_exception", extra={"error": str(exc)}, exc_info=exc)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content=ErrorResponse(
|
||||||
|
error=ErrorDetail(code="internal_error", message="خطای داخلی سرور")
|
||||||
|
).model_dump(),
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(health.router)
|
||||||
|
app.include_router(api_router, prefix=settings.api_v1_prefix)
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
app = create_app()
|
||||||
24
backend/services/delivery/app/middlewares/tenant.py
Normal file
24
backend/services/delivery/app/middlewares/tenant.py
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
"""Tenant header resolution middleware."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
from shared.tenant import HEADER_TENANT_ID, STATE_TENANT_ID
|
||||||
|
|
||||||
|
|
||||||
|
class TenantHeaderMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""Resolve tenant from X-Tenant-ID header only (microservice pattern)."""
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next) -> Response:
|
||||||
|
setattr(request.state, STATE_TENANT_ID, None)
|
||||||
|
raw = request.headers.get(HEADER_TENANT_ID)
|
||||||
|
if raw:
|
||||||
|
try:
|
||||||
|
setattr(request.state, STATE_TENANT_ID, UUID(raw))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
return await call_next(request)
|
||||||
19
backend/services/delivery/app/models/__init__.py
Normal file
19
backend/services/delivery/app/models/__init__.py
Normal 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
|
||||||
51
backend/services/delivery/app/models/base.py
Normal file
51
backend/services/delivery/app/models/base.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
"""Shared model mixins."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, DateTime, Integer, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.models.types import GUID
|
||||||
|
|
||||||
|
|
||||||
|
class UUIDPrimaryKeyMixin:
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(
|
||||||
|
GUID(), primary_key=True, default=uuid.uuid4
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TimestampMixin:
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
server_default=func.now(),
|
||||||
|
onupdate=func.now(),
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TenantMixin:
|
||||||
|
"""Row-level tenancy (ADR-003). Tenant comes from request context, never hardcoded."""
|
||||||
|
|
||||||
|
tenant_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
class SoftDeleteMixin:
|
||||||
|
is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
deleted_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
deleted_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class ActorAuditMixin:
|
||||||
|
created_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
updated_by: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
|
||||||
|
|
||||||
|
class OptimisticLockMixin:
|
||||||
|
version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
||||||
167
backend/services/delivery/app/models/drivers.py
Normal file
167
backend/services/delivery/app/models/drivers.py
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
"""Driver management aggregates — Phase 10.1.
|
||||||
|
|
||||||
|
UUID refs only within delivery_db. No fleet/dispatch ownership.
|
||||||
|
Identity/CRM/Storage are external refs only.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
Boolean,
|
||||||
|
Date,
|
||||||
|
DateTime,
|
||||||
|
Index,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
UniqueConstraint,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from sqlalchemy.types import JSON
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from app.models.base import (
|
||||||
|
ActorAuditMixin,
|
||||||
|
OptimisticLockMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
)
|
||||||
|
from app.models.types import (
|
||||||
|
DriverCredentialKind,
|
||||||
|
DriverDocumentKind,
|
||||||
|
DriverLifecycleAction,
|
||||||
|
DriverStatus,
|
||||||
|
GUID,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Driver(
|
||||||
|
Base,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
ActorAuditMixin,
|
||||||
|
OptimisticLockMixin,
|
||||||
|
):
|
||||||
|
"""Courier/driver profile owned by Delivery Platform."""
|
||||||
|
|
||||||
|
__tablename__ = "drivers"
|
||||||
|
|
||||||
|
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
hub_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
display_name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
first_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
last_name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
mobile: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
|
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
status: Mapped[DriverStatus] = mapped_column(
|
||||||
|
default=DriverStatus.PENDING, nullable=False
|
||||||
|
)
|
||||||
|
external_user_ref: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
external_crm_contact_ref: Mapped[str | None] = mapped_column(
|
||||||
|
String(100), nullable=True
|
||||||
|
)
|
||||||
|
hire_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
capability_tags: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||||
|
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
status_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
status_changed_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "code", name="uq_drivers_tenant_code"),
|
||||||
|
Index("ix_drivers_tenant_status", "tenant_id", "status"),
|
||||||
|
Index("ix_drivers_org", "tenant_id", "organization_id"),
|
||||||
|
Index("ix_drivers_hub", "tenant_id", "hub_id"),
|
||||||
|
Index("ix_drivers_mobile", "tenant_id", "mobile"),
|
||||||
|
Index("ix_drivers_tenant_deleted", "tenant_id", "is_deleted"),
|
||||||
|
Index("ix_drivers_display_name", "tenant_id", "display_name"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DriverCredential(
|
||||||
|
Base,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
ActorAuditMixin,
|
||||||
|
):
|
||||||
|
"""Credential / compliance refs for a driver (binaries via Storage refs)."""
|
||||||
|
|
||||||
|
__tablename__ = "driver_credentials"
|
||||||
|
|
||||||
|
driver_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
kind: Mapped[DriverCredentialKind] = mapped_column(
|
||||||
|
default=DriverCredentialKind.LICENSE, nullable=False
|
||||||
|
)
|
||||||
|
number: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
issued_at: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
expires_at: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||||
|
is_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||||
|
document_file_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"driver_id",
|
||||||
|
"kind",
|
||||||
|
"number",
|
||||||
|
name="uq_driver_credentials_tenant_kind_number",
|
||||||
|
),
|
||||||
|
Index("ix_driver_credentials_driver", "tenant_id", "driver_id"),
|
||||||
|
Index("ix_driver_credentials_tenant_deleted", "tenant_id", "is_deleted"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DriverDocument(
|
||||||
|
Base,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
ActorAuditMixin,
|
||||||
|
):
|
||||||
|
"""Driver document metadata — Storage owns file blobs."""
|
||||||
|
|
||||||
|
__tablename__ = "driver_documents"
|
||||||
|
|
||||||
|
driver_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
kind: Mapped[DriverDocumentKind] = mapped_column(
|
||||||
|
default=DriverDocumentKind.OTHER, nullable=False
|
||||||
|
)
|
||||||
|
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
storage_file_ref: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_driver_documents_driver", "tenant_id", "driver_id"),
|
||||||
|
Index("ix_driver_documents_tenant_deleted", "tenant_id", "is_deleted"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DriverLifecycleEvent(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
"""Append-only driver lifecycle history."""
|
||||||
|
|
||||||
|
__tablename__ = "driver_lifecycle_events"
|
||||||
|
|
||||||
|
driver_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
action: Mapped[DriverLifecycleAction] = mapped_column(nullable=False)
|
||||||
|
from_status: Mapped[DriverStatus] = mapped_column(nullable=False)
|
||||||
|
to_status: Mapped[DriverStatus] = mapped_column(nullable=False)
|
||||||
|
reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
actor_user_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_driver_lifecycle_driver", "tenant_id", "driver_id"),
|
||||||
|
Index("ix_driver_lifecycle_created", "tenant_id", "created_at"),
|
||||||
|
)
|
||||||
303
backend/services/delivery/app/models/foundation.py
Normal file
303
backend/services/delivery/app/models/foundation.py
Normal file
@ -0,0 +1,303 @@
|
|||||||
|
"""Delivery foundation aggregates — Phase 10.0.
|
||||||
|
|
||||||
|
Independent aggregates use UUID references within delivery_db only.
|
||||||
|
No SQLAlchemy relationship graphs between aggregates.
|
||||||
|
No dispatch/routing/tracking engines — shells and contracts only.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import Index, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from sqlalchemy.types import JSON
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from app.models.base import (
|
||||||
|
ActorAuditMixin,
|
||||||
|
OptimisticLockMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
)
|
||||||
|
from app.models.types import (
|
||||||
|
AuditAction,
|
||||||
|
GUID,
|
||||||
|
LifecycleStatus,
|
||||||
|
ProviderKind,
|
||||||
|
ProviderStatus,
|
||||||
|
RoutingEngineStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeliveryOrganization(
|
||||||
|
Base,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
ActorAuditMixin,
|
||||||
|
OptimisticLockMixin,
|
||||||
|
):
|
||||||
|
"""Root delivery organization / fleet operator shell for a tenant (Torbat Driver)."""
|
||||||
|
|
||||||
|
__tablename__ = "delivery_organizations"
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
status: Mapped[LifecycleStatus] = mapped_column(
|
||||||
|
default=LifecycleStatus.DRAFT, nullable=False
|
||||||
|
)
|
||||||
|
legal_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
timezone: Mapped[str] = mapped_column(String(64), default="Asia/Tehran", nullable=False)
|
||||||
|
language: Mapped[str] = mapped_column(String(16), default="fa", nullable=False)
|
||||||
|
currency_code: Mapped[str] = mapped_column(String(3), default="IRR", nullable=False)
|
||||||
|
settings: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "code", name="uq_delivery_organizations_tenant_code"),
|
||||||
|
Index("ix_delivery_organizations_tenant_status", "tenant_id", "status"),
|
||||||
|
Index("ix_delivery_organizations_tenant_deleted", "tenant_id", "is_deleted"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeliveryHub(
|
||||||
|
Base,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
ActorAuditMixin,
|
||||||
|
):
|
||||||
|
"""Operational hub / depot under a delivery organization."""
|
||||||
|
|
||||||
|
__tablename__ = "delivery_hubs"
|
||||||
|
|
||||||
|
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
status: Mapped[LifecycleStatus] = mapped_column(
|
||||||
|
default=LifecycleStatus.ACTIVE, nullable=False
|
||||||
|
)
|
||||||
|
address: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
timezone: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
working_hours: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id", "organization_id", "code", name="uq_delivery_hubs_tenant_code"
|
||||||
|
),
|
||||||
|
Index("ix_delivery_hubs_org", "tenant_id", "organization_id"),
|
||||||
|
Index("ix_delivery_hubs_tenant_deleted", "tenant_id", "is_deleted"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeliveryRole(
|
||||||
|
Base,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
ActorAuditMixin,
|
||||||
|
):
|
||||||
|
"""Local delivery RBAC role catalog shell."""
|
||||||
|
|
||||||
|
__tablename__ = "delivery_roles"
|
||||||
|
|
||||||
|
organization_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
status: Mapped[LifecycleStatus] = mapped_column(
|
||||||
|
default=LifecycleStatus.ACTIVE, nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "code", name="uq_delivery_roles_tenant_code"),
|
||||||
|
Index("ix_delivery_roles_tenant_deleted", "tenant_id", "is_deleted"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeliveryPermission(
|
||||||
|
Base,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
ActorAuditMixin,
|
||||||
|
):
|
||||||
|
"""Local delivery permission catalog shell (complements platform delivery.* tree)."""
|
||||||
|
|
||||||
|
__tablename__ = "delivery_permissions"
|
||||||
|
|
||||||
|
role_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
code: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "code", name="uq_delivery_permissions_tenant_code"),
|
||||||
|
Index("ix_delivery_permissions_tenant_deleted", "tenant_id", "is_deleted"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalProviderConfig(
|
||||||
|
Base,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
ActorAuditMixin,
|
||||||
|
OptimisticLockMixin,
|
||||||
|
):
|
||||||
|
"""External fleet/courier/maps provider registration shell — credentials stay here."""
|
||||||
|
|
||||||
|
__tablename__ = "external_provider_configs"
|
||||||
|
|
||||||
|
organization_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
provider_kind: Mapped[ProviderKind] = mapped_column(
|
||||||
|
default=ProviderKind.CUSTOM, nullable=False
|
||||||
|
)
|
||||||
|
adapter_key: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
status: Mapped[ProviderStatus] = mapped_column(
|
||||||
|
default=ProviderStatus.REGISTERED, nullable=False
|
||||||
|
)
|
||||||
|
priority: Mapped[int] = mapped_column(default=100, nullable=False)
|
||||||
|
credentials_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
config: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
capabilities: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id", "code", name="uq_external_provider_configs_tenant_code"
|
||||||
|
),
|
||||||
|
Index("ix_external_provider_configs_kind", "tenant_id", "provider_kind"),
|
||||||
|
Index("ix_external_provider_configs_tenant_deleted", "tenant_id", "is_deleted"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RoutingEngineRegistration(
|
||||||
|
Base,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
ActorAuditMixin,
|
||||||
|
OptimisticLockMixin,
|
||||||
|
):
|
||||||
|
"""Future routing engine registration — adapter ready, no engine implementation."""
|
||||||
|
|
||||||
|
__tablename__ = "routing_engine_registrations"
|
||||||
|
|
||||||
|
organization_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
adapter_key: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
status: Mapped[RoutingEngineStatus] = mapped_column(
|
||||||
|
default=RoutingEngineStatus.REGISTERED, nullable=False
|
||||||
|
)
|
||||||
|
is_default: Mapped[bool] = mapped_column(default=False, nullable=False)
|
||||||
|
config: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
capabilities: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id", "code", name="uq_routing_engine_registrations_tenant_code"
|
||||||
|
),
|
||||||
|
Index("ix_routing_engine_registrations_tenant_deleted", "tenant_id", "is_deleted"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeliveryConfiguration(
|
||||||
|
Base,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
ActorAuditMixin,
|
||||||
|
OptimisticLockMixin,
|
||||||
|
):
|
||||||
|
"""Tenant delivery configuration — hours, policies, locale shells."""
|
||||||
|
|
||||||
|
__tablename__ = "delivery_configurations"
|
||||||
|
|
||||||
|
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
hub_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
working_hours: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
dispatch_policies: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
tracking_policies: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
settlement_policies: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
custom_fields: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
timezone: Mapped[str] = mapped_column(String(64), default="Asia/Tehran", nullable=False)
|
||||||
|
language: Mapped[str] = mapped_column(String(16), default="fa", nullable=False)
|
||||||
|
currency_code: Mapped[str] = mapped_column(String(3), default="IRR", nullable=False)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"organization_id",
|
||||||
|
"code",
|
||||||
|
name="uq_delivery_configurations_tenant_code",
|
||||||
|
),
|
||||||
|
Index("ix_delivery_configurations_org", "tenant_id", "organization_id"),
|
||||||
|
Index("ix_delivery_configurations_tenant_deleted", "tenant_id", "is_deleted"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeliverySetting(
|
||||||
|
Base,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
ActorAuditMixin,
|
||||||
|
):
|
||||||
|
"""Key/value delivery settings shell."""
|
||||||
|
|
||||||
|
__tablename__ = "delivery_settings"
|
||||||
|
|
||||||
|
organization_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
hub_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
key: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
value: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"organization_id",
|
||||||
|
"hub_id",
|
||||||
|
"key",
|
||||||
|
name="uq_delivery_settings_tenant_key",
|
||||||
|
),
|
||||||
|
Index("ix_delivery_settings_tenant_deleted", "tenant_id", "is_deleted"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DeliveryAuditLog(Base, UUIDPrimaryKeyMixin, TenantMixin, TimestampMixin):
|
||||||
|
"""Append-only delivery audit trail."""
|
||||||
|
|
||||||
|
__tablename__ = "delivery_audit_logs"
|
||||||
|
|
||||||
|
entity_type: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
entity_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
action: Mapped[AuditAction] = mapped_column(nullable=False)
|
||||||
|
actor_user_id: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
changes: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"ix_delivery_audit_entity",
|
||||||
|
"tenant_id",
|
||||||
|
"entity_type",
|
||||||
|
"entity_id",
|
||||||
|
),
|
||||||
|
)
|
||||||
44
backend/services/delivery/app/models/outbox.py
Normal file
44
backend/services/delivery/app/models/outbox.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
"""Transactional outbox model — ADR-006."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Index, Integer, String, func
|
||||||
|
from sqlalchemy import Enum as SAEnum
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from sqlalchemy.types import JSON
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from app.models.base import UUIDPrimaryKeyMixin
|
||||||
|
from app.models.types import GUID
|
||||||
|
from shared.events import EventStatus
|
||||||
|
|
||||||
|
|
||||||
|
class OutboxEvent(UUIDPrimaryKeyMixin, Base):
|
||||||
|
"""Pending domain events written in the same DB transaction as mutations."""
|
||||||
|
|
||||||
|
__tablename__ = "outbox_events"
|
||||||
|
|
||||||
|
tenant_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
event_type: Mapped[str] = mapped_column(String(150), nullable=False)
|
||||||
|
aggregate_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
aggregate_id: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||||
|
payload: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)
|
||||||
|
status: Mapped[EventStatus] = mapped_column(
|
||||||
|
SAEnum(EventStatus, name="delivery_event_status", native_enum=False),
|
||||||
|
default=EventStatus.PENDING,
|
||||||
|
nullable=False,
|
||||||
|
)
|
||||||
|
retry_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), server_default=func.now(), nullable=False
|
||||||
|
)
|
||||||
|
processed_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_delivery_outbox_status", "status"),
|
||||||
|
Index("ix_delivery_outbox_tenant", "tenant_id"),
|
||||||
|
)
|
||||||
118
backend/services/delivery/app/models/types.py
Normal file
118
backend/services/delivery/app/models/types.py
Normal 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"
|
||||||
148
backend/services/delivery/app/permissions/definitions.py
Normal file
148
backend/services/delivery/app/permissions/definitions.py
Normal 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.",
|
||||||
|
)
|
||||||
4
backend/services/delivery/app/policies/__init__.py
Normal file
4
backend/services/delivery/app/policies/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
"""Driver domain policies package."""
|
||||||
|
from app.policies.drivers import DriverLifecyclePolicy
|
||||||
|
|
||||||
|
__all__ = ["DriverLifecyclePolicy"]
|
||||||
35
backend/services/delivery/app/policies/drivers.py
Normal file
35
backend/services/delivery/app/policies/drivers.py
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
"""Driver domain policies — Phase 10.1."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.models.types import DriverLifecycleAction, DriverStatus
|
||||||
|
from app.validators.drivers import (
|
||||||
|
ensure_driver_lifecycle_transition,
|
||||||
|
target_status_for,
|
||||||
|
validate_reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DriverLifecyclePolicy:
|
||||||
|
"""Enforces legal driver status transitions and reason rules."""
|
||||||
|
|
||||||
|
REQUIRES_REASON = frozenset(
|
||||||
|
{
|
||||||
|
DriverLifecycleAction.SUSPEND,
|
||||||
|
DriverLifecycleAction.BLOCK,
|
||||||
|
DriverLifecycleAction.DEACTIVATE,
|
||||||
|
DriverLifecycleAction.ARCHIVE,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
def assert_transition(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
action: DriverLifecycleAction,
|
||||||
|
current: DriverStatus,
|
||||||
|
reason: str | None = None,
|
||||||
|
) -> tuple[DriverStatus, str | None]:
|
||||||
|
ensure_driver_lifecycle_transition(action=action, current=current)
|
||||||
|
cleaned = validate_reason(
|
||||||
|
reason, required=action in self.REQUIRES_REASON
|
||||||
|
)
|
||||||
|
return target_status_for(action), cleaned
|
||||||
13
backend/services/delivery/app/providers/__init__.py
Normal file
13
backend/services/delivery/app/providers/__init__.py
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
"""Platform provider contracts package."""
|
||||||
|
from app.providers.contracts import ( # noqa: F401
|
||||||
|
AIProvider,
|
||||||
|
AccountingProvider,
|
||||||
|
CRMProvider,
|
||||||
|
CommunicationProvider,
|
||||||
|
FleetProvider,
|
||||||
|
IdentityProvider,
|
||||||
|
LoyaltyProvider,
|
||||||
|
NotificationProvider,
|
||||||
|
RoutingEngineProvider,
|
||||||
|
StorageProvider,
|
||||||
|
)
|
||||||
96
backend/services/delivery/app/providers/contracts.py
Normal file
96
backend/services/delivery/app/providers/contracts.py
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
"""Platform capability contracts — interfaces only; no implementations in Delivery."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Protocol
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
|
||||||
|
class AccountingProvider(Protocol):
|
||||||
|
"""Contract for Accounting. Delivery must not post journals."""
|
||||||
|
|
||||||
|
async def create_settlement_ref(
|
||||||
|
self, *, tenant_id: UUID, reference: str, payload: dict[str, Any]
|
||||||
|
) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class CRMProvider(Protocol):
|
||||||
|
"""Contract for CRM. Delivery stores external contact refs only."""
|
||||||
|
|
||||||
|
async def resolve_contact(
|
||||||
|
self, *, tenant_id: UUID, contact_ref: str
|
||||||
|
) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class LoyaltyProvider(Protocol):
|
||||||
|
"""Contract for Loyalty. Delivery must not own points/ledger."""
|
||||||
|
|
||||||
|
async def resolve_member(
|
||||||
|
self, *, tenant_id: UUID, member_ref: str
|
||||||
|
) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class CommunicationProvider(Protocol):
|
||||||
|
"""Contract for Communication Platform. Delivery must not own SMS providers."""
|
||||||
|
|
||||||
|
async def send(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: UUID,
|
||||||
|
channel: str,
|
||||||
|
template_key: str,
|
||||||
|
to: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class NotificationProvider(Protocol):
|
||||||
|
"""Contract for Notification fanout. Delivery must not own message delivery."""
|
||||||
|
|
||||||
|
async def notify(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
tenant_id: UUID,
|
||||||
|
channel: str,
|
||||||
|
template_key: str,
|
||||||
|
payload: dict[str, Any],
|
||||||
|
) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class StorageProvider(Protocol):
|
||||||
|
"""Contract for File Storage. Delivery stores file refs only (e.g. POD)."""
|
||||||
|
|
||||||
|
async def resolve_file(
|
||||||
|
self, *, tenant_id: UUID, file_ref: str
|
||||||
|
) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class AIProvider(Protocol):
|
||||||
|
"""Contract for AI Platform. Delivery must not own model inference."""
|
||||||
|
|
||||||
|
async def suggest(
|
||||||
|
self, *, tenant_id: UUID, task: str, context: dict[str, Any]
|
||||||
|
) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class IdentityProvider(Protocol):
|
||||||
|
"""Contract for Identity. Delivery stores external_user_ref only."""
|
||||||
|
|
||||||
|
async def resolve_user(
|
||||||
|
self, *, tenant_id: UUID, user_ref: str
|
||||||
|
) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class RoutingEngineProvider(Protocol):
|
||||||
|
"""Contract for pluggable routing engines — adapters live in Delivery."""
|
||||||
|
|
||||||
|
async def plan_route(
|
||||||
|
self, *, tenant_id: UUID, request: dict[str, Any]
|
||||||
|
) -> dict[str, Any]: ...
|
||||||
|
|
||||||
|
|
||||||
|
class FleetProvider(Protocol):
|
||||||
|
"""Contract for external fleet/courier providers — adapters live in Delivery."""
|
||||||
|
|
||||||
|
async def request_fulfillment(
|
||||||
|
self, *, tenant_id: UUID, request: dict[str, Any]
|
||||||
|
) -> dict[str, Any]: ...
|
||||||
4
backend/services/delivery/app/queries/__init__.py
Normal file
4
backend/services/delivery/app/queries/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
"""Driver queries package."""
|
||||||
|
from app.queries.drivers import DriverQueries
|
||||||
|
|
||||||
|
__all__ = ["DriverQueries"]
|
||||||
38
backend/services/delivery/app/queries/drivers.py
Normal file
38
backend/services/delivery/app/queries/drivers.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
"""Driver query handlers — Phase 10.1."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.services.drivers import DriverService
|
||||||
|
from app.specifications.drivers import DriverListSpec
|
||||||
|
|
||||||
|
|
||||||
|
class DriverQueries:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.service = DriverService(session)
|
||||||
|
|
||||||
|
async def get(self, tenant_id: UUID, driver_id: UUID):
|
||||||
|
return await self.service.get(tenant_id, driver_id)
|
||||||
|
|
||||||
|
async def list(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
offset: int,
|
||||||
|
limit: int,
|
||||||
|
spec: DriverListSpec | None,
|
||||||
|
):
|
||||||
|
return await self.service.list(
|
||||||
|
tenant_id, offset=offset, limit=limit, spec=spec
|
||||||
|
)
|
||||||
|
|
||||||
|
async def lifecycle(self, tenant_id: UUID, driver_id: UUID, *, limit: int = 100):
|
||||||
|
return await self.service.list_lifecycle(tenant_id, driver_id, limit=limit)
|
||||||
|
|
||||||
|
async def credentials(self, tenant_id: UUID, driver_id: UUID):
|
||||||
|
return await self.service.list_credentials(tenant_id, driver_id)
|
||||||
|
|
||||||
|
async def documents(self, tenant_id: UUID, driver_id: UUID):
|
||||||
|
return await self.service.list_documents(tenant_id, driver_id)
|
||||||
78
backend/services/delivery/app/repositories/base.py
Normal file
78
backend/services/delivery/app/repositories/base.py
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
"""Tenant-aware base repository with soft-delete helpers."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Generic, Sequence, TypeVar
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
|
||||||
|
ModelT = TypeVar("ModelT", bound=Base)
|
||||||
|
|
||||||
|
|
||||||
|
class TenantBaseRepository(Generic[ModelT]):
|
||||||
|
model: type[ModelT]
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def get(self, tenant_id: UUID, entity_id: UUID) -> ModelT | None:
|
||||||
|
clauses = [
|
||||||
|
self.model.tenant_id == tenant_id, # type: ignore[attr-defined]
|
||||||
|
self.model.id == entity_id, # type: ignore[attr-defined]
|
||||||
|
]
|
||||||
|
if hasattr(self.model, "is_deleted"):
|
||||||
|
clauses.append(self.model.is_deleted.is_(False)) # type: ignore[attr-defined]
|
||||||
|
stmt = select(self.model).where(*clauses)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def get_including_deleted(
|
||||||
|
self, tenant_id: UUID, entity_id: UUID
|
||||||
|
) -> ModelT | None:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id, # type: ignore[attr-defined]
|
||||||
|
self.model.id == entity_id, # type: ignore[attr-defined]
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def add(self, entity: ModelT) -> ModelT:
|
||||||
|
self.session.add(entity)
|
||||||
|
await self.session.flush()
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def soft_delete(self, entity: ModelT, *, deleted_by: str | None = None) -> None:
|
||||||
|
entity.is_deleted = True # type: ignore[attr-defined]
|
||||||
|
entity.deleted_at = datetime.now(timezone.utc) # type: ignore[attr-defined]
|
||||||
|
if deleted_by is not None and hasattr(entity, "deleted_by"):
|
||||||
|
entity.deleted_by = deleted_by # type: ignore[attr-defined]
|
||||||
|
await self.session.flush()
|
||||||
|
|
||||||
|
async def restore(self, entity: ModelT) -> None:
|
||||||
|
entity.is_deleted = False # type: ignore[attr-defined]
|
||||||
|
entity.deleted_at = None # type: ignore[attr-defined]
|
||||||
|
if hasattr(entity, "deleted_by"):
|
||||||
|
entity.deleted_by = None # type: ignore[attr-defined]
|
||||||
|
await self.session.flush()
|
||||||
|
|
||||||
|
async def list_by_tenant(
|
||||||
|
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20
|
||||||
|
) -> Sequence[ModelT]:
|
||||||
|
clauses = [self.model.tenant_id == tenant_id] # type: ignore[attr-defined]
|
||||||
|
if hasattr(self.model, "is_deleted"):
|
||||||
|
clauses.append(self.model.is_deleted.is_(False)) # type: ignore[attr-defined]
|
||||||
|
stmt = select(self.model).where(*clauses).offset(offset).limit(limit)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def count_by_tenant(self, tenant_id: UUID) -> int:
|
||||||
|
clauses = [self.model.tenant_id == tenant_id] # type: ignore[attr-defined]
|
||||||
|
if hasattr(self.model, "is_deleted"):
|
||||||
|
clauses.append(self.model.is_deleted.is_(False)) # type: ignore[attr-defined]
|
||||||
|
stmt = select(func.count()).select_from(self.model).where(*clauses)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return int(result.scalar_one())
|
||||||
112
backend/services/delivery/app/repositories/drivers.py
Normal file
112
backend/services/delivery/app/repositories/drivers.py
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
"""Driver repositories — Phase 10.1."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Sequence
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from app.models.drivers import (
|
||||||
|
Driver,
|
||||||
|
DriverCredential,
|
||||||
|
DriverDocument,
|
||||||
|
DriverLifecycleEvent,
|
||||||
|
)
|
||||||
|
from app.repositories.base import TenantBaseRepository
|
||||||
|
from app.specifications.drivers import DriverListSpec
|
||||||
|
|
||||||
|
|
||||||
|
class DriverRepository(TenantBaseRepository[Driver]):
|
||||||
|
model = Driver
|
||||||
|
|
||||||
|
async def get_by_code(self, tenant_id: UUID, code: str) -> Driver | None:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.code == code,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def list_filtered(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
spec: DriverListSpec | None = None,
|
||||||
|
) -> Sequence[Driver]:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
if spec is not None:
|
||||||
|
stmt = spec.apply(stmt)
|
||||||
|
else:
|
||||||
|
stmt = stmt.order_by(self.model.created_at.desc())
|
||||||
|
stmt = stmt.offset(offset).limit(limit)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def count_filtered(
|
||||||
|
self, tenant_id: UUID, *, spec: DriverListSpec | None = None
|
||||||
|
) -> int:
|
||||||
|
stmt = select(func.count()).select_from(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
if spec is not None:
|
||||||
|
clauses = spec.filter_clauses()
|
||||||
|
if clauses:
|
||||||
|
stmt = stmt.where(*clauses)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return int(result.scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
class DriverCredentialRepository(TenantBaseRepository[DriverCredential]):
|
||||||
|
model = DriverCredential
|
||||||
|
|
||||||
|
async def list_for_driver(
|
||||||
|
self, tenant_id: UUID, driver_id: UUID
|
||||||
|
) -> Sequence[DriverCredential]:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.driver_id == driver_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
class DriverDocumentRepository(TenantBaseRepository[DriverDocument]):
|
||||||
|
model = DriverDocument
|
||||||
|
|
||||||
|
async def list_for_driver(
|
||||||
|
self, tenant_id: UUID, driver_id: UUID
|
||||||
|
) -> Sequence[DriverDocument]:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.driver_id == driver_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
class DriverLifecycleEventRepository(TenantBaseRepository[DriverLifecycleEvent]):
|
||||||
|
model = DriverLifecycleEvent
|
||||||
|
|
||||||
|
async def list_for_driver(
|
||||||
|
self, tenant_id: UUID, driver_id: UUID, *, limit: int = 100
|
||||||
|
) -> Sequence[DriverLifecycleEvent]:
|
||||||
|
stmt = (
|
||||||
|
select(self.model)
|
||||||
|
.where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.driver_id == driver_id,
|
||||||
|
)
|
||||||
|
.order_by(self.model.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
142
backend/services/delivery/app/repositories/foundation.py
Normal file
142
backend/services/delivery/app/repositories/foundation.py
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
"""Delivery foundation repositories."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Sequence
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
|
||||||
|
from app.models.foundation import (
|
||||||
|
DeliveryAuditLog,
|
||||||
|
DeliveryConfiguration,
|
||||||
|
DeliveryHub,
|
||||||
|
DeliveryOrganization,
|
||||||
|
DeliveryPermission,
|
||||||
|
DeliveryRole,
|
||||||
|
DeliverySetting,
|
||||||
|
ExternalProviderConfig,
|
||||||
|
RoutingEngineRegistration,
|
||||||
|
)
|
||||||
|
from app.repositories.base import TenantBaseRepository
|
||||||
|
|
||||||
|
|
||||||
|
class DeliveryOrganizationRepository(TenantBaseRepository[DeliveryOrganization]):
|
||||||
|
model = DeliveryOrganization
|
||||||
|
|
||||||
|
async def get_by_code(
|
||||||
|
self, tenant_id: UUID, code: str
|
||||||
|
) -> DeliveryOrganization | None:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.code == code,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
class DeliveryHubRepository(TenantBaseRepository[DeliveryHub]):
|
||||||
|
model = DeliveryHub
|
||||||
|
|
||||||
|
async def list_by_organization(
|
||||||
|
self, tenant_id: UUID, organization_id: UUID, *, offset: int = 0, limit: int = 20
|
||||||
|
) -> Sequence[DeliveryHub]:
|
||||||
|
stmt = (
|
||||||
|
select(self.model)
|
||||||
|
.where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.organization_id == organization_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
.offset(offset)
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
class DeliveryRoleRepository(TenantBaseRepository[DeliveryRole]):
|
||||||
|
model = DeliveryRole
|
||||||
|
|
||||||
|
|
||||||
|
class DeliveryPermissionRepository(TenantBaseRepository[DeliveryPermission]):
|
||||||
|
model = DeliveryPermission
|
||||||
|
|
||||||
|
|
||||||
|
class ExternalProviderConfigRepository(TenantBaseRepository[ExternalProviderConfig]):
|
||||||
|
model = ExternalProviderConfig
|
||||||
|
|
||||||
|
async def get_by_code(
|
||||||
|
self, tenant_id: UUID, code: str
|
||||||
|
) -> ExternalProviderConfig | None:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.code == code,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
class RoutingEngineRegistrationRepository(
|
||||||
|
TenantBaseRepository[RoutingEngineRegistration]
|
||||||
|
):
|
||||||
|
model = RoutingEngineRegistration
|
||||||
|
|
||||||
|
|
||||||
|
class DeliveryConfigurationRepository(TenantBaseRepository[DeliveryConfiguration]):
|
||||||
|
model = DeliveryConfiguration
|
||||||
|
|
||||||
|
|
||||||
|
class DeliverySettingRepository(TenantBaseRepository[DeliverySetting]):
|
||||||
|
model = DeliverySetting
|
||||||
|
|
||||||
|
async def get_by_key(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
key: str,
|
||||||
|
*,
|
||||||
|
organization_id: UUID | None = None,
|
||||||
|
hub_id: UUID | None = None,
|
||||||
|
) -> DeliverySetting | None:
|
||||||
|
clauses = [
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.key == key,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
]
|
||||||
|
if organization_id is None:
|
||||||
|
clauses.append(self.model.organization_id.is_(None))
|
||||||
|
else:
|
||||||
|
clauses.append(self.model.organization_id == organization_id)
|
||||||
|
if hub_id is None:
|
||||||
|
clauses.append(self.model.hub_id.is_(None))
|
||||||
|
else:
|
||||||
|
clauses.append(self.model.hub_id == hub_id)
|
||||||
|
stmt = select(self.model).where(*clauses)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
class DeliveryAuditLogRepository(TenantBaseRepository[DeliveryAuditLog]):
|
||||||
|
model = DeliveryAuditLog
|
||||||
|
|
||||||
|
async def list_for_entity(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
entity_type: str,
|
||||||
|
entity_id: UUID,
|
||||||
|
*,
|
||||||
|
limit: int = 100,
|
||||||
|
) -> Sequence[DeliveryAuditLog]:
|
||||||
|
stmt = (
|
||||||
|
select(self.model)
|
||||||
|
.where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.entity_type == entity_type,
|
||||||
|
self.model.entity_id == entity_id,
|
||||||
|
)
|
||||||
|
.order_by(self.model.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
18
backend/services/delivery/app/schemas/common.py
Normal file
18
backend/services/delivery/app/schemas/common.py
Normal 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
|
||||||
164
backend/services/delivery/app/schemas/drivers.py
Normal file
164
backend/services/delivery/app/schemas/drivers.py
Normal 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
|
||||||
294
backend/services/delivery/app/schemas/foundation.py
Normal file
294
backend/services/delivery/app/schemas/foundation.py
Normal 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
|
||||||
51
backend/services/delivery/app/services/audit_service.py
Normal file
51
backend/services/delivery/app/services/audit_service.py
Normal 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
|
||||||
|
)
|
||||||
478
backend/services/delivery/app/services/drivers.py
Normal file
478
backend/services/delivery/app/services/drivers.py
Normal 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))
|
||||||
779
backend/services/delivery/app/services/foundation.py
Normal file
779
backend/services/delivery/app/services/foundation.py
Normal 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))
|
||||||
4
backend/services/delivery/app/specifications/__init__.py
Normal file
4
backend/services/delivery/app/specifications/__init__.py
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
"""Driver query specifications package."""
|
||||||
|
from app.specifications.drivers import ALLOWED_SORT_FIELDS, DriverListSpec
|
||||||
|
|
||||||
|
__all__ = ["ALLOWED_SORT_FIELDS", "DriverListSpec"]
|
||||||
62
backend/services/delivery/app/specifications/drivers.py
Normal file
62
backend/services/delivery/app/specifications/drivers.py
Normal 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
|
||||||
62
backend/services/delivery/app/tests/conftest.py
Normal file
62
backend/services/delivery/app/tests/conftest.py
Normal 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)}
|
||||||
136
backend/services/delivery/app/tests/test_api.py
Normal file
136
backend/services/delivery/app/tests/test_api.py
Normal 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)
|
||||||
190
backend/services/delivery/app/tests/test_architecture.py
Normal file
190
backend/services/delivery/app/tests/test_architecture.py
Normal 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}"
|
||||||
34
backend/services/delivery/app/tests/test_dependency.py
Normal file
34
backend/services/delivery/app/tests/test_dependency.py
Normal 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
|
||||||
39
backend/services/delivery/app/tests/test_docs.py
Normal file
39
backend/services/delivery/app/tests/test_docs.py
Normal 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
|
||||||
36
backend/services/delivery/app/tests/test_migration.py
Normal file
36
backend/services/delivery/app/tests/test_migration.py
Normal 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)
|
||||||
22
backend/services/delivery/app/tests/test_performance.py
Normal file
22
backend/services/delivery/app/tests/test_performance.py
Normal 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
|
||||||
29
backend/services/delivery/app/tests/test_permissions.py
Normal file
29
backend/services/delivery/app/tests/test_permissions.py
Normal 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
|
||||||
291
backend/services/delivery/app/tests/test_phase_101.py
Normal file
291
backend/services/delivery/app/tests/test_phase_101.py
Normal 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
|
||||||
41
backend/services/delivery/app/tests/test_security.py
Normal file
41
backend/services/delivery/app/tests/test_security.py
Normal 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
|
||||||
14
backend/services/delivery/app/validators/__init__.py
Normal file
14
backend/services/delivery/app/validators/__init__.py
Normal 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",
|
||||||
|
]
|
||||||
140
backend/services/delivery/app/validators/drivers.py
Normal file
140
backend/services/delivery/app/validators/drivers.py
Normal 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")
|
||||||
55
backend/services/delivery/app/validators/foundation.py
Normal file
55
backend/services/delivery/app/validators/foundation.py
Normal 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},
|
||||||
|
)
|
||||||
8
backend/services/delivery/pytest.ini
Normal file
8
backend/services/delivery/pytest.ini
Normal 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_*
|
||||||
14
backend/services/delivery/requirements.txt
Normal file
14
backend/services/delivery/requirements.txt
Normal 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
|
||||||
41
backend/services/delivery/scripts/ensure_db.py
Normal file
41
backend/services/delivery/scripts/ensure_db.py
Normal 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()
|
||||||
@ -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
|
||||||
|
|||||||
45
backend/services/hospitality/README.md
Normal file
45
backend/services/hospitality/README.md
Normal 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)
|
||||||
42
backend/services/hospitality/alembic/env.py
Normal file
42
backend/services/hospitality/alembic/env.py
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
|
||||||
|
from alembic import context
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.core.database import Base
|
||||||
|
import app.models # noqa: F401
|
||||||
|
|
||||||
|
config = context.config
|
||||||
|
config.set_main_option("sqlalchemy.url", settings.database_url_sync)
|
||||||
|
if config.config_file_name:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline():
|
||||||
|
context.configure(
|
||||||
|
url=settings.database_url_sync,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online():
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
@ -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)
|
||||||
@ -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)
|
||||||
@ -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)
|
||||||
@ -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)
|
||||||
@ -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)
|
||||||
@ -1 +1 @@
|
|||||||
__version__ = "0.10.0.0"
|
__version__ = "0.12.8.0"
|
||||||
|
|||||||
39
backend/services/hospitality/app/api/deps.py
Normal file
39
backend/services/hospitality/app/api/deps.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
"""Common API dependencies."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import Depends, Query, Request
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.core.database import get_db
|
||||||
|
from app.core.security import get_current_user
|
||||||
|
from shared.exceptions import TenantNotResolvedError
|
||||||
|
from shared.pagination import PaginationParams
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
from shared.tenant import STATE_TENANT_ID
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"get_db",
|
||||||
|
"get_pagination",
|
||||||
|
"require_tenant",
|
||||||
|
"get_current_user",
|
||||||
|
"AsyncSession",
|
||||||
|
"CurrentUser",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def get_pagination(
|
||||||
|
page: int = Query(default=1, ge=1),
|
||||||
|
page_size: int = Query(default=20, ge=1, le=500),
|
||||||
|
) -> PaginationParams:
|
||||||
|
return PaginationParams(page=page, page_size=page_size)
|
||||||
|
|
||||||
|
|
||||||
|
def require_tenant(request: Request) -> UUID:
|
||||||
|
tenant_id = getattr(request.state, STATE_TENANT_ID, None)
|
||||||
|
if tenant_id is None:
|
||||||
|
raise TenantNotResolvedError(
|
||||||
|
"tenant قابل تشخیص نبود. هدر X-Tenant-ID را ارسال کنید."
|
||||||
|
)
|
||||||
|
return tenant_id
|
||||||
77
backend/services/hospitality/app/api/v1/__init__.py
Normal file
77
backend/services/hospitality/app/api/v1/__init__.py
Normal 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"])
|
||||||
90
backend/services/hospitality/app/api/v1/analytics.py
Normal file
90
backend/services/hospitality/app/api/v1/analytics.py
Normal 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)
|
||||||
72
backend/services/hospitality/app/api/v1/branches.py
Normal file
72
backend/services/hospitality/app/api/v1/branches.py
Normal 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)
|
||||||
|
|
||||||
@ -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)
|
||||||
269
backend/services/hospitality/app/api/v1/catalog.py
Normal file
269
backend/services/hospitality/app/api/v1/catalog.py
Normal 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)
|
||||||
61
backend/services/hospitality/app/api/v1/configurations.py
Normal file
61
backend/services/hospitality/app/api/v1/configurations.py
Normal 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)
|
||||||
92
backend/services/hospitality/app/api/v1/connectors.py
Normal file
92
backend/services/hospitality/app/api/v1/connectors.py
Normal 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)
|
||||||
49
backend/services/hospitality/app/api/v1/dining_areas.py
Normal file
49
backend/services/hospitality/app/api/v1/dining_areas.py
Normal 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)
|
||||||
49
backend/services/hospitality/app/api/v1/events.py
Normal file
49
backend/services/hospitality/app/api/v1/events.py
Normal 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)
|
||||||
46
backend/services/hospitality/app/api/v1/feature_toggles.py
Normal file
46
backend/services/hospitality/app/api/v1/feature_toggles.py
Normal 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)
|
||||||
72
backend/services/hospitality/app/api/v1/health.py
Normal file
72
backend/services/hospitality/app/api/v1/health.py
Normal 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",
|
||||||
|
},
|
||||||
|
}
|
||||||
159
backend/services/hospitality/app/api/v1/kitchen.py
Normal file
159
backend/services/hospitality/app/api/v1/kitchen.py
Normal 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
|
||||||
|
)
|
||||||
49
backend/services/hospitality/app/api/v1/menu_categories.py
Normal file
49
backend/services/hospitality/app/api/v1/menu_categories.py
Normal 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)
|
||||||
49
backend/services/hospitality/app/api/v1/menu_items.py
Normal file
49
backend/services/hospitality/app/api/v1/menu_items.py
Normal 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)
|
||||||
72
backend/services/hospitality/app/api/v1/menus.py
Normal file
72
backend/services/hospitality/app/api/v1/menus.py
Normal 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)
|
||||||
|
|
||||||
49
backend/services/hospitality/app/api/v1/permissions.py
Normal file
49
backend/services/hospitality/app/api/v1/permissions.py
Normal 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)
|
||||||
183
backend/services/hospitality/app/api/v1/pos_lite.py
Normal file
183
backend/services/hospitality/app/api/v1/pos_lite.py
Normal 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
Loading…
Reference in New Issue
Block a user