Compare commits
No commits in common. "0eec9f729b4a9381cd3f62633756fc0e629577da" and "4451b32a33d01151124d62a47c1e30d8ec1d8067" have entirely different histories.
0eec9f729b
...
4451b32a33
@ -1,29 +0,0 @@
|
|||||||
"""Phase 10.2 — Fleet & Vehicle Types schema (additive)."""
|
|
||||||
from alembic import op
|
|
||||||
from app.core.database import Base
|
|
||||||
import app.models # noqa: F401
|
|
||||||
|
|
||||||
revision = "0003_phase_102_fleet"
|
|
||||||
down_revision = "0002_phase_101_drivers"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
_NEW_TABLES = (
|
|
||||||
"fleets",
|
|
||||||
"vehicle_types",
|
|
||||||
"vehicles",
|
|
||||||
"vehicle_assignments",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
bind = op.get_bind()
|
|
||||||
Base.metadata.create_all(bind=bind)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
bind = op.get_bind()
|
|
||||||
for name in reversed(_NEW_TABLES):
|
|
||||||
table = Base.metadata.tables.get(name)
|
|
||||||
if table is not None:
|
|
||||||
table.drop(bind=bind, checkfirst=True)
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
"""Phase 10.3 — Availability, Shifts & Working Zones schema (additive)."""
|
|
||||||
from alembic import op
|
|
||||||
from app.core.database import Base
|
|
||||||
import app.models # noqa: F401
|
|
||||||
|
|
||||||
revision = "0004_phase_103_availability"
|
|
||||||
down_revision = "0003_phase_102_fleet"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
_NEW_TABLES = (
|
|
||||||
"driver_availabilities",
|
|
||||||
"shifts",
|
|
||||||
"shift_assignments",
|
|
||||||
"working_zones",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
bind = op.get_bind()
|
|
||||||
Base.metadata.create_all(bind=bind)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
bind = op.get_bind()
|
|
||||||
for name in reversed(_NEW_TABLES):
|
|
||||||
table = Base.metadata.tables.get(name)
|
|
||||||
if table is not None:
|
|
||||||
table.drop(bind=bind, checkfirst=True)
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
"""Phase 10.4 — Pricing, Capabilities & Bundles schema (additive)."""
|
|
||||||
from alembic import op
|
|
||||||
from app.core.database import Base
|
|
||||||
import app.models # noqa: F401
|
|
||||||
|
|
||||||
revision = "0005_phase_104_pricing"
|
|
||||||
down_revision = "0004_phase_103_availability"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
_NEW_TABLES = (
|
|
||||||
"pricing_rules",
|
|
||||||
"capability_definitions",
|
|
||||||
"capability_bundles",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
bind = op.get_bind()
|
|
||||||
Base.metadata.create_all(bind=bind)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
bind = op.get_bind()
|
|
||||||
for name in reversed(_NEW_TABLES):
|
|
||||||
table = Base.metadata.tables.get(name)
|
|
||||||
if table is not None:
|
|
||||||
table.drop(bind=bind, checkfirst=True)
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
"""Phase 10.5 — Dispatch Engine schema (additive)."""
|
|
||||||
from alembic import op
|
|
||||||
from app.core.database import Base
|
|
||||||
import app.models # noqa: F401
|
|
||||||
|
|
||||||
revision = "0006_phase_105_dispatch"
|
|
||||||
down_revision = "0005_phase_104_pricing"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
_NEW_TABLES = (
|
|
||||||
"dispatch_jobs",
|
|
||||||
"job_assignments",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
bind = op.get_bind()
|
|
||||||
Base.metadata.create_all(bind=bind)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
bind = op.get_bind()
|
|
||||||
for name in reversed(_NEW_TABLES):
|
|
||||||
table = Base.metadata.tables.get(name)
|
|
||||||
if table is not None:
|
|
||||||
table.drop(bind=bind, checkfirst=True)
|
|
||||||
@ -1,28 +0,0 @@
|
|||||||
"""Phase 10.6 — Routing & Optimization schema (additive)."""
|
|
||||||
from alembic import op
|
|
||||||
from app.core.database import Base
|
|
||||||
import app.models # noqa: F401
|
|
||||||
|
|
||||||
revision = "0007_phase_106_routing"
|
|
||||||
down_revision = "0006_phase_105_dispatch"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
_NEW_TABLES = (
|
|
||||||
"route_plans",
|
|
||||||
"route_stops",
|
|
||||||
"optimization_runs",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
bind = op.get_bind()
|
|
||||||
Base.metadata.create_all(bind=bind)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
bind = op.get_bind()
|
|
||||||
for name in reversed(_NEW_TABLES):
|
|
||||||
table = Base.metadata.tables.get(name)
|
|
||||||
if table is not None:
|
|
||||||
table.drop(bind=bind, checkfirst=True)
|
|
||||||
@ -1,31 +0,0 @@
|
|||||||
"""Phase 10.7+10.8 — Tracking, POD & Settlement schema (additive)."""
|
|
||||||
from alembic import op
|
|
||||||
from app.core.database import Base
|
|
||||||
import app.models # noqa: F401
|
|
||||||
|
|
||||||
revision = "0008_phase_107_108_tracking_settlement"
|
|
||||||
down_revision = "0007_phase_106_routing"
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
_NEW_TABLES = (
|
|
||||||
"tracking_sessions",
|
|
||||||
"tracking_points",
|
|
||||||
"proof_of_delivery",
|
|
||||||
"customer_tracking_tokens",
|
|
||||||
"settlement_intents",
|
|
||||||
"settlement_lines",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade():
|
|
||||||
bind = op.get_bind()
|
|
||||||
Base.metadata.create_all(bind=bind)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade():
|
|
||||||
bind = op.get_bind()
|
|
||||||
for name in reversed(_NEW_TABLES):
|
|
||||||
table = Base.metadata.tables.get(name)
|
|
||||||
if table is not None:
|
|
||||||
table.drop(bind=bind, checkfirst=True)
|
|
||||||
@ -1 +1 @@
|
|||||||
__version__ = "0.10.8.0"
|
__version__ = "0.10.1.0"
|
||||||
|
|||||||
@ -2,31 +2,14 @@ from fastapi import APIRouter
|
|||||||
|
|
||||||
from app.api.v1 import (
|
from app.api.v1 import (
|
||||||
audit,
|
audit,
|
||||||
availability,
|
|
||||||
bundles,
|
|
||||||
capabilities,
|
|
||||||
configurations,
|
configurations,
|
||||||
customer_tracking,
|
|
||||||
dispatch_assignments,
|
|
||||||
dispatch_jobs,
|
|
||||||
drivers,
|
drivers,
|
||||||
external_providers,
|
external_providers,
|
||||||
fleets,
|
|
||||||
hubs,
|
hubs,
|
||||||
optimization,
|
|
||||||
organizations,
|
organizations,
|
||||||
permissions,
|
permissions,
|
||||||
pricing_rules,
|
|
||||||
proof_of_delivery,
|
|
||||||
routes,
|
|
||||||
routing_engines,
|
routing_engines,
|
||||||
settings,
|
settings,
|
||||||
settlements,
|
|
||||||
shifts,
|
|
||||||
tracking,
|
|
||||||
vehicle_types,
|
|
||||||
vehicles,
|
|
||||||
working_zones,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
@ -48,51 +31,6 @@ api_router.include_router(
|
|||||||
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
||||||
api_router.include_router(audit.router, prefix="/audit", tags=["audit"])
|
api_router.include_router(audit.router, prefix="/audit", tags=["audit"])
|
||||||
api_router.include_router(drivers.router, prefix="/drivers", tags=["drivers"])
|
api_router.include_router(drivers.router, prefix="/drivers", tags=["drivers"])
|
||||||
api_router.include_router(
|
|
||||||
availability.router, prefix="/drivers", tags=["driver-availability"]
|
|
||||||
)
|
|
||||||
api_router.include_router(fleets.router, prefix="/fleets", tags=["fleets"])
|
|
||||||
api_router.include_router(
|
|
||||||
vehicle_types.router, prefix="/vehicle-types", tags=["vehicle-types"]
|
|
||||||
)
|
|
||||||
api_router.include_router(vehicles.router, prefix="/vehicles", tags=["vehicles"])
|
|
||||||
api_router.include_router(shifts.router, prefix="/shifts", tags=["shifts"])
|
|
||||||
api_router.include_router(
|
|
||||||
working_zones.router, prefix="/working-zones", tags=["working-zones"]
|
|
||||||
)
|
|
||||||
api_router.include_router(
|
|
||||||
pricing_rules.router, prefix="/pricing-rules", tags=["pricing-rules"]
|
|
||||||
)
|
|
||||||
api_router.include_router(
|
|
||||||
capabilities.router, prefix="/capabilities", tags=["capabilities"]
|
|
||||||
)
|
|
||||||
api_router.include_router(bundles.router, prefix="/bundles", tags=["bundles"])
|
|
||||||
api_router.include_router(
|
|
||||||
dispatch_jobs.router, prefix="/dispatch/jobs", tags=["dispatch-jobs"]
|
|
||||||
)
|
|
||||||
api_router.include_router(
|
|
||||||
dispatch_assignments.router,
|
|
||||||
prefix="/dispatch/assignments",
|
|
||||||
tags=["dispatch-assignments"],
|
|
||||||
)
|
|
||||||
api_router.include_router(routes.router, prefix="/routes", tags=["routes"])
|
|
||||||
api_router.include_router(
|
|
||||||
optimization.router, prefix="/optimization", tags=["optimization"]
|
|
||||||
)
|
|
||||||
api_router.include_router(tracking.router, prefix="/tracking", tags=["tracking"])
|
|
||||||
api_router.include_router(
|
|
||||||
proof_of_delivery.router,
|
|
||||||
prefix="/proof-of-delivery",
|
|
||||||
tags=["proof-of-delivery"],
|
|
||||||
)
|
|
||||||
api_router.include_router(
|
|
||||||
customer_tracking.router,
|
|
||||||
prefix="/customer-tracking",
|
|
||||||
tags=["customer-tracking"],
|
|
||||||
)
|
|
||||||
api_router.include_router(
|
|
||||||
settlements.router, prefix="/settlements", tags=["settlements"]
|
|
||||||
)
|
|
||||||
api_router.include_router(
|
api_router.include_router(
|
||||||
permissions.router, prefix="/permissions", tags=["permissions"]
|
permissions.router, prefix="/permissions", tags=["permissions"]
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,40 +0,0 @@
|
|||||||
"""Driver availability APIs — Phase 10.3."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.api.deps import get_db, require_tenant
|
|
||||||
from app.api.permissions import require_permissions
|
|
||||||
from app.commands.availability import AvailabilityCommands
|
|
||||||
from app.permissions.definitions import AVAILABILITY_MANAGE, AVAILABILITY_VIEW
|
|
||||||
from app.queries.availability import AvailabilityQueries
|
|
||||||
from app.schemas.availability import DriverAvailabilityRead, DriverAvailabilityUpsert
|
|
||||||
from shared.security import CurrentUser
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{driver_id}/availability", response_model=DriverAvailabilityRead)
|
|
||||||
async def get_availability(
|
|
||||||
driver_id: UUID,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(AVAILABILITY_VIEW)),
|
|
||||||
):
|
|
||||||
return await AvailabilityQueries(db).get_availability(tenant_id, driver_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/{driver_id}/availability", response_model=DriverAvailabilityRead)
|
|
||||||
async def upsert_availability(
|
|
||||||
driver_id: UUID,
|
|
||||||
body: DriverAvailabilityUpsert,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(AVAILABILITY_MANAGE)),
|
|
||||||
):
|
|
||||||
return await AvailabilityCommands(db).upsert_availability(
|
|
||||||
tenant_id, driver_id, body, actor=user
|
|
||||||
)
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
"""Capability bundle APIs — Phase 10.4."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.api.deps import get_db, get_pagination, require_tenant
|
|
||||||
from app.api.permissions import require_permissions
|
|
||||||
from app.commands.pricing import PricingCommands
|
|
||||||
from app.models.types import CapabilityBundleStatus
|
|
||||||
from app.permissions.definitions import (
|
|
||||||
BUNDLES_CREATE,
|
|
||||||
BUNDLES_DELETE,
|
|
||||||
BUNDLES_UPDATE,
|
|
||||||
BUNDLES_VIEW,
|
|
||||||
)
|
|
||||||
from app.queries.pricing import PricingQueries
|
|
||||||
from app.schemas.pricing import (
|
|
||||||
BundleCreate,
|
|
||||||
BundleListResponse,
|
|
||||||
BundleRead,
|
|
||||||
BundleUpdate,
|
|
||||||
)
|
|
||||||
from app.specifications.pricing import BundleListSpec
|
|
||||||
from shared.pagination import PaginationParams
|
|
||||||
from shared.security import CurrentUser
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _list_spec(
|
|
||||||
status_filter: CapabilityBundleStatus | None = Query(default=None, alias="status"),
|
|
||||||
q: str | None = Query(default=None, max_length=100),
|
|
||||||
sort_by: str = Query(default="created_at"),
|
|
||||||
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
|
||||||
) -> BundleListSpec:
|
|
||||||
return BundleListSpec(
|
|
||||||
status=status_filter, q=q, sort_by=sort_by, sort_dir=sort_dir.lower()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=BundleRead, status_code=status.HTTP_201_CREATED)
|
|
||||||
async def create_bundle(
|
|
||||||
body: BundleCreate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(BUNDLES_CREATE)),
|
|
||||||
):
|
|
||||||
return await PricingCommands(db).create_bundle(tenant_id, body, actor=user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=BundleListResponse)
|
|
||||||
async def list_bundles(
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
pagination: PaginationParams = Depends(get_pagination),
|
|
||||||
spec: BundleListSpec = Depends(_list_spec),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(BUNDLES_VIEW)),
|
|
||||||
):
|
|
||||||
items, total = await PricingQueries(db).list_bundles(
|
|
||||||
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
|
||||||
)
|
|
||||||
return BundleListResponse(
|
|
||||||
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{bundle_id}", response_model=BundleRead)
|
|
||||||
async def get_bundle(
|
|
||||||
bundle_id: UUID,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(BUNDLES_VIEW)),
|
|
||||||
):
|
|
||||||
return await PricingQueries(db).get_bundle(tenant_id, bundle_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{bundle_id}", response_model=BundleRead)
|
|
||||||
async def update_bundle(
|
|
||||||
bundle_id: UUID,
|
|
||||||
body: BundleUpdate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(BUNDLES_UPDATE)),
|
|
||||||
):
|
|
||||||
return await PricingCommands(db).update_bundle(
|
|
||||||
tenant_id, bundle_id, body, actor=user
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{bundle_id}/delete", response_model=BundleRead)
|
|
||||||
async def delete_bundle(
|
|
||||||
bundle_id: UUID,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(BUNDLES_DELETE)),
|
|
||||||
):
|
|
||||||
return await PricingCommands(db).delete_bundle(tenant_id, bundle_id, actor=user)
|
|
||||||
@ -1,100 +0,0 @@
|
|||||||
"""Capability APIs — Phase 10.4."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.api.deps import get_db, get_pagination, require_tenant
|
|
||||||
from app.api.permissions import require_permissions
|
|
||||||
from app.commands.pricing import PricingCommands
|
|
||||||
from app.models.types import CapabilityStatus
|
|
||||||
from app.permissions.definitions import (
|
|
||||||
CAPABILITIES_CREATE,
|
|
||||||
CAPABILITIES_DELETE,
|
|
||||||
CAPABILITIES_UPDATE,
|
|
||||||
CAPABILITIES_VIEW,
|
|
||||||
)
|
|
||||||
from app.queries.pricing import PricingQueries
|
|
||||||
from app.schemas.pricing import (
|
|
||||||
CapabilityCreate,
|
|
||||||
CapabilityListResponse,
|
|
||||||
CapabilityRead,
|
|
||||||
CapabilityUpdate,
|
|
||||||
)
|
|
||||||
from app.specifications.pricing import CapabilityListSpec
|
|
||||||
from shared.pagination import PaginationParams
|
|
||||||
from shared.security import CurrentUser
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _list_spec(
|
|
||||||
status_filter: CapabilityStatus | None = Query(default=None, alias="status"),
|
|
||||||
q: str | None = Query(default=None, max_length=100),
|
|
||||||
sort_by: str = Query(default="created_at"),
|
|
||||||
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
|
||||||
) -> CapabilityListSpec:
|
|
||||||
return CapabilityListSpec(
|
|
||||||
status=status_filter, q=q, sort_by=sort_by, sort_dir=sort_dir.lower()
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=CapabilityRead, status_code=status.HTTP_201_CREATED)
|
|
||||||
async def create_capability(
|
|
||||||
body: CapabilityCreate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(CAPABILITIES_CREATE)),
|
|
||||||
):
|
|
||||||
return await PricingCommands(db).create_capability(tenant_id, body, actor=user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=CapabilityListResponse)
|
|
||||||
async def list_capabilities(
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
pagination: PaginationParams = Depends(get_pagination),
|
|
||||||
spec: CapabilityListSpec = Depends(_list_spec),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(CAPABILITIES_VIEW)),
|
|
||||||
):
|
|
||||||
items, total = await PricingQueries(db).list_capabilities(
|
|
||||||
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
|
||||||
)
|
|
||||||
return CapabilityListResponse(
|
|
||||||
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{cap_id}", response_model=CapabilityRead)
|
|
||||||
async def get_capability(
|
|
||||||
cap_id: UUID,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(CAPABILITIES_VIEW)),
|
|
||||||
):
|
|
||||||
return await PricingQueries(db).get_capability(tenant_id, cap_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{cap_id}", response_model=CapabilityRead)
|
|
||||||
async def update_capability(
|
|
||||||
cap_id: UUID,
|
|
||||||
body: CapabilityUpdate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(CAPABILITIES_UPDATE)),
|
|
||||||
):
|
|
||||||
return await PricingCommands(db).update_capability(
|
|
||||||
tenant_id, cap_id, body, actor=user
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{cap_id}/delete", response_model=CapabilityRead)
|
|
||||||
async def delete_capability(
|
|
||||||
cap_id: UUID,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(CAPABILITIES_DELETE)),
|
|
||||||
):
|
|
||||||
return await PricingCommands(db).delete_capability(tenant_id, cap_id, actor=user)
|
|
||||||
@ -1,88 +0,0 @@
|
|||||||
"""Customer tracking token APIs — Phase 10.7."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.api.deps import get_db, get_pagination, require_tenant
|
|
||||||
from app.api.permissions import require_permissions
|
|
||||||
from app.commands.tracking import TrackingCommands
|
|
||||||
from app.models.types import CustomerTrackingTokenStatus
|
|
||||||
from app.permissions.definitions import (
|
|
||||||
CUSTOMER_TRACKING_CREATE,
|
|
||||||
CUSTOMER_TRACKING_REVOKE,
|
|
||||||
CUSTOMER_TRACKING_VIEW,
|
|
||||||
)
|
|
||||||
from app.queries.tracking import TrackingQueries
|
|
||||||
from app.schemas.tracking import (
|
|
||||||
CustomerTrackingTokenCreate,
|
|
||||||
CustomerTrackingTokenListResponse,
|
|
||||||
CustomerTrackingTokenRead,
|
|
||||||
)
|
|
||||||
from app.specifications.tracking import CustomerTrackingTokenListSpec
|
|
||||||
from shared.pagination import PaginationParams
|
|
||||||
from shared.security import CurrentUser
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _list_spec(
|
|
||||||
status_filter: CustomerTrackingTokenStatus | None = Query(default=None, alias="status"),
|
|
||||||
dispatch_job_id: UUID | None = Query(default=None),
|
|
||||||
sort_by: str = Query(default="created_at"),
|
|
||||||
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
|
||||||
) -> CustomerTrackingTokenListSpec:
|
|
||||||
return CustomerTrackingTokenListSpec(
|
|
||||||
status=status_filter,
|
|
||||||
dispatch_job_id=dispatch_job_id,
|
|
||||||
sort_by=sort_by,
|
|
||||||
sort_dir=sort_dir.lower(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tokens", response_model=CustomerTrackingTokenRead, status_code=status.HTTP_201_CREATED)
|
|
||||||
async def create_token(
|
|
||||||
body: CustomerTrackingTokenCreate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(CUSTOMER_TRACKING_CREATE)),
|
|
||||||
):
|
|
||||||
return await TrackingCommands(db).create_token(tenant_id, body, actor=user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tokens", response_model=CustomerTrackingTokenListResponse)
|
|
||||||
async def list_tokens(
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
pagination: PaginationParams = Depends(get_pagination),
|
|
||||||
spec: CustomerTrackingTokenListSpec = Depends(_list_spec),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(CUSTOMER_TRACKING_VIEW)),
|
|
||||||
):
|
|
||||||
items, total = await TrackingQueries(db).list_tokens(
|
|
||||||
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
|
||||||
)
|
|
||||||
return CustomerTrackingTokenListResponse(
|
|
||||||
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tokens/{token_id}", response_model=CustomerTrackingTokenRead)
|
|
||||||
async def get_token(
|
|
||||||
token_id: UUID,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(CUSTOMER_TRACKING_VIEW)),
|
|
||||||
):
|
|
||||||
return await TrackingQueries(db).get_token(tenant_id, token_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tokens/{token_id}/revoke", response_model=CustomerTrackingTokenRead)
|
|
||||||
async def revoke_token(
|
|
||||||
token_id: UUID,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(CUSTOMER_TRACKING_REVOKE)),
|
|
||||||
):
|
|
||||||
return await TrackingCommands(db).revoke_token(tenant_id, token_id, actor=user)
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
"""Dispatch assignment APIs — Phase 10.5."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.api.deps import get_db, get_pagination, require_tenant
|
|
||||||
from app.api.permissions import require_permissions
|
|
||||||
from app.commands.dispatch import DispatchCommands
|
|
||||||
from app.models.types import JobAssignmentStatus
|
|
||||||
from app.permissions.definitions import (
|
|
||||||
DISPATCH_ASSIGNMENTS_CREATE,
|
|
||||||
DISPATCH_ASSIGNMENTS_UPDATE,
|
|
||||||
DISPATCH_ASSIGNMENTS_VIEW,
|
|
||||||
)
|
|
||||||
from app.queries.dispatch import DispatchQueries
|
|
||||||
from app.schemas.dispatch import (
|
|
||||||
JobAssignmentCreate,
|
|
||||||
JobAssignmentListResponse,
|
|
||||||
JobAssignmentRead,
|
|
||||||
JobAssignmentUpdate,
|
|
||||||
)
|
|
||||||
from app.specifications.dispatch import JobAssignmentListSpec
|
|
||||||
from shared.pagination import PaginationParams
|
|
||||||
from shared.security import CurrentUser
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _list_spec(
|
|
||||||
dispatch_job_id: UUID | None = Query(default=None),
|
|
||||||
driver_id: UUID | None = Query(default=None),
|
|
||||||
status_filter: JobAssignmentStatus | None = Query(default=None, alias="status"),
|
|
||||||
sort_by: str = Query(default="created_at"),
|
|
||||||
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
|
||||||
) -> JobAssignmentListSpec:
|
|
||||||
return JobAssignmentListSpec(
|
|
||||||
dispatch_job_id=dispatch_job_id,
|
|
||||||
driver_id=driver_id,
|
|
||||||
status=status_filter,
|
|
||||||
sort_by=sort_by,
|
|
||||||
sort_dir=sort_dir.lower(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=JobAssignmentRead, status_code=status.HTTP_201_CREATED)
|
|
||||||
async def create_assignment(
|
|
||||||
body: JobAssignmentCreate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(DISPATCH_ASSIGNMENTS_CREATE)),
|
|
||||||
):
|
|
||||||
return await DispatchCommands(db).create_assignment(tenant_id, body, actor=user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=JobAssignmentListResponse)
|
|
||||||
async def list_assignments(
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
pagination: PaginationParams = Depends(get_pagination),
|
|
||||||
spec: JobAssignmentListSpec = Depends(_list_spec),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(DISPATCH_ASSIGNMENTS_VIEW)),
|
|
||||||
):
|
|
||||||
items, total = await DispatchQueries(db).list_assignments(
|
|
||||||
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
|
||||||
)
|
|
||||||
return JobAssignmentListResponse(
|
|
||||||
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{assignment_id}", response_model=JobAssignmentRead)
|
|
||||||
async def get_assignment(
|
|
||||||
assignment_id: UUID,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(DISPATCH_ASSIGNMENTS_VIEW)),
|
|
||||||
):
|
|
||||||
return await DispatchQueries(db).get_assignment(tenant_id, assignment_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{assignment_id}", response_model=JobAssignmentRead)
|
|
||||||
async def update_assignment(
|
|
||||||
assignment_id: UUID,
|
|
||||||
body: JobAssignmentUpdate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(DISPATCH_ASSIGNMENTS_UPDATE)),
|
|
||||||
):
|
|
||||||
return await DispatchCommands(db).update_assignment(
|
|
||||||
tenant_id, assignment_id, body, actor=user
|
|
||||||
)
|
|
||||||
@ -1,120 +0,0 @@
|
|||||||
"""Dispatch job APIs — Phase 10.5."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.api.deps import get_db, get_pagination, require_tenant
|
|
||||||
from app.api.permissions import require_permissions
|
|
||||||
from app.commands.dispatch import DispatchCommands
|
|
||||||
from app.models.types import DispatchJobStatus
|
|
||||||
from app.permissions.definitions import (
|
|
||||||
DISPATCH_JOBS_CREATE,
|
|
||||||
DISPATCH_JOBS_DELETE,
|
|
||||||
DISPATCH_JOBS_STATUS,
|
|
||||||
DISPATCH_JOBS_UPDATE,
|
|
||||||
DISPATCH_JOBS_VIEW,
|
|
||||||
)
|
|
||||||
from app.queries.dispatch import DispatchQueries
|
|
||||||
from app.schemas.dispatch import (
|
|
||||||
DispatchJobCreate,
|
|
||||||
DispatchJobListResponse,
|
|
||||||
DispatchJobRead,
|
|
||||||
DispatchJobStatusRequest,
|
|
||||||
DispatchJobUpdate,
|
|
||||||
)
|
|
||||||
from app.specifications.dispatch import DispatchJobListSpec
|
|
||||||
from shared.pagination import PaginationParams
|
|
||||||
from shared.security import CurrentUser
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _list_spec(
|
|
||||||
status_filter: DispatchJobStatus | None = Query(default=None, alias="status"),
|
|
||||||
organization_id: UUID | None = Query(default=None),
|
|
||||||
hub_id: UUID | None = Query(default=None),
|
|
||||||
q: str | None = Query(default=None, max_length=100),
|
|
||||||
sort_by: str = Query(default="created_at"),
|
|
||||||
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
|
||||||
) -> DispatchJobListSpec:
|
|
||||||
return DispatchJobListSpec(
|
|
||||||
status=status_filter,
|
|
||||||
organization_id=organization_id,
|
|
||||||
hub_id=hub_id,
|
|
||||||
q=q,
|
|
||||||
sort_by=sort_by,
|
|
||||||
sort_dir=sort_dir.lower(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=DispatchJobRead, status_code=status.HTTP_201_CREATED)
|
|
||||||
async def create_dispatch_job(
|
|
||||||
body: DispatchJobCreate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_CREATE)),
|
|
||||||
):
|
|
||||||
return await DispatchCommands(db).create_job(tenant_id, body, actor=user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=DispatchJobListResponse)
|
|
||||||
async def list_dispatch_jobs(
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
pagination: PaginationParams = Depends(get_pagination),
|
|
||||||
spec: DispatchJobListSpec = Depends(_list_spec),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_VIEW)),
|
|
||||||
):
|
|
||||||
items, total = await DispatchQueries(db).list_jobs(
|
|
||||||
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
|
||||||
)
|
|
||||||
return DispatchJobListResponse(
|
|
||||||
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{job_id}", response_model=DispatchJobRead)
|
|
||||||
async def get_dispatch_job(
|
|
||||||
job_id: UUID,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_VIEW)),
|
|
||||||
):
|
|
||||||
return await DispatchQueries(db).get_job(tenant_id, job_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{job_id}", response_model=DispatchJobRead)
|
|
||||||
async def update_dispatch_job(
|
|
||||||
job_id: UUID,
|
|
||||||
body: DispatchJobUpdate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_UPDATE)),
|
|
||||||
):
|
|
||||||
return await DispatchCommands(db).update_job(tenant_id, job_id, body, actor=user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{job_id}/status", response_model=DispatchJobRead)
|
|
||||||
async def apply_dispatch_status(
|
|
||||||
job_id: UUID,
|
|
||||||
body: DispatchJobStatusRequest,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_STATUS)),
|
|
||||||
):
|
|
||||||
return await DispatchCommands(db).apply_status(
|
|
||||||
tenant_id, job_id, body, actor=user
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{job_id}/delete", response_model=DispatchJobRead)
|
|
||||||
async def delete_dispatch_job(
|
|
||||||
job_id: UUID,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_DELETE)),
|
|
||||||
):
|
|
||||||
return await DispatchCommands(db).delete_job(tenant_id, job_id, actor=user)
|
|
||||||
@ -1,102 +0,0 @@
|
|||||||
"""Fleet management APIs — Phase 10.2."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.api.deps import get_db, get_pagination, require_tenant
|
|
||||||
from app.api.permissions import require_permissions
|
|
||||||
from app.commands.fleet import FleetCommands
|
|
||||||
from app.models.types import FleetStatus, VehicleStatus
|
|
||||||
from app.permissions.definitions import (
|
|
||||||
FLEETS_CREATE,
|
|
||||||
FLEETS_DELETE,
|
|
||||||
FLEETS_UPDATE,
|
|
||||||
FLEETS_VIEW,
|
|
||||||
)
|
|
||||||
from app.queries.fleet import FleetQueries
|
|
||||||
from app.schemas.fleet import FleetCreate, FleetListResponse, FleetRead, FleetUpdate
|
|
||||||
from app.specifications.fleet import FLEET_SORT, FleetListSpec
|
|
||||||
from shared.pagination import PaginationParams
|
|
||||||
from shared.security import CurrentUser
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _list_spec(
|
|
||||||
status_filter: FleetStatus | None = Query(default=None, alias="status"),
|
|
||||||
organization_id: UUID | None = Query(default=None),
|
|
||||||
hub_id: UUID | None = Query(default=None),
|
|
||||||
q: str | None = Query(default=None, max_length=100),
|
|
||||||
sort_by: str = Query(default="created_at"),
|
|
||||||
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
|
||||||
) -> FleetListSpec:
|
|
||||||
if sort_by not in FLEET_SORT:
|
|
||||||
sort_by = "created_at"
|
|
||||||
return FleetListSpec(
|
|
||||||
status=status_filter,
|
|
||||||
organization_id=organization_id,
|
|
||||||
hub_id=hub_id,
|
|
||||||
q=q,
|
|
||||||
sort_by=sort_by,
|
|
||||||
sort_dir=sort_dir.lower(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=FleetRead, status_code=status.HTTP_201_CREATED)
|
|
||||||
async def create_fleet(
|
|
||||||
body: FleetCreate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(FLEETS_CREATE)),
|
|
||||||
):
|
|
||||||
return await FleetCommands(db).create_fleet(tenant_id, body, actor=user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=FleetListResponse)
|
|
||||||
async def list_fleets(
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
pagination: PaginationParams = Depends(get_pagination),
|
|
||||||
spec: FleetListSpec = Depends(_list_spec),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(FLEETS_VIEW)),
|
|
||||||
):
|
|
||||||
items, total = await FleetQueries(db).list_fleets(
|
|
||||||
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
|
||||||
)
|
|
||||||
return FleetListResponse(
|
|
||||||
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{fleet_id}", response_model=FleetRead)
|
|
||||||
async def get_fleet(
|
|
||||||
fleet_id: UUID,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(FLEETS_VIEW)),
|
|
||||||
):
|
|
||||||
return await FleetQueries(db).get_fleet(tenant_id, fleet_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{fleet_id}", response_model=FleetRead)
|
|
||||||
async def update_fleet(
|
|
||||||
fleet_id: UUID,
|
|
||||||
body: FleetUpdate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(FLEETS_UPDATE)),
|
|
||||||
):
|
|
||||||
return await FleetCommands(db).update_fleet(tenant_id, fleet_id, body, actor=user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{fleet_id}/delete", response_model=FleetRead)
|
|
||||||
async def delete_fleet(
|
|
||||||
fleet_id: UUID,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(FLEETS_DELETE)),
|
|
||||||
):
|
|
||||||
return await FleetCommands(db).delete_fleet(tenant_id, fleet_id, actor=user)
|
|
||||||
@ -20,7 +20,7 @@ async def capabilities():
|
|||||||
return {
|
return {
|
||||||
"service": settings.service_name,
|
"service": settings.service_name,
|
||||||
"version": __version__,
|
"version": __version__,
|
||||||
"phase": "10.8",
|
"phase": "10.1",
|
||||||
"commercial_product": "Torbat Driver",
|
"commercial_product": "Torbat Driver",
|
||||||
"features": {
|
"features": {
|
||||||
"foundation": True,
|
"foundation": True,
|
||||||
@ -33,23 +33,12 @@ async def capabilities():
|
|||||||
"driver_lifecycle": True,
|
"driver_lifecycle": True,
|
||||||
"driver_credentials": True,
|
"driver_credentials": True,
|
||||||
"driver_documents": True,
|
"driver_documents": True,
|
||||||
"fleet": True,
|
"fleet": False,
|
||||||
"vehicle_types": True,
|
"dispatch_engine": False,
|
||||||
"vehicles": True,
|
"routing_engine": False,
|
||||||
"vehicle_assignments": True,
|
"tracking": False,
|
||||||
"driver_availability": True,
|
"proof_of_delivery": False,
|
||||||
"shifts": True,
|
"settlement": False,
|
||||||
"working_zones": True,
|
|
||||||
"pricing_rules": True,
|
|
||||||
"capabilities_catalog": True,
|
|
||||||
"capability_bundles": True,
|
|
||||||
"dispatch_engine": True,
|
|
||||||
"routing_engine": True,
|
|
||||||
"optimization_runs": True,
|
|
||||||
"tracking": True,
|
|
||||||
"proof_of_delivery": True,
|
|
||||||
"customer_tracking": True,
|
|
||||||
"settlement": True,
|
|
||||||
"ai": False,
|
"ai": False,
|
||||||
},
|
},
|
||||||
"independence": {
|
"independence": {
|
||||||
@ -67,34 +56,31 @@ async def capabilities():
|
|||||||
"pharmacy",
|
"pharmacy",
|
||||||
],
|
],
|
||||||
"integration_mode": "api_and_events_only",
|
"integration_mode": "api_and_events_only",
|
||||||
"accounting_mode": "settlement_refs_only",
|
|
||||||
"no_journal_entries_in_delivery_db": True,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/metrics")
|
@router.get("/metrics")
|
||||||
async def metrics():
|
async def metrics():
|
||||||
"""Phase 10.8 metrics discovery — tenant-scoped counters."""
|
"""Phase 10.1 metrics discovery — driver counters reserved for ops wiring."""
|
||||||
return {
|
return {
|
||||||
"service": settings.service_name,
|
"service": settings.service_name,
|
||||||
"version": __version__,
|
"version": __version__,
|
||||||
"phase": "10.8",
|
"phase": "10.1",
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"organizations": "tenant_scoped",
|
"organizations": "tenant_scoped",
|
||||||
"hubs": "tenant_scoped",
|
"hubs": "tenant_scoped",
|
||||||
|
"external_providers": "tenant_scoped",
|
||||||
|
"routing_engines": "tenant_scoped",
|
||||||
|
"configurations": "tenant_scoped",
|
||||||
"drivers": "tenant_scoped",
|
"drivers": "tenant_scoped",
|
||||||
"fleets": "tenant_scoped",
|
"driver_credentials": "tenant_scoped",
|
||||||
"vehicles": "tenant_scoped",
|
"driver_documents": "tenant_scoped",
|
||||||
"shifts": "tenant_scoped",
|
"driver_lifecycle_events": "tenant_scoped",
|
||||||
"pricing_rules": "tenant_scoped",
|
|
||||||
"dispatch_jobs": "tenant_scoped",
|
|
||||||
"route_plans": "tenant_scoped",
|
|
||||||
"optimization_runs": "tenant_scoped",
|
|
||||||
"tracking_sessions": "tenant_scoped",
|
|
||||||
"proof_of_delivery": "tenant_scoped",
|
|
||||||
"settlement_intents": "tenant_scoped",
|
|
||||||
"outbox_events": "tenant_scoped",
|
"outbox_events": "tenant_scoped",
|
||||||
|
"dispatch_jobs": 0,
|
||||||
|
"active_routes": 0,
|
||||||
|
"tracking_sessions": 0,
|
||||||
},
|
},
|
||||||
"note": "Full delivery platform through phase 10.8; settlement uses AccountingProvider refs only",
|
"note": "Driver aggregates live; dispatch/routing/tracking counters reserved for later phases",
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,74 +0,0 @@
|
|||||||
"""Optimization run APIs — Phase 10.6."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.api.deps import get_db, get_pagination, require_tenant
|
|
||||||
from app.api.permissions import require_permissions
|
|
||||||
from app.commands.routing import RoutingCommands
|
|
||||||
from app.models.types import OptimizationRunStatus
|
|
||||||
from app.permissions.definitions import OPTIMIZATION_RUNS_CREATE, OPTIMIZATION_RUNS_VIEW
|
|
||||||
from app.queries.routing import RoutingQueries
|
|
||||||
from app.schemas.routing import (
|
|
||||||
OptimizationRunCreate,
|
|
||||||
OptimizationRunListResponse,
|
|
||||||
OptimizationRunRead,
|
|
||||||
)
|
|
||||||
from app.specifications.routing import OptimizationRunListSpec
|
|
||||||
from shared.pagination import PaginationParams
|
|
||||||
from shared.security import CurrentUser
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _list_spec(
|
|
||||||
route_plan_id: UUID | None = Query(default=None),
|
|
||||||
status_filter: OptimizationRunStatus | None = Query(default=None, alias="status"),
|
|
||||||
sort_by: str = Query(default="created_at"),
|
|
||||||
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
|
||||||
) -> OptimizationRunListSpec:
|
|
||||||
return OptimizationRunListSpec(
|
|
||||||
route_plan_id=route_plan_id,
|
|
||||||
status=status_filter,
|
|
||||||
sort_by=sort_by,
|
|
||||||
sort_dir=sort_dir.lower(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/runs", response_model=OptimizationRunRead, status_code=status.HTTP_201_CREATED)
|
|
||||||
async def start_optimization(
|
|
||||||
body: OptimizationRunCreate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(OPTIMIZATION_RUNS_CREATE)),
|
|
||||||
):
|
|
||||||
return await RoutingCommands(db).start_optimization(tenant_id, body, actor=user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/runs", response_model=OptimizationRunListResponse)
|
|
||||||
async def list_optimization_runs(
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
pagination: PaginationParams = Depends(get_pagination),
|
|
||||||
spec: OptimizationRunListSpec = Depends(_list_spec),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(OPTIMIZATION_RUNS_VIEW)),
|
|
||||||
):
|
|
||||||
items, total = await RoutingQueries(db).list_optimization_runs(
|
|
||||||
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
|
||||||
)
|
|
||||||
return OptimizationRunListResponse(
|
|
||||||
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/runs/{run_id}", response_model=OptimizationRunRead)
|
|
||||||
async def get_optimization_run(
|
|
||||||
run_id: UUID,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(OPTIMIZATION_RUNS_VIEW)),
|
|
||||||
):
|
|
||||||
return await RoutingQueries(db).get_optimization_run(tenant_id, run_id)
|
|
||||||
@ -1,103 +0,0 @@
|
|||||||
"""Pricing rule APIs — Phase 10.4."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.api.deps import get_db, get_pagination, require_tenant
|
|
||||||
from app.api.permissions import require_permissions
|
|
||||||
from app.commands.pricing import PricingCommands
|
|
||||||
from app.models.types import PricingRuleStatus
|
|
||||||
from app.permissions.definitions import (
|
|
||||||
PRICING_RULES_CREATE,
|
|
||||||
PRICING_RULES_DELETE,
|
|
||||||
PRICING_RULES_UPDATE,
|
|
||||||
PRICING_RULES_VIEW,
|
|
||||||
)
|
|
||||||
from app.queries.pricing import PricingQueries
|
|
||||||
from app.schemas.pricing import (
|
|
||||||
PricingRuleCreate,
|
|
||||||
PricingRuleListResponse,
|
|
||||||
PricingRuleRead,
|
|
||||||
PricingRuleUpdate,
|
|
||||||
)
|
|
||||||
from app.specifications.pricing import PricingRuleListSpec
|
|
||||||
from shared.pagination import PaginationParams
|
|
||||||
from shared.security import CurrentUser
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _list_spec(
|
|
||||||
status_filter: PricingRuleStatus | None = Query(default=None, alias="status"),
|
|
||||||
organization_id: UUID | None = Query(default=None),
|
|
||||||
q: str | None = Query(default=None, max_length=100),
|
|
||||||
sort_by: str = Query(default="created_at"),
|
|
||||||
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
|
||||||
) -> PricingRuleListSpec:
|
|
||||||
return PricingRuleListSpec(
|
|
||||||
status=status_filter,
|
|
||||||
organization_id=organization_id,
|
|
||||||
q=q,
|
|
||||||
sort_by=sort_by,
|
|
||||||
sort_dir=sort_dir.lower(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=PricingRuleRead, status_code=status.HTTP_201_CREATED)
|
|
||||||
async def create_pricing_rule(
|
|
||||||
body: PricingRuleCreate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(PRICING_RULES_CREATE)),
|
|
||||||
):
|
|
||||||
return await PricingCommands(db).create_rule(tenant_id, body, actor=user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=PricingRuleListResponse)
|
|
||||||
async def list_pricing_rules(
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
pagination: PaginationParams = Depends(get_pagination),
|
|
||||||
spec: PricingRuleListSpec = Depends(_list_spec),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(PRICING_RULES_VIEW)),
|
|
||||||
):
|
|
||||||
items, total = await PricingQueries(db).list_rules(
|
|
||||||
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
|
||||||
)
|
|
||||||
return PricingRuleListResponse(
|
|
||||||
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{rule_id}", response_model=PricingRuleRead)
|
|
||||||
async def get_pricing_rule(
|
|
||||||
rule_id: UUID,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(PRICING_RULES_VIEW)),
|
|
||||||
):
|
|
||||||
return await PricingQueries(db).get_rule(tenant_id, rule_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{rule_id}", response_model=PricingRuleRead)
|
|
||||||
async def update_pricing_rule(
|
|
||||||
rule_id: UUID,
|
|
||||||
body: PricingRuleUpdate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(PRICING_RULES_UPDATE)),
|
|
||||||
):
|
|
||||||
return await PricingCommands(db).update_rule(tenant_id, rule_id, body, actor=user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{rule_id}/delete", response_model=PricingRuleRead)
|
|
||||||
async def delete_pricing_rule(
|
|
||||||
rule_id: UUID,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(PRICING_RULES_DELETE)),
|
|
||||||
):
|
|
||||||
return await PricingCommands(db).delete_rule(tenant_id, rule_id, actor=user)
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
"""Proof of delivery APIs — Phase 10.7."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.api.deps import get_db, get_pagination, require_tenant
|
|
||||||
from app.api.permissions import require_permissions
|
|
||||||
from app.commands.tracking import TrackingCommands
|
|
||||||
from app.models.types import ProofOfDeliveryStatus
|
|
||||||
from app.permissions.definitions import (
|
|
||||||
PROOF_OF_DELIVERY_CREATE,
|
|
||||||
PROOF_OF_DELIVERY_UPDATE,
|
|
||||||
PROOF_OF_DELIVERY_VIEW,
|
|
||||||
)
|
|
||||||
from app.queries.tracking import TrackingQueries
|
|
||||||
from app.schemas.tracking import (
|
|
||||||
ProofOfDeliveryCreate,
|
|
||||||
ProofOfDeliveryListResponse,
|
|
||||||
ProofOfDeliveryRead,
|
|
||||||
ProofOfDeliveryUpdate,
|
|
||||||
)
|
|
||||||
from app.specifications.tracking import ProofOfDeliveryListSpec
|
|
||||||
from shared.pagination import PaginationParams
|
|
||||||
from shared.security import CurrentUser
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _list_spec(
|
|
||||||
status_filter: ProofOfDeliveryStatus | None = Query(default=None, alias="status"),
|
|
||||||
dispatch_job_id: UUID | None = Query(default=None),
|
|
||||||
sort_by: str = Query(default="created_at"),
|
|
||||||
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
|
||||||
) -> ProofOfDeliveryListSpec:
|
|
||||||
return ProofOfDeliveryListSpec(
|
|
||||||
status=status_filter,
|
|
||||||
dispatch_job_id=dispatch_job_id,
|
|
||||||
sort_by=sort_by,
|
|
||||||
sort_dir=sort_dir.lower(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=ProofOfDeliveryRead, status_code=status.HTTP_201_CREATED)
|
|
||||||
async def capture_pod(
|
|
||||||
body: ProofOfDeliveryCreate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(PROOF_OF_DELIVERY_CREATE)),
|
|
||||||
):
|
|
||||||
return await TrackingCommands(db).capture_pod(tenant_id, body, actor=user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=ProofOfDeliveryListResponse)
|
|
||||||
async def list_pods(
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
pagination: PaginationParams = Depends(get_pagination),
|
|
||||||
spec: ProofOfDeliveryListSpec = Depends(_list_spec),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(PROOF_OF_DELIVERY_VIEW)),
|
|
||||||
):
|
|
||||||
items, total = await TrackingQueries(db).list_pods(
|
|
||||||
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
|
||||||
)
|
|
||||||
return ProofOfDeliveryListResponse(
|
|
||||||
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{pod_id}", response_model=ProofOfDeliveryRead)
|
|
||||||
async def get_pod(
|
|
||||||
pod_id: UUID,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(PROOF_OF_DELIVERY_VIEW)),
|
|
||||||
):
|
|
||||||
return await TrackingQueries(db).get_pod(tenant_id, pod_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{pod_id}", response_model=ProofOfDeliveryRead)
|
|
||||||
async def update_pod(
|
|
||||||
pod_id: UUID,
|
|
||||||
body: ProofOfDeliveryUpdate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(PROOF_OF_DELIVERY_UPDATE)),
|
|
||||||
):
|
|
||||||
return await TrackingCommands(db).update_pod(tenant_id, pod_id, body, actor=user)
|
|
||||||
@ -1,134 +0,0 @@
|
|||||||
"""Route plan APIs — Phase 10.6."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.api.deps import get_db, get_pagination, require_tenant
|
|
||||||
from app.api.permissions import require_permissions
|
|
||||||
from app.commands.routing import RoutingCommands
|
|
||||||
from app.models.types import RoutePlanStatus
|
|
||||||
from app.permissions.definitions import (
|
|
||||||
ROUTES_CREATE,
|
|
||||||
ROUTES_DELETE,
|
|
||||||
ROUTES_STOPS_MANAGE,
|
|
||||||
ROUTES_STOPS_VIEW,
|
|
||||||
ROUTES_UPDATE,
|
|
||||||
ROUTES_VIEW,
|
|
||||||
)
|
|
||||||
from app.queries.routing import RoutingQueries
|
|
||||||
from app.schemas.routing import (
|
|
||||||
RoutePlanCreate,
|
|
||||||
RoutePlanListResponse,
|
|
||||||
RoutePlanRead,
|
|
||||||
RoutePlanUpdate,
|
|
||||||
RouteStopCreate,
|
|
||||||
RouteStopRead,
|
|
||||||
)
|
|
||||||
from app.specifications.routing import RoutePlanListSpec
|
|
||||||
from shared.pagination import PaginationParams
|
|
||||||
from shared.security import CurrentUser
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _list_spec(
|
|
||||||
status_filter: RoutePlanStatus | None = Query(default=None, alias="status"),
|
|
||||||
organization_id: UUID | None = Query(default=None),
|
|
||||||
driver_id: UUID | None = Query(default=None),
|
|
||||||
q: str | None = Query(default=None, max_length=100),
|
|
||||||
sort_by: str = Query(default="created_at"),
|
|
||||||
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
|
||||||
) -> RoutePlanListSpec:
|
|
||||||
return RoutePlanListSpec(
|
|
||||||
status=status_filter,
|
|
||||||
organization_id=organization_id,
|
|
||||||
driver_id=driver_id,
|
|
||||||
q=q,
|
|
||||||
sort_by=sort_by,
|
|
||||||
sort_dir=sort_dir.lower(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=RoutePlanRead, status_code=status.HTTP_201_CREATED)
|
|
||||||
async def create_route(
|
|
||||||
body: RoutePlanCreate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(ROUTES_CREATE)),
|
|
||||||
):
|
|
||||||
return await RoutingCommands(db).create_route(tenant_id, body, actor=user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=RoutePlanListResponse)
|
|
||||||
async def list_routes(
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
pagination: PaginationParams = Depends(get_pagination),
|
|
||||||
spec: RoutePlanListSpec = Depends(_list_spec),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(ROUTES_VIEW)),
|
|
||||||
):
|
|
||||||
items, total = await RoutingQueries(db).list_routes(
|
|
||||||
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
|
||||||
)
|
|
||||||
return RoutePlanListResponse(
|
|
||||||
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{plan_id}", response_model=RoutePlanRead)
|
|
||||||
async def get_route(
|
|
||||||
plan_id: UUID,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(ROUTES_VIEW)),
|
|
||||||
):
|
|
||||||
return await RoutingQueries(db).get_route(tenant_id, plan_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{plan_id}", response_model=RoutePlanRead)
|
|
||||||
async def update_route(
|
|
||||||
plan_id: UUID,
|
|
||||||
body: RoutePlanUpdate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(ROUTES_UPDATE)),
|
|
||||||
):
|
|
||||||
return await RoutingCommands(db).update_route(tenant_id, plan_id, body, actor=user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{plan_id}/delete", response_model=RoutePlanRead)
|
|
||||||
async def delete_route(
|
|
||||||
plan_id: UUID,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(ROUTES_DELETE)),
|
|
||||||
):
|
|
||||||
return await RoutingCommands(db).delete_route(tenant_id, plan_id, actor=user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/{plan_id}/stops",
|
|
||||||
response_model=RouteStopRead,
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
)
|
|
||||||
async def add_stop(
|
|
||||||
plan_id: UUID,
|
|
||||||
body: RouteStopCreate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(ROUTES_STOPS_MANAGE)),
|
|
||||||
):
|
|
||||||
return await RoutingCommands(db).add_stop(tenant_id, plan_id, body, actor=user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{plan_id}/stops", response_model=list[RouteStopRead])
|
|
||||||
async def list_stops(
|
|
||||||
plan_id: UUID,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(ROUTES_STOPS_VIEW)),
|
|
||||||
):
|
|
||||||
return await RoutingQueries(db).list_stops(tenant_id, plan_id)
|
|
||||||
@ -1,169 +0,0 @@
|
|||||||
"""Settlement APIs — Phase 10.8."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.api.deps import get_db, get_pagination, require_tenant
|
|
||||||
from app.api.permissions import require_permissions
|
|
||||||
from app.commands.settlement import SettlementCommands
|
|
||||||
from app.models.types import SettlementIntentStatus
|
|
||||||
from app.permissions.definitions import (
|
|
||||||
SETTLEMENTS_CREATE,
|
|
||||||
SETTLEMENTS_LINES_MANAGE,
|
|
||||||
SETTLEMENTS_LINES_VIEW,
|
|
||||||
SETTLEMENTS_STATUS,
|
|
||||||
SETTLEMENTS_UPDATE,
|
|
||||||
SETTLEMENTS_VIEW,
|
|
||||||
)
|
|
||||||
from app.queries.settlement import SettlementQueries
|
|
||||||
from app.schemas.settlement import (
|
|
||||||
SettlementActionRequest,
|
|
||||||
SettlementIntentCreate,
|
|
||||||
SettlementIntentListResponse,
|
|
||||||
SettlementIntentRead,
|
|
||||||
SettlementIntentUpdate,
|
|
||||||
SettlementLineCreate,
|
|
||||||
SettlementLineRead,
|
|
||||||
)
|
|
||||||
from app.specifications.settlement import SettlementIntentListSpec
|
|
||||||
from shared.pagination import PaginationParams
|
|
||||||
from shared.security import CurrentUser
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _list_spec(
|
|
||||||
status_filter: SettlementIntentStatus | None = Query(default=None, alias="status"),
|
|
||||||
organization_id: UUID | None = Query(default=None),
|
|
||||||
driver_id: UUID | None = Query(default=None),
|
|
||||||
q: str | None = Query(default=None, max_length=100),
|
|
||||||
sort_by: str = Query(default="created_at"),
|
|
||||||
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
|
||||||
) -> SettlementIntentListSpec:
|
|
||||||
return SettlementIntentListSpec(
|
|
||||||
status=status_filter,
|
|
||||||
organization_id=organization_id,
|
|
||||||
driver_id=driver_id,
|
|
||||||
q=q,
|
|
||||||
sort_by=sort_by,
|
|
||||||
sort_dir=sort_dir.lower(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=SettlementIntentRead, status_code=status.HTTP_201_CREATED)
|
|
||||||
async def create_settlement(
|
|
||||||
body: SettlementIntentCreate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_CREATE)),
|
|
||||||
):
|
|
||||||
return await SettlementCommands(db).create(tenant_id, body, actor=user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=SettlementIntentListResponse)
|
|
||||||
async def list_settlements(
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
pagination: PaginationParams = Depends(get_pagination),
|
|
||||||
spec: SettlementIntentListSpec = Depends(_list_spec),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(SETTLEMENTS_VIEW)),
|
|
||||||
):
|
|
||||||
items, total = await SettlementQueries(db).list(
|
|
||||||
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
|
||||||
)
|
|
||||||
return SettlementIntentListResponse(
|
|
||||||
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{intent_id}", response_model=SettlementIntentRead)
|
|
||||||
async def get_settlement(
|
|
||||||
intent_id: UUID,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(SETTLEMENTS_VIEW)),
|
|
||||||
):
|
|
||||||
return await SettlementQueries(db).get(tenant_id, intent_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{intent_id}", response_model=SettlementIntentRead)
|
|
||||||
async def update_settlement(
|
|
||||||
intent_id: UUID,
|
|
||||||
body: SettlementIntentUpdate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_UPDATE)),
|
|
||||||
):
|
|
||||||
return await SettlementCommands(db).update(tenant_id, intent_id, body, actor=user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{intent_id}/submit", response_model=SettlementIntentRead)
|
|
||||||
async def submit_settlement(
|
|
||||||
intent_id: UUID,
|
|
||||||
body: SettlementActionRequest,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_STATUS)),
|
|
||||||
):
|
|
||||||
return await SettlementCommands(db).submit(tenant_id, intent_id, body, actor=user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{intent_id}/settle", response_model=SettlementIntentRead)
|
|
||||||
async def settle_settlement(
|
|
||||||
intent_id: UUID,
|
|
||||||
body: SettlementActionRequest,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_STATUS)),
|
|
||||||
):
|
|
||||||
return await SettlementCommands(db).settle(tenant_id, intent_id, body, actor=user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{intent_id}/fail", response_model=SettlementIntentRead)
|
|
||||||
async def fail_settlement(
|
|
||||||
intent_id: UUID,
|
|
||||||
body: SettlementActionRequest,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_STATUS)),
|
|
||||||
):
|
|
||||||
return await SettlementCommands(db).fail(tenant_id, intent_id, body, actor=user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{intent_id}/cancel", response_model=SettlementIntentRead)
|
|
||||||
async def cancel_settlement(
|
|
||||||
intent_id: UUID,
|
|
||||||
body: SettlementActionRequest,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_STATUS)),
|
|
||||||
):
|
|
||||||
return await SettlementCommands(db).cancel(tenant_id, intent_id, body, actor=user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/{intent_id}/lines",
|
|
||||||
response_model=SettlementLineRead,
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
)
|
|
||||||
async def add_settlement_line(
|
|
||||||
intent_id: UUID,
|
|
||||||
body: SettlementLineCreate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_LINES_MANAGE)),
|
|
||||||
):
|
|
||||||
return await SettlementCommands(db).add_line(tenant_id, intent_id, body, actor=user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{intent_id}/lines", response_model=list[SettlementLineRead])
|
|
||||||
async def list_settlement_lines(
|
|
||||||
intent_id: UUID,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(SETTLEMENTS_LINES_VIEW)),
|
|
||||||
):
|
|
||||||
return await SettlementQueries(db).lines(tenant_id, intent_id)
|
|
||||||
@ -1,156 +0,0 @@
|
|||||||
"""Shift APIs — Phase 10.3."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.api.deps import get_db, get_pagination, require_tenant
|
|
||||||
from app.api.permissions import require_permissions
|
|
||||||
from app.commands.availability import AvailabilityCommands
|
|
||||||
from app.models.types import ShiftStatus
|
|
||||||
from app.permissions.definitions import (
|
|
||||||
SHIFTS_ASSIGNMENTS_MANAGE,
|
|
||||||
SHIFTS_ASSIGNMENTS_VIEW,
|
|
||||||
SHIFTS_CREATE,
|
|
||||||
SHIFTS_DELETE,
|
|
||||||
SHIFTS_UPDATE,
|
|
||||||
SHIFTS_VIEW,
|
|
||||||
)
|
|
||||||
from app.queries.availability import AvailabilityQueries
|
|
||||||
from app.schemas.availability import (
|
|
||||||
ShiftAssignmentCreate,
|
|
||||||
ShiftAssignmentRead,
|
|
||||||
ShiftAssignmentUpdate,
|
|
||||||
ShiftCreate,
|
|
||||||
ShiftListResponse,
|
|
||||||
ShiftRead,
|
|
||||||
ShiftUpdate,
|
|
||||||
)
|
|
||||||
from app.specifications.availability import ShiftListSpec
|
|
||||||
from shared.pagination import PaginationParams
|
|
||||||
from shared.security import CurrentUser
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _list_spec(
|
|
||||||
status_filter: ShiftStatus | None = Query(default=None, alias="status"),
|
|
||||||
organization_id: UUID | None = Query(default=None),
|
|
||||||
hub_id: UUID | None = Query(default=None),
|
|
||||||
q: str | None = Query(default=None, max_length=100),
|
|
||||||
sort_by: str = Query(default="starts_at"),
|
|
||||||
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
|
||||||
) -> ShiftListSpec:
|
|
||||||
return ShiftListSpec(
|
|
||||||
status=status_filter,
|
|
||||||
organization_id=organization_id,
|
|
||||||
hub_id=hub_id,
|
|
||||||
q=q,
|
|
||||||
sort_by=sort_by,
|
|
||||||
sort_dir=sort_dir.lower(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=ShiftRead, status_code=status.HTTP_201_CREATED)
|
|
||||||
async def create_shift(
|
|
||||||
body: ShiftCreate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(SHIFTS_CREATE)),
|
|
||||||
):
|
|
||||||
return await AvailabilityCommands(db).create_shift(tenant_id, body, actor=user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=ShiftListResponse)
|
|
||||||
async def list_shifts(
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
pagination: PaginationParams = Depends(get_pagination),
|
|
||||||
spec: ShiftListSpec = Depends(_list_spec),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(SHIFTS_VIEW)),
|
|
||||||
):
|
|
||||||
items, total = await AvailabilityQueries(db).list_shifts(
|
|
||||||
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
|
||||||
)
|
|
||||||
return ShiftListResponse(
|
|
||||||
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{shift_id}", response_model=ShiftRead)
|
|
||||||
async def get_shift(
|
|
||||||
shift_id: UUID,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(SHIFTS_VIEW)),
|
|
||||||
):
|
|
||||||
return await AvailabilityQueries(db).get_shift(tenant_id, shift_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{shift_id}", response_model=ShiftRead)
|
|
||||||
async def update_shift(
|
|
||||||
shift_id: UUID,
|
|
||||||
body: ShiftUpdate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(SHIFTS_UPDATE)),
|
|
||||||
):
|
|
||||||
return await AvailabilityCommands(db).update_shift(
|
|
||||||
tenant_id, shift_id, body, actor=user
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{shift_id}/delete", response_model=ShiftRead)
|
|
||||||
async def delete_shift(
|
|
||||||
shift_id: UUID,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(SHIFTS_DELETE)),
|
|
||||||
):
|
|
||||||
return await AvailabilityCommands(db).delete_shift(tenant_id, shift_id, actor=user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/{shift_id}/assignments",
|
|
||||||
response_model=ShiftAssignmentRead,
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
)
|
|
||||||
async def assign_shift(
|
|
||||||
shift_id: UUID,
|
|
||||||
body: ShiftAssignmentCreate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(SHIFTS_ASSIGNMENTS_MANAGE)),
|
|
||||||
):
|
|
||||||
return await AvailabilityCommands(db).assign_shift(
|
|
||||||
tenant_id, shift_id, body, actor=user
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{shift_id}/assignments", response_model=list[ShiftAssignmentRead])
|
|
||||||
async def list_shift_assignments(
|
|
||||||
shift_id: UUID,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(SHIFTS_ASSIGNMENTS_VIEW)),
|
|
||||||
):
|
|
||||||
return await AvailabilityQueries(db).list_shift_assignments(tenant_id, shift_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
|
||||||
"/{shift_id}/assignments/{assignment_id}",
|
|
||||||
response_model=ShiftAssignmentRead,
|
|
||||||
)
|
|
||||||
async def update_shift_assignment(
|
|
||||||
shift_id: UUID,
|
|
||||||
assignment_id: UUID,
|
|
||||||
body: ShiftAssignmentUpdate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(SHIFTS_ASSIGNMENTS_MANAGE)),
|
|
||||||
):
|
|
||||||
return await AvailabilityCommands(db).update_shift_assignment(
|
|
||||||
tenant_id, shift_id, assignment_id, body, actor=user
|
|
||||||
)
|
|
||||||
@ -1,126 +0,0 @@
|
|||||||
"""Tracking session APIs — Phase 10.7."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.api.deps import get_db, get_pagination, require_tenant
|
|
||||||
from app.api.permissions import require_permissions
|
|
||||||
from app.commands.tracking import TrackingCommands
|
|
||||||
from app.models.types import TrackingSessionStatus
|
|
||||||
from app.permissions.definitions import (
|
|
||||||
TRACKING_POINTS_MANAGE,
|
|
||||||
TRACKING_POINTS_VIEW,
|
|
||||||
TRACKING_SESSIONS_CREATE,
|
|
||||||
TRACKING_SESSIONS_UPDATE,
|
|
||||||
TRACKING_SESSIONS_VIEW,
|
|
||||||
)
|
|
||||||
from app.queries.tracking import TrackingQueries
|
|
||||||
from app.schemas.tracking import (
|
|
||||||
TrackingPointCreate,
|
|
||||||
TrackingPointRead,
|
|
||||||
TrackingSessionCreate,
|
|
||||||
TrackingSessionListResponse,
|
|
||||||
TrackingSessionRead,
|
|
||||||
TrackingSessionUpdate,
|
|
||||||
)
|
|
||||||
from app.specifications.tracking import TrackingSessionListSpec
|
|
||||||
from shared.pagination import PaginationParams
|
|
||||||
from shared.security import CurrentUser
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _list_spec(
|
|
||||||
status_filter: TrackingSessionStatus | None = Query(default=None, alias="status"),
|
|
||||||
dispatch_job_id: UUID | None = Query(default=None),
|
|
||||||
driver_id: UUID | None = Query(default=None),
|
|
||||||
sort_by: str = Query(default="created_at"),
|
|
||||||
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
|
||||||
) -> TrackingSessionListSpec:
|
|
||||||
return TrackingSessionListSpec(
|
|
||||||
status=status_filter,
|
|
||||||
dispatch_job_id=dispatch_job_id,
|
|
||||||
driver_id=driver_id,
|
|
||||||
sort_by=sort_by,
|
|
||||||
sort_dir=sort_dir.lower(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/sessions", response_model=TrackingSessionRead, status_code=status.HTTP_201_CREATED)
|
|
||||||
async def start_session(
|
|
||||||
body: TrackingSessionCreate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(TRACKING_SESSIONS_CREATE)),
|
|
||||||
):
|
|
||||||
return await TrackingCommands(db).start_session(tenant_id, body, actor=user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sessions", response_model=TrackingSessionListResponse)
|
|
||||||
async def list_sessions(
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
pagination: PaginationParams = Depends(get_pagination),
|
|
||||||
spec: TrackingSessionListSpec = Depends(_list_spec),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(TRACKING_SESSIONS_VIEW)),
|
|
||||||
):
|
|
||||||
items, total = await TrackingQueries(db).list_sessions(
|
|
||||||
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
|
||||||
)
|
|
||||||
return TrackingSessionListResponse(
|
|
||||||
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sessions/{session_id}", response_model=TrackingSessionRead)
|
|
||||||
async def get_session(
|
|
||||||
session_id: UUID,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(TRACKING_SESSIONS_VIEW)),
|
|
||||||
):
|
|
||||||
return await TrackingQueries(db).get_session(tenant_id, session_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/sessions/{session_id}", response_model=TrackingSessionRead)
|
|
||||||
async def update_session(
|
|
||||||
session_id: UUID,
|
|
||||||
body: TrackingSessionUpdate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(TRACKING_SESSIONS_UPDATE)),
|
|
||||||
):
|
|
||||||
return await TrackingCommands(db).update_session(
|
|
||||||
tenant_id, session_id, body, actor=user
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/sessions/{session_id}/points",
|
|
||||||
response_model=TrackingPointRead,
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
)
|
|
||||||
async def record_point(
|
|
||||||
session_id: UUID,
|
|
||||||
body: TrackingPointCreate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(TRACKING_POINTS_MANAGE)),
|
|
||||||
):
|
|
||||||
return await TrackingCommands(db).record_point(
|
|
||||||
tenant_id, session_id, body, actor=user
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/sessions/{session_id}/points", response_model=list[TrackingPointRead])
|
|
||||||
async def list_points(
|
|
||||||
session_id: UUID,
|
|
||||||
limit: int = Query(default=500, ge=1, le=2000),
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(TRACKING_POINTS_VIEW)),
|
|
||||||
):
|
|
||||||
return await TrackingQueries(db).list_points(tenant_id, session_id, limit=limit)
|
|
||||||
@ -1,96 +0,0 @@
|
|||||||
"""Vehicle type APIs — Phase 10.2."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.api.deps import get_db, get_pagination, require_tenant
|
|
||||||
from app.api.permissions import require_permissions
|
|
||||||
from app.commands.fleet import FleetCommands
|
|
||||||
from app.permissions.definitions import (
|
|
||||||
VEHICLE_TYPES_CREATE,
|
|
||||||
VEHICLE_TYPES_DELETE,
|
|
||||||
VEHICLE_TYPES_UPDATE,
|
|
||||||
VEHICLE_TYPES_VIEW,
|
|
||||||
)
|
|
||||||
from app.queries.fleet import FleetQueries
|
|
||||||
from app.schemas.fleet import (
|
|
||||||
VehicleTypeCreate,
|
|
||||||
VehicleTypeListResponse,
|
|
||||||
VehicleTypeRead,
|
|
||||||
VehicleTypeUpdate,
|
|
||||||
)
|
|
||||||
from app.specifications.fleet import VehicleTypeListSpec
|
|
||||||
from shared.pagination import PaginationParams
|
|
||||||
from shared.security import CurrentUser
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _list_spec(
|
|
||||||
q: str | None = Query(default=None, max_length=100),
|
|
||||||
sort_by: str = Query(default="created_at"),
|
|
||||||
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
|
||||||
) -> VehicleTypeListSpec:
|
|
||||||
return VehicleTypeListSpec(q=q, sort_by=sort_by, sort_dir=sort_dir.lower())
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=VehicleTypeRead, status_code=status.HTTP_201_CREATED)
|
|
||||||
async def create_vehicle_type(
|
|
||||||
body: VehicleTypeCreate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(VEHICLE_TYPES_CREATE)),
|
|
||||||
):
|
|
||||||
return await FleetCommands(db).create_vehicle_type(tenant_id, body, actor=user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=VehicleTypeListResponse)
|
|
||||||
async def list_vehicle_types(
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
pagination: PaginationParams = Depends(get_pagination),
|
|
||||||
spec: VehicleTypeListSpec = Depends(_list_spec),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(VEHICLE_TYPES_VIEW)),
|
|
||||||
):
|
|
||||||
items, total = await FleetQueries(db).list_vehicle_types(
|
|
||||||
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
|
||||||
)
|
|
||||||
return VehicleTypeListResponse(
|
|
||||||
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{type_id}", response_model=VehicleTypeRead)
|
|
||||||
async def get_vehicle_type(
|
|
||||||
type_id: UUID,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(VEHICLE_TYPES_VIEW)),
|
|
||||||
):
|
|
||||||
return await FleetQueries(db).get_vehicle_type(tenant_id, type_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{type_id}", response_model=VehicleTypeRead)
|
|
||||||
async def update_vehicle_type(
|
|
||||||
type_id: UUID,
|
|
||||||
body: VehicleTypeUpdate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(VEHICLE_TYPES_UPDATE)),
|
|
||||||
):
|
|
||||||
return await FleetCommands(db).update_vehicle_type(
|
|
||||||
tenant_id, type_id, body, actor=user
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{type_id}/delete", response_model=VehicleTypeRead)
|
|
||||||
async def delete_vehicle_type(
|
|
||||||
type_id: UUID,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(VEHICLE_TYPES_DELETE)),
|
|
||||||
):
|
|
||||||
return await FleetCommands(db).delete_vehicle_type(tenant_id, type_id, actor=user)
|
|
||||||
@ -1,154 +0,0 @@
|
|||||||
"""Vehicle APIs — Phase 10.2."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.api.deps import get_db, get_pagination, require_tenant
|
|
||||||
from app.api.permissions import require_permissions
|
|
||||||
from app.commands.fleet import FleetCommands
|
|
||||||
from app.models.types import VehicleStatus
|
|
||||||
from app.permissions.definitions import (
|
|
||||||
VEHICLES_ASSIGNMENTS_MANAGE,
|
|
||||||
VEHICLES_ASSIGNMENTS_VIEW,
|
|
||||||
VEHICLES_CREATE,
|
|
||||||
VEHICLES_DELETE,
|
|
||||||
VEHICLES_UPDATE,
|
|
||||||
VEHICLES_VIEW,
|
|
||||||
)
|
|
||||||
from app.queries.fleet import FleetQueries
|
|
||||||
from app.schemas.fleet import (
|
|
||||||
VehicleAssignmentCreate,
|
|
||||||
VehicleAssignmentRead,
|
|
||||||
VehicleAssignmentUpdate,
|
|
||||||
VehicleCreate,
|
|
||||||
VehicleListResponse,
|
|
||||||
VehicleRead,
|
|
||||||
VehicleUpdate,
|
|
||||||
)
|
|
||||||
from app.specifications.fleet import VehicleListSpec
|
|
||||||
from shared.pagination import PaginationParams
|
|
||||||
from shared.security import CurrentUser
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _list_spec(
|
|
||||||
fleet_id: UUID | None = Query(default=None),
|
|
||||||
status_filter: VehicleStatus | None = Query(default=None, alias="status"),
|
|
||||||
q: str | None = Query(default=None, max_length=100),
|
|
||||||
sort_by: str = Query(default="created_at"),
|
|
||||||
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
|
||||||
) -> VehicleListSpec:
|
|
||||||
return VehicleListSpec(
|
|
||||||
fleet_id=fleet_id,
|
|
||||||
status=status_filter,
|
|
||||||
q=q,
|
|
||||||
sort_by=sort_by,
|
|
||||||
sort_dir=sort_dir.lower(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=VehicleRead, status_code=status.HTTP_201_CREATED)
|
|
||||||
async def create_vehicle(
|
|
||||||
body: VehicleCreate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(VEHICLES_CREATE)),
|
|
||||||
):
|
|
||||||
return await FleetCommands(db).create_vehicle(tenant_id, body, actor=user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=VehicleListResponse)
|
|
||||||
async def list_vehicles(
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
pagination: PaginationParams = Depends(get_pagination),
|
|
||||||
spec: VehicleListSpec = Depends(_list_spec),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(VEHICLES_VIEW)),
|
|
||||||
):
|
|
||||||
items, total = await FleetQueries(db).list_vehicles(
|
|
||||||
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
|
||||||
)
|
|
||||||
return VehicleListResponse(
|
|
||||||
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{vehicle_id}", response_model=VehicleRead)
|
|
||||||
async def get_vehicle(
|
|
||||||
vehicle_id: UUID,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(VEHICLES_VIEW)),
|
|
||||||
):
|
|
||||||
return await FleetQueries(db).get_vehicle(tenant_id, vehicle_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{vehicle_id}", response_model=VehicleRead)
|
|
||||||
async def update_vehicle(
|
|
||||||
vehicle_id: UUID,
|
|
||||||
body: VehicleUpdate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(VEHICLES_UPDATE)),
|
|
||||||
):
|
|
||||||
return await FleetCommands(db).update_vehicle(
|
|
||||||
tenant_id, vehicle_id, body, actor=user
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{vehicle_id}/delete", response_model=VehicleRead)
|
|
||||||
async def delete_vehicle(
|
|
||||||
vehicle_id: UUID,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(VEHICLES_DELETE)),
|
|
||||||
):
|
|
||||||
return await FleetCommands(db).delete_vehicle(tenant_id, vehicle_id, actor=user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post(
|
|
||||||
"/{vehicle_id}/assignments",
|
|
||||||
response_model=VehicleAssignmentRead,
|
|
||||||
status_code=status.HTTP_201_CREATED,
|
|
||||||
)
|
|
||||||
async def create_assignment(
|
|
||||||
vehicle_id: UUID,
|
|
||||||
body: VehicleAssignmentCreate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(VEHICLES_ASSIGNMENTS_MANAGE)),
|
|
||||||
):
|
|
||||||
return await FleetCommands(db).create_assignment(
|
|
||||||
tenant_id, vehicle_id, body, actor=user
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{vehicle_id}/assignments", response_model=list[VehicleAssignmentRead])
|
|
||||||
async def list_assignments(
|
|
||||||
vehicle_id: UUID,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(VEHICLES_ASSIGNMENTS_VIEW)),
|
|
||||||
):
|
|
||||||
return await FleetQueries(db).list_assignments(tenant_id, vehicle_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch(
|
|
||||||
"/{vehicle_id}/assignments/{assignment_id}",
|
|
||||||
response_model=VehicleAssignmentRead,
|
|
||||||
)
|
|
||||||
async def update_assignment(
|
|
||||||
vehicle_id: UUID,
|
|
||||||
assignment_id: UUID,
|
|
||||||
body: VehicleAssignmentUpdate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(VEHICLES_ASSIGNMENTS_MANAGE)),
|
|
||||||
):
|
|
||||||
return await FleetCommands(db).update_assignment(
|
|
||||||
tenant_id, vehicle_id, assignment_id, body, actor=user
|
|
||||||
)
|
|
||||||
@ -1,105 +0,0 @@
|
|||||||
"""Working zone APIs — Phase 10.3."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, status
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.api.deps import get_db, get_pagination, require_tenant
|
|
||||||
from app.api.permissions import require_permissions
|
|
||||||
from app.commands.availability import AvailabilityCommands
|
|
||||||
from app.models.types import WorkingZoneStatus
|
|
||||||
from app.permissions.definitions import (
|
|
||||||
WORKING_ZONES_CREATE,
|
|
||||||
WORKING_ZONES_DELETE,
|
|
||||||
WORKING_ZONES_UPDATE,
|
|
||||||
WORKING_ZONES_VIEW,
|
|
||||||
)
|
|
||||||
from app.queries.availability import AvailabilityQueries
|
|
||||||
from app.schemas.availability import (
|
|
||||||
WorkingZoneCreate,
|
|
||||||
WorkingZoneListResponse,
|
|
||||||
WorkingZoneRead,
|
|
||||||
WorkingZoneUpdate,
|
|
||||||
)
|
|
||||||
from app.specifications.availability import WorkingZoneListSpec
|
|
||||||
from shared.pagination import PaginationParams
|
|
||||||
from shared.security import CurrentUser
|
|
||||||
|
|
||||||
router = APIRouter()
|
|
||||||
|
|
||||||
|
|
||||||
def _list_spec(
|
|
||||||
status_filter: WorkingZoneStatus | None = Query(default=None, alias="status"),
|
|
||||||
organization_id: UUID | None = Query(default=None),
|
|
||||||
q: str | None = Query(default=None, max_length=100),
|
|
||||||
sort_by: str = Query(default="created_at"),
|
|
||||||
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
|
||||||
) -> WorkingZoneListSpec:
|
|
||||||
return WorkingZoneListSpec(
|
|
||||||
status=status_filter,
|
|
||||||
organization_id=organization_id,
|
|
||||||
q=q,
|
|
||||||
sort_by=sort_by,
|
|
||||||
sort_dir=sort_dir.lower(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("", response_model=WorkingZoneRead, status_code=status.HTTP_201_CREATED)
|
|
||||||
async def create_zone(
|
|
||||||
body: WorkingZoneCreate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(WORKING_ZONES_CREATE)),
|
|
||||||
):
|
|
||||||
return await AvailabilityCommands(db).create_zone(tenant_id, body, actor=user)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=WorkingZoneListResponse)
|
|
||||||
async def list_zones(
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
pagination: PaginationParams = Depends(get_pagination),
|
|
||||||
spec: WorkingZoneListSpec = Depends(_list_spec),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(WORKING_ZONES_VIEW)),
|
|
||||||
):
|
|
||||||
items, total = await AvailabilityQueries(db).list_zones(
|
|
||||||
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
|
||||||
)
|
|
||||||
return WorkingZoneListResponse(
|
|
||||||
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{zone_id}", response_model=WorkingZoneRead)
|
|
||||||
async def get_zone(
|
|
||||||
zone_id: UUID,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
_user: CurrentUser = Depends(require_permissions(WORKING_ZONES_VIEW)),
|
|
||||||
):
|
|
||||||
return await AvailabilityQueries(db).get_zone(tenant_id, zone_id)
|
|
||||||
|
|
||||||
|
|
||||||
@router.patch("/{zone_id}", response_model=WorkingZoneRead)
|
|
||||||
async def update_zone(
|
|
||||||
zone_id: UUID,
|
|
||||||
body: WorkingZoneUpdate,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(WORKING_ZONES_UPDATE)),
|
|
||||||
):
|
|
||||||
return await AvailabilityCommands(db).update_zone(
|
|
||||||
tenant_id, zone_id, body, actor=user
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.post("/{zone_id}/delete", response_model=WorkingZoneRead)
|
|
||||||
async def delete_zone(
|
|
||||||
zone_id: UUID,
|
|
||||||
tenant_id: UUID = Depends(require_tenant),
|
|
||||||
db: AsyncSession = Depends(get_db),
|
|
||||||
user: CurrentUser = Depends(require_permissions(WORKING_ZONES_DELETE)),
|
|
||||||
):
|
|
||||||
return await AvailabilityCommands(db).delete_zone(tenant_id, zone_id, actor=user)
|
|
||||||
@ -1,90 +0,0 @@
|
|||||||
"""Availability command handlers — Phase 10.3."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.schemas.availability import (
|
|
||||||
DriverAvailabilityUpsert,
|
|
||||||
ShiftAssignmentCreate,
|
|
||||||
ShiftAssignmentUpdate,
|
|
||||||
ShiftCreate,
|
|
||||||
ShiftUpdate,
|
|
||||||
WorkingZoneCreate,
|
|
||||||
WorkingZoneUpdate,
|
|
||||||
)
|
|
||||||
from app.services.availability import AvailabilityService, ShiftService, WorkingZoneService
|
|
||||||
from shared.security import CurrentUser
|
|
||||||
|
|
||||||
|
|
||||||
class AvailabilityCommands:
|
|
||||||
def __init__(self, session: AsyncSession) -> None:
|
|
||||||
self.availability = AvailabilityService(session)
|
|
||||||
self.shifts = ShiftService(session)
|
|
||||||
self.zones = WorkingZoneService(session)
|
|
||||||
|
|
||||||
async def upsert_availability(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
driver_id: UUID,
|
|
||||||
body: DriverAvailabilityUpsert,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None,
|
|
||||||
):
|
|
||||||
return await self.availability.upsert_for_driver(
|
|
||||||
tenant_id, driver_id, body, actor=actor
|
|
||||||
)
|
|
||||||
|
|
||||||
async def create_shift(
|
|
||||||
self, tenant_id: UUID, body: ShiftCreate, *, actor: CurrentUser | None
|
|
||||||
):
|
|
||||||
return await self.shifts.create(tenant_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def update_shift(
|
|
||||||
self, tenant_id: UUID, shift_id: UUID, body: ShiftUpdate, *, actor: CurrentUser | None
|
|
||||||
):
|
|
||||||
return await self.shifts.update(tenant_id, shift_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def delete_shift(
|
|
||||||
self, tenant_id: UUID, shift_id: UUID, *, actor: CurrentUser | None
|
|
||||||
):
|
|
||||||
return await self.shifts.soft_delete(tenant_id, shift_id, actor=actor)
|
|
||||||
|
|
||||||
async def assign_shift(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
shift_id: UUID,
|
|
||||||
body: ShiftAssignmentCreate,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None,
|
|
||||||
):
|
|
||||||
return await self.shifts.assign_driver(tenant_id, shift_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def update_shift_assignment(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
shift_id: UUID,
|
|
||||||
assignment_id: UUID,
|
|
||||||
body: ShiftAssignmentUpdate,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None,
|
|
||||||
):
|
|
||||||
return await self.shifts.update_assignment(
|
|
||||||
tenant_id, shift_id, assignment_id, body, actor=actor
|
|
||||||
)
|
|
||||||
|
|
||||||
async def create_zone(
|
|
||||||
self, tenant_id: UUID, body: WorkingZoneCreate, *, actor: CurrentUser | None
|
|
||||||
):
|
|
||||||
return await self.zones.create(tenant_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def update_zone(
|
|
||||||
self, tenant_id: UUID, zone_id: UUID, body: WorkingZoneUpdate, *, actor: CurrentUser | None
|
|
||||||
):
|
|
||||||
return await self.zones.update(tenant_id, zone_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def delete_zone(
|
|
||||||
self, tenant_id: UUID, zone_id: UUID, *, actor: CurrentUser | None
|
|
||||||
):
|
|
||||||
return await self.zones.soft_delete(tenant_id, zone_id, actor=actor)
|
|
||||||
@ -1,62 +0,0 @@
|
|||||||
"""Dispatch command handlers — Phase 10.5."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.schemas.dispatch import (
|
|
||||||
DispatchJobCreate,
|
|
||||||
DispatchJobStatusRequest,
|
|
||||||
DispatchJobUpdate,
|
|
||||||
JobAssignmentCreate,
|
|
||||||
JobAssignmentUpdate,
|
|
||||||
)
|
|
||||||
from app.services.dispatch import DispatchJobService, JobAssignmentService
|
|
||||||
from shared.security import CurrentUser
|
|
||||||
|
|
||||||
|
|
||||||
class DispatchCommands:
|
|
||||||
def __init__(self, session: AsyncSession) -> None:
|
|
||||||
self.jobs = DispatchJobService(session)
|
|
||||||
self.assignments = JobAssignmentService(session)
|
|
||||||
|
|
||||||
async def create_job(
|
|
||||||
self, tenant_id: UUID, body: DispatchJobCreate, *, actor: CurrentUser | None
|
|
||||||
):
|
|
||||||
return await self.jobs.create(tenant_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def update_job(
|
|
||||||
self, tenant_id: UUID, job_id: UUID, body: DispatchJobUpdate, *, actor: CurrentUser | None
|
|
||||||
):
|
|
||||||
return await self.jobs.update(tenant_id, job_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def apply_status(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
job_id: UUID,
|
|
||||||
body: DispatchJobStatusRequest,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None,
|
|
||||||
):
|
|
||||||
return await self.jobs.apply_status(tenant_id, job_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def delete_job(
|
|
||||||
self, tenant_id: UUID, job_id: UUID, *, actor: CurrentUser | None
|
|
||||||
):
|
|
||||||
return await self.jobs.soft_delete(tenant_id, job_id, actor=actor)
|
|
||||||
|
|
||||||
async def create_assignment(
|
|
||||||
self, tenant_id: UUID, body: JobAssignmentCreate, *, actor: CurrentUser | None
|
|
||||||
):
|
|
||||||
return await self.assignments.create(tenant_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def update_assignment(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
assignment_id: UUID,
|
|
||||||
body: JobAssignmentUpdate,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None,
|
|
||||||
):
|
|
||||||
return await self.assignments.update(tenant_id, assignment_id, body, actor=actor)
|
|
||||||
@ -1,92 +0,0 @@
|
|||||||
"""Fleet command handlers — Phase 10.2."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.schemas.fleet import (
|
|
||||||
FleetCreate,
|
|
||||||
FleetUpdate,
|
|
||||||
VehicleAssignmentCreate,
|
|
||||||
VehicleAssignmentUpdate,
|
|
||||||
VehicleCreate,
|
|
||||||
VehicleTypeCreate,
|
|
||||||
VehicleTypeUpdate,
|
|
||||||
VehicleUpdate,
|
|
||||||
)
|
|
||||||
from app.services.fleet import FleetService, VehicleService, VehicleTypeService
|
|
||||||
from shared.security import CurrentUser
|
|
||||||
|
|
||||||
|
|
||||||
class FleetCommands:
|
|
||||||
def __init__(self, session: AsyncSession) -> None:
|
|
||||||
self.fleets = FleetService(session)
|
|
||||||
self.types = VehicleTypeService(session)
|
|
||||||
self.vehicles = VehicleService(session)
|
|
||||||
|
|
||||||
async def create_fleet(self, tenant_id: UUID, body: FleetCreate, *, actor: CurrentUser | None):
|
|
||||||
return await self.fleets.create(tenant_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def update_fleet(
|
|
||||||
self, tenant_id: UUID, fleet_id: UUID, body: FleetUpdate, *, actor: CurrentUser | None
|
|
||||||
):
|
|
||||||
return await self.fleets.update(tenant_id, fleet_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def delete_fleet(self, tenant_id: UUID, fleet_id: UUID, *, actor: CurrentUser | None):
|
|
||||||
return await self.fleets.soft_delete(tenant_id, fleet_id, actor=actor)
|
|
||||||
|
|
||||||
async def create_vehicle_type(
|
|
||||||
self, tenant_id: UUID, body: VehicleTypeCreate, *, actor: CurrentUser | None
|
|
||||||
):
|
|
||||||
return await self.types.create(tenant_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def update_vehicle_type(
|
|
||||||
self, tenant_id: UUID, type_id: UUID, body: VehicleTypeUpdate, *, actor: CurrentUser | None
|
|
||||||
):
|
|
||||||
return await self.types.update(tenant_id, type_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def delete_vehicle_type(
|
|
||||||
self, tenant_id: UUID, type_id: UUID, *, actor: CurrentUser | None
|
|
||||||
):
|
|
||||||
return await self.types.soft_delete(tenant_id, type_id, actor=actor)
|
|
||||||
|
|
||||||
async def create_vehicle(
|
|
||||||
self, tenant_id: UUID, body: VehicleCreate, *, actor: CurrentUser | None
|
|
||||||
):
|
|
||||||
return await self.vehicles.create(tenant_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def update_vehicle(
|
|
||||||
self, tenant_id: UUID, vehicle_id: UUID, body: VehicleUpdate, *, actor: CurrentUser | None
|
|
||||||
):
|
|
||||||
return await self.vehicles.update(tenant_id, vehicle_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def delete_vehicle(
|
|
||||||
self, tenant_id: UUID, vehicle_id: UUID, *, actor: CurrentUser | None
|
|
||||||
):
|
|
||||||
return await self.vehicles.soft_delete(tenant_id, vehicle_id, actor=actor)
|
|
||||||
|
|
||||||
async def create_assignment(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
vehicle_id: UUID,
|
|
||||||
body: VehicleAssignmentCreate,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None,
|
|
||||||
):
|
|
||||||
return await self.vehicles.create_assignment(
|
|
||||||
tenant_id, vehicle_id, body, actor=actor
|
|
||||||
)
|
|
||||||
|
|
||||||
async def update_assignment(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
vehicle_id: UUID,
|
|
||||||
assignment_id: UUID,
|
|
||||||
body: VehicleAssignmentUpdate,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None,
|
|
||||||
):
|
|
||||||
return await self.vehicles.update_assignment(
|
|
||||||
tenant_id, vehicle_id, assignment_id, body, actor=actor
|
|
||||||
)
|
|
||||||
@ -1,69 +0,0 @@
|
|||||||
"""Pricing command handlers — Phase 10.4."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.schemas.pricing import (
|
|
||||||
BundleCreate,
|
|
||||||
BundleUpdate,
|
|
||||||
CapabilityCreate,
|
|
||||||
CapabilityUpdate,
|
|
||||||
PricingRuleCreate,
|
|
||||||
PricingRuleUpdate,
|
|
||||||
)
|
|
||||||
from app.services.pricing import BundleService, CapabilityService, PricingRuleService
|
|
||||||
from shared.security import CurrentUser
|
|
||||||
|
|
||||||
|
|
||||||
class PricingCommands:
|
|
||||||
def __init__(self, session: AsyncSession) -> None:
|
|
||||||
self.rules = PricingRuleService(session)
|
|
||||||
self.capabilities = CapabilityService(session)
|
|
||||||
self.bundles = BundleService(session)
|
|
||||||
|
|
||||||
async def create_rule(
|
|
||||||
self, tenant_id: UUID, body: PricingRuleCreate, *, actor: CurrentUser | None
|
|
||||||
):
|
|
||||||
return await self.rules.create(tenant_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def update_rule(
|
|
||||||
self, tenant_id: UUID, rule_id: UUID, body: PricingRuleUpdate, *, actor: CurrentUser | None
|
|
||||||
):
|
|
||||||
return await self.rules.update(tenant_id, rule_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def delete_rule(
|
|
||||||
self, tenant_id: UUID, rule_id: UUID, *, actor: CurrentUser | None
|
|
||||||
):
|
|
||||||
return await self.rules.soft_delete(tenant_id, rule_id, actor=actor)
|
|
||||||
|
|
||||||
async def create_capability(
|
|
||||||
self, tenant_id: UUID, body: CapabilityCreate, *, actor: CurrentUser | None
|
|
||||||
):
|
|
||||||
return await self.capabilities.create(tenant_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def update_capability(
|
|
||||||
self, tenant_id: UUID, cap_id: UUID, body: CapabilityUpdate, *, actor: CurrentUser | None
|
|
||||||
):
|
|
||||||
return await self.capabilities.update(tenant_id, cap_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def delete_capability(
|
|
||||||
self, tenant_id: UUID, cap_id: UUID, *, actor: CurrentUser | None
|
|
||||||
):
|
|
||||||
return await self.capabilities.soft_delete(tenant_id, cap_id, actor=actor)
|
|
||||||
|
|
||||||
async def create_bundle(
|
|
||||||
self, tenant_id: UUID, body: BundleCreate, *, actor: CurrentUser | None
|
|
||||||
):
|
|
||||||
return await self.bundles.create(tenant_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def update_bundle(
|
|
||||||
self, tenant_id: UUID, bundle_id: UUID, body: BundleUpdate, *, actor: CurrentUser | None
|
|
||||||
):
|
|
||||||
return await self.bundles.update(tenant_id, bundle_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def delete_bundle(
|
|
||||||
self, tenant_id: UUID, bundle_id: UUID, *, actor: CurrentUser | None
|
|
||||||
):
|
|
||||||
return await self.bundles.soft_delete(tenant_id, bundle_id, actor=actor)
|
|
||||||
@ -1,51 +0,0 @@
|
|||||||
"""Routing command handlers — Phase 10.6."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.schemas.routing import (
|
|
||||||
OptimizationRunCreate,
|
|
||||||
RoutePlanCreate,
|
|
||||||
RoutePlanUpdate,
|
|
||||||
RouteStopCreate,
|
|
||||||
)
|
|
||||||
from app.services.routing import OptimizationRunService, RoutePlanService
|
|
||||||
from shared.security import CurrentUser
|
|
||||||
|
|
||||||
|
|
||||||
class RoutingCommands:
|
|
||||||
def __init__(self, session: AsyncSession) -> None:
|
|
||||||
self.routes = RoutePlanService(session)
|
|
||||||
self.optimization = OptimizationRunService(session)
|
|
||||||
|
|
||||||
async def create_route(
|
|
||||||
self, tenant_id: UUID, body: RoutePlanCreate, *, actor: CurrentUser | None
|
|
||||||
):
|
|
||||||
return await self.routes.create(tenant_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def update_route(
|
|
||||||
self, tenant_id: UUID, plan_id: UUID, body: RoutePlanUpdate, *, actor: CurrentUser | None
|
|
||||||
):
|
|
||||||
return await self.routes.update(tenant_id, plan_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def delete_route(
|
|
||||||
self, tenant_id: UUID, plan_id: UUID, *, actor: CurrentUser | None
|
|
||||||
):
|
|
||||||
return await self.routes.soft_delete(tenant_id, plan_id, actor=actor)
|
|
||||||
|
|
||||||
async def add_stop(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
plan_id: UUID,
|
|
||||||
body: RouteStopCreate,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None,
|
|
||||||
):
|
|
||||||
return await self.routes.add_stop(tenant_id, plan_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def start_optimization(
|
|
||||||
self, tenant_id: UUID, body: OptimizationRunCreate, *, actor: CurrentUser | None
|
|
||||||
):
|
|
||||||
return await self.optimization.start(tenant_id, body, actor=actor)
|
|
||||||
@ -1,85 +0,0 @@
|
|||||||
"""Settlement command handlers — Phase 10.8."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.schemas.settlement import (
|
|
||||||
SettlementActionRequest,
|
|
||||||
SettlementIntentCreate,
|
|
||||||
SettlementIntentUpdate,
|
|
||||||
SettlementLineCreate,
|
|
||||||
)
|
|
||||||
from app.services.settlement import SettlementService
|
|
||||||
from shared.security import CurrentUser
|
|
||||||
|
|
||||||
|
|
||||||
class SettlementCommands:
|
|
||||||
def __init__(self, session: AsyncSession) -> None:
|
|
||||||
self.service = SettlementService(session)
|
|
||||||
|
|
||||||
async def create(
|
|
||||||
self, tenant_id: UUID, body: SettlementIntentCreate, *, actor: CurrentUser | None
|
|
||||||
):
|
|
||||||
return await self.service.create(tenant_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def update(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
intent_id: UUID,
|
|
||||||
body: SettlementIntentUpdate,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None,
|
|
||||||
):
|
|
||||||
return await self.service.update(tenant_id, intent_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def submit(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
intent_id: UUID,
|
|
||||||
body: SettlementActionRequest,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None,
|
|
||||||
):
|
|
||||||
return await self.service.submit(tenant_id, intent_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def settle(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
intent_id: UUID,
|
|
||||||
body: SettlementActionRequest,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None,
|
|
||||||
):
|
|
||||||
return await self.service.settle(tenant_id, intent_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def fail(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
intent_id: UUID,
|
|
||||||
body: SettlementActionRequest,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None,
|
|
||||||
):
|
|
||||||
return await self.service.fail(tenant_id, intent_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def cancel(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
intent_id: UUID,
|
|
||||||
body: SettlementActionRequest,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None,
|
|
||||||
):
|
|
||||||
return await self.service.cancel(tenant_id, intent_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def add_line(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
intent_id: UUID,
|
|
||||||
body: SettlementLineCreate,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None,
|
|
||||||
):
|
|
||||||
return await self.service.add_line(tenant_id, intent_id, body, actor=actor)
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
"""Tracking command handlers — Phase 10.7."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.schemas.tracking import (
|
|
||||||
CustomerTrackingTokenCreate,
|
|
||||||
ProofOfDeliveryCreate,
|
|
||||||
ProofOfDeliveryUpdate,
|
|
||||||
TrackingPointCreate,
|
|
||||||
TrackingSessionCreate,
|
|
||||||
TrackingSessionUpdate,
|
|
||||||
)
|
|
||||||
from app.services.tracking import (
|
|
||||||
CustomerTrackingTokenService,
|
|
||||||
ProofOfDeliveryService,
|
|
||||||
TrackingSessionService,
|
|
||||||
)
|
|
||||||
from shared.security import CurrentUser
|
|
||||||
|
|
||||||
|
|
||||||
class TrackingCommands:
|
|
||||||
def __init__(self, session: AsyncSession) -> None:
|
|
||||||
self.sessions = TrackingSessionService(session)
|
|
||||||
self.pod = ProofOfDeliveryService(session)
|
|
||||||
self.tokens = CustomerTrackingTokenService(session)
|
|
||||||
|
|
||||||
async def start_session(
|
|
||||||
self, tenant_id: UUID, body: TrackingSessionCreate, *, actor: CurrentUser | None
|
|
||||||
):
|
|
||||||
return await self.sessions.start(tenant_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def update_session(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
session_id: UUID,
|
|
||||||
body: TrackingSessionUpdate,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None,
|
|
||||||
):
|
|
||||||
return await self.sessions.update(tenant_id, session_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def record_point(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
session_id: UUID,
|
|
||||||
body: TrackingPointCreate,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None,
|
|
||||||
):
|
|
||||||
return await self.sessions.record_point(
|
|
||||||
tenant_id, session_id, body, actor=actor
|
|
||||||
)
|
|
||||||
|
|
||||||
async def capture_pod(
|
|
||||||
self, tenant_id: UUID, body: ProofOfDeliveryCreate, *, actor: CurrentUser | None
|
|
||||||
):
|
|
||||||
return await self.pod.capture(tenant_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def update_pod(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
pod_id: UUID,
|
|
||||||
body: ProofOfDeliveryUpdate,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None,
|
|
||||||
):
|
|
||||||
return await self.pod.update(tenant_id, pod_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def create_token(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
body: CustomerTrackingTokenCreate,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None,
|
|
||||||
):
|
|
||||||
return await self.tokens.create(tenant_id, body, actor=actor)
|
|
||||||
|
|
||||||
async def revoke_token(
|
|
||||||
self, tenant_id: UUID, token_id: UUID, *, actor: CurrentUser | None
|
|
||||||
):
|
|
||||||
return await self.tokens.revoke(tenant_id, token_id, actor=actor)
|
|
||||||
@ -31,75 +31,3 @@ class DeliveryEventType(str, enum.Enum):
|
|||||||
DRIVER_CREDENTIAL_ADDED = "delivery.driver.credential_added"
|
DRIVER_CREDENTIAL_ADDED = "delivery.driver.credential_added"
|
||||||
DRIVER_DOCUMENT_ATTACHED = "delivery.driver.document_attached"
|
DRIVER_DOCUMENT_ATTACHED = "delivery.driver.document_attached"
|
||||||
DRIVER_STATUS_CHANGED = "delivery.driver.status_changed"
|
DRIVER_STATUS_CHANGED = "delivery.driver.status_changed"
|
||||||
|
|
||||||
# Phase 10.2 — Fleet & Vehicle Types
|
|
||||||
FLEET_CREATED = "delivery.fleet.created"
|
|
||||||
FLEET_UPDATED = "delivery.fleet.updated"
|
|
||||||
FLEET_DELETED = "delivery.fleet.deleted"
|
|
||||||
VEHICLE_TYPE_CREATED = "delivery.vehicle_type.created"
|
|
||||||
VEHICLE_TYPE_UPDATED = "delivery.vehicle_type.updated"
|
|
||||||
VEHICLE_TYPE_DELETED = "delivery.vehicle_type.deleted"
|
|
||||||
VEHICLE_CREATED = "delivery.vehicle.created"
|
|
||||||
VEHICLE_UPDATED = "delivery.vehicle.updated"
|
|
||||||
VEHICLE_DELETED = "delivery.vehicle.deleted"
|
|
||||||
VEHICLE_ASSIGNMENT_CREATED = "delivery.vehicle_assignment.created"
|
|
||||||
VEHICLE_ASSIGNMENT_UPDATED = "delivery.vehicle_assignment.updated"
|
|
||||||
VEHICLE_ASSIGNMENT_ENDED = "delivery.vehicle_assignment.ended"
|
|
||||||
|
|
||||||
# Phase 10.3 — Availability, Shifts & Working Zones
|
|
||||||
DRIVER_AVAILABILITY_UPDATED = "delivery.driver_availability.updated"
|
|
||||||
SHIFT_CREATED = "delivery.shift.created"
|
|
||||||
SHIFT_UPDATED = "delivery.shift.updated"
|
|
||||||
SHIFT_DELETED = "delivery.shift.deleted"
|
|
||||||
SHIFT_ASSIGNMENT_CREATED = "delivery.shift_assignment.created"
|
|
||||||
SHIFT_ASSIGNMENT_UPDATED = "delivery.shift_assignment.updated"
|
|
||||||
WORKING_ZONE_CREATED = "delivery.working_zone.created"
|
|
||||||
WORKING_ZONE_UPDATED = "delivery.working_zone.updated"
|
|
||||||
WORKING_ZONE_DELETED = "delivery.working_zone.deleted"
|
|
||||||
|
|
||||||
# Phase 10.4 — Pricing, Capabilities & Bundles
|
|
||||||
PRICING_RULE_CREATED = "delivery.pricing_rule.created"
|
|
||||||
PRICING_RULE_UPDATED = "delivery.pricing_rule.updated"
|
|
||||||
PRICING_RULE_DELETED = "delivery.pricing_rule.deleted"
|
|
||||||
CAPABILITY_CREATED = "delivery.capability.created"
|
|
||||||
CAPABILITY_UPDATED = "delivery.capability.updated"
|
|
||||||
CAPABILITY_DELETED = "delivery.capability.deleted"
|
|
||||||
BUNDLE_CREATED = "delivery.bundle.created"
|
|
||||||
BUNDLE_UPDATED = "delivery.bundle.updated"
|
|
||||||
BUNDLE_DELETED = "delivery.bundle.deleted"
|
|
||||||
|
|
||||||
# Phase 10.5 — Dispatch Engine
|
|
||||||
DISPATCH_JOB_CREATED = "delivery.dispatch_job.created"
|
|
||||||
DISPATCH_JOB_UPDATED = "delivery.dispatch_job.updated"
|
|
||||||
DISPATCH_JOB_STATUS_CHANGED = "delivery.dispatch_job.status_changed"
|
|
||||||
DISPATCH_JOB_DELETED = "delivery.dispatch_job.deleted"
|
|
||||||
JOB_ASSIGNMENT_CREATED = "delivery.job_assignment.created"
|
|
||||||
JOB_ASSIGNMENT_UPDATED = "delivery.job_assignment.updated"
|
|
||||||
JOB_ASSIGNMENT_STATUS_CHANGED = "delivery.job_assignment.status_changed"
|
|
||||||
|
|
||||||
# Phase 10.6 — Routing & Optimization
|
|
||||||
ROUTE_PLAN_CREATED = "delivery.route_plan.created"
|
|
||||||
ROUTE_PLAN_UPDATED = "delivery.route_plan.updated"
|
|
||||||
ROUTE_PLAN_DELETED = "delivery.route_plan.deleted"
|
|
||||||
ROUTE_STOP_ADDED = "delivery.route_stop.added"
|
|
||||||
OPTIMIZATION_RUN_STARTED = "delivery.optimization_run.started"
|
|
||||||
OPTIMIZATION_RUN_COMPLETED = "delivery.optimization_run.completed"
|
|
||||||
OPTIMIZATION_RUN_FAILED = "delivery.optimization_run.failed"
|
|
||||||
|
|
||||||
# Phase 10.7 — Tracking & Proof of Delivery
|
|
||||||
TRACKING_SESSION_STARTED = "delivery.tracking_session.started"
|
|
||||||
TRACKING_SESSION_UPDATED = "delivery.tracking_session.updated"
|
|
||||||
TRACKING_SESSION_ENDED = "delivery.tracking_session.ended"
|
|
||||||
TRACKING_POINT_RECORDED = "delivery.tracking_point.recorded"
|
|
||||||
PROOF_OF_DELIVERY_CAPTURED = "delivery.proof_of_delivery.captured"
|
|
||||||
PROOF_OF_DELIVERY_UPDATED = "delivery.proof_of_delivery.updated"
|
|
||||||
CUSTOMER_TRACKING_TOKEN_CREATED = "delivery.customer_tracking_token.created"
|
|
||||||
CUSTOMER_TRACKING_TOKEN_REVOKED = "delivery.customer_tracking_token.revoked"
|
|
||||||
|
|
||||||
# Phase 10.8 — Settlement
|
|
||||||
SETTLEMENT_INTENT_CREATED = "delivery.settlement_intent.created"
|
|
||||||
SETTLEMENT_INTENT_UPDATED = "delivery.settlement_intent.updated"
|
|
||||||
SETTLEMENT_INTENT_SUBMITTED = "delivery.settlement_intent.submitted"
|
|
||||||
SETTLEMENT_INTENT_SETTLED = "delivery.settlement_intent.settled"
|
|
||||||
SETTLEMENT_INTENT_FAILED = "delivery.settlement_intent.failed"
|
|
||||||
SETTLEMENT_LINE_ADDED = "delivery.settlement_line.added"
|
|
||||||
|
|||||||
@ -1,18 +1,10 @@
|
|||||||
"""Import all models for Alembic metadata discovery."""
|
"""Import all models for Alembic metadata discovery."""
|
||||||
from app.models.availability import ( # noqa: F401
|
|
||||||
DriverAvailability,
|
|
||||||
Shift,
|
|
||||||
ShiftAssignment,
|
|
||||||
WorkingZone,
|
|
||||||
)
|
|
||||||
from app.models.dispatch import DispatchJob, JobAssignment # noqa: F401
|
|
||||||
from app.models.drivers import ( # noqa: F401
|
from app.models.drivers import ( # noqa: F401
|
||||||
Driver,
|
Driver,
|
||||||
DriverCredential,
|
DriverCredential,
|
||||||
DriverDocument,
|
DriverDocument,
|
||||||
DriverLifecycleEvent,
|
DriverLifecycleEvent,
|
||||||
)
|
)
|
||||||
from app.models.fleet import Fleet, Vehicle, VehicleAssignment, VehicleType # noqa: F401
|
|
||||||
from app.models.foundation import ( # noqa: F401
|
from app.models.foundation import ( # noqa: F401
|
||||||
DeliveryAuditLog,
|
DeliveryAuditLog,
|
||||||
DeliveryConfiguration,
|
DeliveryConfiguration,
|
||||||
@ -25,16 +17,3 @@ from app.models.foundation import ( # noqa: F401
|
|||||||
RoutingEngineRegistration,
|
RoutingEngineRegistration,
|
||||||
)
|
)
|
||||||
from app.models.outbox import OutboxEvent # noqa: F401
|
from app.models.outbox import OutboxEvent # noqa: F401
|
||||||
from app.models.pricing import ( # noqa: F401
|
|
||||||
CapabilityBundle,
|
|
||||||
CapabilityDefinition,
|
|
||||||
PricingRule,
|
|
||||||
)
|
|
||||||
from app.models.routing import OptimizationRun, RoutePlan, RouteStop # noqa: F401
|
|
||||||
from app.models.settlement import SettlementIntent, SettlementLine # noqa: F401
|
|
||||||
from app.models.tracking import ( # noqa: F401
|
|
||||||
CustomerTrackingToken,
|
|
||||||
ProofOfDelivery,
|
|
||||||
TrackingPoint,
|
|
||||||
TrackingSession,
|
|
||||||
)
|
|
||||||
|
|||||||
@ -1,142 +0,0 @@
|
|||||||
"""Availability, shifts & working zones — Phase 10.3."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import DateTime, Index, String, Text, UniqueConstraint
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
from sqlalchemy.types import JSON
|
|
||||||
|
|
||||||
from app.core.database import Base
|
|
||||||
from app.models.base import (
|
|
||||||
ActorAuditMixin,
|
|
||||||
OptimisticLockMixin,
|
|
||||||
SoftDeleteMixin,
|
|
||||||
TenantMixin,
|
|
||||||
TimestampMixin,
|
|
||||||
UUIDPrimaryKeyMixin,
|
|
||||||
)
|
|
||||||
from app.models.types import (
|
|
||||||
DriverAvailabilityStatus,
|
|
||||||
GUID,
|
|
||||||
ShiftAssignmentStatus,
|
|
||||||
ShiftStatus,
|
|
||||||
WorkingZoneStatus,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DriverAvailability(
|
|
||||||
Base,
|
|
||||||
UUIDPrimaryKeyMixin,
|
|
||||||
TenantMixin,
|
|
||||||
TimestampMixin,
|
|
||||||
SoftDeleteMixin,
|
|
||||||
ActorAuditMixin,
|
|
||||||
OptimisticLockMixin,
|
|
||||||
):
|
|
||||||
__tablename__ = "driver_availabilities"
|
|
||||||
|
|
||||||
driver_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
||||||
status: Mapped[DriverAvailabilityStatus] = mapped_column(
|
|
||||||
default=DriverAvailabilityStatus.OFFLINE, nullable=False
|
|
||||||
)
|
|
||||||
effective_from: Mapped[datetime | None] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=True
|
|
||||||
)
|
|
||||||
effective_until: Mapped[datetime | None] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=True
|
|
||||||
)
|
|
||||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
Index("ix_driver_availabilities_driver", "tenant_id", "driver_id"),
|
|
||||||
Index("ix_driver_availabilities_tenant_deleted", "tenant_id", "is_deleted"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Shift(
|
|
||||||
Base,
|
|
||||||
UUIDPrimaryKeyMixin,
|
|
||||||
TenantMixin,
|
|
||||||
TimestampMixin,
|
|
||||||
SoftDeleteMixin,
|
|
||||||
ActorAuditMixin,
|
|
||||||
OptimisticLockMixin,
|
|
||||||
):
|
|
||||||
__tablename__ = "shifts"
|
|
||||||
|
|
||||||
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
||||||
hub_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
|
||||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
status: Mapped[ShiftStatus] = mapped_column(
|
|
||||||
default=ShiftStatus.DRAFT, nullable=False
|
|
||||||
)
|
|
||||||
starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
||||||
ends_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
|
||||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint("tenant_id", "code", name="uq_shifts_tenant_code"),
|
|
||||||
Index("ix_shifts_tenant_status", "tenant_id", "status"),
|
|
||||||
Index("ix_shifts_tenant_deleted", "tenant_id", "is_deleted"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ShiftAssignment(
|
|
||||||
Base,
|
|
||||||
UUIDPrimaryKeyMixin,
|
|
||||||
TenantMixin,
|
|
||||||
TimestampMixin,
|
|
||||||
SoftDeleteMixin,
|
|
||||||
ActorAuditMixin,
|
|
||||||
):
|
|
||||||
__tablename__ = "shift_assignments"
|
|
||||||
|
|
||||||
shift_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
||||||
driver_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
||||||
status: Mapped[ShiftAssignmentStatus] = mapped_column(
|
|
||||||
default=ShiftAssignmentStatus.ASSIGNED, nullable=False
|
|
||||||
)
|
|
||||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint(
|
|
||||||
"tenant_id",
|
|
||||||
"shift_id",
|
|
||||||
"driver_id",
|
|
||||||
name="uq_shift_assignments_tenant_shift_driver",
|
|
||||||
),
|
|
||||||
Index("ix_shift_assignments_shift", "tenant_id", "shift_id"),
|
|
||||||
Index("ix_shift_assignments_driver", "tenant_id", "driver_id"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class WorkingZone(
|
|
||||||
Base,
|
|
||||||
UUIDPrimaryKeyMixin,
|
|
||||||
TenantMixin,
|
|
||||||
TimestampMixin,
|
|
||||||
SoftDeleteMixin,
|
|
||||||
ActorAuditMixin,
|
|
||||||
OptimisticLockMixin,
|
|
||||||
):
|
|
||||||
__tablename__ = "working_zones"
|
|
||||||
|
|
||||||
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
||||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
status: Mapped[WorkingZoneStatus] = mapped_column(
|
|
||||||
default=WorkingZoneStatus.ACTIVE, nullable=False
|
|
||||||
)
|
|
||||||
geo_boundary_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
||||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint("tenant_id", "code", name="uq_working_zones_tenant_code"),
|
|
||||||
Index("ix_working_zones_tenant_status", "tenant_id", "status"),
|
|
||||||
Index("ix_working_zones_tenant_deleted", "tenant_id", "is_deleted"),
|
|
||||||
)
|
|
||||||
@ -1,89 +0,0 @@
|
|||||||
"""Dispatch engine aggregates — Phase 10.5."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import DateTime, Index, String, Text, UniqueConstraint
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
from sqlalchemy.types import JSON
|
|
||||||
|
|
||||||
from app.core.database import Base
|
|
||||||
from app.models.base import (
|
|
||||||
ActorAuditMixin,
|
|
||||||
OptimisticLockMixin,
|
|
||||||
SoftDeleteMixin,
|
|
||||||
TenantMixin,
|
|
||||||
TimestampMixin,
|
|
||||||
UUIDPrimaryKeyMixin,
|
|
||||||
)
|
|
||||||
from app.models.types import DispatchJobStatus, GUID, JobAssignmentStatus
|
|
||||||
|
|
||||||
|
|
||||||
class DispatchJob(
|
|
||||||
Base,
|
|
||||||
UUIDPrimaryKeyMixin,
|
|
||||||
TenantMixin,
|
|
||||||
TimestampMixin,
|
|
||||||
SoftDeleteMixin,
|
|
||||||
ActorAuditMixin,
|
|
||||||
OptimisticLockMixin,
|
|
||||||
):
|
|
||||||
"""Vertical job refs only — no deep order ownership."""
|
|
||||||
|
|
||||||
__tablename__ = "dispatch_jobs"
|
|
||||||
|
|
||||||
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
||||||
hub_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
|
||||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
||||||
status: Mapped[DispatchJobStatus] = mapped_column(
|
|
||||||
default=DispatchJobStatus.PENDING, nullable=False
|
|
||||||
)
|
|
||||||
external_order_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
||||||
external_source: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
|
||||||
priority: Mapped[int] = mapped_column(default=0, nullable=False)
|
|
||||||
pickup_address_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
||||||
dropoff_address_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
||||||
scheduled_at: Mapped[datetime | None] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=True
|
|
||||||
)
|
|
||||||
status_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
|
||||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint("tenant_id", "code", name="uq_dispatch_jobs_tenant_code"),
|
|
||||||
Index("ix_dispatch_jobs_tenant_status", "tenant_id", "status"),
|
|
||||||
Index("ix_dispatch_jobs_external_ref", "tenant_id", "external_order_ref"),
|
|
||||||
Index("ix_dispatch_jobs_tenant_deleted", "tenant_id", "is_deleted"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class JobAssignment(
|
|
||||||
Base,
|
|
||||||
UUIDPrimaryKeyMixin,
|
|
||||||
TenantMixin,
|
|
||||||
TimestampMixin,
|
|
||||||
SoftDeleteMixin,
|
|
||||||
ActorAuditMixin,
|
|
||||||
OptimisticLockMixin,
|
|
||||||
):
|
|
||||||
__tablename__ = "job_assignments"
|
|
||||||
|
|
||||||
dispatch_job_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
||||||
driver_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
||||||
vehicle_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
|
||||||
status: Mapped[JobAssignmentStatus] = mapped_column(
|
|
||||||
default=JobAssignmentStatus.PENDING, nullable=False
|
|
||||||
)
|
|
||||||
assigned_at: Mapped[datetime | None] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=True
|
|
||||||
)
|
|
||||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
Index("ix_job_assignments_job", "tenant_id", "dispatch_job_id"),
|
|
||||||
Index("ix_job_assignments_driver", "tenant_id", "driver_id"),
|
|
||||||
Index("ix_job_assignments_tenant_status", "tenant_id", "status"),
|
|
||||||
Index("ix_job_assignments_tenant_deleted", "tenant_id", "is_deleted"),
|
|
||||||
)
|
|
||||||
@ -1,126 +0,0 @@
|
|||||||
"""Fleet & Vehicle aggregates — Phase 10.2."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from sqlalchemy import Index, String, Text, UniqueConstraint
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
from sqlalchemy.types import JSON
|
|
||||||
|
|
||||||
from app.core.database import Base
|
|
||||||
from app.models.base import (
|
|
||||||
ActorAuditMixin,
|
|
||||||
OptimisticLockMixin,
|
|
||||||
SoftDeleteMixin,
|
|
||||||
TenantMixin,
|
|
||||||
TimestampMixin,
|
|
||||||
UUIDPrimaryKeyMixin,
|
|
||||||
)
|
|
||||||
from app.models.types import FleetStatus, GUID, VehicleAssignmentStatus, VehicleStatus
|
|
||||||
|
|
||||||
|
|
||||||
class Fleet(
|
|
||||||
Base,
|
|
||||||
UUIDPrimaryKeyMixin,
|
|
||||||
TenantMixin,
|
|
||||||
TimestampMixin,
|
|
||||||
SoftDeleteMixin,
|
|
||||||
ActorAuditMixin,
|
|
||||||
OptimisticLockMixin,
|
|
||||||
):
|
|
||||||
__tablename__ = "fleets"
|
|
||||||
|
|
||||||
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
||||||
hub_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
|
||||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
status: Mapped[FleetStatus] = mapped_column(default=FleetStatus.DRAFT, nullable=False)
|
|
||||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint("tenant_id", "code", name="uq_fleets_tenant_code"),
|
|
||||||
Index("ix_fleets_tenant_status", "tenant_id", "status"),
|
|
||||||
Index("ix_fleets_org", "tenant_id", "organization_id"),
|
|
||||||
Index("ix_fleets_tenant_deleted", "tenant_id", "is_deleted"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class VehicleType(
|
|
||||||
Base,
|
|
||||||
UUIDPrimaryKeyMixin,
|
|
||||||
TenantMixin,
|
|
||||||
TimestampMixin,
|
|
||||||
SoftDeleteMixin,
|
|
||||||
ActorAuditMixin,
|
|
||||||
OptimisticLockMixin,
|
|
||||||
):
|
|
||||||
__tablename__ = "vehicle_types"
|
|
||||||
|
|
||||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
capacity_kg: Mapped[float | None] = mapped_column(nullable=True)
|
|
||||||
capacity_volume: Mapped[float | None] = mapped_column(nullable=True)
|
|
||||||
capability_tags: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
|
||||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint("tenant_id", "code", name="uq_vehicle_types_tenant_code"),
|
|
||||||
Index("ix_vehicle_types_tenant_deleted", "tenant_id", "is_deleted"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class Vehicle(
|
|
||||||
Base,
|
|
||||||
UUIDPrimaryKeyMixin,
|
|
||||||
TenantMixin,
|
|
||||||
TimestampMixin,
|
|
||||||
SoftDeleteMixin,
|
|
||||||
ActorAuditMixin,
|
|
||||||
OptimisticLockMixin,
|
|
||||||
):
|
|
||||||
__tablename__ = "vehicles"
|
|
||||||
|
|
||||||
fleet_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
||||||
vehicle_type_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
||||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
||||||
plate_number: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
|
||||||
status: Mapped[VehicleStatus] = mapped_column(
|
|
||||||
default=VehicleStatus.AVAILABLE, nullable=False
|
|
||||||
)
|
|
||||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint("tenant_id", "code", name="uq_vehicles_tenant_code"),
|
|
||||||
Index("ix_vehicles_fleet", "tenant_id", "fleet_id"),
|
|
||||||
Index("ix_vehicles_tenant_status", "tenant_id", "status"),
|
|
||||||
Index("ix_vehicles_tenant_deleted", "tenant_id", "is_deleted"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class VehicleAssignment(
|
|
||||||
Base,
|
|
||||||
UUIDPrimaryKeyMixin,
|
|
||||||
TenantMixin,
|
|
||||||
TimestampMixin,
|
|
||||||
SoftDeleteMixin,
|
|
||||||
ActorAuditMixin,
|
|
||||||
):
|
|
||||||
"""Shell assignment linking vehicles to drivers — no dispatch engine."""
|
|
||||||
|
|
||||||
__tablename__ = "vehicle_assignments"
|
|
||||||
|
|
||||||
vehicle_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
||||||
driver_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
||||||
status: Mapped[VehicleAssignmentStatus] = mapped_column(
|
|
||||||
default=VehicleAssignmentStatus.ACTIVE, nullable=False
|
|
||||||
)
|
|
||||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
Index("ix_vehicle_assignments_vehicle", "tenant_id", "vehicle_id"),
|
|
||||||
Index("ix_vehicle_assignments_driver", "tenant_id", "driver_id"),
|
|
||||||
Index("ix_vehicle_assignments_tenant_deleted", "tenant_id", "is_deleted"),
|
|
||||||
)
|
|
||||||
@ -1,108 +0,0 @@
|
|||||||
"""Pricing, capabilities & bundles — Phase 10.4."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
from sqlalchemy import Index, Numeric, String, Text, UniqueConstraint
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
from sqlalchemy.types import JSON
|
|
||||||
|
|
||||||
from app.core.database import Base
|
|
||||||
from app.models.base import (
|
|
||||||
ActorAuditMixin,
|
|
||||||
OptimisticLockMixin,
|
|
||||||
SoftDeleteMixin,
|
|
||||||
TenantMixin,
|
|
||||||
TimestampMixin,
|
|
||||||
UUIDPrimaryKeyMixin,
|
|
||||||
)
|
|
||||||
from app.models.types import (
|
|
||||||
CapabilityBundleStatus,
|
|
||||||
CapabilityStatus,
|
|
||||||
GUID,
|
|
||||||
PricingRuleStatus,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PricingRule(
|
|
||||||
Base,
|
|
||||||
UUIDPrimaryKeyMixin,
|
|
||||||
TenantMixin,
|
|
||||||
TimestampMixin,
|
|
||||||
SoftDeleteMixin,
|
|
||||||
ActorAuditMixin,
|
|
||||||
OptimisticLockMixin,
|
|
||||||
):
|
|
||||||
__tablename__ = "pricing_rules"
|
|
||||||
|
|
||||||
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
||||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
status: Mapped[PricingRuleStatus] = mapped_column(
|
|
||||||
default=PricingRuleStatus.DRAFT, nullable=False
|
|
||||||
)
|
|
||||||
base_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
|
||||||
currency_code: Mapped[str] = mapped_column(String(3), nullable=False, default="IRR")
|
|
||||||
rule_config: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
||||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint("tenant_id", "code", name="uq_pricing_rules_tenant_code"),
|
|
||||||
Index("ix_pricing_rules_tenant_status", "tenant_id", "status"),
|
|
||||||
Index("ix_pricing_rules_tenant_deleted", "tenant_id", "is_deleted"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CapabilityDefinition(
|
|
||||||
Base,
|
|
||||||
UUIDPrimaryKeyMixin,
|
|
||||||
TenantMixin,
|
|
||||||
TimestampMixin,
|
|
||||||
SoftDeleteMixin,
|
|
||||||
ActorAuditMixin,
|
|
||||||
OptimisticLockMixin,
|
|
||||||
):
|
|
||||||
__tablename__ = "capability_definitions"
|
|
||||||
|
|
||||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
status: Mapped[CapabilityStatus] = mapped_column(
|
|
||||||
default=CapabilityStatus.ACTIVE, nullable=False
|
|
||||||
)
|
|
||||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint(
|
|
||||||
"tenant_id", "code", name="uq_capability_definitions_tenant_code"
|
|
||||||
),
|
|
||||||
Index("ix_capability_definitions_tenant_deleted", "tenant_id", "is_deleted"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CapabilityBundle(
|
|
||||||
Base,
|
|
||||||
UUIDPrimaryKeyMixin,
|
|
||||||
TenantMixin,
|
|
||||||
TimestampMixin,
|
|
||||||
SoftDeleteMixin,
|
|
||||||
ActorAuditMixin,
|
|
||||||
OptimisticLockMixin,
|
|
||||||
):
|
|
||||||
__tablename__ = "capability_bundles"
|
|
||||||
|
|
||||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
||||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
|
||||||
status: Mapped[CapabilityBundleStatus] = mapped_column(
|
|
||||||
default=CapabilityBundleStatus.DRAFT, nullable=False
|
|
||||||
)
|
|
||||||
capability_codes: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
|
||||||
pricing_rule_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
|
||||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint("tenant_id", "code", name="uq_capability_bundles_tenant_code"),
|
|
||||||
Index("ix_capability_bundles_tenant_status", "tenant_id", "status"),
|
|
||||||
Index("ix_capability_bundles_tenant_deleted", "tenant_id", "is_deleted"),
|
|
||||||
)
|
|
||||||
@ -1,110 +0,0 @@
|
|||||||
"""Routing & optimization aggregates — Phase 10.6."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import DateTime, Index, Integer, String, Text, UniqueConstraint
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
from sqlalchemy.types import JSON
|
|
||||||
|
|
||||||
from app.core.database import Base
|
|
||||||
from app.models.base import (
|
|
||||||
ActorAuditMixin,
|
|
||||||
OptimisticLockMixin,
|
|
||||||
SoftDeleteMixin,
|
|
||||||
TenantMixin,
|
|
||||||
TimestampMixin,
|
|
||||||
UUIDPrimaryKeyMixin,
|
|
||||||
)
|
|
||||||
from app.models.types import GUID, OptimizationRunStatus, RoutePlanStatus, RouteStopStatus
|
|
||||||
|
|
||||||
|
|
||||||
class RoutePlan(
|
|
||||||
Base,
|
|
||||||
UUIDPrimaryKeyMixin,
|
|
||||||
TenantMixin,
|
|
||||||
TimestampMixin,
|
|
||||||
SoftDeleteMixin,
|
|
||||||
ActorAuditMixin,
|
|
||||||
OptimisticLockMixin,
|
|
||||||
):
|
|
||||||
__tablename__ = "route_plans"
|
|
||||||
|
|
||||||
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
||||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
||||||
status: Mapped[RoutePlanStatus] = mapped_column(
|
|
||||||
default=RoutePlanStatus.DRAFT, nullable=False
|
|
||||||
)
|
|
||||||
driver_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
|
||||||
dispatch_job_ids: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
|
||||||
routing_engine_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
||||||
planned_at: Mapped[datetime | None] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=True
|
|
||||||
)
|
|
||||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint("tenant_id", "code", name="uq_route_plans_tenant_code"),
|
|
||||||
Index("ix_route_plans_tenant_status", "tenant_id", "status"),
|
|
||||||
Index("ix_route_plans_tenant_deleted", "tenant_id", "is_deleted"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class RouteStop(
|
|
||||||
Base,
|
|
||||||
UUIDPrimaryKeyMixin,
|
|
||||||
TenantMixin,
|
|
||||||
TimestampMixin,
|
|
||||||
SoftDeleteMixin,
|
|
||||||
ActorAuditMixin,
|
|
||||||
):
|
|
||||||
__tablename__ = "route_stops"
|
|
||||||
|
|
||||||
route_plan_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
||||||
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
|
|
||||||
status: Mapped[RouteStopStatus] = mapped_column(
|
|
||||||
default=RouteStopStatus.PENDING, nullable=False
|
|
||||||
)
|
|
||||||
address_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
||||||
external_order_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
||||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
Index("ix_route_stops_plan", "tenant_id", "route_plan_id"),
|
|
||||||
Index("ix_route_stops_tenant_deleted", "tenant_id", "is_deleted"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class OptimizationRun(
|
|
||||||
Base,
|
|
||||||
UUIDPrimaryKeyMixin,
|
|
||||||
TenantMixin,
|
|
||||||
TimestampMixin,
|
|
||||||
SoftDeleteMixin,
|
|
||||||
ActorAuditMixin,
|
|
||||||
):
|
|
||||||
"""External routing via RoutingEngineProvider protocol refs only."""
|
|
||||||
|
|
||||||
__tablename__ = "optimization_runs"
|
|
||||||
|
|
||||||
route_plan_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
|
||||||
status: Mapped[OptimizationRunStatus] = mapped_column(
|
|
||||||
default=OptimizationRunStatus.PENDING, nullable=False
|
|
||||||
)
|
|
||||||
routing_engine_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
||||||
request_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
||||||
result_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
||||||
started_at: Mapped[datetime | None] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=True
|
|
||||||
)
|
|
||||||
completed_at: Mapped[datetime | None] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=True
|
|
||||||
)
|
|
||||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
Index("ix_optimization_runs_tenant_status", "tenant_id", "status"),
|
|
||||||
Index("ix_optimization_runs_plan", "tenant_id", "route_plan_id"),
|
|
||||||
)
|
|
||||||
@ -1,78 +0,0 @@
|
|||||||
"""Settlement aggregates — Phase 10.8.
|
|
||||||
|
|
||||||
Uses AccountingProvider protocol refs only — NO journal entries in delivery_db.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
from sqlalchemy import Index, Numeric, String, Text, UniqueConstraint
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
from sqlalchemy.types import JSON
|
|
||||||
|
|
||||||
from app.core.database import Base
|
|
||||||
from app.models.base import (
|
|
||||||
ActorAuditMixin,
|
|
||||||
OptimisticLockMixin,
|
|
||||||
SoftDeleteMixin,
|
|
||||||
TenantMixin,
|
|
||||||
TimestampMixin,
|
|
||||||
UUIDPrimaryKeyMixin,
|
|
||||||
)
|
|
||||||
from app.models.types import GUID, SettlementIntentStatus, SettlementLineKind
|
|
||||||
|
|
||||||
|
|
||||||
class SettlementIntent(
|
|
||||||
Base,
|
|
||||||
UUIDPrimaryKeyMixin,
|
|
||||||
TenantMixin,
|
|
||||||
TimestampMixin,
|
|
||||||
SoftDeleteMixin,
|
|
||||||
ActorAuditMixin,
|
|
||||||
OptimisticLockMixin,
|
|
||||||
):
|
|
||||||
__tablename__ = "settlement_intents"
|
|
||||||
|
|
||||||
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
||||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
|
||||||
status: Mapped[SettlementIntentStatus] = mapped_column(
|
|
||||||
default=SettlementIntentStatus.DRAFT, nullable=False
|
|
||||||
)
|
|
||||||
driver_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
|
||||||
dispatch_job_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
|
||||||
external_settlement_ref: Mapped[str | None] = mapped_column(
|
|
||||||
String(255), nullable=True
|
|
||||||
)
|
|
||||||
total_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
|
||||||
currency_code: Mapped[str] = mapped_column(String(3), nullable=False, default="IRR")
|
|
||||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint("tenant_id", "code", name="uq_settlement_intents_tenant_code"),
|
|
||||||
Index("ix_settlement_intents_tenant_status", "tenant_id", "status"),
|
|
||||||
Index("ix_settlement_intents_tenant_deleted", "tenant_id", "is_deleted"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SettlementLine(
|
|
||||||
Base,
|
|
||||||
UUIDPrimaryKeyMixin,
|
|
||||||
TenantMixin,
|
|
||||||
TimestampMixin,
|
|
||||||
SoftDeleteMixin,
|
|
||||||
ActorAuditMixin,
|
|
||||||
):
|
|
||||||
__tablename__ = "settlement_lines"
|
|
||||||
|
|
||||||
settlement_intent_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
||||||
kind: Mapped[SettlementLineKind] = mapped_column(nullable=False)
|
|
||||||
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
||||||
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
|
||||||
currency_code: Mapped[str] = mapped_column(String(3), nullable=False, default="IRR")
|
|
||||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
Index("ix_settlement_lines_intent", "tenant_id", "settlement_intent_id"),
|
|
||||||
)
|
|
||||||
@ -1,138 +0,0 @@
|
|||||||
"""Tracking & proof of delivery — Phase 10.7."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import DateTime, Float, Index, String, Text, UniqueConstraint
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
from sqlalchemy.types import JSON
|
|
||||||
|
|
||||||
from app.core.database import Base
|
|
||||||
from app.models.base import (
|
|
||||||
ActorAuditMixin,
|
|
||||||
OptimisticLockMixin,
|
|
||||||
SoftDeleteMixin,
|
|
||||||
TenantMixin,
|
|
||||||
TimestampMixin,
|
|
||||||
UUIDPrimaryKeyMixin,
|
|
||||||
)
|
|
||||||
from app.models.types import (
|
|
||||||
CustomerTrackingTokenStatus,
|
|
||||||
GUID,
|
|
||||||
ProofOfDeliveryStatus,
|
|
||||||
TrackingSessionStatus,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TrackingSession(
|
|
||||||
Base,
|
|
||||||
UUIDPrimaryKeyMixin,
|
|
||||||
TenantMixin,
|
|
||||||
TimestampMixin,
|
|
||||||
SoftDeleteMixin,
|
|
||||||
ActorAuditMixin,
|
|
||||||
OptimisticLockMixin,
|
|
||||||
):
|
|
||||||
__tablename__ = "tracking_sessions"
|
|
||||||
|
|
||||||
dispatch_job_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
|
||||||
driver_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
|
||||||
status: Mapped[TrackingSessionStatus] = mapped_column(
|
|
||||||
default=TrackingSessionStatus.ACTIVE, nullable=False
|
|
||||||
)
|
|
||||||
started_at: Mapped[datetime | None] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=True
|
|
||||||
)
|
|
||||||
ended_at: Mapped[datetime | None] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=True
|
|
||||||
)
|
|
||||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
Index("ix_tracking_sessions_tenant_status", "tenant_id", "status"),
|
|
||||||
Index("ix_tracking_sessions_job", "tenant_id", "dispatch_job_id"),
|
|
||||||
Index("ix_tracking_sessions_tenant_deleted", "tenant_id", "is_deleted"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TrackingPoint(
|
|
||||||
Base,
|
|
||||||
UUIDPrimaryKeyMixin,
|
|
||||||
TenantMixin,
|
|
||||||
TimestampMixin,
|
|
||||||
):
|
|
||||||
__tablename__ = "tracking_points"
|
|
||||||
|
|
||||||
tracking_session_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
||||||
latitude: Mapped[float] = mapped_column(Float, nullable=False)
|
|
||||||
longitude: Mapped[float] = mapped_column(Float, nullable=False)
|
|
||||||
recorded_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=False
|
|
||||||
)
|
|
||||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
Index("ix_tracking_points_session", "tenant_id", "tracking_session_id"),
|
|
||||||
Index("ix_tracking_points_recorded", "tenant_id", "recorded_at"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ProofOfDelivery(
|
|
||||||
Base,
|
|
||||||
UUIDPrimaryKeyMixin,
|
|
||||||
TenantMixin,
|
|
||||||
TimestampMixin,
|
|
||||||
SoftDeleteMixin,
|
|
||||||
ActorAuditMixin,
|
|
||||||
OptimisticLockMixin,
|
|
||||||
):
|
|
||||||
"""Storage file refs for POD only — no blob ownership."""
|
|
||||||
|
|
||||||
__tablename__ = "proof_of_delivery"
|
|
||||||
|
|
||||||
dispatch_job_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
||||||
status: Mapped[ProofOfDeliveryStatus] = mapped_column(
|
|
||||||
default=ProofOfDeliveryStatus.PENDING, nullable=False
|
|
||||||
)
|
|
||||||
signature_file_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
||||||
photo_file_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
||||||
delivery_code: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
|
||||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
|
||||||
captured_at: Mapped[datetime | None] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=True
|
|
||||||
)
|
|
||||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
Index("ix_proof_of_delivery_job", "tenant_id", "dispatch_job_id"),
|
|
||||||
Index("ix_proof_of_delivery_tenant_status", "tenant_id", "status"),
|
|
||||||
Index("ix_proof_of_delivery_tenant_deleted", "tenant_id", "is_deleted"),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CustomerTrackingToken(
|
|
||||||
Base,
|
|
||||||
UUIDPrimaryKeyMixin,
|
|
||||||
TenantMixin,
|
|
||||||
TimestampMixin,
|
|
||||||
SoftDeleteMixin,
|
|
||||||
ActorAuditMixin,
|
|
||||||
):
|
|
||||||
__tablename__ = "customer_tracking_tokens"
|
|
||||||
|
|
||||||
dispatch_job_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
|
||||||
token: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
||||||
status: Mapped[CustomerTrackingTokenStatus] = mapped_column(
|
|
||||||
default=CustomerTrackingTokenStatus.ACTIVE, nullable=False
|
|
||||||
)
|
|
||||||
expires_at: Mapped[datetime | None] = mapped_column(
|
|
||||||
DateTime(timezone=True), nullable=True
|
|
||||||
)
|
|
||||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
|
||||||
|
|
||||||
__table_args__ = (
|
|
||||||
UniqueConstraint("tenant_id", "token", name="uq_customer_tracking_tokens_token"),
|
|
||||||
Index("ix_customer_tracking_tokens_job", "tenant_id", "dispatch_job_id"),
|
|
||||||
Index("ix_customer_tracking_tokens_tenant_status", "tenant_id", "status"),
|
|
||||||
)
|
|
||||||
@ -116,162 +116,3 @@ class DriverDocumentKind(str, enum.Enum):
|
|||||||
CONTRACT = "contract"
|
CONTRACT = "contract"
|
||||||
PHOTO = "photo"
|
PHOTO = "photo"
|
||||||
OTHER = "other"
|
OTHER = "other"
|
||||||
|
|
||||||
|
|
||||||
# Phase 10.2 — Fleet & Vehicle Types
|
|
||||||
class FleetStatus(str, enum.Enum):
|
|
||||||
DRAFT = "draft"
|
|
||||||
ACTIVE = "active"
|
|
||||||
INACTIVE = "inactive"
|
|
||||||
ARCHIVED = "archived"
|
|
||||||
|
|
||||||
|
|
||||||
class VehicleStatus(str, enum.Enum):
|
|
||||||
AVAILABLE = "available"
|
|
||||||
ASSIGNED = "assigned"
|
|
||||||
MAINTENANCE = "maintenance"
|
|
||||||
INACTIVE = "inactive"
|
|
||||||
ARCHIVED = "archived"
|
|
||||||
|
|
||||||
|
|
||||||
class VehicleAssignmentStatus(str, enum.Enum):
|
|
||||||
ACTIVE = "active"
|
|
||||||
ENDED = "ended"
|
|
||||||
CANCELLED = "cancelled"
|
|
||||||
|
|
||||||
|
|
||||||
# Phase 10.3 — Availability, Shifts & Working Zones
|
|
||||||
class DriverAvailabilityStatus(str, enum.Enum):
|
|
||||||
ONLINE = "online"
|
|
||||||
OFFLINE = "offline"
|
|
||||||
BUSY = "busy"
|
|
||||||
BREAK = "break"
|
|
||||||
|
|
||||||
|
|
||||||
class ShiftStatus(str, enum.Enum):
|
|
||||||
DRAFT = "draft"
|
|
||||||
SCHEDULED = "scheduled"
|
|
||||||
ACTIVE = "active"
|
|
||||||
COMPLETED = "completed"
|
|
||||||
CANCELLED = "cancelled"
|
|
||||||
|
|
||||||
|
|
||||||
class ShiftAssignmentStatus(str, enum.Enum):
|
|
||||||
ASSIGNED = "assigned"
|
|
||||||
CHECKED_IN = "checked_in"
|
|
||||||
COMPLETED = "completed"
|
|
||||||
CANCELLED = "cancelled"
|
|
||||||
|
|
||||||
|
|
||||||
class WorkingZoneStatus(str, enum.Enum):
|
|
||||||
ACTIVE = "active"
|
|
||||||
INACTIVE = "inactive"
|
|
||||||
|
|
||||||
|
|
||||||
# Phase 10.4 — Pricing, Capabilities & Bundles
|
|
||||||
class PricingRuleStatus(str, enum.Enum):
|
|
||||||
DRAFT = "draft"
|
|
||||||
ACTIVE = "active"
|
|
||||||
INACTIVE = "inactive"
|
|
||||||
ARCHIVED = "archived"
|
|
||||||
|
|
||||||
|
|
||||||
class CapabilityStatus(str, enum.Enum):
|
|
||||||
ACTIVE = "active"
|
|
||||||
INACTIVE = "inactive"
|
|
||||||
|
|
||||||
|
|
||||||
class CapabilityBundleStatus(str, enum.Enum):
|
|
||||||
DRAFT = "draft"
|
|
||||||
ACTIVE = "active"
|
|
||||||
INACTIVE = "inactive"
|
|
||||||
ARCHIVED = "archived"
|
|
||||||
|
|
||||||
|
|
||||||
# Phase 10.5 — Dispatch Engine
|
|
||||||
class DispatchJobStatus(str, enum.Enum):
|
|
||||||
PENDING = "pending"
|
|
||||||
ASSIGNED = "assigned"
|
|
||||||
IN_PROGRESS = "in_progress"
|
|
||||||
COMPLETED = "completed"
|
|
||||||
CANCELLED = "cancelled"
|
|
||||||
FAILED = "failed"
|
|
||||||
|
|
||||||
|
|
||||||
class DispatchJobAction(str, enum.Enum):
|
|
||||||
ASSIGN = "assign"
|
|
||||||
START = "start"
|
|
||||||
COMPLETE = "complete"
|
|
||||||
CANCEL = "cancel"
|
|
||||||
FAIL = "fail"
|
|
||||||
|
|
||||||
|
|
||||||
class JobAssignmentStatus(str, enum.Enum):
|
|
||||||
PENDING = "pending"
|
|
||||||
ACCEPTED = "accepted"
|
|
||||||
REJECTED = "rejected"
|
|
||||||
ACTIVE = "active"
|
|
||||||
COMPLETED = "completed"
|
|
||||||
CANCELLED = "cancelled"
|
|
||||||
|
|
||||||
|
|
||||||
# Phase 10.6 — Routing & Optimization
|
|
||||||
class RoutePlanStatus(str, enum.Enum):
|
|
||||||
DRAFT = "draft"
|
|
||||||
PLANNED = "planned"
|
|
||||||
ACTIVE = "active"
|
|
||||||
COMPLETED = "completed"
|
|
||||||
CANCELLED = "cancelled"
|
|
||||||
|
|
||||||
|
|
||||||
class RouteStopStatus(str, enum.Enum):
|
|
||||||
PENDING = "pending"
|
|
||||||
ARRIVED = "arrived"
|
|
||||||
COMPLETED = "completed"
|
|
||||||
SKIPPED = "skipped"
|
|
||||||
|
|
||||||
|
|
||||||
class OptimizationRunStatus(str, enum.Enum):
|
|
||||||
PENDING = "pending"
|
|
||||||
RUNNING = "running"
|
|
||||||
COMPLETED = "completed"
|
|
||||||
FAILED = "failed"
|
|
||||||
|
|
||||||
|
|
||||||
# Phase 10.7 — Tracking & Proof of Delivery
|
|
||||||
class TrackingSessionStatus(str, enum.Enum):
|
|
||||||
ACTIVE = "active"
|
|
||||||
PAUSED = "paused"
|
|
||||||
COMPLETED = "completed"
|
|
||||||
CANCELLED = "cancelled"
|
|
||||||
|
|
||||||
|
|
||||||
class ProofOfDeliveryStatus(str, enum.Enum):
|
|
||||||
PENDING = "pending"
|
|
||||||
CAPTURED = "captured"
|
|
||||||
VERIFIED = "verified"
|
|
||||||
REJECTED = "rejected"
|
|
||||||
|
|
||||||
|
|
||||||
class CustomerTrackingTokenStatus(str, enum.Enum):
|
|
||||||
ACTIVE = "active"
|
|
||||||
EXPIRED = "expired"
|
|
||||||
REVOKED = "revoked"
|
|
||||||
|
|
||||||
|
|
||||||
# Phase 10.8 — Settlement
|
|
||||||
class SettlementIntentStatus(str, enum.Enum):
|
|
||||||
DRAFT = "draft"
|
|
||||||
PENDING = "pending"
|
|
||||||
SUBMITTED = "submitted"
|
|
||||||
SETTLED = "settled"
|
|
||||||
FAILED = "failed"
|
|
||||||
CANCELLED = "cancelled"
|
|
||||||
|
|
||||||
|
|
||||||
class SettlementLineKind(str, enum.Enum):
|
|
||||||
DELIVERY_FEE = "delivery_fee"
|
|
||||||
TIP = "tip"
|
|
||||||
SURCHARGE = "surcharge"
|
|
||||||
ADJUSTMENT = "adjustment"
|
|
||||||
OTHER = "other"
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"""Delivery permission definitions — Phase 10.0–10.8."""
|
"""Delivery permission definitions — Phase 10.0 foundation + 10.1 drivers."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
DELIVERY_VIEW = "delivery.view"
|
DELIVERY_VIEW = "delivery.view"
|
||||||
@ -58,134 +58,18 @@ DRIVERS_DOCUMENTS_VIEW = "delivery.drivers.documents.view"
|
|||||||
DRIVERS_DOCUMENTS_MANAGE = "delivery.drivers.documents.manage"
|
DRIVERS_DOCUMENTS_MANAGE = "delivery.drivers.documents.manage"
|
||||||
DRIVERS_MANAGE = "delivery.drivers.manage"
|
DRIVERS_MANAGE = "delivery.drivers.manage"
|
||||||
|
|
||||||
# Phase 10.2 — Fleet & Vehicles
|
# Planned trees (registered for discovery; enforced in later phases)
|
||||||
FLEETS_VIEW = "delivery.fleets.view"
|
FLEET_VIEW = "delivery.fleet.view"
|
||||||
FLEETS_CREATE = "delivery.fleets.create"
|
FLEET_MANAGE = "delivery.fleet.manage"
|
||||||
FLEETS_UPDATE = "delivery.fleets.update"
|
|
||||||
FLEETS_DELETE = "delivery.fleets.delete"
|
|
||||||
FLEETS_MANAGE = "delivery.fleets.manage"
|
|
||||||
|
|
||||||
VEHICLE_TYPES_VIEW = "delivery.vehicle_types.view"
|
|
||||||
VEHICLE_TYPES_CREATE = "delivery.vehicle_types.create"
|
|
||||||
VEHICLE_TYPES_UPDATE = "delivery.vehicle_types.update"
|
|
||||||
VEHICLE_TYPES_DELETE = "delivery.vehicle_types.delete"
|
|
||||||
VEHICLE_TYPES_MANAGE = "delivery.vehicle_types.manage"
|
|
||||||
|
|
||||||
VEHICLES_VIEW = "delivery.vehicles.view"
|
|
||||||
VEHICLES_CREATE = "delivery.vehicles.create"
|
|
||||||
VEHICLES_UPDATE = "delivery.vehicles.update"
|
|
||||||
VEHICLES_DELETE = "delivery.vehicles.delete"
|
|
||||||
VEHICLES_ASSIGNMENTS_VIEW = "delivery.vehicles.assignments.view"
|
|
||||||
VEHICLES_ASSIGNMENTS_MANAGE = "delivery.vehicles.assignments.manage"
|
|
||||||
VEHICLES_MANAGE = "delivery.vehicles.manage"
|
|
||||||
|
|
||||||
# Phase 10.3 — Availability, Shifts & Working Zones
|
|
||||||
AVAILABILITY_VIEW = "delivery.availability.view"
|
|
||||||
AVAILABILITY_MANAGE = "delivery.availability.manage"
|
|
||||||
|
|
||||||
SHIFTS_VIEW = "delivery.shifts.view"
|
|
||||||
SHIFTS_CREATE = "delivery.shifts.create"
|
|
||||||
SHIFTS_UPDATE = "delivery.shifts.update"
|
|
||||||
SHIFTS_DELETE = "delivery.shifts.delete"
|
|
||||||
SHIFTS_ASSIGNMENTS_VIEW = "delivery.shifts.assignments.view"
|
|
||||||
SHIFTS_ASSIGNMENTS_MANAGE = "delivery.shifts.assignments.manage"
|
|
||||||
SHIFTS_MANAGE = "delivery.shifts.manage"
|
|
||||||
|
|
||||||
WORKING_ZONES_VIEW = "delivery.working_zones.view"
|
|
||||||
WORKING_ZONES_CREATE = "delivery.working_zones.create"
|
|
||||||
WORKING_ZONES_UPDATE = "delivery.working_zones.update"
|
|
||||||
WORKING_ZONES_DELETE = "delivery.working_zones.delete"
|
|
||||||
WORKING_ZONES_MANAGE = "delivery.working_zones.manage"
|
|
||||||
|
|
||||||
# Phase 10.4 — Pricing, Capabilities & Bundles
|
|
||||||
PRICING_RULES_VIEW = "delivery.pricing_rules.view"
|
|
||||||
PRICING_RULES_CREATE = "delivery.pricing_rules.create"
|
|
||||||
PRICING_RULES_UPDATE = "delivery.pricing_rules.update"
|
|
||||||
PRICING_RULES_DELETE = "delivery.pricing_rules.delete"
|
|
||||||
PRICING_RULES_MANAGE = "delivery.pricing_rules.manage"
|
|
||||||
|
|
||||||
CAPABILITIES_VIEW = "delivery.capabilities.view"
|
|
||||||
CAPABILITIES_CREATE = "delivery.capabilities.create"
|
|
||||||
CAPABILITIES_UPDATE = "delivery.capabilities.update"
|
|
||||||
CAPABILITIES_DELETE = "delivery.capabilities.delete"
|
|
||||||
CAPABILITIES_MANAGE = "delivery.capabilities.manage"
|
|
||||||
|
|
||||||
BUNDLES_VIEW = "delivery.bundles.view"
|
|
||||||
BUNDLES_CREATE = "delivery.bundles.create"
|
|
||||||
BUNDLES_UPDATE = "delivery.bundles.update"
|
|
||||||
BUNDLES_DELETE = "delivery.bundles.delete"
|
|
||||||
BUNDLES_MANAGE = "delivery.bundles.manage"
|
|
||||||
|
|
||||||
# Phase 10.5 — Dispatch Engine
|
|
||||||
DISPATCH_JOBS_VIEW = "delivery.dispatch.jobs.view"
|
|
||||||
DISPATCH_JOBS_CREATE = "delivery.dispatch.jobs.create"
|
|
||||||
DISPATCH_JOBS_UPDATE = "delivery.dispatch.jobs.update"
|
|
||||||
DISPATCH_JOBS_DELETE = "delivery.dispatch.jobs.delete"
|
|
||||||
DISPATCH_JOBS_STATUS = "delivery.dispatch.jobs.status"
|
|
||||||
DISPATCH_JOBS_MANAGE = "delivery.dispatch.jobs.manage"
|
|
||||||
|
|
||||||
DISPATCH_ASSIGNMENTS_VIEW = "delivery.dispatch.assignments.view"
|
|
||||||
DISPATCH_ASSIGNMENTS_CREATE = "delivery.dispatch.assignments.create"
|
|
||||||
DISPATCH_ASSIGNMENTS_UPDATE = "delivery.dispatch.assignments.update"
|
|
||||||
DISPATCH_ASSIGNMENTS_MANAGE = "delivery.dispatch.assignments.manage"
|
|
||||||
|
|
||||||
DISPATCH_VIEW = "delivery.dispatch.view"
|
DISPATCH_VIEW = "delivery.dispatch.view"
|
||||||
DISPATCH_MANAGE = "delivery.dispatch.manage"
|
DISPATCH_MANAGE = "delivery.dispatch.manage"
|
||||||
|
|
||||||
# Phase 10.6 — Routing & Optimization
|
|
||||||
ROUTES_VIEW = "delivery.routes.view"
|
|
||||||
ROUTES_CREATE = "delivery.routes.create"
|
|
||||||
ROUTES_UPDATE = "delivery.routes.update"
|
|
||||||
ROUTES_DELETE = "delivery.routes.delete"
|
|
||||||
ROUTES_STOPS_VIEW = "delivery.routes.stops.view"
|
|
||||||
ROUTES_STOPS_MANAGE = "delivery.routes.stops.manage"
|
|
||||||
ROUTES_MANAGE = "delivery.routes.manage"
|
|
||||||
|
|
||||||
OPTIMIZATION_RUNS_VIEW = "delivery.optimization.runs.view"
|
|
||||||
OPTIMIZATION_RUNS_CREATE = "delivery.optimization.runs.create"
|
|
||||||
OPTIMIZATION_RUNS_MANAGE = "delivery.optimization.runs.manage"
|
|
||||||
|
|
||||||
ROUTING_VIEW = "delivery.routing.view"
|
ROUTING_VIEW = "delivery.routing.view"
|
||||||
ROUTING_MANAGE = "delivery.routing.manage"
|
ROUTING_MANAGE = "delivery.routing.manage"
|
||||||
|
|
||||||
# Phase 10.7 — Tracking & Proof of Delivery
|
|
||||||
TRACKING_SESSIONS_VIEW = "delivery.tracking.sessions.view"
|
|
||||||
TRACKING_SESSIONS_CREATE = "delivery.tracking.sessions.create"
|
|
||||||
TRACKING_SESSIONS_UPDATE = "delivery.tracking.sessions.update"
|
|
||||||
TRACKING_SESSIONS_MANAGE = "delivery.tracking.sessions.manage"
|
|
||||||
|
|
||||||
TRACKING_POINTS_VIEW = "delivery.tracking.points.view"
|
|
||||||
TRACKING_POINTS_MANAGE = "delivery.tracking.points.manage"
|
|
||||||
|
|
||||||
PROOF_OF_DELIVERY_VIEW = "delivery.proof_of_delivery.view"
|
|
||||||
PROOF_OF_DELIVERY_CREATE = "delivery.proof_of_delivery.create"
|
|
||||||
PROOF_OF_DELIVERY_UPDATE = "delivery.proof_of_delivery.update"
|
|
||||||
PROOF_OF_DELIVERY_MANAGE = "delivery.proof_of_delivery.manage"
|
|
||||||
|
|
||||||
CUSTOMER_TRACKING_VIEW = "delivery.customer_tracking.view"
|
|
||||||
CUSTOMER_TRACKING_CREATE = "delivery.customer_tracking.create"
|
|
||||||
CUSTOMER_TRACKING_REVOKE = "delivery.customer_tracking.revoke"
|
|
||||||
CUSTOMER_TRACKING_MANAGE = "delivery.customer_tracking.manage"
|
|
||||||
|
|
||||||
TRACKING_VIEW = "delivery.tracking.view"
|
TRACKING_VIEW = "delivery.tracking.view"
|
||||||
TRACKING_MANAGE = "delivery.tracking.manage"
|
TRACKING_MANAGE = "delivery.tracking.manage"
|
||||||
|
|
||||||
# Phase 10.8 — Settlement
|
|
||||||
SETTLEMENTS_VIEW = "delivery.settlements.view"
|
|
||||||
SETTLEMENTS_CREATE = "delivery.settlements.create"
|
|
||||||
SETTLEMENTS_UPDATE = "delivery.settlements.update"
|
|
||||||
SETTLEMENTS_STATUS = "delivery.settlements.status"
|
|
||||||
SETTLEMENTS_LINES_VIEW = "delivery.settlements.lines.view"
|
|
||||||
SETTLEMENTS_LINES_MANAGE = "delivery.settlements.lines.manage"
|
|
||||||
SETTLEMENTS_MANAGE = "delivery.settlements.manage"
|
|
||||||
|
|
||||||
SETTLEMENT_VIEW = "delivery.settlement.view"
|
SETTLEMENT_VIEW = "delivery.settlement.view"
|
||||||
SETTLEMENT_MANAGE = "delivery.settlement.manage"
|
SETTLEMENT_MANAGE = "delivery.settlement.manage"
|
||||||
|
|
||||||
# Legacy aliases
|
|
||||||
FLEET_VIEW = FLEETS_VIEW
|
|
||||||
FLEET_MANAGE = FLEETS_MANAGE
|
|
||||||
|
|
||||||
ALL_PERMISSIONS: list[str] = [
|
ALL_PERMISSIONS: list[str] = [
|
||||||
DELIVERY_VIEW,
|
DELIVERY_VIEW,
|
||||||
DELIVERY_MANAGE,
|
DELIVERY_MANAGE,
|
||||||
@ -234,103 +118,16 @@ ALL_PERMISSIONS: list[str] = [
|
|||||||
DRIVERS_DOCUMENTS_VIEW,
|
DRIVERS_DOCUMENTS_VIEW,
|
||||||
DRIVERS_DOCUMENTS_MANAGE,
|
DRIVERS_DOCUMENTS_MANAGE,
|
||||||
DRIVERS_MANAGE,
|
DRIVERS_MANAGE,
|
||||||
FLEETS_VIEW,
|
|
||||||
FLEETS_CREATE,
|
|
||||||
FLEETS_UPDATE,
|
|
||||||
FLEETS_DELETE,
|
|
||||||
FLEETS_MANAGE,
|
|
||||||
VEHICLE_TYPES_VIEW,
|
|
||||||
VEHICLE_TYPES_CREATE,
|
|
||||||
VEHICLE_TYPES_UPDATE,
|
|
||||||
VEHICLE_TYPES_DELETE,
|
|
||||||
VEHICLE_TYPES_MANAGE,
|
|
||||||
VEHICLES_VIEW,
|
|
||||||
VEHICLES_CREATE,
|
|
||||||
VEHICLES_UPDATE,
|
|
||||||
VEHICLES_DELETE,
|
|
||||||
VEHICLES_ASSIGNMENTS_VIEW,
|
|
||||||
VEHICLES_ASSIGNMENTS_MANAGE,
|
|
||||||
VEHICLES_MANAGE,
|
|
||||||
AVAILABILITY_VIEW,
|
|
||||||
AVAILABILITY_MANAGE,
|
|
||||||
SHIFTS_VIEW,
|
|
||||||
SHIFTS_CREATE,
|
|
||||||
SHIFTS_UPDATE,
|
|
||||||
SHIFTS_DELETE,
|
|
||||||
SHIFTS_ASSIGNMENTS_VIEW,
|
|
||||||
SHIFTS_ASSIGNMENTS_MANAGE,
|
|
||||||
SHIFTS_MANAGE,
|
|
||||||
WORKING_ZONES_VIEW,
|
|
||||||
WORKING_ZONES_CREATE,
|
|
||||||
WORKING_ZONES_UPDATE,
|
|
||||||
WORKING_ZONES_DELETE,
|
|
||||||
WORKING_ZONES_MANAGE,
|
|
||||||
PRICING_RULES_VIEW,
|
|
||||||
PRICING_RULES_CREATE,
|
|
||||||
PRICING_RULES_UPDATE,
|
|
||||||
PRICING_RULES_DELETE,
|
|
||||||
PRICING_RULES_MANAGE,
|
|
||||||
CAPABILITIES_VIEW,
|
|
||||||
CAPABILITIES_CREATE,
|
|
||||||
CAPABILITIES_UPDATE,
|
|
||||||
CAPABILITIES_DELETE,
|
|
||||||
CAPABILITIES_MANAGE,
|
|
||||||
BUNDLES_VIEW,
|
|
||||||
BUNDLES_CREATE,
|
|
||||||
BUNDLES_UPDATE,
|
|
||||||
BUNDLES_DELETE,
|
|
||||||
BUNDLES_MANAGE,
|
|
||||||
DISPATCH_JOBS_VIEW,
|
|
||||||
DISPATCH_JOBS_CREATE,
|
|
||||||
DISPATCH_JOBS_UPDATE,
|
|
||||||
DISPATCH_JOBS_DELETE,
|
|
||||||
DISPATCH_JOBS_STATUS,
|
|
||||||
DISPATCH_JOBS_MANAGE,
|
|
||||||
DISPATCH_ASSIGNMENTS_VIEW,
|
|
||||||
DISPATCH_ASSIGNMENTS_CREATE,
|
|
||||||
DISPATCH_ASSIGNMENTS_UPDATE,
|
|
||||||
DISPATCH_ASSIGNMENTS_MANAGE,
|
|
||||||
DISPATCH_VIEW,
|
|
||||||
DISPATCH_MANAGE,
|
|
||||||
ROUTES_VIEW,
|
|
||||||
ROUTES_CREATE,
|
|
||||||
ROUTES_UPDATE,
|
|
||||||
ROUTES_DELETE,
|
|
||||||
ROUTES_STOPS_VIEW,
|
|
||||||
ROUTES_STOPS_MANAGE,
|
|
||||||
ROUTES_MANAGE,
|
|
||||||
OPTIMIZATION_RUNS_VIEW,
|
|
||||||
OPTIMIZATION_RUNS_CREATE,
|
|
||||||
OPTIMIZATION_RUNS_MANAGE,
|
|
||||||
ROUTING_VIEW,
|
|
||||||
ROUTING_MANAGE,
|
|
||||||
TRACKING_SESSIONS_VIEW,
|
|
||||||
TRACKING_SESSIONS_CREATE,
|
|
||||||
TRACKING_SESSIONS_UPDATE,
|
|
||||||
TRACKING_SESSIONS_MANAGE,
|
|
||||||
TRACKING_POINTS_VIEW,
|
|
||||||
TRACKING_POINTS_MANAGE,
|
|
||||||
PROOF_OF_DELIVERY_VIEW,
|
|
||||||
PROOF_OF_DELIVERY_CREATE,
|
|
||||||
PROOF_OF_DELIVERY_UPDATE,
|
|
||||||
PROOF_OF_DELIVERY_MANAGE,
|
|
||||||
CUSTOMER_TRACKING_VIEW,
|
|
||||||
CUSTOMER_TRACKING_CREATE,
|
|
||||||
CUSTOMER_TRACKING_REVOKE,
|
|
||||||
CUSTOMER_TRACKING_MANAGE,
|
|
||||||
TRACKING_VIEW,
|
|
||||||
TRACKING_MANAGE,
|
|
||||||
SETTLEMENTS_VIEW,
|
|
||||||
SETTLEMENTS_CREATE,
|
|
||||||
SETTLEMENTS_UPDATE,
|
|
||||||
SETTLEMENTS_STATUS,
|
|
||||||
SETTLEMENTS_LINES_VIEW,
|
|
||||||
SETTLEMENTS_LINES_MANAGE,
|
|
||||||
SETTLEMENTS_MANAGE,
|
|
||||||
SETTLEMENT_VIEW,
|
|
||||||
SETTLEMENT_MANAGE,
|
|
||||||
FLEET_VIEW,
|
FLEET_VIEW,
|
||||||
FLEET_MANAGE,
|
FLEET_MANAGE,
|
||||||
|
DISPATCH_VIEW,
|
||||||
|
DISPATCH_MANAGE,
|
||||||
|
ROUTING_VIEW,
|
||||||
|
ROUTING_MANAGE,
|
||||||
|
TRACKING_VIEW,
|
||||||
|
TRACKING_MANAGE,
|
||||||
|
SETTLEMENT_VIEW,
|
||||||
|
SETTLEMENT_MANAGE,
|
||||||
]
|
]
|
||||||
|
|
||||||
PERMISSION_PREFIXES: tuple[str, ...] = (
|
PERMISSION_PREFIXES: tuple[str, ...] = (
|
||||||
@ -343,23 +140,9 @@ PERMISSION_PREFIXES: tuple[str, ...] = (
|
|||||||
"delivery.settings.",
|
"delivery.settings.",
|
||||||
"delivery.audit.",
|
"delivery.audit.",
|
||||||
"delivery.drivers.",
|
"delivery.drivers.",
|
||||||
"delivery.fleets.",
|
|
||||||
"delivery.vehicle_types.",
|
|
||||||
"delivery.vehicles.",
|
|
||||||
"delivery.availability.",
|
|
||||||
"delivery.shifts.",
|
|
||||||
"delivery.working_zones.",
|
|
||||||
"delivery.pricing_rules.",
|
|
||||||
"delivery.capabilities.",
|
|
||||||
"delivery.bundles.",
|
|
||||||
"delivery.dispatch.",
|
|
||||||
"delivery.routes.",
|
|
||||||
"delivery.optimization.",
|
|
||||||
"delivery.tracking.",
|
|
||||||
"delivery.proof_of_delivery.",
|
|
||||||
"delivery.customer_tracking.",
|
|
||||||
"delivery.settlements.",
|
|
||||||
"delivery.fleet.",
|
"delivery.fleet.",
|
||||||
|
"delivery.dispatch.",
|
||||||
"delivery.routing.",
|
"delivery.routing.",
|
||||||
|
"delivery.tracking.",
|
||||||
"delivery.settlement.",
|
"delivery.settlement.",
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,28 +0,0 @@
|
|||||||
"""Dispatch domain policies — Phase 10.5."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from app.models.types import DispatchJobAction, DispatchJobStatus
|
|
||||||
from app.validators.dispatch import (
|
|
||||||
ensure_dispatch_job_transition,
|
|
||||||
target_status_for,
|
|
||||||
validate_dispatch_reason,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class DispatchJobPolicy:
|
|
||||||
REQUIRES_REASON = frozenset(
|
|
||||||
{DispatchJobAction.CANCEL, DispatchJobAction.FAIL}
|
|
||||||
)
|
|
||||||
|
|
||||||
def assert_transition(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
action: DispatchJobAction,
|
|
||||||
current: DispatchJobStatus,
|
|
||||||
reason: str | None = None,
|
|
||||||
) -> tuple[DispatchJobStatus, str | None]:
|
|
||||||
ensure_dispatch_job_transition(action=action, current=current)
|
|
||||||
cleaned = validate_dispatch_reason(
|
|
||||||
reason, required=action in self.REQUIRES_REASON
|
|
||||||
)
|
|
||||||
return target_status_for(action), cleaned
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
"""Settlement domain policies — Phase 10.8."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from app.models.types import SettlementIntentStatus
|
|
||||||
from app.validators.dispatch import validate_dispatch_reason
|
|
||||||
from app.validators.settlement import (
|
|
||||||
ensure_settlement_transition,
|
|
||||||
target_settlement_status,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class SettlementIntentPolicy:
|
|
||||||
REQUIRES_REASON = frozenset({"fail", "cancel"})
|
|
||||||
|
|
||||||
def assert_transition(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
action: str,
|
|
||||||
current: SettlementIntentStatus,
|
|
||||||
reason: str | None = None,
|
|
||||||
) -> tuple[SettlementIntentStatus, str | None]:
|
|
||||||
ensure_settlement_transition(action=action, current=current)
|
|
||||||
cleaned = validate_dispatch_reason(
|
|
||||||
reason, required=action in self.REQUIRES_REASON
|
|
||||||
)
|
|
||||||
return target_settlement_status(action), cleaned
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
"""Availability query handlers — Phase 10.3."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.services.availability import AvailabilityService, ShiftService, WorkingZoneService
|
|
||||||
from app.specifications.availability import ShiftListSpec, WorkingZoneListSpec
|
|
||||||
|
|
||||||
|
|
||||||
class AvailabilityQueries:
|
|
||||||
def __init__(self, session: AsyncSession) -> None:
|
|
||||||
self.availability = AvailabilityService(session)
|
|
||||||
self.shifts = ShiftService(session)
|
|
||||||
self.zones = WorkingZoneService(session)
|
|
||||||
|
|
||||||
async def get_availability(self, tenant_id: UUID, driver_id: UUID):
|
|
||||||
return await self.availability.get_for_driver(tenant_id, driver_id)
|
|
||||||
|
|
||||||
async def get_shift(self, tenant_id: UUID, shift_id: UUID):
|
|
||||||
return await self.shifts.get(tenant_id, shift_id)
|
|
||||||
|
|
||||||
async def list_shifts(
|
|
||||||
self, tenant_id: UUID, *, offset: int, limit: int, spec: ShiftListSpec | None
|
|
||||||
):
|
|
||||||
return await self.shifts.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
|
||||||
|
|
||||||
async def list_shift_assignments(self, tenant_id: UUID, shift_id: UUID):
|
|
||||||
return await self.shifts.list_assignments(tenant_id, shift_id)
|
|
||||||
|
|
||||||
async def get_zone(self, tenant_id: UUID, zone_id: UUID):
|
|
||||||
return await self.zones.get(tenant_id, zone_id)
|
|
||||||
|
|
||||||
async def list_zones(
|
|
||||||
self, tenant_id: UUID, *, offset: int, limit: int, spec: WorkingZoneListSpec | None
|
|
||||||
):
|
|
||||||
return await self.zones.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
"""Dispatch query handlers — Phase 10.5."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.services.dispatch import DispatchJobService, JobAssignmentService
|
|
||||||
from app.specifications.dispatch import DispatchJobListSpec, JobAssignmentListSpec
|
|
||||||
|
|
||||||
|
|
||||||
class DispatchQueries:
|
|
||||||
def __init__(self, session: AsyncSession) -> None:
|
|
||||||
self.jobs = DispatchJobService(session)
|
|
||||||
self.assignments = JobAssignmentService(session)
|
|
||||||
|
|
||||||
async def get_job(self, tenant_id: UUID, job_id: UUID):
|
|
||||||
return await self.jobs.get(tenant_id, job_id)
|
|
||||||
|
|
||||||
async def list_jobs(
|
|
||||||
self, tenant_id: UUID, *, offset: int, limit: int, spec: DispatchJobListSpec | None
|
|
||||||
):
|
|
||||||
return await self.jobs.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
|
||||||
|
|
||||||
async def get_assignment(self, tenant_id: UUID, assignment_id: UUID):
|
|
||||||
return await self.assignments.get(tenant_id, assignment_id)
|
|
||||||
|
|
||||||
async def list_assignments(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
*,
|
|
||||||
offset: int,
|
|
||||||
limit: int,
|
|
||||||
spec: JobAssignmentListSpec | None,
|
|
||||||
):
|
|
||||||
return await self.assignments.list(
|
|
||||||
tenant_id, offset=offset, limit=limit, spec=spec
|
|
||||||
)
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
"""Fleet query handlers — Phase 10.2."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.services.fleet import FleetService, VehicleService, VehicleTypeService
|
|
||||||
from app.specifications.fleet import FleetListSpec, VehicleListSpec, VehicleTypeListSpec
|
|
||||||
|
|
||||||
|
|
||||||
class FleetQueries:
|
|
||||||
def __init__(self, session: AsyncSession) -> None:
|
|
||||||
self.fleets = FleetService(session)
|
|
||||||
self.types = VehicleTypeService(session)
|
|
||||||
self.vehicles = VehicleService(session)
|
|
||||||
|
|
||||||
async def get_fleet(self, tenant_id: UUID, fleet_id: UUID):
|
|
||||||
return await self.fleets.get(tenant_id, fleet_id)
|
|
||||||
|
|
||||||
async def list_fleets(
|
|
||||||
self, tenant_id: UUID, *, offset: int, limit: int, spec: FleetListSpec | None
|
|
||||||
):
|
|
||||||
return await self.fleets.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
|
||||||
|
|
||||||
async def get_vehicle_type(self, tenant_id: UUID, type_id: UUID):
|
|
||||||
return await self.types.get(tenant_id, type_id)
|
|
||||||
|
|
||||||
async def list_vehicle_types(
|
|
||||||
self, tenant_id: UUID, *, offset: int, limit: int, spec: VehicleTypeListSpec | None
|
|
||||||
):
|
|
||||||
return await self.types.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
|
||||||
|
|
||||||
async def get_vehicle(self, tenant_id: UUID, vehicle_id: UUID):
|
|
||||||
return await self.vehicles.get(tenant_id, vehicle_id)
|
|
||||||
|
|
||||||
async def list_vehicles(
|
|
||||||
self, tenant_id: UUID, *, offset: int, limit: int, spec: VehicleListSpec | None
|
|
||||||
):
|
|
||||||
return await self.vehicles.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
|
||||||
|
|
||||||
async def list_assignments(self, tenant_id: UUID, vehicle_id: UUID):
|
|
||||||
return await self.vehicles.list_assignments(tenant_id, vehicle_id)
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
"""Pricing query handlers — Phase 10.4."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.services.pricing import BundleService, CapabilityService, PricingRuleService
|
|
||||||
from app.specifications.pricing import (
|
|
||||||
BundleListSpec,
|
|
||||||
CapabilityListSpec,
|
|
||||||
PricingRuleListSpec,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PricingQueries:
|
|
||||||
def __init__(self, session: AsyncSession) -> None:
|
|
||||||
self.rules = PricingRuleService(session)
|
|
||||||
self.capabilities = CapabilityService(session)
|
|
||||||
self.bundles = BundleService(session)
|
|
||||||
|
|
||||||
async def get_rule(self, tenant_id: UUID, rule_id: UUID):
|
|
||||||
return await self.rules.get(tenant_id, rule_id)
|
|
||||||
|
|
||||||
async def list_rules(
|
|
||||||
self, tenant_id: UUID, *, offset: int, limit: int, spec: PricingRuleListSpec | None
|
|
||||||
):
|
|
||||||
return await self.rules.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
|
||||||
|
|
||||||
async def get_capability(self, tenant_id: UUID, cap_id: UUID):
|
|
||||||
return await self.capabilities.get(tenant_id, cap_id)
|
|
||||||
|
|
||||||
async def list_capabilities(
|
|
||||||
self, tenant_id: UUID, *, offset: int, limit: int, spec: CapabilityListSpec | None
|
|
||||||
):
|
|
||||||
return await self.capabilities.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
|
||||||
|
|
||||||
async def get_bundle(self, tenant_id: UUID, bundle_id: UUID):
|
|
||||||
return await self.bundles.get(tenant_id, bundle_id)
|
|
||||||
|
|
||||||
async def list_bundles(
|
|
||||||
self, tenant_id: UUID, *, offset: int, limit: int, spec: BundleListSpec | None
|
|
||||||
):
|
|
||||||
return await self.bundles.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
|
||||||
@ -1,41 +0,0 @@
|
|||||||
"""Routing query handlers — Phase 10.6."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.services.routing import OptimizationRunService, RoutePlanService
|
|
||||||
from app.specifications.routing import OptimizationRunListSpec, RoutePlanListSpec
|
|
||||||
|
|
||||||
|
|
||||||
class RoutingQueries:
|
|
||||||
def __init__(self, session: AsyncSession) -> None:
|
|
||||||
self.routes = RoutePlanService(session)
|
|
||||||
self.optimization = OptimizationRunService(session)
|
|
||||||
|
|
||||||
async def get_route(self, tenant_id: UUID, plan_id: UUID):
|
|
||||||
return await self.routes.get(tenant_id, plan_id)
|
|
||||||
|
|
||||||
async def list_routes(
|
|
||||||
self, tenant_id: UUID, *, offset: int, limit: int, spec: RoutePlanListSpec | None
|
|
||||||
):
|
|
||||||
return await self.routes.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
|
||||||
|
|
||||||
async def list_stops(self, tenant_id: UUID, plan_id: UUID):
|
|
||||||
return await self.routes.list_stops(tenant_id, plan_id)
|
|
||||||
|
|
||||||
async def get_optimization_run(self, tenant_id: UUID, run_id: UUID):
|
|
||||||
return await self.optimization.get(tenant_id, run_id)
|
|
||||||
|
|
||||||
async def list_optimization_runs(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
*,
|
|
||||||
offset: int,
|
|
||||||
limit: int,
|
|
||||||
spec: OptimizationRunListSpec | None,
|
|
||||||
):
|
|
||||||
return await self.optimization.list(
|
|
||||||
tenant_id, offset=offset, limit=limit, spec=spec
|
|
||||||
)
|
|
||||||
@ -1,32 +0,0 @@
|
|||||||
"""Settlement query handlers — Phase 10.8."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.services.settlement import SettlementService
|
|
||||||
from app.specifications.settlement import SettlementIntentListSpec
|
|
||||||
|
|
||||||
|
|
||||||
class SettlementQueries:
|
|
||||||
def __init__(self, session: AsyncSession) -> None:
|
|
||||||
self.service = SettlementService(session)
|
|
||||||
|
|
||||||
async def get(self, tenant_id: UUID, intent_id: UUID):
|
|
||||||
return await self.service.get(tenant_id, intent_id)
|
|
||||||
|
|
||||||
async def list(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
*,
|
|
||||||
offset: int,
|
|
||||||
limit: int,
|
|
||||||
spec: SettlementIntentListSpec | None,
|
|
||||||
):
|
|
||||||
return await self.service.list(
|
|
||||||
tenant_id, offset=offset, limit=limit, spec=spec
|
|
||||||
)
|
|
||||||
|
|
||||||
async def lines(self, tenant_id: UUID, intent_id: UUID):
|
|
||||||
return await self.service.list_lines(tenant_id, intent_id)
|
|
||||||
@ -1,68 +0,0 @@
|
|||||||
"""Tracking query handlers — Phase 10.7."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.services.tracking import (
|
|
||||||
CustomerTrackingTokenService,
|
|
||||||
ProofOfDeliveryService,
|
|
||||||
TrackingSessionService,
|
|
||||||
)
|
|
||||||
from app.specifications.tracking import (
|
|
||||||
CustomerTrackingTokenListSpec,
|
|
||||||
ProofOfDeliveryListSpec,
|
|
||||||
TrackingSessionListSpec,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TrackingQueries:
|
|
||||||
def __init__(self, session: AsyncSession) -> None:
|
|
||||||
self.sessions = TrackingSessionService(session)
|
|
||||||
self.pod = ProofOfDeliveryService(session)
|
|
||||||
self.tokens = CustomerTrackingTokenService(session)
|
|
||||||
|
|
||||||
async def get_session(self, tenant_id: UUID, session_id: UUID):
|
|
||||||
return await self.sessions.get(tenant_id, session_id)
|
|
||||||
|
|
||||||
async def list_sessions(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
*,
|
|
||||||
offset: int,
|
|
||||||
limit: int,
|
|
||||||
spec: TrackingSessionListSpec | None,
|
|
||||||
):
|
|
||||||
return await self.sessions.list(
|
|
||||||
tenant_id, offset=offset, limit=limit, spec=spec
|
|
||||||
)
|
|
||||||
|
|
||||||
async def list_points(self, tenant_id: UUID, session_id: UUID, *, limit: int = 500):
|
|
||||||
return await self.sessions.list_points(tenant_id, session_id, limit=limit)
|
|
||||||
|
|
||||||
async def get_pod(self, tenant_id: UUID, pod_id: UUID):
|
|
||||||
return await self.pod.get(tenant_id, pod_id)
|
|
||||||
|
|
||||||
async def list_pods(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
*,
|
|
||||||
offset: int,
|
|
||||||
limit: int,
|
|
||||||
spec: ProofOfDeliveryListSpec | None,
|
|
||||||
):
|
|
||||||
return await self.pod.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
|
||||||
|
|
||||||
async def get_token(self, tenant_id: UUID, token_id: UUID):
|
|
||||||
return await self.tokens.get(tenant_id, token_id)
|
|
||||||
|
|
||||||
async def list_tokens(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
*,
|
|
||||||
offset: int,
|
|
||||||
limit: int,
|
|
||||||
spec: CustomerTrackingTokenListSpec | None,
|
|
||||||
):
|
|
||||||
return await self.tokens.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
|
||||||
@ -1,145 +0,0 @@
|
|||||||
"""Availability repositories — Phase 10.3."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Sequence
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
|
||||||
|
|
||||||
from app.models.availability import (
|
|
||||||
DriverAvailability,
|
|
||||||
Shift,
|
|
||||||
ShiftAssignment,
|
|
||||||
WorkingZone,
|
|
||||||
)
|
|
||||||
from app.repositories.base import TenantBaseRepository
|
|
||||||
from app.specifications.availability import ShiftListSpec, WorkingZoneListSpec
|
|
||||||
|
|
||||||
|
|
||||||
class DriverAvailabilityRepository(TenantBaseRepository[DriverAvailability]):
|
|
||||||
model = DriverAvailability
|
|
||||||
|
|
||||||
async def get_for_driver(
|
|
||||||
self, tenant_id: UUID, driver_id: UUID
|
|
||||||
) -> DriverAvailability | None:
|
|
||||||
stmt = (
|
|
||||||
select(self.model)
|
|
||||||
.where(
|
|
||||||
self.model.tenant_id == tenant_id,
|
|
||||||
self.model.driver_id == driver_id,
|
|
||||||
self.model.is_deleted.is_(False),
|
|
||||||
)
|
|
||||||
.order_by(self.model.updated_at.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
result = await self.session.execute(stmt)
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
|
|
||||||
class ShiftRepository(TenantBaseRepository[Shift]):
|
|
||||||
model = Shift
|
|
||||||
|
|
||||||
async def get_by_code(self, tenant_id: UUID, code: str) -> Shift | None:
|
|
||||||
stmt = select(self.model).where(
|
|
||||||
self.model.tenant_id == tenant_id,
|
|
||||||
self.model.code == code,
|
|
||||||
self.model.is_deleted.is_(False),
|
|
||||||
)
|
|
||||||
result = await self.session.execute(stmt)
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
async def list_filtered(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
*,
|
|
||||||
offset: int = 0,
|
|
||||||
limit: int = 20,
|
|
||||||
spec: ShiftListSpec | None = None,
|
|
||||||
) -> Sequence[Shift]:
|
|
||||||
stmt = select(self.model).where(
|
|
||||||
self.model.tenant_id == tenant_id,
|
|
||||||
self.model.is_deleted.is_(False),
|
|
||||||
)
|
|
||||||
if spec is not None:
|
|
||||||
stmt = spec.apply(stmt)
|
|
||||||
else:
|
|
||||||
stmt = stmt.order_by(self.model.starts_at.desc())
|
|
||||||
stmt = stmt.offset(offset).limit(limit)
|
|
||||||
result = await self.session.execute(stmt)
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
async def count_filtered(
|
|
||||||
self, tenant_id: UUID, *, spec: ShiftListSpec | None = None
|
|
||||||
) -> int:
|
|
||||||
stmt = select(func.count()).select_from(self.model).where(
|
|
||||||
self.model.tenant_id == tenant_id,
|
|
||||||
self.model.is_deleted.is_(False),
|
|
||||||
)
|
|
||||||
if spec is not None:
|
|
||||||
clauses = spec.filter_clauses()
|
|
||||||
if clauses:
|
|
||||||
stmt = stmt.where(*clauses)
|
|
||||||
result = await self.session.execute(stmt)
|
|
||||||
return int(result.scalar_one())
|
|
||||||
|
|
||||||
|
|
||||||
class ShiftAssignmentRepository(TenantBaseRepository[ShiftAssignment]):
|
|
||||||
model = ShiftAssignment
|
|
||||||
|
|
||||||
async def list_for_shift(
|
|
||||||
self, tenant_id: UUID, shift_id: UUID
|
|
||||||
) -> Sequence[ShiftAssignment]:
|
|
||||||
stmt = select(self.model).where(
|
|
||||||
self.model.tenant_id == tenant_id,
|
|
||||||
self.model.shift_id == shift_id,
|
|
||||||
self.model.is_deleted.is_(False),
|
|
||||||
)
|
|
||||||
result = await self.session.execute(stmt)
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
|
|
||||||
class WorkingZoneRepository(TenantBaseRepository[WorkingZone]):
|
|
||||||
model = WorkingZone
|
|
||||||
|
|
||||||
async def get_by_code(self, tenant_id: UUID, code: str) -> WorkingZone | None:
|
|
||||||
stmt = select(self.model).where(
|
|
||||||
self.model.tenant_id == tenant_id,
|
|
||||||
self.model.code == code,
|
|
||||||
self.model.is_deleted.is_(False),
|
|
||||||
)
|
|
||||||
result = await self.session.execute(stmt)
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
async def list_filtered(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
*,
|
|
||||||
offset: int = 0,
|
|
||||||
limit: int = 20,
|
|
||||||
spec: WorkingZoneListSpec | None = None,
|
|
||||||
) -> Sequence[WorkingZone]:
|
|
||||||
stmt = select(self.model).where(
|
|
||||||
self.model.tenant_id == tenant_id,
|
|
||||||
self.model.is_deleted.is_(False),
|
|
||||||
)
|
|
||||||
if spec is not None:
|
|
||||||
stmt = spec.apply(stmt)
|
|
||||||
else:
|
|
||||||
stmt = stmt.order_by(self.model.created_at.desc())
|
|
||||||
stmt = stmt.offset(offset).limit(limit)
|
|
||||||
result = await self.session.execute(stmt)
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
async def count_filtered(
|
|
||||||
self, tenant_id: UUID, *, spec: WorkingZoneListSpec | None = None
|
|
||||||
) -> int:
|
|
||||||
stmt = select(func.count()).select_from(self.model).where(
|
|
||||||
self.model.tenant_id == tenant_id,
|
|
||||||
self.model.is_deleted.is_(False),
|
|
||||||
)
|
|
||||||
if spec is not None:
|
|
||||||
clauses = spec.filter_clauses()
|
|
||||||
if clauses:
|
|
||||||
stmt = stmt.where(*clauses)
|
|
||||||
result = await self.session.execute(stmt)
|
|
||||||
return int(result.scalar_one())
|
|
||||||
@ -1,107 +0,0 @@
|
|||||||
"""Dispatch repositories — Phase 10.5."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Sequence
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
|
||||||
|
|
||||||
from app.models.dispatch import DispatchJob, JobAssignment
|
|
||||||
from app.repositories.base import TenantBaseRepository
|
|
||||||
from app.specifications.dispatch import DispatchJobListSpec, JobAssignmentListSpec
|
|
||||||
|
|
||||||
|
|
||||||
class DispatchJobRepository(TenantBaseRepository[DispatchJob]):
|
|
||||||
model = DispatchJob
|
|
||||||
|
|
||||||
async def get_by_code(self, tenant_id: UUID, code: str) -> DispatchJob | None:
|
|
||||||
stmt = select(self.model).where(
|
|
||||||
self.model.tenant_id == tenant_id,
|
|
||||||
self.model.code == code,
|
|
||||||
self.model.is_deleted.is_(False),
|
|
||||||
)
|
|
||||||
result = await self.session.execute(stmt)
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
async def list_filtered(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
*,
|
|
||||||
offset: int = 0,
|
|
||||||
limit: int = 20,
|
|
||||||
spec: DispatchJobListSpec | None = None,
|
|
||||||
) -> Sequence[DispatchJob]:
|
|
||||||
stmt = select(self.model).where(
|
|
||||||
self.model.tenant_id == tenant_id,
|
|
||||||
self.model.is_deleted.is_(False),
|
|
||||||
)
|
|
||||||
if spec is not None:
|
|
||||||
stmt = spec.apply(stmt)
|
|
||||||
else:
|
|
||||||
stmt = stmt.order_by(self.model.created_at.desc())
|
|
||||||
stmt = stmt.offset(offset).limit(limit)
|
|
||||||
result = await self.session.execute(stmt)
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
async def count_filtered(
|
|
||||||
self, tenant_id: UUID, *, spec: DispatchJobListSpec | None = None
|
|
||||||
) -> int:
|
|
||||||
stmt = select(func.count()).select_from(self.model).where(
|
|
||||||
self.model.tenant_id == tenant_id,
|
|
||||||
self.model.is_deleted.is_(False),
|
|
||||||
)
|
|
||||||
if spec is not None:
|
|
||||||
clauses = spec.filter_clauses()
|
|
||||||
if clauses:
|
|
||||||
stmt = stmt.where(*clauses)
|
|
||||||
result = await self.session.execute(stmt)
|
|
||||||
return int(result.scalar_one())
|
|
||||||
|
|
||||||
|
|
||||||
class JobAssignmentRepository(TenantBaseRepository[JobAssignment]):
|
|
||||||
model = JobAssignment
|
|
||||||
|
|
||||||
async def list_filtered(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
*,
|
|
||||||
offset: int = 0,
|
|
||||||
limit: int = 20,
|
|
||||||
spec: JobAssignmentListSpec | None = None,
|
|
||||||
) -> Sequence[JobAssignment]:
|
|
||||||
stmt = select(self.model).where(
|
|
||||||
self.model.tenant_id == tenant_id,
|
|
||||||
self.model.is_deleted.is_(False),
|
|
||||||
)
|
|
||||||
if spec is not None:
|
|
||||||
stmt = spec.apply(stmt)
|
|
||||||
else:
|
|
||||||
stmt = stmt.order_by(self.model.created_at.desc())
|
|
||||||
stmt = stmt.offset(offset).limit(limit)
|
|
||||||
result = await self.session.execute(stmt)
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
async def count_filtered(
|
|
||||||
self, tenant_id: UUID, *, spec: JobAssignmentListSpec | None = None
|
|
||||||
) -> int:
|
|
||||||
stmt = select(func.count()).select_from(self.model).where(
|
|
||||||
self.model.tenant_id == tenant_id,
|
|
||||||
self.model.is_deleted.is_(False),
|
|
||||||
)
|
|
||||||
if spec is not None:
|
|
||||||
clauses = spec.filter_clauses()
|
|
||||||
if clauses:
|
|
||||||
stmt = stmt.where(*clauses)
|
|
||||||
result = await self.session.execute(stmt)
|
|
||||||
return int(result.scalar_one())
|
|
||||||
|
|
||||||
async def list_for_job(
|
|
||||||
self, tenant_id: UUID, dispatch_job_id: UUID
|
|
||||||
) -> Sequence[JobAssignment]:
|
|
||||||
stmt = select(self.model).where(
|
|
||||||
self.model.tenant_id == tenant_id,
|
|
||||||
self.model.dispatch_job_id == dispatch_job_id,
|
|
||||||
self.model.is_deleted.is_(False),
|
|
||||||
)
|
|
||||||
result = await self.session.execute(stmt)
|
|
||||||
return result.scalars().all()
|
|
||||||
@ -1,184 +0,0 @@
|
|||||||
"""Fleet repositories — Phase 10.2."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Sequence
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
|
||||||
|
|
||||||
from app.models.fleet import Fleet, Vehicle, VehicleAssignment, VehicleType
|
|
||||||
from app.models.types import VehicleAssignmentStatus
|
|
||||||
from app.repositories.base import TenantBaseRepository
|
|
||||||
from app.specifications.fleet import FleetListSpec, VehicleListSpec, VehicleTypeListSpec
|
|
||||||
|
|
||||||
|
|
||||||
class FleetRepository(TenantBaseRepository[Fleet]):
|
|
||||||
model = Fleet
|
|
||||||
|
|
||||||
async def get_by_code(self, tenant_id: UUID, code: str) -> Fleet | None:
|
|
||||||
stmt = select(self.model).where(
|
|
||||||
self.model.tenant_id == tenant_id,
|
|
||||||
self.model.code == code,
|
|
||||||
self.model.is_deleted.is_(False),
|
|
||||||
)
|
|
||||||
result = await self.session.execute(stmt)
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
async def list_filtered(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
*,
|
|
||||||
offset: int = 0,
|
|
||||||
limit: int = 20,
|
|
||||||
spec: FleetListSpec | None = None,
|
|
||||||
) -> Sequence[Fleet]:
|
|
||||||
stmt = select(self.model).where(
|
|
||||||
self.model.tenant_id == tenant_id,
|
|
||||||
self.model.is_deleted.is_(False),
|
|
||||||
)
|
|
||||||
if spec is not None:
|
|
||||||
stmt = spec.apply(stmt)
|
|
||||||
else:
|
|
||||||
stmt = stmt.order_by(self.model.created_at.desc())
|
|
||||||
stmt = stmt.offset(offset).limit(limit)
|
|
||||||
result = await self.session.execute(stmt)
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
async def count_filtered(
|
|
||||||
self, tenant_id: UUID, *, spec: FleetListSpec | None = None
|
|
||||||
) -> int:
|
|
||||||
stmt = select(func.count()).select_from(self.model).where(
|
|
||||||
self.model.tenant_id == tenant_id,
|
|
||||||
self.model.is_deleted.is_(False),
|
|
||||||
)
|
|
||||||
if spec is not None:
|
|
||||||
clauses = spec.filter_clauses()
|
|
||||||
if clauses:
|
|
||||||
stmt = stmt.where(*clauses)
|
|
||||||
result = await self.session.execute(stmt)
|
|
||||||
return int(result.scalar_one())
|
|
||||||
|
|
||||||
|
|
||||||
class VehicleTypeRepository(TenantBaseRepository[VehicleType]):
|
|
||||||
model = VehicleType
|
|
||||||
|
|
||||||
async def get_by_code(self, tenant_id: UUID, code: str) -> VehicleType | None:
|
|
||||||
stmt = select(self.model).where(
|
|
||||||
self.model.tenant_id == tenant_id,
|
|
||||||
self.model.code == code,
|
|
||||||
self.model.is_deleted.is_(False),
|
|
||||||
)
|
|
||||||
result = await self.session.execute(stmt)
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
async def list_filtered(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
*,
|
|
||||||
offset: int = 0,
|
|
||||||
limit: int = 20,
|
|
||||||
spec: VehicleTypeListSpec | None = None,
|
|
||||||
) -> Sequence[VehicleType]:
|
|
||||||
stmt = select(self.model).where(
|
|
||||||
self.model.tenant_id == tenant_id,
|
|
||||||
self.model.is_deleted.is_(False),
|
|
||||||
)
|
|
||||||
if spec is not None:
|
|
||||||
stmt = spec.apply(stmt)
|
|
||||||
else:
|
|
||||||
stmt = stmt.order_by(self.model.created_at.desc())
|
|
||||||
stmt = stmt.offset(offset).limit(limit)
|
|
||||||
result = await self.session.execute(stmt)
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
async def count_filtered(
|
|
||||||
self, tenant_id: UUID, *, spec: VehicleTypeListSpec | None = None
|
|
||||||
) -> int:
|
|
||||||
stmt = select(func.count()).select_from(self.model).where(
|
|
||||||
self.model.tenant_id == tenant_id,
|
|
||||||
self.model.is_deleted.is_(False),
|
|
||||||
)
|
|
||||||
if spec is not None:
|
|
||||||
clauses = spec.filter_clauses()
|
|
||||||
if clauses:
|
|
||||||
stmt = stmt.where(*clauses)
|
|
||||||
result = await self.session.execute(stmt)
|
|
||||||
return int(result.scalar_one())
|
|
||||||
|
|
||||||
|
|
||||||
class VehicleRepository(TenantBaseRepository[Vehicle]):
|
|
||||||
model = Vehicle
|
|
||||||
|
|
||||||
async def get_by_code(self, tenant_id: UUID, code: str) -> Vehicle | None:
|
|
||||||
stmt = select(self.model).where(
|
|
||||||
self.model.tenant_id == tenant_id,
|
|
||||||
self.model.code == code,
|
|
||||||
self.model.is_deleted.is_(False),
|
|
||||||
)
|
|
||||||
result = await self.session.execute(stmt)
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
|
|
||||||
async def list_filtered(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
*,
|
|
||||||
offset: int = 0,
|
|
||||||
limit: int = 20,
|
|
||||||
spec: VehicleListSpec | None = None,
|
|
||||||
) -> Sequence[Vehicle]:
|
|
||||||
stmt = select(self.model).where(
|
|
||||||
self.model.tenant_id == tenant_id,
|
|
||||||
self.model.is_deleted.is_(False),
|
|
||||||
)
|
|
||||||
if spec is not None:
|
|
||||||
stmt = spec.apply(stmt)
|
|
||||||
else:
|
|
||||||
stmt = stmt.order_by(self.model.created_at.desc())
|
|
||||||
stmt = stmt.offset(offset).limit(limit)
|
|
||||||
result = await self.session.execute(stmt)
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
async def count_filtered(
|
|
||||||
self, tenant_id: UUID, *, spec: VehicleListSpec | None = None
|
|
||||||
) -> int:
|
|
||||||
stmt = select(func.count()).select_from(self.model).where(
|
|
||||||
self.model.tenant_id == tenant_id,
|
|
||||||
self.model.is_deleted.is_(False),
|
|
||||||
)
|
|
||||||
if spec is not None:
|
|
||||||
clauses = spec.filter_clauses()
|
|
||||||
if clauses:
|
|
||||||
stmt = stmt.where(*clauses)
|
|
||||||
result = await self.session.execute(stmt)
|
|
||||||
return int(result.scalar_one())
|
|
||||||
|
|
||||||
|
|
||||||
class VehicleAssignmentRepository(TenantBaseRepository[VehicleAssignment]):
|
|
||||||
model = VehicleAssignment
|
|
||||||
|
|
||||||
async def list_for_vehicle(
|
|
||||||
self, tenant_id: UUID, vehicle_id: UUID
|
|
||||||
) -> Sequence[VehicleAssignment]:
|
|
||||||
stmt = (
|
|
||||||
select(self.model)
|
|
||||||
.where(
|
|
||||||
self.model.tenant_id == tenant_id,
|
|
||||||
self.model.vehicle_id == vehicle_id,
|
|
||||||
self.model.is_deleted.is_(False),
|
|
||||||
)
|
|
||||||
.order_by(self.model.created_at.desc())
|
|
||||||
)
|
|
||||||
result = await self.session.execute(stmt)
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
async def get_active_for_vehicle(
|
|
||||||
self, tenant_id: UUID, vehicle_id: UUID
|
|
||||||
) -> VehicleAssignment | None:
|
|
||||||
stmt = select(self.model).where(
|
|
||||||
self.model.tenant_id == tenant_id,
|
|
||||||
self.model.vehicle_id == vehicle_id,
|
|
||||||
self.model.status == VehicleAssignmentStatus.ACTIVE,
|
|
||||||
self.model.is_deleted.is_(False),
|
|
||||||
)
|
|
||||||
result = await self.session.execute(stmt)
|
|
||||||
return result.scalar_one_or_none()
|
|
||||||
@ -1,158 +0,0 @@
|
|||||||
"""Pricing repositories — Phase 10.4."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Sequence
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
|
||||||
|
|
||||||
from app.models.pricing import CapabilityBundle, CapabilityDefinition, PricingRule
|
|
||||||
from app.repositories.base import TenantBaseRepository
|
|
||||||
from app.specifications.pricing import (
|
|
||||||
BundleListSpec,
|
|
||||||
CapabilityListSpec,
|
|
||||||
PricingRuleListSpec,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class PricingRuleRepository(TenantBaseRepository[PricingRule]):
|
|
||||||
model = PricingRule
|
|
||||||
|
|
||||||
async def get_by_code(self, tenant_id: UUID, code: str) -> PricingRule | 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: PricingRuleListSpec | None = None,
|
|
||||||
) -> Sequence[PricingRule]:
|
|
||||||
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: PricingRuleListSpec | 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 CapabilityDefinitionRepository(TenantBaseRepository[CapabilityDefinition]):
|
|
||||||
model = CapabilityDefinition
|
|
||||||
|
|
||||||
async def get_by_code(
|
|
||||||
self, tenant_id: UUID, code: str
|
|
||||||
) -> CapabilityDefinition | 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: CapabilityListSpec | None = None,
|
|
||||||
) -> Sequence[CapabilityDefinition]:
|
|
||||||
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: CapabilityListSpec | 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 CapabilityBundleRepository(TenantBaseRepository[CapabilityBundle]):
|
|
||||||
model = CapabilityBundle
|
|
||||||
|
|
||||||
async def get_by_code(self, tenant_id: UUID, code: str) -> CapabilityBundle | 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: BundleListSpec | None = None,
|
|
||||||
) -> Sequence[CapabilityBundle]:
|
|
||||||
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: BundleListSpec | 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())
|
|
||||||
@ -1,115 +0,0 @@
|
|||||||
"""Routing repositories — Phase 10.6."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Sequence
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
|
||||||
|
|
||||||
from app.models.routing import OptimizationRun, RoutePlan, RouteStop
|
|
||||||
from app.repositories.base import TenantBaseRepository
|
|
||||||
from app.specifications.routing import OptimizationRunListSpec, RoutePlanListSpec
|
|
||||||
|
|
||||||
|
|
||||||
class RoutePlanRepository(TenantBaseRepository[RoutePlan]):
|
|
||||||
model = RoutePlan
|
|
||||||
|
|
||||||
async def get_by_code(self, tenant_id: UUID, code: str) -> RoutePlan | 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: RoutePlanListSpec | None = None,
|
|
||||||
) -> Sequence[RoutePlan]:
|
|
||||||
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: RoutePlanListSpec | 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 RouteStopRepository(TenantBaseRepository[RouteStop]):
|
|
||||||
model = RouteStop
|
|
||||||
|
|
||||||
async def list_for_plan(
|
|
||||||
self, tenant_id: UUID, route_plan_id: UUID
|
|
||||||
) -> Sequence[RouteStop]:
|
|
||||||
stmt = (
|
|
||||||
select(self.model)
|
|
||||||
.where(
|
|
||||||
self.model.tenant_id == tenant_id,
|
|
||||||
self.model.route_plan_id == route_plan_id,
|
|
||||||
self.model.is_deleted.is_(False),
|
|
||||||
)
|
|
||||||
.order_by(self.model.sequence.asc())
|
|
||||||
)
|
|
||||||
result = await self.session.execute(stmt)
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
|
|
||||||
class OptimizationRunRepository(TenantBaseRepository[OptimizationRun]):
|
|
||||||
model = OptimizationRun
|
|
||||||
|
|
||||||
async def list_filtered(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
*,
|
|
||||||
offset: int = 0,
|
|
||||||
limit: int = 20,
|
|
||||||
spec: OptimizationRunListSpec | None = None,
|
|
||||||
) -> Sequence[OptimizationRun]:
|
|
||||||
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: OptimizationRunListSpec | 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())
|
|
||||||
@ -1,75 +0,0 @@
|
|||||||
"""Settlement repositories — Phase 10.8."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Sequence
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
|
||||||
|
|
||||||
from app.models.settlement import SettlementIntent, SettlementLine
|
|
||||||
from app.repositories.base import TenantBaseRepository
|
|
||||||
from app.specifications.settlement import SettlementIntentListSpec
|
|
||||||
|
|
||||||
|
|
||||||
class SettlementIntentRepository(TenantBaseRepository[SettlementIntent]):
|
|
||||||
model = SettlementIntent
|
|
||||||
|
|
||||||
async def get_by_code(
|
|
||||||
self, tenant_id: UUID, code: str
|
|
||||||
) -> SettlementIntent | 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: SettlementIntentListSpec | None = None,
|
|
||||||
) -> Sequence[SettlementIntent]:
|
|
||||||
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: SettlementIntentListSpec | 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 SettlementLineRepository(TenantBaseRepository[SettlementLine]):
|
|
||||||
model = SettlementLine
|
|
||||||
|
|
||||||
async def list_for_intent(
|
|
||||||
self, tenant_id: UUID, settlement_intent_id: UUID
|
|
||||||
) -> Sequence[SettlementLine]:
|
|
||||||
stmt = select(self.model).where(
|
|
||||||
self.model.tenant_id == tenant_id,
|
|
||||||
self.model.settlement_intent_id == settlement_intent_id,
|
|
||||||
self.model.is_deleted.is_(False),
|
|
||||||
)
|
|
||||||
result = await self.session.execute(stmt)
|
|
||||||
return result.scalars().all()
|
|
||||||
@ -1,164 +0,0 @@
|
|||||||
"""Tracking repositories — Phase 10.7."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Sequence
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy import func, select
|
|
||||||
|
|
||||||
from app.models.tracking import (
|
|
||||||
CustomerTrackingToken,
|
|
||||||
ProofOfDelivery,
|
|
||||||
TrackingPoint,
|
|
||||||
TrackingSession,
|
|
||||||
)
|
|
||||||
from app.repositories.base import TenantBaseRepository
|
|
||||||
from app.specifications.tracking import (
|
|
||||||
CustomerTrackingTokenListSpec,
|
|
||||||
ProofOfDeliveryListSpec,
|
|
||||||
TrackingSessionListSpec,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TrackingSessionRepository(TenantBaseRepository[TrackingSession]):
|
|
||||||
model = TrackingSession
|
|
||||||
|
|
||||||
async def list_filtered(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
*,
|
|
||||||
offset: int = 0,
|
|
||||||
limit: int = 20,
|
|
||||||
spec: TrackingSessionListSpec | None = None,
|
|
||||||
) -> Sequence[TrackingSession]:
|
|
||||||
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: TrackingSessionListSpec | 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 TrackingPointRepository(TenantBaseRepository[TrackingPoint]):
|
|
||||||
model = TrackingPoint
|
|
||||||
|
|
||||||
async def list_for_session(
|
|
||||||
self, tenant_id: UUID, tracking_session_id: UUID, *, limit: int = 500
|
|
||||||
) -> Sequence[TrackingPoint]:
|
|
||||||
stmt = (
|
|
||||||
select(self.model)
|
|
||||||
.where(
|
|
||||||
self.model.tenant_id == tenant_id,
|
|
||||||
self.model.tracking_session_id == tracking_session_id,
|
|
||||||
)
|
|
||||||
.order_by(self.model.recorded_at.asc())
|
|
||||||
.limit(limit)
|
|
||||||
)
|
|
||||||
result = await self.session.execute(stmt)
|
|
||||||
return result.scalars().all()
|
|
||||||
|
|
||||||
|
|
||||||
class ProofOfDeliveryRepository(TenantBaseRepository[ProofOfDelivery]):
|
|
||||||
model = ProofOfDelivery
|
|
||||||
|
|
||||||
async def list_filtered(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
*,
|
|
||||||
offset: int = 0,
|
|
||||||
limit: int = 20,
|
|
||||||
spec: ProofOfDeliveryListSpec | None = None,
|
|
||||||
) -> Sequence[ProofOfDelivery]:
|
|
||||||
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: ProofOfDeliveryListSpec | 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 CustomerTrackingTokenRepository(TenantBaseRepository[CustomerTrackingToken]):
|
|
||||||
model = CustomerTrackingToken
|
|
||||||
|
|
||||||
async def get_by_token(
|
|
||||||
self, tenant_id: UUID, token: str
|
|
||||||
) -> CustomerTrackingToken | None:
|
|
||||||
stmt = select(self.model).where(
|
|
||||||
self.model.tenant_id == tenant_id,
|
|
||||||
self.model.token == token,
|
|
||||||
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: CustomerTrackingTokenListSpec | None = None,
|
|
||||||
) -> Sequence[CustomerTrackingToken]:
|
|
||||||
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: CustomerTrackingTokenListSpec | 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())
|
|
||||||
@ -1,172 +0,0 @@
|
|||||||
"""Availability, shifts & working zones DTOs — Phase 10.3."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
from app.models.types import (
|
|
||||||
DriverAvailabilityStatus,
|
|
||||||
ShiftAssignmentStatus,
|
|
||||||
ShiftStatus,
|
|
||||||
WorkingZoneStatus,
|
|
||||||
)
|
|
||||||
from app.schemas.common import ORMBase
|
|
||||||
|
|
||||||
_FORBID = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
|
|
||||||
class DriverAvailabilityUpsert(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
status: DriverAvailabilityStatus
|
|
||||||
effective_from: datetime | None = None
|
|
||||||
effective_until: datetime | None = None
|
|
||||||
notes: str | None = None
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
version: int | None = Field(default=None, ge=1)
|
|
||||||
|
|
||||||
|
|
||||||
class DriverAvailabilityRead(ORMBase):
|
|
||||||
id: UUID
|
|
||||||
tenant_id: UUID
|
|
||||||
driver_id: UUID
|
|
||||||
status: DriverAvailabilityStatus
|
|
||||||
effective_from: datetime | None
|
|
||||||
effective_until: datetime | None
|
|
||||||
notes: str | None
|
|
||||||
metadata_json: dict | None
|
|
||||||
version: int
|
|
||||||
is_deleted: bool
|
|
||||||
created_by: str | None
|
|
||||||
updated_by: str | None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class ShiftCreate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
organization_id: UUID
|
|
||||||
hub_id: UUID | None = None
|
|
||||||
code: str = Field(max_length=50)
|
|
||||||
name: str = Field(max_length=255)
|
|
||||||
status: ShiftStatus = ShiftStatus.DRAFT
|
|
||||||
starts_at: datetime
|
|
||||||
ends_at: datetime
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ShiftUpdate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
hub_id: UUID | None = None
|
|
||||||
name: str | None = Field(default=None, max_length=255)
|
|
||||||
status: ShiftStatus | None = None
|
|
||||||
starts_at: datetime | None = None
|
|
||||||
ends_at: datetime | None = None
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
version: int = Field(ge=1)
|
|
||||||
|
|
||||||
|
|
||||||
class ShiftRead(ORMBase):
|
|
||||||
id: UUID
|
|
||||||
tenant_id: UUID
|
|
||||||
organization_id: UUID
|
|
||||||
hub_id: UUID | None
|
|
||||||
code: str
|
|
||||||
name: str
|
|
||||||
status: ShiftStatus
|
|
||||||
starts_at: datetime
|
|
||||||
ends_at: datetime
|
|
||||||
metadata_json: dict | None
|
|
||||||
version: int
|
|
||||||
is_deleted: bool
|
|
||||||
created_by: str | None
|
|
||||||
updated_by: str | None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class ShiftListResponse(BaseModel):
|
|
||||||
items: list[ShiftRead]
|
|
||||||
total: int
|
|
||||||
page: int
|
|
||||||
page_size: int
|
|
||||||
|
|
||||||
|
|
||||||
class ShiftAssignmentCreate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
driver_id: UUID
|
|
||||||
notes: str | None = None
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ShiftAssignmentUpdate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
status: ShiftAssignmentStatus | None = None
|
|
||||||
notes: str | None = None
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ShiftAssignmentRead(ORMBase):
|
|
||||||
id: UUID
|
|
||||||
tenant_id: UUID
|
|
||||||
shift_id: UUID
|
|
||||||
driver_id: UUID
|
|
||||||
status: ShiftAssignmentStatus
|
|
||||||
notes: str | None
|
|
||||||
metadata_json: dict | None
|
|
||||||
is_deleted: bool
|
|
||||||
created_by: str | None
|
|
||||||
updated_by: str | None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class WorkingZoneCreate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
organization_id: UUID
|
|
||||||
code: str = Field(max_length=50)
|
|
||||||
name: str = Field(max_length=255)
|
|
||||||
status: WorkingZoneStatus = WorkingZoneStatus.ACTIVE
|
|
||||||
geo_boundary_ref: str | None = Field(default=None, max_length=255)
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class WorkingZoneUpdate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
name: str | None = Field(default=None, max_length=255)
|
|
||||||
status: WorkingZoneStatus | None = None
|
|
||||||
geo_boundary_ref: str | None = Field(default=None, max_length=255)
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
version: int = Field(ge=1)
|
|
||||||
|
|
||||||
|
|
||||||
class WorkingZoneRead(ORMBase):
|
|
||||||
id: UUID
|
|
||||||
tenant_id: UUID
|
|
||||||
organization_id: UUID
|
|
||||||
code: str
|
|
||||||
name: str
|
|
||||||
status: WorkingZoneStatus
|
|
||||||
geo_boundary_ref: str | None
|
|
||||||
metadata_json: dict | None
|
|
||||||
version: int
|
|
||||||
is_deleted: bool
|
|
||||||
created_by: str | None
|
|
||||||
updated_by: str | None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class WorkingZoneListResponse(BaseModel):
|
|
||||||
items: list[WorkingZoneRead]
|
|
||||||
total: int
|
|
||||||
page: int
|
|
||||||
page_size: int
|
|
||||||
@ -1,122 +0,0 @@
|
|||||||
"""Dispatch engine DTOs — Phase 10.5."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
from app.models.types import DispatchJobAction, DispatchJobStatus, JobAssignmentStatus
|
|
||||||
from app.schemas.common import ORMBase
|
|
||||||
|
|
||||||
_FORBID = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
|
|
||||||
class DispatchJobCreate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
organization_id: UUID
|
|
||||||
hub_id: UUID | None = None
|
|
||||||
code: str = Field(max_length=50)
|
|
||||||
external_order_ref: str | None = Field(default=None, max_length=255)
|
|
||||||
external_source: str | None = Field(default=None, max_length=100)
|
|
||||||
priority: int = 0
|
|
||||||
pickup_address_ref: str | None = Field(default=None, max_length=255)
|
|
||||||
dropoff_address_ref: str | None = Field(default=None, max_length=255)
|
|
||||||
scheduled_at: datetime | None = None
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class DispatchJobUpdate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
hub_id: UUID | None = None
|
|
||||||
priority: int | None = None
|
|
||||||
pickup_address_ref: str | None = Field(default=None, max_length=255)
|
|
||||||
dropoff_address_ref: str | None = Field(default=None, max_length=255)
|
|
||||||
scheduled_at: datetime | None = None
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
version: int = Field(ge=1)
|
|
||||||
|
|
||||||
|
|
||||||
class DispatchJobStatusRequest(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
action: DispatchJobAction
|
|
||||||
reason: str | None = Field(default=None, max_length=500)
|
|
||||||
version: int = Field(ge=1)
|
|
||||||
|
|
||||||
|
|
||||||
class DispatchJobRead(ORMBase):
|
|
||||||
id: UUID
|
|
||||||
tenant_id: UUID
|
|
||||||
organization_id: UUID
|
|
||||||
hub_id: UUID | None
|
|
||||||
code: str
|
|
||||||
status: DispatchJobStatus
|
|
||||||
external_order_ref: str | None
|
|
||||||
external_source: str | None
|
|
||||||
priority: int
|
|
||||||
pickup_address_ref: str | None
|
|
||||||
dropoff_address_ref: str | None
|
|
||||||
scheduled_at: datetime | None
|
|
||||||
status_reason: str | None
|
|
||||||
metadata_json: dict | None
|
|
||||||
version: int
|
|
||||||
is_deleted: bool
|
|
||||||
created_by: str | None
|
|
||||||
updated_by: str | None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class DispatchJobListResponse(BaseModel):
|
|
||||||
items: list[DispatchJobRead]
|
|
||||||
total: int
|
|
||||||
page: int
|
|
||||||
page_size: int
|
|
||||||
|
|
||||||
|
|
||||||
class JobAssignmentCreate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
dispatch_job_id: UUID
|
|
||||||
driver_id: UUID
|
|
||||||
vehicle_id: UUID | None = None
|
|
||||||
notes: str | None = None
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class JobAssignmentUpdate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
status: JobAssignmentStatus | None = None
|
|
||||||
vehicle_id: UUID | None = None
|
|
||||||
notes: str | None = None
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
version: int = Field(ge=1)
|
|
||||||
|
|
||||||
|
|
||||||
class JobAssignmentRead(ORMBase):
|
|
||||||
id: UUID
|
|
||||||
tenant_id: UUID
|
|
||||||
dispatch_job_id: UUID
|
|
||||||
driver_id: UUID
|
|
||||||
vehicle_id: UUID | None
|
|
||||||
status: JobAssignmentStatus
|
|
||||||
assigned_at: datetime | None
|
|
||||||
notes: str | None
|
|
||||||
metadata_json: dict | None
|
|
||||||
version: int
|
|
||||||
is_deleted: bool
|
|
||||||
created_by: str | None
|
|
||||||
updated_by: str | None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class JobAssignmentListResponse(BaseModel):
|
|
||||||
items: list[JobAssignmentRead]
|
|
||||||
total: int
|
|
||||||
page: int
|
|
||||||
page_size: int
|
|
||||||
@ -1,184 +0,0 @@
|
|||||||
"""Fleet & vehicle DTOs — Phase 10.2."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
from app.models.types import FleetStatus, VehicleAssignmentStatus, VehicleStatus
|
|
||||||
from app.schemas.common import ORMBase
|
|
||||||
|
|
||||||
_FORBID = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
|
|
||||||
class FleetCreate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
organization_id: UUID
|
|
||||||
hub_id: UUID | None = None
|
|
||||||
code: str = Field(max_length=50)
|
|
||||||
name: str = Field(max_length=255)
|
|
||||||
description: str | None = None
|
|
||||||
status: FleetStatus = FleetStatus.DRAFT
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class FleetUpdate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
hub_id: UUID | None = None
|
|
||||||
name: str | None = Field(default=None, max_length=255)
|
|
||||||
description: str | None = None
|
|
||||||
status: FleetStatus | None = None
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
version: int = Field(ge=1)
|
|
||||||
|
|
||||||
|
|
||||||
class FleetRead(ORMBase):
|
|
||||||
id: UUID
|
|
||||||
tenant_id: UUID
|
|
||||||
organization_id: UUID
|
|
||||||
hub_id: UUID | None
|
|
||||||
code: str
|
|
||||||
name: str
|
|
||||||
description: str | None
|
|
||||||
status: FleetStatus
|
|
||||||
metadata_json: dict | None
|
|
||||||
version: int
|
|
||||||
is_deleted: bool
|
|
||||||
created_by: str | None
|
|
||||||
updated_by: str | None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class FleetListResponse(BaseModel):
|
|
||||||
items: list[FleetRead]
|
|
||||||
total: int
|
|
||||||
page: int
|
|
||||||
page_size: int
|
|
||||||
|
|
||||||
|
|
||||||
class VehicleTypeCreate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
code: str = Field(max_length=50)
|
|
||||||
name: str = Field(max_length=255)
|
|
||||||
description: str | None = None
|
|
||||||
capacity_kg: float | None = None
|
|
||||||
capacity_volume: float | None = None
|
|
||||||
capability_tags: list[str] | None = None
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class VehicleTypeUpdate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
name: str | None = Field(default=None, max_length=255)
|
|
||||||
description: str | None = None
|
|
||||||
capacity_kg: float | None = None
|
|
||||||
capacity_volume: float | None = None
|
|
||||||
capability_tags: list[str] | None = None
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
version: int = Field(ge=1)
|
|
||||||
|
|
||||||
|
|
||||||
class VehicleTypeRead(ORMBase):
|
|
||||||
id: UUID
|
|
||||||
tenant_id: UUID
|
|
||||||
code: str
|
|
||||||
name: str
|
|
||||||
description: str | None
|
|
||||||
capacity_kg: float | None
|
|
||||||
capacity_volume: float | None
|
|
||||||
capability_tags: list | None
|
|
||||||
metadata_json: dict | None
|
|
||||||
version: int
|
|
||||||
is_deleted: bool
|
|
||||||
created_by: str | None
|
|
||||||
updated_by: str | None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class VehicleTypeListResponse(BaseModel):
|
|
||||||
items: list[VehicleTypeRead]
|
|
||||||
total: int
|
|
||||||
page: int
|
|
||||||
page_size: int
|
|
||||||
|
|
||||||
|
|
||||||
class VehicleCreate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
fleet_id: UUID
|
|
||||||
vehicle_type_id: UUID
|
|
||||||
code: str = Field(max_length=50)
|
|
||||||
plate_number: str | None = Field(default=None, max_length=32)
|
|
||||||
status: VehicleStatus = VehicleStatus.AVAILABLE
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class VehicleUpdate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
plate_number: str | None = Field(default=None, max_length=32)
|
|
||||||
status: VehicleStatus | None = None
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
version: int = Field(ge=1)
|
|
||||||
|
|
||||||
|
|
||||||
class VehicleRead(ORMBase):
|
|
||||||
id: UUID
|
|
||||||
tenant_id: UUID
|
|
||||||
fleet_id: UUID
|
|
||||||
vehicle_type_id: UUID
|
|
||||||
code: str
|
|
||||||
plate_number: str | None
|
|
||||||
status: VehicleStatus
|
|
||||||
metadata_json: dict | None
|
|
||||||
version: int
|
|
||||||
is_deleted: bool
|
|
||||||
created_by: str | None
|
|
||||||
updated_by: str | None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class VehicleListResponse(BaseModel):
|
|
||||||
items: list[VehicleRead]
|
|
||||||
total: int
|
|
||||||
page: int
|
|
||||||
page_size: int
|
|
||||||
|
|
||||||
|
|
||||||
class VehicleAssignmentCreate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
driver_id: UUID
|
|
||||||
notes: str | None = None
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class VehicleAssignmentUpdate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
status: VehicleAssignmentStatus | None = None
|
|
||||||
notes: str | None = None
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class VehicleAssignmentRead(ORMBase):
|
|
||||||
id: UUID
|
|
||||||
tenant_id: UUID
|
|
||||||
vehicle_id: UUID
|
|
||||||
driver_id: UUID
|
|
||||||
status: VehicleAssignmentStatus
|
|
||||||
notes: str | None
|
|
||||||
metadata_json: dict | None
|
|
||||||
is_deleted: bool
|
|
||||||
created_by: str | None
|
|
||||||
updated_by: str | None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
@ -1,153 +0,0 @@
|
|||||||
"""Pricing, capabilities & bundles DTOs — Phase 10.4."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from decimal import Decimal
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
from app.models.types import CapabilityBundleStatus, CapabilityStatus, PricingRuleStatus
|
|
||||||
from app.schemas.common import ORMBase
|
|
||||||
|
|
||||||
_FORBID = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
|
|
||||||
class PricingRuleCreate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
organization_id: UUID
|
|
||||||
code: str = Field(max_length=50)
|
|
||||||
name: str = Field(max_length=255)
|
|
||||||
status: PricingRuleStatus = PricingRuleStatus.DRAFT
|
|
||||||
base_amount: Decimal = Field(gt=0)
|
|
||||||
currency_code: str = Field(default="IRR", max_length=3)
|
|
||||||
rule_config: dict | None = None
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class PricingRuleUpdate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
name: str | None = Field(default=None, max_length=255)
|
|
||||||
status: PricingRuleStatus | None = None
|
|
||||||
base_amount: Decimal | None = Field(default=None, gt=0)
|
|
||||||
currency_code: str | None = Field(default=None, max_length=3)
|
|
||||||
rule_config: dict | None = None
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
version: int = Field(ge=1)
|
|
||||||
|
|
||||||
|
|
||||||
class PricingRuleRead(ORMBase):
|
|
||||||
id: UUID
|
|
||||||
tenant_id: UUID
|
|
||||||
organization_id: UUID
|
|
||||||
code: str
|
|
||||||
name: str
|
|
||||||
status: PricingRuleStatus
|
|
||||||
base_amount: Decimal
|
|
||||||
currency_code: str
|
|
||||||
rule_config: dict | None
|
|
||||||
metadata_json: dict | None
|
|
||||||
version: int
|
|
||||||
is_deleted: bool
|
|
||||||
created_by: str | None
|
|
||||||
updated_by: str | None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class PricingRuleListResponse(BaseModel):
|
|
||||||
items: list[PricingRuleRead]
|
|
||||||
total: int
|
|
||||||
page: int
|
|
||||||
page_size: int
|
|
||||||
|
|
||||||
|
|
||||||
class CapabilityCreate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
code: str = Field(max_length=50)
|
|
||||||
name: str = Field(max_length=255)
|
|
||||||
description: str | None = None
|
|
||||||
status: CapabilityStatus = CapabilityStatus.ACTIVE
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class CapabilityUpdate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
name: str | None = Field(default=None, max_length=255)
|
|
||||||
description: str | None = None
|
|
||||||
status: CapabilityStatus | None = None
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
version: int = Field(ge=1)
|
|
||||||
|
|
||||||
|
|
||||||
class CapabilityRead(ORMBase):
|
|
||||||
id: UUID
|
|
||||||
tenant_id: UUID
|
|
||||||
code: str
|
|
||||||
name: str
|
|
||||||
description: str | None
|
|
||||||
status: CapabilityStatus
|
|
||||||
metadata_json: dict | None
|
|
||||||
version: int
|
|
||||||
is_deleted: bool
|
|
||||||
created_by: str | None
|
|
||||||
updated_by: str | None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class CapabilityListResponse(BaseModel):
|
|
||||||
items: list[CapabilityRead]
|
|
||||||
total: int
|
|
||||||
page: int
|
|
||||||
page_size: int
|
|
||||||
|
|
||||||
|
|
||||||
class BundleCreate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
code: str = Field(max_length=50)
|
|
||||||
name: str = Field(max_length=255)
|
|
||||||
status: CapabilityBundleStatus = CapabilityBundleStatus.DRAFT
|
|
||||||
capability_codes: list[str] | None = None
|
|
||||||
pricing_rule_id: UUID | None = None
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class BundleUpdate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
name: str | None = Field(default=None, max_length=255)
|
|
||||||
status: CapabilityBundleStatus | None = None
|
|
||||||
capability_codes: list[str] | None = None
|
|
||||||
pricing_rule_id: UUID | None = None
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
version: int = Field(ge=1)
|
|
||||||
|
|
||||||
|
|
||||||
class BundleRead(ORMBase):
|
|
||||||
id: UUID
|
|
||||||
tenant_id: UUID
|
|
||||||
code: str
|
|
||||||
name: str
|
|
||||||
status: CapabilityBundleStatus
|
|
||||||
capability_codes: list | None
|
|
||||||
pricing_rule_id: UUID | None
|
|
||||||
metadata_json: dict | None
|
|
||||||
version: int
|
|
||||||
is_deleted: bool
|
|
||||||
created_by: str | None
|
|
||||||
updated_by: str | None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class BundleListResponse(BaseModel):
|
|
||||||
items: list[BundleRead]
|
|
||||||
total: int
|
|
||||||
page: int
|
|
||||||
page_size: int
|
|
||||||
@ -1,120 +0,0 @@
|
|||||||
"""Routing & optimization DTOs — Phase 10.6."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
from app.models.types import OptimizationRunStatus, RoutePlanStatus, RouteStopStatus
|
|
||||||
from app.schemas.common import ORMBase
|
|
||||||
|
|
||||||
_FORBID = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
|
|
||||||
class RoutePlanCreate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
organization_id: UUID
|
|
||||||
code: str = Field(max_length=50)
|
|
||||||
driver_id: UUID | None = None
|
|
||||||
dispatch_job_ids: list[str] | None = None
|
|
||||||
routing_engine_ref: str | None = Field(default=None, max_length=255)
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class RoutePlanUpdate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
driver_id: UUID | None = None
|
|
||||||
status: RoutePlanStatus | None = None
|
|
||||||
dispatch_job_ids: list[str] | None = None
|
|
||||||
routing_engine_ref: str | None = Field(default=None, max_length=255)
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
version: int = Field(ge=1)
|
|
||||||
|
|
||||||
|
|
||||||
class RoutePlanRead(ORMBase):
|
|
||||||
id: UUID
|
|
||||||
tenant_id: UUID
|
|
||||||
organization_id: UUID
|
|
||||||
code: str
|
|
||||||
status: RoutePlanStatus
|
|
||||||
driver_id: UUID | None
|
|
||||||
dispatch_job_ids: list | None
|
|
||||||
routing_engine_ref: str | None
|
|
||||||
planned_at: datetime | None
|
|
||||||
metadata_json: dict | None
|
|
||||||
version: int
|
|
||||||
is_deleted: bool
|
|
||||||
created_by: str | None
|
|
||||||
updated_by: str | None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class RoutePlanListResponse(BaseModel):
|
|
||||||
items: list[RoutePlanRead]
|
|
||||||
total: int
|
|
||||||
page: int
|
|
||||||
page_size: int
|
|
||||||
|
|
||||||
|
|
||||||
class RouteStopCreate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
sequence: int = Field(ge=0)
|
|
||||||
address_ref: str | None = Field(default=None, max_length=255)
|
|
||||||
external_order_ref: str | None = Field(default=None, max_length=255)
|
|
||||||
notes: str | None = None
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class RouteStopRead(ORMBase):
|
|
||||||
id: UUID
|
|
||||||
tenant_id: UUID
|
|
||||||
route_plan_id: UUID
|
|
||||||
sequence: int
|
|
||||||
status: RouteStopStatus
|
|
||||||
address_ref: str | None
|
|
||||||
external_order_ref: str | None
|
|
||||||
notes: str | None
|
|
||||||
metadata_json: dict | None
|
|
||||||
is_deleted: bool
|
|
||||||
created_by: str | None
|
|
||||||
updated_by: str | None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class OptimizationRunCreate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
route_plan_id: UUID | None = None
|
|
||||||
routing_engine_ref: str | None = Field(default=None, max_length=255)
|
|
||||||
request_payload: dict | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class OptimizationRunRead(ORMBase):
|
|
||||||
id: UUID
|
|
||||||
tenant_id: UUID
|
|
||||||
route_plan_id: UUID | None
|
|
||||||
status: OptimizationRunStatus
|
|
||||||
routing_engine_ref: str | None
|
|
||||||
request_payload: dict | None
|
|
||||||
result_payload: dict | None
|
|
||||||
started_at: datetime | None
|
|
||||||
completed_at: datetime | None
|
|
||||||
error_message: str | None
|
|
||||||
is_deleted: bool
|
|
||||||
created_by: str | None
|
|
||||||
updated_by: str | None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class OptimizationRunListResponse(BaseModel):
|
|
||||||
items: list[OptimizationRunRead]
|
|
||||||
total: int
|
|
||||||
page: int
|
|
||||||
page_size: int
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
"""Settlement DTOs — Phase 10.8."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from decimal import Decimal
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
from app.models.types import SettlementIntentStatus, SettlementLineKind
|
|
||||||
from app.schemas.common import ORMBase
|
|
||||||
|
|
||||||
_FORBID = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
|
|
||||||
class SettlementIntentCreate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
organization_id: UUID
|
|
||||||
code: str = Field(max_length=50)
|
|
||||||
driver_id: UUID | None = None
|
|
||||||
dispatch_job_id: UUID | None = None
|
|
||||||
total_amount: Decimal = Field(gt=0)
|
|
||||||
currency_code: str = Field(default="IRR", max_length=3)
|
|
||||||
notes: str | None = None
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class SettlementIntentUpdate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
driver_id: UUID | None = None
|
|
||||||
dispatch_job_id: UUID | None = None
|
|
||||||
total_amount: Decimal | None = Field(default=None, gt=0)
|
|
||||||
currency_code: str | None = Field(default=None, max_length=3)
|
|
||||||
notes: str | None = None
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
version: int = Field(ge=1)
|
|
||||||
|
|
||||||
|
|
||||||
class SettlementActionRequest(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
reason: str | None = Field(default=None, max_length=500)
|
|
||||||
version: int = Field(ge=1)
|
|
||||||
|
|
||||||
|
|
||||||
class SettlementIntentRead(ORMBase):
|
|
||||||
id: UUID
|
|
||||||
tenant_id: UUID
|
|
||||||
organization_id: UUID
|
|
||||||
code: str
|
|
||||||
status: SettlementIntentStatus
|
|
||||||
driver_id: UUID | None
|
|
||||||
dispatch_job_id: UUID | None
|
|
||||||
external_settlement_ref: str | None
|
|
||||||
total_amount: Decimal
|
|
||||||
currency_code: str
|
|
||||||
notes: str | None
|
|
||||||
metadata_json: dict | None
|
|
||||||
version: int
|
|
||||||
is_deleted: bool
|
|
||||||
created_by: str | None
|
|
||||||
updated_by: str | None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class SettlementIntentListResponse(BaseModel):
|
|
||||||
items: list[SettlementIntentRead]
|
|
||||||
total: int
|
|
||||||
page: int
|
|
||||||
page_size: int
|
|
||||||
|
|
||||||
|
|
||||||
class SettlementLineCreate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
kind: SettlementLineKind
|
|
||||||
description: str | None = Field(default=None, max_length=255)
|
|
||||||
amount: Decimal = Field(gt=0)
|
|
||||||
currency_code: str = Field(default="IRR", max_length=3)
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class SettlementLineRead(ORMBase):
|
|
||||||
id: UUID
|
|
||||||
tenant_id: UUID
|
|
||||||
settlement_intent_id: UUID
|
|
||||||
kind: SettlementLineKind
|
|
||||||
description: str | None
|
|
||||||
amount: Decimal
|
|
||||||
currency_code: str
|
|
||||||
metadata_json: dict | None
|
|
||||||
is_deleted: bool
|
|
||||||
created_by: str | None
|
|
||||||
updated_by: str | None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
@ -1,156 +0,0 @@
|
|||||||
"""Tracking & proof of delivery DTOs — Phase 10.7."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
|
||||||
|
|
||||||
from app.models.types import (
|
|
||||||
CustomerTrackingTokenStatus,
|
|
||||||
ProofOfDeliveryStatus,
|
|
||||||
TrackingSessionStatus,
|
|
||||||
)
|
|
||||||
from app.schemas.common import ORMBase
|
|
||||||
|
|
||||||
_FORBID = ConfigDict(extra="forbid")
|
|
||||||
|
|
||||||
|
|
||||||
class TrackingSessionCreate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
dispatch_job_id: UUID | None = None
|
|
||||||
driver_id: UUID | None = None
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class TrackingSessionUpdate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
status: TrackingSessionStatus | None = None
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
version: int = Field(ge=1)
|
|
||||||
|
|
||||||
|
|
||||||
class TrackingSessionRead(ORMBase):
|
|
||||||
id: UUID
|
|
||||||
tenant_id: UUID
|
|
||||||
dispatch_job_id: UUID | None
|
|
||||||
driver_id: UUID | None
|
|
||||||
status: TrackingSessionStatus
|
|
||||||
started_at: datetime | None
|
|
||||||
ended_at: datetime | None
|
|
||||||
metadata_json: dict | None
|
|
||||||
version: int
|
|
||||||
is_deleted: bool
|
|
||||||
created_by: str | None
|
|
||||||
updated_by: str | None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class TrackingSessionListResponse(BaseModel):
|
|
||||||
items: list[TrackingSessionRead]
|
|
||||||
total: int
|
|
||||||
page: int
|
|
||||||
page_size: int
|
|
||||||
|
|
||||||
|
|
||||||
class TrackingPointCreate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
latitude: float = Field(ge=-90, le=90)
|
|
||||||
longitude: float = Field(ge=-180, le=180)
|
|
||||||
recorded_at: datetime
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class TrackingPointRead(ORMBase):
|
|
||||||
id: UUID
|
|
||||||
tenant_id: UUID
|
|
||||||
tracking_session_id: UUID
|
|
||||||
latitude: float
|
|
||||||
longitude: float
|
|
||||||
recorded_at: datetime
|
|
||||||
metadata_json: dict | None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class ProofOfDeliveryCreate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
dispatch_job_id: UUID
|
|
||||||
signature_file_ref: str | None = Field(default=None, max_length=255)
|
|
||||||
photo_file_ref: str | None = Field(default=None, max_length=255)
|
|
||||||
delivery_code: str | None = Field(default=None, max_length=50)
|
|
||||||
notes: str | None = None
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class ProofOfDeliveryUpdate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
status: ProofOfDeliveryStatus | None = None
|
|
||||||
signature_file_ref: str | None = Field(default=None, max_length=255)
|
|
||||||
photo_file_ref: str | None = Field(default=None, max_length=255)
|
|
||||||
delivery_code: str | None = Field(default=None, max_length=50)
|
|
||||||
notes: str | None = None
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
version: int = Field(ge=1)
|
|
||||||
|
|
||||||
|
|
||||||
class ProofOfDeliveryRead(ORMBase):
|
|
||||||
id: UUID
|
|
||||||
tenant_id: UUID
|
|
||||||
dispatch_job_id: UUID
|
|
||||||
status: ProofOfDeliveryStatus
|
|
||||||
signature_file_ref: str | None
|
|
||||||
photo_file_ref: str | None
|
|
||||||
delivery_code: str | None
|
|
||||||
notes: str | None
|
|
||||||
captured_at: datetime | None
|
|
||||||
metadata_json: dict | None
|
|
||||||
version: int
|
|
||||||
is_deleted: bool
|
|
||||||
created_by: str | None
|
|
||||||
updated_by: str | None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class ProofOfDeliveryListResponse(BaseModel):
|
|
||||||
items: list[ProofOfDeliveryRead]
|
|
||||||
total: int
|
|
||||||
page: int
|
|
||||||
page_size: int
|
|
||||||
|
|
||||||
|
|
||||||
class CustomerTrackingTokenCreate(BaseModel):
|
|
||||||
model_config = _FORBID
|
|
||||||
|
|
||||||
dispatch_job_id: UUID
|
|
||||||
expires_at: datetime | None = None
|
|
||||||
metadata_json: dict | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class CustomerTrackingTokenRead(ORMBase):
|
|
||||||
id: UUID
|
|
||||||
tenant_id: UUID
|
|
||||||
dispatch_job_id: UUID
|
|
||||||
token: str
|
|
||||||
status: CustomerTrackingTokenStatus
|
|
||||||
expires_at: datetime | None
|
|
||||||
metadata_json: dict | None
|
|
||||||
is_deleted: bool
|
|
||||||
created_by: str | None
|
|
||||||
updated_by: str | None
|
|
||||||
created_at: datetime
|
|
||||||
updated_at: datetime
|
|
||||||
|
|
||||||
|
|
||||||
class CustomerTrackingTokenListResponse(BaseModel):
|
|
||||||
items: list[CustomerTrackingTokenRead]
|
|
||||||
total: int
|
|
||||||
page: int
|
|
||||||
page_size: int
|
|
||||||
@ -1,29 +0,0 @@
|
|||||||
"""Shared service helpers for delivery phases 10.2+."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import date, datetime
|
|
||||||
from decimal import Decimal
|
|
||||||
from typing import Any
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from shared.security import CurrentUser
|
|
||||||
|
|
||||||
|
|
||||||
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, Decimal):
|
|
||||||
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
|
|
||||||
@ -1,395 +0,0 @@
|
|||||||
"""Availability application services — Phase 10.3."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
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.availability import (
|
|
||||||
DriverAvailability,
|
|
||||||
Shift,
|
|
||||||
ShiftAssignment,
|
|
||||||
WorkingZone,
|
|
||||||
)
|
|
||||||
from app.models.types import AuditAction, ShiftAssignmentStatus
|
|
||||||
from app.repositories.availability import (
|
|
||||||
DriverAvailabilityRepository,
|
|
||||||
ShiftAssignmentRepository,
|
|
||||||
ShiftRepository,
|
|
||||||
WorkingZoneRepository,
|
|
||||||
)
|
|
||||||
from app.repositories.drivers import DriverRepository
|
|
||||||
from app.repositories.foundation import (
|
|
||||||
DeliveryHubRepository,
|
|
||||||
DeliveryOrganizationRepository,
|
|
||||||
)
|
|
||||||
from app.schemas.availability import (
|
|
||||||
DriverAvailabilityUpsert,
|
|
||||||
ShiftAssignmentCreate,
|
|
||||||
ShiftAssignmentUpdate,
|
|
||||||
ShiftCreate,
|
|
||||||
ShiftUpdate,
|
|
||||||
WorkingZoneCreate,
|
|
||||||
WorkingZoneUpdate,
|
|
||||||
)
|
|
||||||
from app.services._helpers import actor_id, jsonable
|
|
||||||
from app.services.audit_service import AuditService
|
|
||||||
from app.specifications.availability import ShiftListSpec, WorkingZoneListSpec
|
|
||||||
from app.validators import ensure_optimistic_version, validate_code
|
|
||||||
from app.validators.availability import (
|
|
||||||
validate_availability_window,
|
|
||||||
validate_shift_name,
|
|
||||||
validate_shift_window,
|
|
||||||
validate_zone_name,
|
|
||||||
)
|
|
||||||
from shared.exceptions import AppError, NotFoundError
|
|
||||||
from shared.security import CurrentUser
|
|
||||||
|
|
||||||
|
|
||||||
class AvailabilityService:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
publisher: TransactionalEventPublisher | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.session = session
|
|
||||||
self.repo = DriverAvailabilityRepository(session)
|
|
||||||
self.drivers = DriverRepository(session)
|
|
||||||
self.audit = AuditService(session)
|
|
||||||
self.publisher = publisher or TransactionalEventPublisher(session)
|
|
||||||
|
|
||||||
async def upsert_for_driver(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
driver_id: UUID,
|
|
||||||
body: DriverAvailabilityUpsert,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None = None,
|
|
||||||
) -> DriverAvailability:
|
|
||||||
if await self.drivers.get(tenant_id, driver_id) is None:
|
|
||||||
raise NotFoundError("راننده یافت نشد")
|
|
||||||
validate_availability_window(
|
|
||||||
effective_from=body.effective_from, effective_until=body.effective_until
|
|
||||||
)
|
|
||||||
entity = await self.repo.get_for_driver(tenant_id, driver_id)
|
|
||||||
if entity is None:
|
|
||||||
entity = DriverAvailability(
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
driver_id=driver_id,
|
|
||||||
status=body.status,
|
|
||||||
effective_from=body.effective_from,
|
|
||||||
effective_until=body.effective_until,
|
|
||||||
notes=body.notes,
|
|
||||||
metadata_json=body.metadata_json,
|
|
||||||
created_by=actor_id(actor),
|
|
||||||
updated_by=actor_id(actor),
|
|
||||||
)
|
|
||||||
await self.repo.add(entity)
|
|
||||||
else:
|
|
||||||
if body.version is not None:
|
|
||||||
ensure_optimistic_version(entity, body.version)
|
|
||||||
entity.status = body.status
|
|
||||||
entity.effective_from = body.effective_from
|
|
||||||
entity.effective_until = body.effective_until
|
|
||||||
entity.notes = body.notes
|
|
||||||
entity.metadata_json = body.metadata_json
|
|
||||||
entity.version += 1
|
|
||||||
entity.updated_by = actor_id(actor)
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.DRIVER_AVAILABILITY_UPDATED,
|
|
||||||
aggregate_type="driver_availability",
|
|
||||||
aggregate_id=entity.id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
payload={"driver_id": str(driver_id), "status": entity.status.value},
|
|
||||||
)
|
|
||||||
await self.session.commit()
|
|
||||||
await self.session.refresh(entity)
|
|
||||||
return entity
|
|
||||||
|
|
||||||
async def get_for_driver(
|
|
||||||
self, tenant_id: UUID, driver_id: UUID
|
|
||||||
) -> DriverAvailability:
|
|
||||||
if await self.drivers.get(tenant_id, driver_id) is None:
|
|
||||||
raise NotFoundError("راننده یافت نشد")
|
|
||||||
entity = await self.repo.get_for_driver(tenant_id, driver_id)
|
|
||||||
if entity is None:
|
|
||||||
raise NotFoundError("دسترسی راننده یافت نشد")
|
|
||||||
return entity
|
|
||||||
|
|
||||||
|
|
||||||
class ShiftService:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
publisher: TransactionalEventPublisher | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.session = session
|
|
||||||
self.repo = ShiftRepository(session)
|
|
||||||
self.assignments = ShiftAssignmentRepository(session)
|
|
||||||
self.drivers = DriverRepository(session)
|
|
||||||
self.orgs = DeliveryOrganizationRepository(session)
|
|
||||||
self.hubs = DeliveryHubRepository(session)
|
|
||||||
self.audit = AuditService(session)
|
|
||||||
self.publisher = publisher or TransactionalEventPublisher(session)
|
|
||||||
|
|
||||||
async def create(
|
|
||||||
self, tenant_id: UUID, body: ShiftCreate, *, actor: CurrentUser | None = None
|
|
||||||
) -> Shift:
|
|
||||||
if await self.orgs.get(tenant_id, body.organization_id) is None:
|
|
||||||
raise NotFoundError("سازمان یافت نشد")
|
|
||||||
validate_shift_window(starts_at=body.starts_at, ends_at=body.ends_at)
|
|
||||||
code = validate_code(body.code)
|
|
||||||
if await self.repo.get_by_code(tenant_id, code):
|
|
||||||
raise AppError("کد شیفت تکراری است", error_code="duplicate_code", status_code=409)
|
|
||||||
entity = Shift(
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
organization_id=body.organization_id,
|
|
||||||
hub_id=body.hub_id,
|
|
||||||
code=code,
|
|
||||||
name=validate_shift_name(body.name),
|
|
||||||
status=body.status,
|
|
||||||
starts_at=body.starts_at,
|
|
||||||
ends_at=body.ends_at,
|
|
||||||
metadata_json=body.metadata_json,
|
|
||||||
created_by=actor_id(actor),
|
|
||||||
updated_by=actor_id(actor),
|
|
||||||
)
|
|
||||||
await self.repo.add(entity)
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.SHIFT_CREATED,
|
|
||||||
aggregate_type="shift",
|
|
||||||
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, shift_id: UUID) -> Shift:
|
|
||||||
entity = await self.repo.get(tenant_id, shift_id)
|
|
||||||
if entity is None:
|
|
||||||
raise NotFoundError("شیفت یافت نشد")
|
|
||||||
return entity
|
|
||||||
|
|
||||||
async def list(
|
|
||||||
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: ShiftListSpec | None = None
|
|
||||||
) -> tuple[list[Shift], int]:
|
|
||||||
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
|
||||||
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
|
||||||
|
|
||||||
async def update(
|
|
||||||
self, tenant_id: UUID, shift_id: UUID, body: ShiftUpdate, *, actor: CurrentUser | None = None
|
|
||||||
) -> Shift:
|
|
||||||
entity = await self.get(tenant_id, shift_id)
|
|
||||||
ensure_optimistic_version(entity, body.version)
|
|
||||||
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
|
||||||
starts = data.get("starts_at", entity.starts_at)
|
|
||||||
ends = data.get("ends_at", entity.ends_at)
|
|
||||||
validate_shift_window(starts_at=starts, ends_at=ends)
|
|
||||||
if "name" in data and data["name"] is not None:
|
|
||||||
data["name"] = validate_shift_name(data["name"])
|
|
||||||
for key, value in data.items():
|
|
||||||
setattr(entity, key, value)
|
|
||||||
entity.version += 1
|
|
||||||
entity.updated_by = actor_id(actor)
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.SHIFT_UPDATED,
|
|
||||||
aggregate_type="shift",
|
|
||||||
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, shift_id: UUID, *, actor: CurrentUser | None = None
|
|
||||||
) -> Shift:
|
|
||||||
entity = await self.get(tenant_id, shift_id)
|
|
||||||
await self.repo.soft_delete(entity, deleted_by=actor_id(actor))
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.SHIFT_DELETED,
|
|
||||||
aggregate_type="shift",
|
|
||||||
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 assign_driver(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
shift_id: UUID,
|
|
||||||
body: ShiftAssignmentCreate,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None = None,
|
|
||||||
) -> ShiftAssignment:
|
|
||||||
await self.get(tenant_id, shift_id)
|
|
||||||
if await self.drivers.get(tenant_id, body.driver_id) is None:
|
|
||||||
raise NotFoundError("راننده یافت نشد")
|
|
||||||
entity = ShiftAssignment(
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
shift_id=shift_id,
|
|
||||||
driver_id=body.driver_id,
|
|
||||||
status=ShiftAssignmentStatus.ASSIGNED,
|
|
||||||
notes=body.notes,
|
|
||||||
metadata_json=body.metadata_json,
|
|
||||||
created_by=actor_id(actor),
|
|
||||||
updated_by=actor_id(actor),
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
await self.assignments.add(entity)
|
|
||||||
except IntegrityError as exc:
|
|
||||||
raise AppError(
|
|
||||||
"تخصیص شیفت تکراری است",
|
|
||||||
error_code="duplicate_shift_assignment",
|
|
||||||
status_code=409,
|
|
||||||
) from exc
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.SHIFT_ASSIGNMENT_CREATED,
|
|
||||||
aggregate_type="shift_assignment",
|
|
||||||
aggregate_id=entity.id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
payload={"shift_id": str(shift_id), "driver_id": str(body.driver_id)},
|
|
||||||
)
|
|
||||||
await self.session.commit()
|
|
||||||
await self.session.refresh(entity)
|
|
||||||
return entity
|
|
||||||
|
|
||||||
async def list_assignments(
|
|
||||||
self, tenant_id: UUID, shift_id: UUID
|
|
||||||
) -> list[ShiftAssignment]:
|
|
||||||
await self.get(tenant_id, shift_id)
|
|
||||||
return list(await self.assignments.list_for_shift(tenant_id, shift_id))
|
|
||||||
|
|
||||||
async def update_assignment(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
shift_id: UUID,
|
|
||||||
assignment_id: UUID,
|
|
||||||
body: ShiftAssignmentUpdate,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None = None,
|
|
||||||
) -> ShiftAssignment:
|
|
||||||
await self.get(tenant_id, shift_id)
|
|
||||||
entity = await self.assignments.get(tenant_id, assignment_id)
|
|
||||||
if entity is None or entity.shift_id != shift_id:
|
|
||||||
raise NotFoundError("تخصیص شیفت یافت نشد")
|
|
||||||
data = body.model_dump(exclude_unset=True)
|
|
||||||
for key, value in data.items():
|
|
||||||
setattr(entity, key, value)
|
|
||||||
entity.updated_by = actor_id(actor)
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.SHIFT_ASSIGNMENT_UPDATED,
|
|
||||||
aggregate_type="shift_assignment",
|
|
||||||
aggregate_id=entity.id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
payload={"shift_id": str(shift_id)},
|
|
||||||
)
|
|
||||||
await self.session.commit()
|
|
||||||
await self.session.refresh(entity)
|
|
||||||
return entity
|
|
||||||
|
|
||||||
|
|
||||||
class WorkingZoneService:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
publisher: TransactionalEventPublisher | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.session = session
|
|
||||||
self.repo = WorkingZoneRepository(session)
|
|
||||||
self.orgs = DeliveryOrganizationRepository(session)
|
|
||||||
self.publisher = publisher or TransactionalEventPublisher(session)
|
|
||||||
|
|
||||||
async def create(
|
|
||||||
self, tenant_id: UUID, body: WorkingZoneCreate, *, actor: CurrentUser | None = None
|
|
||||||
) -> WorkingZone:
|
|
||||||
if await self.orgs.get(tenant_id, body.organization_id) is None:
|
|
||||||
raise NotFoundError("سازمان یافت نشد")
|
|
||||||
code = validate_code(body.code)
|
|
||||||
if await self.repo.get_by_code(tenant_id, code):
|
|
||||||
raise AppError("کد منطقه کاری تکراری است", error_code="duplicate_code", status_code=409)
|
|
||||||
entity = WorkingZone(
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
organization_id=body.organization_id,
|
|
||||||
code=code,
|
|
||||||
name=validate_zone_name(body.name),
|
|
||||||
status=body.status,
|
|
||||||
geo_boundary_ref=body.geo_boundary_ref,
|
|
||||||
metadata_json=body.metadata_json,
|
|
||||||
created_by=actor_id(actor),
|
|
||||||
updated_by=actor_id(actor),
|
|
||||||
)
|
|
||||||
await self.repo.add(entity)
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.WORKING_ZONE_CREATED,
|
|
||||||
aggregate_type="working_zone",
|
|
||||||
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, zone_id: UUID) -> WorkingZone:
|
|
||||||
entity = await self.repo.get(tenant_id, zone_id)
|
|
||||||
if entity is None:
|
|
||||||
raise NotFoundError("منطقه کاری یافت نشد")
|
|
||||||
return entity
|
|
||||||
|
|
||||||
async def list(
|
|
||||||
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: WorkingZoneListSpec | None = None
|
|
||||||
) -> tuple[list[WorkingZone], int]:
|
|
||||||
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
|
||||||
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
|
||||||
|
|
||||||
async def update(
|
|
||||||
self, tenant_id: UUID, zone_id: UUID, body: WorkingZoneUpdate, *, actor: CurrentUser | None = None
|
|
||||||
) -> WorkingZone:
|
|
||||||
entity = await self.get(tenant_id, zone_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_zone_name(data["name"])
|
|
||||||
for key, value in data.items():
|
|
||||||
setattr(entity, key, value)
|
|
||||||
entity.version += 1
|
|
||||||
entity.updated_by = actor_id(actor)
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.WORKING_ZONE_UPDATED,
|
|
||||||
aggregate_type="working_zone",
|
|
||||||
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, zone_id: UUID, *, actor: CurrentUser | None = None
|
|
||||||
) -> WorkingZone:
|
|
||||||
entity = await self.get(tenant_id, zone_id)
|
|
||||||
await self.repo.soft_delete(entity, deleted_by=actor_id(actor))
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.WORKING_ZONE_DELETED,
|
|
||||||
aggregate_type="working_zone",
|
|
||||||
aggregate_id=entity.id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
payload={"code": entity.code},
|
|
||||||
)
|
|
||||||
await self.session.commit()
|
|
||||||
await self.session.refresh(entity)
|
|
||||||
return entity
|
|
||||||
@ -1,281 +0,0 @@
|
|||||||
"""Dispatch application services — Phase 10.5."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.events.publisher import TransactionalEventPublisher
|
|
||||||
from app.events.types import DeliveryEventType
|
|
||||||
from app.models.dispatch import DispatchJob, JobAssignment
|
|
||||||
from app.models.types import AuditAction, JobAssignmentStatus
|
|
||||||
from app.policies.dispatch import DispatchJobPolicy
|
|
||||||
from app.repositories.dispatch import DispatchJobRepository, JobAssignmentRepository
|
|
||||||
from app.repositories.drivers import DriverRepository
|
|
||||||
from app.repositories.foundation import (
|
|
||||||
DeliveryHubRepository,
|
|
||||||
DeliveryOrganizationRepository,
|
|
||||||
)
|
|
||||||
from app.repositories.fleet import VehicleRepository
|
|
||||||
from app.schemas.dispatch import (
|
|
||||||
DispatchJobCreate,
|
|
||||||
DispatchJobStatusRequest,
|
|
||||||
DispatchJobUpdate,
|
|
||||||
JobAssignmentCreate,
|
|
||||||
JobAssignmentUpdate,
|
|
||||||
)
|
|
||||||
from app.services._helpers import actor_id, jsonable
|
|
||||||
from app.services.audit_service import AuditService
|
|
||||||
from app.specifications.dispatch import DispatchJobListSpec, JobAssignmentListSpec
|
|
||||||
from app.validators import ensure_optimistic_version, validate_code
|
|
||||||
from shared.exceptions import AppError, NotFoundError
|
|
||||||
from shared.security import CurrentUser
|
|
||||||
|
|
||||||
|
|
||||||
class DispatchJobService:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
publisher: TransactionalEventPublisher | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.session = session
|
|
||||||
self.repo = DispatchJobRepository(session)
|
|
||||||
self.orgs = DeliveryOrganizationRepository(session)
|
|
||||||
self.hubs = DeliveryHubRepository(session)
|
|
||||||
self.audit = AuditService(session)
|
|
||||||
self.publisher = publisher or TransactionalEventPublisher(session)
|
|
||||||
self.policy = DispatchJobPolicy()
|
|
||||||
|
|
||||||
async def create(
|
|
||||||
self, tenant_id: UUID, body: DispatchJobCreate, *, actor: CurrentUser | None = None
|
|
||||||
) -> DispatchJob:
|
|
||||||
if await self.orgs.get(tenant_id, body.organization_id) is None:
|
|
||||||
raise NotFoundError("سازمان یافت نشد")
|
|
||||||
code = validate_code(body.code)
|
|
||||||
if await self.repo.get_by_code(tenant_id, code):
|
|
||||||
raise AppError("کد کار ارسال تکراری است", error_code="duplicate_code", status_code=409)
|
|
||||||
entity = DispatchJob(
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
organization_id=body.organization_id,
|
|
||||||
hub_id=body.hub_id,
|
|
||||||
code=code,
|
|
||||||
external_order_ref=body.external_order_ref,
|
|
||||||
external_source=body.external_source,
|
|
||||||
priority=body.priority,
|
|
||||||
pickup_address_ref=body.pickup_address_ref,
|
|
||||||
dropoff_address_ref=body.dropoff_address_ref,
|
|
||||||
scheduled_at=body.scheduled_at,
|
|
||||||
metadata_json=body.metadata_json,
|
|
||||||
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="dispatch_job",
|
|
||||||
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.DISPATCH_JOB_CREATED,
|
|
||||||
aggregate_type="dispatch_job",
|
|
||||||
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, job_id: UUID) -> DispatchJob:
|
|
||||||
entity = await self.repo.get(tenant_id, job_id)
|
|
||||||
if entity is None:
|
|
||||||
raise NotFoundError("کار ارسال یافت نشد")
|
|
||||||
return entity
|
|
||||||
|
|
||||||
async def list(
|
|
||||||
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: DispatchJobListSpec | None = None
|
|
||||||
) -> tuple[list[DispatchJob], int]:
|
|
||||||
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
|
||||||
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
|
||||||
|
|
||||||
async def update(
|
|
||||||
self, tenant_id: UUID, job_id: UUID, body: DispatchJobUpdate, *, actor: CurrentUser | None = None
|
|
||||||
) -> DispatchJob:
|
|
||||||
entity = await self.get(tenant_id, job_id)
|
|
||||||
ensure_optimistic_version(entity, body.version)
|
|
||||||
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
|
||||||
for key, value in data.items():
|
|
||||||
setattr(entity, key, value)
|
|
||||||
entity.version += 1
|
|
||||||
entity.updated_by = actor_id(actor)
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.DISPATCH_JOB_UPDATED,
|
|
||||||
aggregate_type="dispatch_job",
|
|
||||||
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_status(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
job_id: UUID,
|
|
||||||
body: DispatchJobStatusRequest,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None = None,
|
|
||||||
) -> DispatchJob:
|
|
||||||
entity = await self.get(tenant_id, job_id)
|
|
||||||
ensure_optimistic_version(entity, body.version)
|
|
||||||
from_status = entity.status
|
|
||||||
to_status, reason = self.policy.assert_transition(
|
|
||||||
action=body.action, current=from_status, reason=body.reason
|
|
||||||
)
|
|
||||||
entity.status = to_status
|
|
||||||
entity.status_reason = reason
|
|
||||||
entity.version += 1
|
|
||||||
entity.updated_by = actor_id(actor)
|
|
||||||
await self.audit.record(
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
entity_type="dispatch_job",
|
|
||||||
entity_id=entity.id,
|
|
||||||
action=AuditAction.STATUS_CHANGE,
|
|
||||||
actor_user_id=actor_id(actor),
|
|
||||||
changes={
|
|
||||||
"from_status": from_status.value,
|
|
||||||
"to_status": to_status.value,
|
|
||||||
"action": body.action.value,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.DISPATCH_JOB_STATUS_CHANGED,
|
|
||||||
aggregate_type="dispatch_job",
|
|
||||||
aggregate_id=entity.id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
payload={
|
|
||||||
"action": body.action.value,
|
|
||||||
"from_status": from_status.value,
|
|
||||||
"to_status": to_status.value,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await self.session.commit()
|
|
||||||
await self.session.refresh(entity)
|
|
||||||
return entity
|
|
||||||
|
|
||||||
async def soft_delete(
|
|
||||||
self, tenant_id: UUID, job_id: UUID, *, actor: CurrentUser | None = None
|
|
||||||
) -> DispatchJob:
|
|
||||||
entity = await self.get(tenant_id, job_id)
|
|
||||||
await self.repo.soft_delete(entity, deleted_by=actor_id(actor))
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.DISPATCH_JOB_DELETED,
|
|
||||||
aggregate_type="dispatch_job",
|
|
||||||
aggregate_id=entity.id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
payload={"code": entity.code},
|
|
||||||
)
|
|
||||||
await self.session.commit()
|
|
||||||
await self.session.refresh(entity)
|
|
||||||
return entity
|
|
||||||
|
|
||||||
|
|
||||||
class JobAssignmentService:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
publisher: TransactionalEventPublisher | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.session = session
|
|
||||||
self.repo = JobAssignmentRepository(session)
|
|
||||||
self.jobs = DispatchJobRepository(session)
|
|
||||||
self.drivers = DriverRepository(session)
|
|
||||||
self.vehicles = VehicleRepository(session)
|
|
||||||
self.publisher = publisher or TransactionalEventPublisher(session)
|
|
||||||
|
|
||||||
async def create(
|
|
||||||
self, tenant_id: UUID, body: JobAssignmentCreate, *, actor: CurrentUser | None = None
|
|
||||||
) -> JobAssignment:
|
|
||||||
if await self.jobs.get(tenant_id, body.dispatch_job_id) is None:
|
|
||||||
raise NotFoundError("کار ارسال یافت نشد")
|
|
||||||
if await self.drivers.get(tenant_id, body.driver_id) is None:
|
|
||||||
raise NotFoundError("راننده یافت نشد")
|
|
||||||
if body.vehicle_id and await self.vehicles.get(tenant_id, body.vehicle_id) is None:
|
|
||||||
raise NotFoundError("خودرو یافت نشد")
|
|
||||||
entity = JobAssignment(
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
dispatch_job_id=body.dispatch_job_id,
|
|
||||||
driver_id=body.driver_id,
|
|
||||||
vehicle_id=body.vehicle_id,
|
|
||||||
status=JobAssignmentStatus.PENDING,
|
|
||||||
assigned_at=datetime.now(timezone.utc),
|
|
||||||
notes=body.notes,
|
|
||||||
metadata_json=body.metadata_json,
|
|
||||||
created_by=actor_id(actor),
|
|
||||||
updated_by=actor_id(actor),
|
|
||||||
)
|
|
||||||
await self.repo.add(entity)
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.JOB_ASSIGNMENT_CREATED,
|
|
||||||
aggregate_type="job_assignment",
|
|
||||||
aggregate_id=entity.id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
payload={"dispatch_job_id": str(body.dispatch_job_id)},
|
|
||||||
)
|
|
||||||
await self.session.commit()
|
|
||||||
await self.session.refresh(entity)
|
|
||||||
return entity
|
|
||||||
|
|
||||||
async def get(self, tenant_id: UUID, assignment_id: UUID) -> JobAssignment:
|
|
||||||
entity = await self.repo.get(tenant_id, assignment_id)
|
|
||||||
if entity is None:
|
|
||||||
raise NotFoundError("تخصیص کار یافت نشد")
|
|
||||||
return entity
|
|
||||||
|
|
||||||
async def list(
|
|
||||||
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: JobAssignmentListSpec | None = None
|
|
||||||
) -> tuple[list[JobAssignment], int]:
|
|
||||||
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
|
||||||
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
|
||||||
|
|
||||||
async def update(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
assignment_id: UUID,
|
|
||||||
body: JobAssignmentUpdate,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None = None,
|
|
||||||
) -> JobAssignment:
|
|
||||||
entity = await self.get(tenant_id, assignment_id)
|
|
||||||
ensure_optimistic_version(entity, body.version)
|
|
||||||
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
|
||||||
old_status = entity.status
|
|
||||||
for key, value in data.items():
|
|
||||||
setattr(entity, key, value)
|
|
||||||
entity.version += 1
|
|
||||||
entity.updated_by = actor_id(actor)
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.JOB_ASSIGNMENT_UPDATED,
|
|
||||||
aggregate_type="job_assignment",
|
|
||||||
aggregate_id=entity.id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
payload={"dispatch_job_id": str(entity.dispatch_job_id)},
|
|
||||||
)
|
|
||||||
if "status" in data and entity.status != old_status:
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.JOB_ASSIGNMENT_STATUS_CHANGED,
|
|
||||||
aggregate_type="job_assignment",
|
|
||||||
aggregate_id=entity.id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
payload={"status": entity.status.value},
|
|
||||||
)
|
|
||||||
await self.session.commit()
|
|
||||||
await self.session.refresh(entity)
|
|
||||||
return entity
|
|
||||||
@ -1,452 +0,0 @@
|
|||||||
"""Fleet & vehicle application services — Phase 10.2."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
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.fleet import Fleet, Vehicle, VehicleAssignment, VehicleType
|
|
||||||
from app.models.types import AuditAction, VehicleAssignmentStatus, VehicleStatus
|
|
||||||
from app.repositories.drivers import DriverRepository
|
|
||||||
from app.repositories.fleet import (
|
|
||||||
FleetRepository,
|
|
||||||
VehicleAssignmentRepository,
|
|
||||||
VehicleRepository,
|
|
||||||
VehicleTypeRepository,
|
|
||||||
)
|
|
||||||
from app.repositories.foundation import (
|
|
||||||
DeliveryHubRepository,
|
|
||||||
DeliveryOrganizationRepository,
|
|
||||||
)
|
|
||||||
from app.schemas.fleet import (
|
|
||||||
FleetCreate,
|
|
||||||
FleetUpdate,
|
|
||||||
VehicleAssignmentCreate,
|
|
||||||
VehicleAssignmentUpdate,
|
|
||||||
VehicleCreate,
|
|
||||||
VehicleTypeCreate,
|
|
||||||
VehicleTypeUpdate,
|
|
||||||
VehicleUpdate,
|
|
||||||
)
|
|
||||||
from app.services.audit_service import AuditService
|
|
||||||
from app.services._helpers import actor_id, jsonable
|
|
||||||
from app.specifications.fleet import FleetListSpec, VehicleListSpec, VehicleTypeListSpec
|
|
||||||
from app.validators import ensure_optimistic_version, validate_code
|
|
||||||
from app.validators.fleet import (
|
|
||||||
validate_fleet_name,
|
|
||||||
validate_plate_number,
|
|
||||||
validate_vehicle_type_name,
|
|
||||||
)
|
|
||||||
from shared.exceptions import AppError, NotFoundError
|
|
||||||
from shared.security import CurrentUser
|
|
||||||
|
|
||||||
|
|
||||||
class FleetService:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
publisher: TransactionalEventPublisher | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.session = session
|
|
||||||
self.repo = FleetRepository(session)
|
|
||||||
self.orgs = DeliveryOrganizationRepository(session)
|
|
||||||
self.hubs = DeliveryHubRepository(session)
|
|
||||||
self.audit = AuditService(session)
|
|
||||||
self.publisher = publisher or TransactionalEventPublisher(session)
|
|
||||||
|
|
||||||
async def create(
|
|
||||||
self, tenant_id: UUID, body: FleetCreate, *, actor: CurrentUser | None = None
|
|
||||||
) -> Fleet:
|
|
||||||
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("هاب یافت نشد")
|
|
||||||
code = validate_code(body.code)
|
|
||||||
if await self.repo.get_by_code(tenant_id, code):
|
|
||||||
raise AppError("کد ناوگان تکراری است", error_code="duplicate_code", status_code=409)
|
|
||||||
entity = Fleet(
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
organization_id=body.organization_id,
|
|
||||||
hub_id=body.hub_id,
|
|
||||||
code=code,
|
|
||||||
name=validate_fleet_name(body.name),
|
|
||||||
description=body.description,
|
|
||||||
status=body.status,
|
|
||||||
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="fleet",
|
|
||||||
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.FLEET_CREATED,
|
|
||||||
aggregate_type="fleet",
|
|
||||||
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, fleet_id: UUID) -> Fleet:
|
|
||||||
entity = await self.repo.get(tenant_id, fleet_id)
|
|
||||||
if entity is None:
|
|
||||||
raise NotFoundError("ناوگان یافت نشد")
|
|
||||||
return entity
|
|
||||||
|
|
||||||
async def list(
|
|
||||||
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: FleetListSpec | None = None
|
|
||||||
) -> tuple[list[Fleet], 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, fleet_id: UUID, body: FleetUpdate, *, actor: CurrentUser | None = None
|
|
||||||
) -> Fleet:
|
|
||||||
entity = await self.get(tenant_id, fleet_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_fleet_name(data["name"])
|
|
||||||
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="fleet",
|
|
||||||
entity_id=entity.id,
|
|
||||||
action=AuditAction.UPDATE,
|
|
||||||
actor_user_id=actor_id(actor),
|
|
||||||
changes=jsonable(data),
|
|
||||||
)
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.FLEET_UPDATED,
|
|
||||||
aggregate_type="fleet",
|
|
||||||
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, fleet_id: UUID, *, actor: CurrentUser | None = None
|
|
||||||
) -> Fleet:
|
|
||||||
entity = await self.get(tenant_id, fleet_id)
|
|
||||||
await self.repo.soft_delete(entity, deleted_by=actor_id(actor))
|
|
||||||
await self.audit.record(
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
entity_type="fleet",
|
|
||||||
entity_id=entity.id,
|
|
||||||
action=AuditAction.DELETE,
|
|
||||||
actor_user_id=actor_id(actor),
|
|
||||||
)
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.FLEET_DELETED,
|
|
||||||
aggregate_type="fleet",
|
|
||||||
aggregate_id=entity.id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
payload={"code": entity.code},
|
|
||||||
)
|
|
||||||
await self.session.commit()
|
|
||||||
await self.session.refresh(entity)
|
|
||||||
return entity
|
|
||||||
|
|
||||||
|
|
||||||
class VehicleTypeService:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
publisher: TransactionalEventPublisher | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.session = session
|
|
||||||
self.repo = VehicleTypeRepository(session)
|
|
||||||
self.audit = AuditService(session)
|
|
||||||
self.publisher = publisher or TransactionalEventPublisher(session)
|
|
||||||
|
|
||||||
async def create(
|
|
||||||
self, tenant_id: UUID, body: VehicleTypeCreate, *, actor: CurrentUser | None = None
|
|
||||||
) -> VehicleType:
|
|
||||||
code = validate_code(body.code)
|
|
||||||
if await self.repo.get_by_code(tenant_id, code):
|
|
||||||
raise AppError("کد نوع خودرو تکراری است", error_code="duplicate_code", status_code=409)
|
|
||||||
entity = VehicleType(
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
code=code,
|
|
||||||
name=validate_vehicle_type_name(body.name),
|
|
||||||
description=body.description,
|
|
||||||
capacity_kg=body.capacity_kg,
|
|
||||||
capacity_volume=body.capacity_volume,
|
|
||||||
capability_tags=body.capability_tags,
|
|
||||||
metadata_json=body.metadata_json,
|
|
||||||
created_by=actor_id(actor),
|
|
||||||
updated_by=actor_id(actor),
|
|
||||||
)
|
|
||||||
await self.repo.add(entity)
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.VEHICLE_TYPE_CREATED,
|
|
||||||
aggregate_type="vehicle_type",
|
|
||||||
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, type_id: UUID) -> VehicleType:
|
|
||||||
entity = await self.repo.get(tenant_id, type_id)
|
|
||||||
if entity is None:
|
|
||||||
raise NotFoundError("نوع خودرو یافت نشد")
|
|
||||||
return entity
|
|
||||||
|
|
||||||
async def list(
|
|
||||||
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: VehicleTypeListSpec | None = None
|
|
||||||
) -> tuple[list[VehicleType], int]:
|
|
||||||
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
|
||||||
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
|
||||||
|
|
||||||
async def update(
|
|
||||||
self, tenant_id: UUID, type_id: UUID, body: VehicleTypeUpdate, *, actor: CurrentUser | None = None
|
|
||||||
) -> VehicleType:
|
|
||||||
entity = await self.get(tenant_id, type_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_vehicle_type_name(data["name"])
|
|
||||||
for key, value in data.items():
|
|
||||||
setattr(entity, key, value)
|
|
||||||
entity.version += 1
|
|
||||||
entity.updated_by = actor_id(actor)
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.VEHICLE_TYPE_UPDATED,
|
|
||||||
aggregate_type="vehicle_type",
|
|
||||||
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, type_id: UUID, *, actor: CurrentUser | None = None
|
|
||||||
) -> VehicleType:
|
|
||||||
entity = await self.get(tenant_id, type_id)
|
|
||||||
await self.repo.soft_delete(entity, deleted_by=actor_id(actor))
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.VEHICLE_TYPE_DELETED,
|
|
||||||
aggregate_type="vehicle_type",
|
|
||||||
aggregate_id=entity.id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
payload={"code": entity.code},
|
|
||||||
)
|
|
||||||
await self.session.commit()
|
|
||||||
await self.session.refresh(entity)
|
|
||||||
return entity
|
|
||||||
|
|
||||||
|
|
||||||
class VehicleService:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
publisher: TransactionalEventPublisher | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.session = session
|
|
||||||
self.repo = VehicleRepository(session)
|
|
||||||
self.fleets = FleetRepository(session)
|
|
||||||
self.types = VehicleTypeRepository(session)
|
|
||||||
self.assignments = VehicleAssignmentRepository(session)
|
|
||||||
self.drivers = DriverRepository(session)
|
|
||||||
self.audit = AuditService(session)
|
|
||||||
self.publisher = publisher or TransactionalEventPublisher(session)
|
|
||||||
|
|
||||||
async def create(
|
|
||||||
self, tenant_id: UUID, body: VehicleCreate, *, actor: CurrentUser | None = None
|
|
||||||
) -> Vehicle:
|
|
||||||
if await self.fleets.get(tenant_id, body.fleet_id) is None:
|
|
||||||
raise NotFoundError("ناوگان یافت نشد")
|
|
||||||
if await self.types.get(tenant_id, body.vehicle_type_id) is None:
|
|
||||||
raise NotFoundError("نوع خودرو یافت نشد")
|
|
||||||
code = validate_code(body.code)
|
|
||||||
if await self.repo.get_by_code(tenant_id, code):
|
|
||||||
raise AppError("کد خودرو تکراری است", error_code="duplicate_code", status_code=409)
|
|
||||||
entity = Vehicle(
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
fleet_id=body.fleet_id,
|
|
||||||
vehicle_type_id=body.vehicle_type_id,
|
|
||||||
code=code,
|
|
||||||
plate_number=validate_plate_number(body.plate_number),
|
|
||||||
status=body.status,
|
|
||||||
metadata_json=body.metadata_json,
|
|
||||||
created_by=actor_id(actor),
|
|
||||||
updated_by=actor_id(actor),
|
|
||||||
)
|
|
||||||
await self.repo.add(entity)
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.VEHICLE_CREATED,
|
|
||||||
aggregate_type="vehicle",
|
|
||||||
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, vehicle_id: UUID) -> Vehicle:
|
|
||||||
entity = await self.repo.get(tenant_id, vehicle_id)
|
|
||||||
if entity is None:
|
|
||||||
raise NotFoundError("خودرو یافت نشد")
|
|
||||||
return entity
|
|
||||||
|
|
||||||
async def list(
|
|
||||||
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: VehicleListSpec | None = None
|
|
||||||
) -> tuple[list[Vehicle], int]:
|
|
||||||
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
|
||||||
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
|
||||||
|
|
||||||
async def update(
|
|
||||||
self, tenant_id: UUID, vehicle_id: UUID, body: VehicleUpdate, *, actor: CurrentUser | None = None
|
|
||||||
) -> Vehicle:
|
|
||||||
entity = await self.get(tenant_id, vehicle_id)
|
|
||||||
ensure_optimistic_version(entity, body.version)
|
|
||||||
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
|
||||||
if "plate_number" in data:
|
|
||||||
data["plate_number"] = validate_plate_number(data["plate_number"])
|
|
||||||
for key, value in data.items():
|
|
||||||
setattr(entity, key, value)
|
|
||||||
entity.version += 1
|
|
||||||
entity.updated_by = actor_id(actor)
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.VEHICLE_UPDATED,
|
|
||||||
aggregate_type="vehicle",
|
|
||||||
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, vehicle_id: UUID, *, actor: CurrentUser | None = None
|
|
||||||
) -> Vehicle:
|
|
||||||
entity = await self.get(tenant_id, vehicle_id)
|
|
||||||
await self.repo.soft_delete(entity, deleted_by=actor_id(actor))
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.VEHICLE_DELETED,
|
|
||||||
aggregate_type="vehicle",
|
|
||||||
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 create_assignment(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
vehicle_id: UUID,
|
|
||||||
body: VehicleAssignmentCreate,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None = None,
|
|
||||||
) -> VehicleAssignment:
|
|
||||||
await self.get(tenant_id, vehicle_id)
|
|
||||||
if await self.drivers.get(tenant_id, body.driver_id) is None:
|
|
||||||
raise NotFoundError("راننده یافت نشد")
|
|
||||||
active = await self.assignments.get_active_for_vehicle(tenant_id, vehicle_id)
|
|
||||||
if active is not None:
|
|
||||||
raise AppError(
|
|
||||||
"خودرو دارای تخصیص فعال است",
|
|
||||||
status_code=409,
|
|
||||||
error_code="active_assignment_exists",
|
|
||||||
)
|
|
||||||
entity = VehicleAssignment(
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
vehicle_id=vehicle_id,
|
|
||||||
driver_id=body.driver_id,
|
|
||||||
status=VehicleAssignmentStatus.ACTIVE,
|
|
||||||
notes=body.notes,
|
|
||||||
metadata_json=body.metadata_json,
|
|
||||||
created_by=actor_id(actor),
|
|
||||||
updated_by=actor_id(actor),
|
|
||||||
)
|
|
||||||
await self.assignments.add(entity)
|
|
||||||
vehicle = await self.get(tenant_id, vehicle_id)
|
|
||||||
vehicle.status = VehicleStatus.ASSIGNED
|
|
||||||
vehicle.version += 1
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.VEHICLE_ASSIGNMENT_CREATED,
|
|
||||||
aggregate_type="vehicle_assignment",
|
|
||||||
aggregate_id=entity.id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
payload={"vehicle_id": str(vehicle_id), "driver_id": str(body.driver_id)},
|
|
||||||
)
|
|
||||||
await self.session.commit()
|
|
||||||
await self.session.refresh(entity)
|
|
||||||
return entity
|
|
||||||
|
|
||||||
async def list_assignments(
|
|
||||||
self, tenant_id: UUID, vehicle_id: UUID
|
|
||||||
) -> list[VehicleAssignment]:
|
|
||||||
await self.get(tenant_id, vehicle_id)
|
|
||||||
return list(await self.assignments.list_for_vehicle(tenant_id, vehicle_id))
|
|
||||||
|
|
||||||
async def update_assignment(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
vehicle_id: UUID,
|
|
||||||
assignment_id: UUID,
|
|
||||||
body: VehicleAssignmentUpdate,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None = None,
|
|
||||||
) -> VehicleAssignment:
|
|
||||||
await self.get(tenant_id, vehicle_id)
|
|
||||||
entity = await self.assignments.get(tenant_id, assignment_id)
|
|
||||||
if entity is None or entity.vehicle_id != vehicle_id:
|
|
||||||
raise NotFoundError("تخصیص یافت نشد")
|
|
||||||
data = body.model_dump(exclude_unset=True)
|
|
||||||
for key, value in data.items():
|
|
||||||
setattr(entity, key, value)
|
|
||||||
entity.updated_by = actor_id(actor)
|
|
||||||
event = DeliveryEventType.VEHICLE_ASSIGNMENT_UPDATED
|
|
||||||
if entity.status == VehicleAssignmentStatus.ENDED:
|
|
||||||
event = DeliveryEventType.VEHICLE_ASSIGNMENT_ENDED
|
|
||||||
vehicle = await self.get(tenant_id, vehicle_id)
|
|
||||||
vehicle.status = VehicleStatus.AVAILABLE
|
|
||||||
vehicle.version += 1
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=event,
|
|
||||||
aggregate_type="vehicle_assignment",
|
|
||||||
aggregate_id=entity.id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
payload={"vehicle_id": str(vehicle_id)},
|
|
||||||
)
|
|
||||||
await self.session.commit()
|
|
||||||
await self.session.refresh(entity)
|
|
||||||
return entity
|
|
||||||
@ -1,330 +0,0 @@
|
|||||||
"""Pricing application services — Phase 10.4."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.events.publisher import TransactionalEventPublisher
|
|
||||||
from app.events.types import DeliveryEventType
|
|
||||||
from app.models.pricing import CapabilityBundle, CapabilityDefinition, PricingRule
|
|
||||||
from app.repositories.foundation import DeliveryOrganizationRepository
|
|
||||||
from app.repositories.pricing import (
|
|
||||||
CapabilityBundleRepository,
|
|
||||||
CapabilityDefinitionRepository,
|
|
||||||
PricingRuleRepository,
|
|
||||||
)
|
|
||||||
from app.schemas.pricing import (
|
|
||||||
BundleCreate,
|
|
||||||
BundleUpdate,
|
|
||||||
CapabilityCreate,
|
|
||||||
CapabilityUpdate,
|
|
||||||
PricingRuleCreate,
|
|
||||||
PricingRuleUpdate,
|
|
||||||
)
|
|
||||||
from app.services._helpers import actor_id
|
|
||||||
from app.specifications.pricing import (
|
|
||||||
BundleListSpec,
|
|
||||||
CapabilityListSpec,
|
|
||||||
PricingRuleListSpec,
|
|
||||||
)
|
|
||||||
from app.validators import ensure_optimistic_version, validate_code
|
|
||||||
from app.validators.pricing import (
|
|
||||||
validate_base_amount,
|
|
||||||
validate_bundle_name,
|
|
||||||
validate_capability_name,
|
|
||||||
validate_currency,
|
|
||||||
validate_pricing_name,
|
|
||||||
)
|
|
||||||
from shared.exceptions import AppError, NotFoundError
|
|
||||||
from shared.security import CurrentUser
|
|
||||||
|
|
||||||
|
|
||||||
class PricingRuleService:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
publisher: TransactionalEventPublisher | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.session = session
|
|
||||||
self.repo = PricingRuleRepository(session)
|
|
||||||
self.orgs = DeliveryOrganizationRepository(session)
|
|
||||||
self.publisher = publisher or TransactionalEventPublisher(session)
|
|
||||||
|
|
||||||
async def create(
|
|
||||||
self, tenant_id: UUID, body: PricingRuleCreate, *, actor: CurrentUser | None = None
|
|
||||||
) -> PricingRule:
|
|
||||||
if await self.orgs.get(tenant_id, body.organization_id) is None:
|
|
||||||
raise NotFoundError("سازمان یافت نشد")
|
|
||||||
code = validate_code(body.code)
|
|
||||||
if await self.repo.get_by_code(tenant_id, code):
|
|
||||||
raise AppError("کد قیمتگذاری تکراری است", error_code="duplicate_code", status_code=409)
|
|
||||||
entity = PricingRule(
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
organization_id=body.organization_id,
|
|
||||||
code=code,
|
|
||||||
name=validate_pricing_name(body.name),
|
|
||||||
status=body.status,
|
|
||||||
base_amount=validate_base_amount(body.base_amount),
|
|
||||||
currency_code=validate_currency(body.currency_code),
|
|
||||||
rule_config=body.rule_config,
|
|
||||||
metadata_json=body.metadata_json,
|
|
||||||
created_by=actor_id(actor),
|
|
||||||
updated_by=actor_id(actor),
|
|
||||||
)
|
|
||||||
await self.repo.add(entity)
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.PRICING_RULE_CREATED,
|
|
||||||
aggregate_type="pricing_rule",
|
|
||||||
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, rule_id: UUID) -> PricingRule:
|
|
||||||
entity = await self.repo.get(tenant_id, rule_id)
|
|
||||||
if entity is None:
|
|
||||||
raise NotFoundError("قانون قیمتگذاری یافت نشد")
|
|
||||||
return entity
|
|
||||||
|
|
||||||
async def list(
|
|
||||||
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: PricingRuleListSpec | None = None
|
|
||||||
) -> tuple[list[PricingRule], int]:
|
|
||||||
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
|
||||||
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
|
||||||
|
|
||||||
async def update(
|
|
||||||
self, tenant_id: UUID, rule_id: UUID, body: PricingRuleUpdate, *, actor: CurrentUser | None = None
|
|
||||||
) -> PricingRule:
|
|
||||||
entity = await self.get(tenant_id, rule_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_pricing_name(data["name"])
|
|
||||||
if "base_amount" in data and data["base_amount"] is not None:
|
|
||||||
data["base_amount"] = validate_base_amount(data["base_amount"])
|
|
||||||
if "currency_code" in data and data["currency_code"] is not None:
|
|
||||||
data["currency_code"] = validate_currency(data["currency_code"])
|
|
||||||
for key, value in data.items():
|
|
||||||
setattr(entity, key, value)
|
|
||||||
entity.version += 1
|
|
||||||
entity.updated_by = actor_id(actor)
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.PRICING_RULE_UPDATED,
|
|
||||||
aggregate_type="pricing_rule",
|
|
||||||
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, rule_id: UUID, *, actor: CurrentUser | None = None
|
|
||||||
) -> PricingRule:
|
|
||||||
entity = await self.get(tenant_id, rule_id)
|
|
||||||
await self.repo.soft_delete(entity, deleted_by=actor_id(actor))
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.PRICING_RULE_DELETED,
|
|
||||||
aggregate_type="pricing_rule",
|
|
||||||
aggregate_id=entity.id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
payload={"code": entity.code},
|
|
||||||
)
|
|
||||||
await self.session.commit()
|
|
||||||
await self.session.refresh(entity)
|
|
||||||
return entity
|
|
||||||
|
|
||||||
|
|
||||||
class CapabilityService:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
publisher: TransactionalEventPublisher | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.session = session
|
|
||||||
self.repo = CapabilityDefinitionRepository(session)
|
|
||||||
self.publisher = publisher or TransactionalEventPublisher(session)
|
|
||||||
|
|
||||||
async def create(
|
|
||||||
self, tenant_id: UUID, body: CapabilityCreate, *, actor: CurrentUser | None = None
|
|
||||||
) -> CapabilityDefinition:
|
|
||||||
code = validate_code(body.code)
|
|
||||||
if await self.repo.get_by_code(tenant_id, code):
|
|
||||||
raise AppError("کد قابلیت تکراری است", error_code="duplicate_code", status_code=409)
|
|
||||||
entity = CapabilityDefinition(
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
code=code,
|
|
||||||
name=validate_capability_name(body.name),
|
|
||||||
description=body.description,
|
|
||||||
status=body.status,
|
|
||||||
metadata_json=body.metadata_json,
|
|
||||||
created_by=actor_id(actor),
|
|
||||||
updated_by=actor_id(actor),
|
|
||||||
)
|
|
||||||
await self.repo.add(entity)
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.CAPABILITY_CREATED,
|
|
||||||
aggregate_type="capability",
|
|
||||||
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, cap_id: UUID) -> CapabilityDefinition:
|
|
||||||
entity = await self.repo.get(tenant_id, cap_id)
|
|
||||||
if entity is None:
|
|
||||||
raise NotFoundError("قابلیت یافت نشد")
|
|
||||||
return entity
|
|
||||||
|
|
||||||
async def list(
|
|
||||||
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: CapabilityListSpec | None = None
|
|
||||||
) -> tuple[list[CapabilityDefinition], int]:
|
|
||||||
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
|
||||||
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
|
||||||
|
|
||||||
async def update(
|
|
||||||
self, tenant_id: UUID, cap_id: UUID, body: CapabilityUpdate, *, actor: CurrentUser | None = None
|
|
||||||
) -> CapabilityDefinition:
|
|
||||||
entity = await self.get(tenant_id, cap_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_capability_name(data["name"])
|
|
||||||
for key, value in data.items():
|
|
||||||
setattr(entity, key, value)
|
|
||||||
entity.version += 1
|
|
||||||
entity.updated_by = actor_id(actor)
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.CAPABILITY_UPDATED,
|
|
||||||
aggregate_type="capability",
|
|
||||||
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, cap_id: UUID, *, actor: CurrentUser | None = None
|
|
||||||
) -> CapabilityDefinition:
|
|
||||||
entity = await self.get(tenant_id, cap_id)
|
|
||||||
await self.repo.soft_delete(entity, deleted_by=actor_id(actor))
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.CAPABILITY_DELETED,
|
|
||||||
aggregate_type="capability",
|
|
||||||
aggregate_id=entity.id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
payload={"code": entity.code},
|
|
||||||
)
|
|
||||||
await self.session.commit()
|
|
||||||
await self.session.refresh(entity)
|
|
||||||
return entity
|
|
||||||
|
|
||||||
|
|
||||||
class BundleService:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
publisher: TransactionalEventPublisher | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.session = session
|
|
||||||
self.repo = CapabilityBundleRepository(session)
|
|
||||||
self.rules = PricingRuleRepository(session)
|
|
||||||
self.publisher = publisher or TransactionalEventPublisher(session)
|
|
||||||
|
|
||||||
async def create(
|
|
||||||
self, tenant_id: UUID, body: BundleCreate, *, actor: CurrentUser | None = None
|
|
||||||
) -> CapabilityBundle:
|
|
||||||
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.pricing_rule_id and await self.rules.get(tenant_id, body.pricing_rule_id) is None:
|
|
||||||
raise NotFoundError("قانون قیمتگذاری یافت نشد")
|
|
||||||
entity = CapabilityBundle(
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
code=code,
|
|
||||||
name=validate_bundle_name(body.name),
|
|
||||||
status=body.status,
|
|
||||||
capability_codes=body.capability_codes,
|
|
||||||
pricing_rule_id=body.pricing_rule_id,
|
|
||||||
metadata_json=body.metadata_json,
|
|
||||||
created_by=actor_id(actor),
|
|
||||||
updated_by=actor_id(actor),
|
|
||||||
)
|
|
||||||
await self.repo.add(entity)
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.BUNDLE_CREATED,
|
|
||||||
aggregate_type="bundle",
|
|
||||||
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, bundle_id: UUID) -> CapabilityBundle:
|
|
||||||
entity = await self.repo.get(tenant_id, bundle_id)
|
|
||||||
if entity is None:
|
|
||||||
raise NotFoundError("بسته قابلیت یافت نشد")
|
|
||||||
return entity
|
|
||||||
|
|
||||||
async def list(
|
|
||||||
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: BundleListSpec | None = None
|
|
||||||
) -> tuple[list[CapabilityBundle], int]:
|
|
||||||
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
|
||||||
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
|
||||||
|
|
||||||
async def update(
|
|
||||||
self, tenant_id: UUID, bundle_id: UUID, body: BundleUpdate, *, actor: CurrentUser | None = None
|
|
||||||
) -> CapabilityBundle:
|
|
||||||
entity = await self.get(tenant_id, bundle_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_bundle_name(data["name"])
|
|
||||||
if "pricing_rule_id" in data and data["pricing_rule_id"]:
|
|
||||||
if await self.rules.get(tenant_id, data["pricing_rule_id"]) is None:
|
|
||||||
raise NotFoundError("قانون قیمتگذاری یافت نشد")
|
|
||||||
for key, value in data.items():
|
|
||||||
setattr(entity, key, value)
|
|
||||||
entity.version += 1
|
|
||||||
entity.updated_by = actor_id(actor)
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.BUNDLE_UPDATED,
|
|
||||||
aggregate_type="bundle",
|
|
||||||
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, bundle_id: UUID, *, actor: CurrentUser | None = None
|
|
||||||
) -> CapabilityBundle:
|
|
||||||
entity = await self.get(tenant_id, bundle_id)
|
|
||||||
await self.repo.soft_delete(entity, deleted_by=actor_id(actor))
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.BUNDLE_DELETED,
|
|
||||||
aggregate_type="bundle",
|
|
||||||
aggregate_id=entity.id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
payload={"code": entity.code},
|
|
||||||
)
|
|
||||||
await self.session.commit()
|
|
||||||
await self.session.refresh(entity)
|
|
||||||
return entity
|
|
||||||
@ -1,234 +0,0 @@
|
|||||||
"""Routing application services — Phase 10.6."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.events.publisher import TransactionalEventPublisher
|
|
||||||
from app.events.types import DeliveryEventType
|
|
||||||
from app.models.routing import OptimizationRun, RoutePlan, RouteStop
|
|
||||||
from app.models.types import OptimizationRunStatus, RoutePlanStatus
|
|
||||||
from app.repositories.foundation import DeliveryOrganizationRepository
|
|
||||||
from app.repositories.routing import (
|
|
||||||
OptimizationRunRepository,
|
|
||||||
RoutePlanRepository,
|
|
||||||
RouteStopRepository,
|
|
||||||
)
|
|
||||||
from app.schemas.routing import (
|
|
||||||
OptimizationRunCreate,
|
|
||||||
RoutePlanCreate,
|
|
||||||
RoutePlanUpdate,
|
|
||||||
RouteStopCreate,
|
|
||||||
)
|
|
||||||
from app.services._helpers import actor_id
|
|
||||||
from app.specifications.routing import OptimizationRunListSpec, RoutePlanListSpec
|
|
||||||
from app.validators import ensure_optimistic_version, validate_code
|
|
||||||
from app.validators.routing import validate_stop_sequence
|
|
||||||
from shared.exceptions import AppError, NotFoundError
|
|
||||||
from shared.security import CurrentUser
|
|
||||||
|
|
||||||
|
|
||||||
class RoutePlanService:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
publisher: TransactionalEventPublisher | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.session = session
|
|
||||||
self.repo = RoutePlanRepository(session)
|
|
||||||
self.stops = RouteStopRepository(session)
|
|
||||||
self.orgs = DeliveryOrganizationRepository(session)
|
|
||||||
self.publisher = publisher or TransactionalEventPublisher(session)
|
|
||||||
|
|
||||||
async def create(
|
|
||||||
self, tenant_id: UUID, body: RoutePlanCreate, *, actor: CurrentUser | None = None
|
|
||||||
) -> RoutePlan:
|
|
||||||
if await self.orgs.get(tenant_id, body.organization_id) is None:
|
|
||||||
raise NotFoundError("سازمان یافت نشد")
|
|
||||||
code = validate_code(body.code)
|
|
||||||
if await self.repo.get_by_code(tenant_id, code):
|
|
||||||
raise AppError("کد مسیر تکراری است", error_code="duplicate_code", status_code=409)
|
|
||||||
entity = RoutePlan(
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
organization_id=body.organization_id,
|
|
||||||
code=code,
|
|
||||||
driver_id=body.driver_id,
|
|
||||||
dispatch_job_ids=body.dispatch_job_ids,
|
|
||||||
routing_engine_ref=body.routing_engine_ref,
|
|
||||||
metadata_json=body.metadata_json,
|
|
||||||
created_by=actor_id(actor),
|
|
||||||
updated_by=actor_id(actor),
|
|
||||||
)
|
|
||||||
await self.repo.add(entity)
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.ROUTE_PLAN_CREATED,
|
|
||||||
aggregate_type="route_plan",
|
|
||||||
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, plan_id: UUID) -> RoutePlan:
|
|
||||||
entity = await self.repo.get(tenant_id, plan_id)
|
|
||||||
if entity is None:
|
|
||||||
raise NotFoundError("برنامه مسیر یافت نشد")
|
|
||||||
return entity
|
|
||||||
|
|
||||||
async def list(
|
|
||||||
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: RoutePlanListSpec | None = None
|
|
||||||
) -> tuple[list[RoutePlan], int]:
|
|
||||||
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
|
||||||
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
|
||||||
|
|
||||||
async def update(
|
|
||||||
self, tenant_id: UUID, plan_id: UUID, body: RoutePlanUpdate, *, actor: CurrentUser | None = None
|
|
||||||
) -> RoutePlan:
|
|
||||||
entity = await self.get(tenant_id, plan_id)
|
|
||||||
ensure_optimistic_version(entity, body.version)
|
|
||||||
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
|
||||||
for key, value in data.items():
|
|
||||||
setattr(entity, key, value)
|
|
||||||
entity.version += 1
|
|
||||||
entity.updated_by = actor_id(actor)
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.ROUTE_PLAN_UPDATED,
|
|
||||||
aggregate_type="route_plan",
|
|
||||||
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, plan_id: UUID, *, actor: CurrentUser | None = None
|
|
||||||
) -> RoutePlan:
|
|
||||||
entity = await self.get(tenant_id, plan_id)
|
|
||||||
await self.repo.soft_delete(entity, deleted_by=actor_id(actor))
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.ROUTE_PLAN_DELETED,
|
|
||||||
aggregate_type="route_plan",
|
|
||||||
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 add_stop(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
plan_id: UUID,
|
|
||||||
body: RouteStopCreate,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None = None,
|
|
||||||
) -> RouteStop:
|
|
||||||
await self.get(tenant_id, plan_id)
|
|
||||||
validate_stop_sequence(body.sequence)
|
|
||||||
entity = RouteStop(
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
route_plan_id=plan_id,
|
|
||||||
sequence=body.sequence,
|
|
||||||
address_ref=body.address_ref,
|
|
||||||
external_order_ref=body.external_order_ref,
|
|
||||||
notes=body.notes,
|
|
||||||
metadata_json=body.metadata_json,
|
|
||||||
created_by=actor_id(actor),
|
|
||||||
updated_by=actor_id(actor),
|
|
||||||
)
|
|
||||||
await self.stops.add(entity)
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.ROUTE_STOP_ADDED,
|
|
||||||
aggregate_type="route_stop",
|
|
||||||
aggregate_id=entity.id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
payload={"route_plan_id": str(plan_id), "sequence": body.sequence},
|
|
||||||
)
|
|
||||||
await self.session.commit()
|
|
||||||
await self.session.refresh(entity)
|
|
||||||
return entity
|
|
||||||
|
|
||||||
async def list_stops(self, tenant_id: UUID, plan_id: UUID) -> list[RouteStop]:
|
|
||||||
await self.get(tenant_id, plan_id)
|
|
||||||
return list(await self.stops.list_for_plan(tenant_id, plan_id))
|
|
||||||
|
|
||||||
|
|
||||||
class OptimizationRunService:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
publisher: TransactionalEventPublisher | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.session = session
|
|
||||||
self.repo = OptimizationRunRepository(session)
|
|
||||||
self.plans = RoutePlanRepository(session)
|
|
||||||
self.publisher = publisher or TransactionalEventPublisher(session)
|
|
||||||
|
|
||||||
async def start(
|
|
||||||
self, tenant_id: UUID, body: OptimizationRunCreate, *, actor: CurrentUser | None = None
|
|
||||||
) -> OptimizationRun:
|
|
||||||
if body.route_plan_id and await self.plans.get(tenant_id, body.route_plan_id) is None:
|
|
||||||
raise NotFoundError("برنامه مسیر یافت نشد")
|
|
||||||
entity = OptimizationRun(
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
route_plan_id=body.route_plan_id,
|
|
||||||
status=OptimizationRunStatus.RUNNING,
|
|
||||||
routing_engine_ref=body.routing_engine_ref,
|
|
||||||
request_payload=body.request_payload,
|
|
||||||
started_at=datetime.now(timezone.utc),
|
|
||||||
created_by=actor_id(actor),
|
|
||||||
updated_by=actor_id(actor),
|
|
||||||
)
|
|
||||||
await self.repo.add(entity)
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.OPTIMIZATION_RUN_STARTED,
|
|
||||||
aggregate_type="optimization_run",
|
|
||||||
aggregate_id=entity.id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
payload={"route_plan_id": str(body.route_plan_id) if body.route_plan_id else None},
|
|
||||||
)
|
|
||||||
entity.status = OptimizationRunStatus.COMPLETED
|
|
||||||
entity.completed_at = datetime.now(timezone.utc)
|
|
||||||
entity.result_payload = {"status": "stub", "message": "optimization stub"}
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.OPTIMIZATION_RUN_COMPLETED,
|
|
||||||
aggregate_type="optimization_run",
|
|
||||||
aggregate_id=entity.id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
payload={"route_plan_id": str(body.route_plan_id) if body.route_plan_id else None},
|
|
||||||
)
|
|
||||||
if body.route_plan_id:
|
|
||||||
plan = await self.plans.get(tenant_id, body.route_plan_id)
|
|
||||||
if plan is not None:
|
|
||||||
plan.status = RoutePlanStatus.PLANNED
|
|
||||||
plan.planned_at = datetime.now(timezone.utc)
|
|
||||||
plan.version += 1
|
|
||||||
await self.session.commit()
|
|
||||||
await self.session.refresh(entity)
|
|
||||||
return entity
|
|
||||||
|
|
||||||
async def get(self, tenant_id: UUID, run_id: UUID) -> OptimizationRun:
|
|
||||||
entity = await self.repo.get(tenant_id, run_id)
|
|
||||||
if entity is None:
|
|
||||||
raise NotFoundError("اجرای بهینهسازی یافت نشد")
|
|
||||||
return entity
|
|
||||||
|
|
||||||
async def list(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
*,
|
|
||||||
offset: int = 0,
|
|
||||||
limit: int = 20,
|
|
||||||
spec: OptimizationRunListSpec | None = None,
|
|
||||||
) -> tuple[list[OptimizationRun], int]:
|
|
||||||
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
|
||||||
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
|
||||||
@ -1,328 +0,0 @@
|
|||||||
"""Settlement application services — Phase 10.8.
|
|
||||||
|
|
||||||
Uses AccountingProvider protocol refs only — NO journal entries in delivery_db.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import Any
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.events.publisher import TransactionalEventPublisher
|
|
||||||
from app.events.types import DeliveryEventType
|
|
||||||
from app.models.settlement import SettlementIntent, SettlementLine
|
|
||||||
from app.models.types import AuditAction, SettlementIntentStatus
|
|
||||||
from app.policies.settlement import SettlementIntentPolicy
|
|
||||||
from app.providers.contracts import AccountingProvider
|
|
||||||
from app.repositories.foundation import DeliveryOrganizationRepository
|
|
||||||
from app.repositories.settlement import SettlementIntentRepository, SettlementLineRepository
|
|
||||||
from app.schemas.settlement import (
|
|
||||||
SettlementActionRequest,
|
|
||||||
SettlementIntentCreate,
|
|
||||||
SettlementIntentUpdate,
|
|
||||||
SettlementLineCreate,
|
|
||||||
)
|
|
||||||
from app.services._helpers import actor_id, jsonable
|
|
||||||
from app.services.audit_service import AuditService
|
|
||||||
from app.specifications.settlement import SettlementIntentListSpec
|
|
||||||
from app.validators import ensure_optimistic_version, validate_code
|
|
||||||
from app.validators.pricing import validate_currency, validate_line_amount
|
|
||||||
from app.validators.settlement import validate_settlement_amount
|
|
||||||
from shared.exceptions import AppError, NotFoundError
|
|
||||||
from shared.security import CurrentUser
|
|
||||||
|
|
||||||
|
|
||||||
class StubAccountingProvider:
|
|
||||||
"""Test/dev stub — creates external settlement refs only."""
|
|
||||||
|
|
||||||
async def create_settlement_ref(
|
|
||||||
self, *, tenant_id: UUID, reference: str, payload: dict[str, Any]
|
|
||||||
) -> dict[str, Any]:
|
|
||||||
return {
|
|
||||||
"settlement_ref": f"acct://settlement/{tenant_id}/{reference}",
|
|
||||||
"status": "accepted",
|
|
||||||
"payload": payload,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class SettlementService:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
publisher: TransactionalEventPublisher | None = None,
|
|
||||||
accounting: AccountingProvider | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.session = session
|
|
||||||
self.repo = SettlementIntentRepository(session)
|
|
||||||
self.lines = SettlementLineRepository(session)
|
|
||||||
self.orgs = DeliveryOrganizationRepository(session)
|
|
||||||
self.audit = AuditService(session)
|
|
||||||
self.publisher = publisher or TransactionalEventPublisher(session)
|
|
||||||
self.accounting = accounting or StubAccountingProvider()
|
|
||||||
self.policy = SettlementIntentPolicy()
|
|
||||||
|
|
||||||
async def create(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
body: SettlementIntentCreate,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None = None,
|
|
||||||
) -> SettlementIntent:
|
|
||||||
if await self.orgs.get(tenant_id, body.organization_id) is None:
|
|
||||||
raise NotFoundError("سازمان یافت نشد")
|
|
||||||
code = validate_code(body.code)
|
|
||||||
if await self.repo.get_by_code(tenant_id, code):
|
|
||||||
raise AppError("کد تسویه تکراری است", error_code="duplicate_code", status_code=409)
|
|
||||||
entity = SettlementIntent(
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
organization_id=body.organization_id,
|
|
||||||
code=code,
|
|
||||||
driver_id=body.driver_id,
|
|
||||||
dispatch_job_id=body.dispatch_job_id,
|
|
||||||
total_amount=validate_settlement_amount(body.total_amount),
|
|
||||||
currency_code=validate_currency(body.currency_code),
|
|
||||||
notes=body.notes,
|
|
||||||
metadata_json=body.metadata_json,
|
|
||||||
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="settlement_intent",
|
|
||||||
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.SETTLEMENT_INTENT_CREATED,
|
|
||||||
aggregate_type="settlement_intent",
|
|
||||||
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, intent_id: UUID) -> SettlementIntent:
|
|
||||||
entity = await self.repo.get(tenant_id, intent_id)
|
|
||||||
if entity is None:
|
|
||||||
raise NotFoundError("قصد تسویه یافت نشد")
|
|
||||||
return entity
|
|
||||||
|
|
||||||
async def list(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
*,
|
|
||||||
offset: int = 0,
|
|
||||||
limit: int = 20,
|
|
||||||
spec: SettlementIntentListSpec | None = None,
|
|
||||||
) -> tuple[list[SettlementIntent], int]:
|
|
||||||
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
|
||||||
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
|
||||||
|
|
||||||
async def update(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
intent_id: UUID,
|
|
||||||
body: SettlementIntentUpdate,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None = None,
|
|
||||||
) -> SettlementIntent:
|
|
||||||
entity = await self.get(tenant_id, intent_id)
|
|
||||||
if entity.status != SettlementIntentStatus.DRAFT:
|
|
||||||
raise AppError(
|
|
||||||
"فقط تسویه پیشنویس قابل ویرایش است",
|
|
||||||
status_code=409,
|
|
||||||
error_code="settlement_not_editable",
|
|
||||||
)
|
|
||||||
ensure_optimistic_version(entity, body.version)
|
|
||||||
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
|
||||||
if "total_amount" in data and data["total_amount"] is not None:
|
|
||||||
data["total_amount"] = validate_settlement_amount(data["total_amount"])
|
|
||||||
if "currency_code" in data and data["currency_code"] is not None:
|
|
||||||
data["currency_code"] = validate_currency(data["currency_code"])
|
|
||||||
for key, value in data.items():
|
|
||||||
setattr(entity, key, value)
|
|
||||||
entity.version += 1
|
|
||||||
entity.updated_by = actor_id(actor)
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.SETTLEMENT_INTENT_UPDATED,
|
|
||||||
aggregate_type="settlement_intent",
|
|
||||||
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_action(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
intent_id: UUID,
|
|
||||||
action: str,
|
|
||||||
body: SettlementActionRequest,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None = None,
|
|
||||||
) -> SettlementIntent:
|
|
||||||
entity = await self.get(tenant_id, intent_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.version += 1
|
|
||||||
entity.updated_by = actor_id(actor)
|
|
||||||
if action == "submit":
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.SETTLEMENT_INTENT_SUBMITTED,
|
|
||||||
aggregate_type="settlement_intent",
|
|
||||||
aggregate_id=entity.id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
payload={"code": entity.code},
|
|
||||||
)
|
|
||||||
elif action == "settle":
|
|
||||||
result = await self.accounting.create_settlement_ref(
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
reference=entity.code,
|
|
||||||
payload={
|
|
||||||
"amount": str(entity.total_amount),
|
|
||||||
"currency": entity.currency_code,
|
|
||||||
"driver_id": str(entity.driver_id) if entity.driver_id else None,
|
|
||||||
"dispatch_job_id": str(entity.dispatch_job_id)
|
|
||||||
if entity.dispatch_job_id
|
|
||||||
else None,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
entity.external_settlement_ref = result.get("settlement_ref")
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.SETTLEMENT_INTENT_SETTLED,
|
|
||||||
aggregate_type="settlement_intent",
|
|
||||||
aggregate_id=entity.id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
payload={
|
|
||||||
"code": entity.code,
|
|
||||||
"external_settlement_ref": entity.external_settlement_ref,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
elif action == "fail":
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.SETTLEMENT_INTENT_FAILED,
|
|
||||||
aggregate_type="settlement_intent",
|
|
||||||
aggregate_id=entity.id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
payload={"code": entity.code, "reason": reason},
|
|
||||||
)
|
|
||||||
await self.audit.record(
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
entity_type="settlement_intent",
|
|
||||||
entity_id=entity.id,
|
|
||||||
action=AuditAction.STATUS_CHANGE,
|
|
||||||
actor_user_id=actor_id(actor),
|
|
||||||
changes={
|
|
||||||
"action": action,
|
|
||||||
"from_status": from_status.value,
|
|
||||||
"to_status": to_status.value,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
await self.session.commit()
|
|
||||||
await self.session.refresh(entity)
|
|
||||||
return entity
|
|
||||||
|
|
||||||
async def submit(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
intent_id: UUID,
|
|
||||||
body: SettlementActionRequest,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None = None,
|
|
||||||
) -> SettlementIntent:
|
|
||||||
return await self._apply_action(
|
|
||||||
tenant_id, intent_id, "submit", body, actor=actor
|
|
||||||
)
|
|
||||||
|
|
||||||
async def settle(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
intent_id: UUID,
|
|
||||||
body: SettlementActionRequest,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None = None,
|
|
||||||
) -> SettlementIntent:
|
|
||||||
return await self._apply_action(
|
|
||||||
tenant_id, intent_id, "settle", body, actor=actor
|
|
||||||
)
|
|
||||||
|
|
||||||
async def fail(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
intent_id: UUID,
|
|
||||||
body: SettlementActionRequest,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None = None,
|
|
||||||
) -> SettlementIntent:
|
|
||||||
return await self._apply_action(
|
|
||||||
tenant_id, intent_id, "fail", body, actor=actor
|
|
||||||
)
|
|
||||||
|
|
||||||
async def cancel(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
intent_id: UUID,
|
|
||||||
body: SettlementActionRequest,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None = None,
|
|
||||||
) -> SettlementIntent:
|
|
||||||
return await self._apply_action(
|
|
||||||
tenant_id, intent_id, "cancel", body, actor=actor
|
|
||||||
)
|
|
||||||
|
|
||||||
async def add_line(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
intent_id: UUID,
|
|
||||||
body: SettlementLineCreate,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None = None,
|
|
||||||
) -> SettlementLine:
|
|
||||||
entity = await self.get(tenant_id, intent_id)
|
|
||||||
if entity.status != SettlementIntentStatus.DRAFT:
|
|
||||||
raise AppError(
|
|
||||||
"فقط به تسویه پیشنویس میتوان سطر اضافه کرد",
|
|
||||||
status_code=409,
|
|
||||||
error_code="settlement_not_editable",
|
|
||||||
)
|
|
||||||
line = SettlementLine(
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
settlement_intent_id=intent_id,
|
|
||||||
kind=body.kind,
|
|
||||||
description=body.description,
|
|
||||||
amount=validate_line_amount(body.amount),
|
|
||||||
currency_code=validate_currency(body.currency_code),
|
|
||||||
metadata_json=body.metadata_json,
|
|
||||||
created_by=actor_id(actor),
|
|
||||||
updated_by=actor_id(actor),
|
|
||||||
)
|
|
||||||
await self.lines.add(line)
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.SETTLEMENT_LINE_ADDED,
|
|
||||||
aggregate_type="settlement_line",
|
|
||||||
aggregate_id=line.id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
payload={"settlement_intent_id": str(intent_id), "kind": body.kind.value},
|
|
||||||
)
|
|
||||||
await self.session.commit()
|
|
||||||
await self.session.refresh(line)
|
|
||||||
return line
|
|
||||||
|
|
||||||
async def list_lines(
|
|
||||||
self, tenant_id: UUID, intent_id: UUID
|
|
||||||
) -> list[SettlementLine]:
|
|
||||||
await self.get(tenant_id, intent_id)
|
|
||||||
return list(await self.lines.list_for_intent(tenant_id, intent_id))
|
|
||||||
@ -1,344 +0,0 @@
|
|||||||
"""Tracking application services — Phase 10.7."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import secrets
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
|
|
||||||
from app.events.publisher import TransactionalEventPublisher
|
|
||||||
from app.events.types import DeliveryEventType
|
|
||||||
from app.models.tracking import (
|
|
||||||
CustomerTrackingToken,
|
|
||||||
ProofOfDelivery,
|
|
||||||
TrackingPoint,
|
|
||||||
TrackingSession,
|
|
||||||
)
|
|
||||||
from app.models.types import CustomerTrackingTokenStatus, ProofOfDeliveryStatus, TrackingSessionStatus
|
|
||||||
from app.repositories.dispatch import DispatchJobRepository
|
|
||||||
from app.repositories.tracking import (
|
|
||||||
CustomerTrackingTokenRepository,
|
|
||||||
ProofOfDeliveryRepository,
|
|
||||||
TrackingPointRepository,
|
|
||||||
TrackingSessionRepository,
|
|
||||||
)
|
|
||||||
from app.schemas.tracking import (
|
|
||||||
CustomerTrackingTokenCreate,
|
|
||||||
ProofOfDeliveryCreate,
|
|
||||||
ProofOfDeliveryUpdate,
|
|
||||||
TrackingPointCreate,
|
|
||||||
TrackingSessionCreate,
|
|
||||||
TrackingSessionUpdate,
|
|
||||||
)
|
|
||||||
from app.services._helpers import actor_id
|
|
||||||
from app.specifications.tracking import (
|
|
||||||
CustomerTrackingTokenListSpec,
|
|
||||||
ProofOfDeliveryListSpec,
|
|
||||||
TrackingSessionListSpec,
|
|
||||||
)
|
|
||||||
from app.validators import ensure_optimistic_version
|
|
||||||
from app.validators.tracking import validate_coordinates, validate_pod_refs
|
|
||||||
from shared.exceptions import AppError, NotFoundError
|
|
||||||
from shared.security import CurrentUser
|
|
||||||
|
|
||||||
|
|
||||||
class TrackingSessionService:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
publisher: TransactionalEventPublisher | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.session = session
|
|
||||||
self.repo = TrackingSessionRepository(session)
|
|
||||||
self.points = TrackingPointRepository(session)
|
|
||||||
self.jobs = DispatchJobRepository(session)
|
|
||||||
self.publisher = publisher or TransactionalEventPublisher(session)
|
|
||||||
|
|
||||||
async def start(
|
|
||||||
self, tenant_id: UUID, body: TrackingSessionCreate, *, actor: CurrentUser | None = None
|
|
||||||
) -> TrackingSession:
|
|
||||||
if body.dispatch_job_id and await self.jobs.get(tenant_id, body.dispatch_job_id) is None:
|
|
||||||
raise NotFoundError("کار ارسال یافت نشد")
|
|
||||||
entity = TrackingSession(
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
dispatch_job_id=body.dispatch_job_id,
|
|
||||||
driver_id=body.driver_id,
|
|
||||||
status=TrackingSessionStatus.ACTIVE,
|
|
||||||
started_at=datetime.now(timezone.utc),
|
|
||||||
metadata_json=body.metadata_json,
|
|
||||||
created_by=actor_id(actor),
|
|
||||||
updated_by=actor_id(actor),
|
|
||||||
)
|
|
||||||
await self.repo.add(entity)
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.TRACKING_SESSION_STARTED,
|
|
||||||
aggregate_type="tracking_session",
|
|
||||||
aggregate_id=entity.id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
payload={"dispatch_job_id": str(body.dispatch_job_id) if body.dispatch_job_id else None},
|
|
||||||
)
|
|
||||||
await self.session.commit()
|
|
||||||
await self.session.refresh(entity)
|
|
||||||
return entity
|
|
||||||
|
|
||||||
async def get(self, tenant_id: UUID, session_id: UUID) -> TrackingSession:
|
|
||||||
entity = await self.repo.get(tenant_id, session_id)
|
|
||||||
if entity is None:
|
|
||||||
raise NotFoundError("جلسه ردیابی یافت نشد")
|
|
||||||
return entity
|
|
||||||
|
|
||||||
async def list(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
*,
|
|
||||||
offset: int = 0,
|
|
||||||
limit: int = 20,
|
|
||||||
spec: TrackingSessionListSpec | None = None,
|
|
||||||
) -> tuple[list[TrackingSession], int]:
|
|
||||||
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
|
||||||
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
|
||||||
|
|
||||||
async def update(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
session_id: UUID,
|
|
||||||
body: TrackingSessionUpdate,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None = None,
|
|
||||||
) -> TrackingSession:
|
|
||||||
entity = await self.get(tenant_id, session_id)
|
|
||||||
ensure_optimistic_version(entity, body.version)
|
|
||||||
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
|
||||||
for key, value in data.items():
|
|
||||||
setattr(entity, key, value)
|
|
||||||
if entity.status in {TrackingSessionStatus.COMPLETED, TrackingSessionStatus.CANCELLED}:
|
|
||||||
entity.ended_at = datetime.now(timezone.utc)
|
|
||||||
entity.version += 1
|
|
||||||
entity.updated_by = actor_id(actor)
|
|
||||||
event = DeliveryEventType.TRACKING_SESSION_UPDATED
|
|
||||||
if entity.status == TrackingSessionStatus.COMPLETED:
|
|
||||||
event = DeliveryEventType.TRACKING_SESSION_ENDED
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=event,
|
|
||||||
aggregate_type="tracking_session",
|
|
||||||
aggregate_id=entity.id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
payload={"status": entity.status.value},
|
|
||||||
)
|
|
||||||
await self.session.commit()
|
|
||||||
await self.session.refresh(entity)
|
|
||||||
return entity
|
|
||||||
|
|
||||||
async def record_point(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
session_id: UUID,
|
|
||||||
body: TrackingPointCreate,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None = None,
|
|
||||||
) -> TrackingPoint:
|
|
||||||
await self.get(tenant_id, session_id)
|
|
||||||
validate_coordinates(latitude=body.latitude, longitude=body.longitude)
|
|
||||||
entity = TrackingPoint(
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
tracking_session_id=session_id,
|
|
||||||
latitude=body.latitude,
|
|
||||||
longitude=body.longitude,
|
|
||||||
recorded_at=body.recorded_at,
|
|
||||||
metadata_json=body.metadata_json,
|
|
||||||
)
|
|
||||||
await self.points.add(entity)
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.TRACKING_POINT_RECORDED,
|
|
||||||
aggregate_type="tracking_point",
|
|
||||||
aggregate_id=entity.id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
payload={"tracking_session_id": str(session_id)},
|
|
||||||
)
|
|
||||||
await self.session.commit()
|
|
||||||
await self.session.refresh(entity)
|
|
||||||
return entity
|
|
||||||
|
|
||||||
async def list_points(
|
|
||||||
self, tenant_id: UUID, session_id: UUID, *, limit: int = 500
|
|
||||||
) -> list[TrackingPoint]:
|
|
||||||
await self.get(tenant_id, session_id)
|
|
||||||
return list(await self.points.list_for_session(tenant_id, session_id, limit=limit))
|
|
||||||
|
|
||||||
|
|
||||||
class ProofOfDeliveryService:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
publisher: TransactionalEventPublisher | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.session = session
|
|
||||||
self.repo = ProofOfDeliveryRepository(session)
|
|
||||||
self.jobs = DispatchJobRepository(session)
|
|
||||||
self.publisher = publisher or TransactionalEventPublisher(session)
|
|
||||||
|
|
||||||
async def capture(
|
|
||||||
self, tenant_id: UUID, body: ProofOfDeliveryCreate, *, actor: CurrentUser | None = None
|
|
||||||
) -> ProofOfDelivery:
|
|
||||||
if await self.jobs.get(tenant_id, body.dispatch_job_id) is None:
|
|
||||||
raise NotFoundError("کار ارسال یافت نشد")
|
|
||||||
validate_pod_refs(
|
|
||||||
signature_file_ref=body.signature_file_ref,
|
|
||||||
photo_file_ref=body.photo_file_ref,
|
|
||||||
)
|
|
||||||
entity = ProofOfDelivery(
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
dispatch_job_id=body.dispatch_job_id,
|
|
||||||
status=ProofOfDeliveryStatus.CAPTURED,
|
|
||||||
signature_file_ref=body.signature_file_ref,
|
|
||||||
photo_file_ref=body.photo_file_ref,
|
|
||||||
delivery_code=body.delivery_code,
|
|
||||||
notes=body.notes,
|
|
||||||
captured_at=datetime.now(timezone.utc),
|
|
||||||
metadata_json=body.metadata_json,
|
|
||||||
created_by=actor_id(actor),
|
|
||||||
updated_by=actor_id(actor),
|
|
||||||
)
|
|
||||||
await self.repo.add(entity)
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.PROOF_OF_DELIVERY_CAPTURED,
|
|
||||||
aggregate_type="proof_of_delivery",
|
|
||||||
aggregate_id=entity.id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
payload={"dispatch_job_id": str(body.dispatch_job_id)},
|
|
||||||
)
|
|
||||||
await self.session.commit()
|
|
||||||
await self.session.refresh(entity)
|
|
||||||
return entity
|
|
||||||
|
|
||||||
async def get(self, tenant_id: UUID, pod_id: UUID) -> ProofOfDelivery:
|
|
||||||
entity = await self.repo.get(tenant_id, pod_id)
|
|
||||||
if entity is None:
|
|
||||||
raise NotFoundError("اثبات تحویل یافت نشد")
|
|
||||||
return entity
|
|
||||||
|
|
||||||
async def list(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
*,
|
|
||||||
offset: int = 0,
|
|
||||||
limit: int = 20,
|
|
||||||
spec: ProofOfDeliveryListSpec | None = None,
|
|
||||||
) -> tuple[list[ProofOfDelivery], int]:
|
|
||||||
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
|
||||||
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
|
||||||
|
|
||||||
async def update(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
pod_id: UUID,
|
|
||||||
body: ProofOfDeliveryUpdate,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None = None,
|
|
||||||
) -> ProofOfDelivery:
|
|
||||||
entity = await self.get(tenant_id, pod_id)
|
|
||||||
ensure_optimistic_version(entity, body.version)
|
|
||||||
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
|
||||||
for key, value in data.items():
|
|
||||||
setattr(entity, key, value)
|
|
||||||
entity.version += 1
|
|
||||||
entity.updated_by = actor_id(actor)
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.PROOF_OF_DELIVERY_UPDATED,
|
|
||||||
aggregate_type="proof_of_delivery",
|
|
||||||
aggregate_id=entity.id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
payload={"dispatch_job_id": str(entity.dispatch_job_id)},
|
|
||||||
)
|
|
||||||
await self.session.commit()
|
|
||||||
await self.session.refresh(entity)
|
|
||||||
return entity
|
|
||||||
|
|
||||||
|
|
||||||
class CustomerTrackingTokenService:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
session: AsyncSession,
|
|
||||||
*,
|
|
||||||
publisher: TransactionalEventPublisher | None = None,
|
|
||||||
) -> None:
|
|
||||||
self.session = session
|
|
||||||
self.repo = CustomerTrackingTokenRepository(session)
|
|
||||||
self.jobs = DispatchJobRepository(session)
|
|
||||||
self.publisher = publisher or TransactionalEventPublisher(session)
|
|
||||||
|
|
||||||
async def create(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
body: CustomerTrackingTokenCreate,
|
|
||||||
*,
|
|
||||||
actor: CurrentUser | None = None,
|
|
||||||
) -> CustomerTrackingToken:
|
|
||||||
if await self.jobs.get(tenant_id, body.dispatch_job_id) is None:
|
|
||||||
raise NotFoundError("کار ارسال یافت نشد")
|
|
||||||
token = secrets.token_urlsafe(32)
|
|
||||||
entity = CustomerTrackingToken(
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
dispatch_job_id=body.dispatch_job_id,
|
|
||||||
token=token,
|
|
||||||
status=CustomerTrackingTokenStatus.ACTIVE,
|
|
||||||
expires_at=body.expires_at,
|
|
||||||
metadata_json=body.metadata_json,
|
|
||||||
created_by=actor_id(actor),
|
|
||||||
updated_by=actor_id(actor),
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
await self.repo.add(entity)
|
|
||||||
except Exception as exc:
|
|
||||||
raise AppError(
|
|
||||||
"ایجاد توکن ردیابی ناموفق بود",
|
|
||||||
status_code=409,
|
|
||||||
error_code="token_create_failed",
|
|
||||||
) from exc
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.CUSTOMER_TRACKING_TOKEN_CREATED,
|
|
||||||
aggregate_type="customer_tracking_token",
|
|
||||||
aggregate_id=entity.id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
payload={"dispatch_job_id": str(body.dispatch_job_id)},
|
|
||||||
)
|
|
||||||
await self.session.commit()
|
|
||||||
await self.session.refresh(entity)
|
|
||||||
return entity
|
|
||||||
|
|
||||||
async def get(self, tenant_id: UUID, token_id: UUID) -> CustomerTrackingToken:
|
|
||||||
entity = await self.repo.get(tenant_id, token_id)
|
|
||||||
if entity is None:
|
|
||||||
raise NotFoundError("توکن ردیابی یافت نشد")
|
|
||||||
return entity
|
|
||||||
|
|
||||||
async def list(
|
|
||||||
self,
|
|
||||||
tenant_id: UUID,
|
|
||||||
*,
|
|
||||||
offset: int = 0,
|
|
||||||
limit: int = 20,
|
|
||||||
spec: CustomerTrackingTokenListSpec | None = None,
|
|
||||||
) -> tuple[list[CustomerTrackingToken], int]:
|
|
||||||
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
|
||||||
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
|
||||||
|
|
||||||
async def revoke(
|
|
||||||
self, tenant_id: UUID, token_id: UUID, *, actor: CurrentUser | None = None
|
|
||||||
) -> CustomerTrackingToken:
|
|
||||||
entity = await self.get(tenant_id, token_id)
|
|
||||||
entity.status = CustomerTrackingTokenStatus.REVOKED
|
|
||||||
entity.updated_by = actor_id(actor)
|
|
||||||
await self.publisher.publish(
|
|
||||||
event_type=DeliveryEventType.CUSTOMER_TRACKING_TOKEN_REVOKED,
|
|
||||||
aggregate_type="customer_tracking_token",
|
|
||||||
aggregate_id=entity.id,
|
|
||||||
tenant_id=tenant_id,
|
|
||||||
payload={"dispatch_job_id": str(entity.dispatch_job_id)},
|
|
||||||
)
|
|
||||||
await self.session.commit()
|
|
||||||
await self.session.refresh(entity)
|
|
||||||
return entity
|
|
||||||
@ -1,77 +0,0 @@
|
|||||||
"""Availability query specifications — Phase 10.3."""
|
|
||||||
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.availability import Shift, WorkingZone
|
|
||||||
from app.models.types import ShiftStatus, WorkingZoneStatus
|
|
||||||
|
|
||||||
SHIFT_SORT = frozenset({"created_at", "starts_at", "ends_at", "code", "status"})
|
|
||||||
ZONE_SORT = frozenset({"created_at", "updated_at", "code", "name", "status"})
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class ShiftListSpec:
|
|
||||||
status: ShiftStatus | None = None
|
|
||||||
organization_id: UUID | None = None
|
|
||||||
hub_id: UUID | None = None
|
|
||||||
q: str | None = None
|
|
||||||
sort_by: str = "starts_at"
|
|
||||||
sort_dir: str = "desc"
|
|
||||||
|
|
||||||
def filter_clauses(self) -> list[ColumnElement]:
|
|
||||||
clauses: list[ColumnElement] = []
|
|
||||||
if self.status is not None:
|
|
||||||
clauses.append(Shift.status == self.status)
|
|
||||||
if self.organization_id is not None:
|
|
||||||
clauses.append(Shift.organization_id == self.organization_id)
|
|
||||||
if self.hub_id is not None:
|
|
||||||
clauses.append(Shift.hub_id == self.hub_id)
|
|
||||||
if self.q:
|
|
||||||
term = f"%{self.q.strip()}%"
|
|
||||||
clauses.append(or_(Shift.code.ilike(term), Shift.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 SHIFT_SORT else "starts_at"
|
|
||||||
column = getattr(Shift, sort_key)
|
|
||||||
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
|
||||||
return stmt.order_by(order)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class WorkingZoneListSpec:
|
|
||||||
status: WorkingZoneStatus | None = None
|
|
||||||
organization_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(WorkingZone.status == self.status)
|
|
||||||
if self.organization_id is not None:
|
|
||||||
clauses.append(WorkingZone.organization_id == self.organization_id)
|
|
||||||
if self.q:
|
|
||||||
term = f"%{self.q.strip()}%"
|
|
||||||
clauses.append(
|
|
||||||
or_(WorkingZone.code.ilike(term), WorkingZone.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 ZONE_SORT else "created_at"
|
|
||||||
column = getattr(WorkingZone, sort_key)
|
|
||||||
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
|
||||||
return stmt.order_by(order)
|
|
||||||
@ -1,79 +0,0 @@
|
|||||||
"""Dispatch query specifications — Phase 10.5."""
|
|
||||||
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.dispatch import DispatchJob, JobAssignment
|
|
||||||
from app.models.types import DispatchJobStatus, JobAssignmentStatus
|
|
||||||
|
|
||||||
JOB_SORT = frozenset({"created_at", "updated_at", "code", "status", "priority"})
|
|
||||||
ASSIGN_SORT = frozenset({"created_at", "updated_at", "status"})
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class DispatchJobListSpec:
|
|
||||||
status: DispatchJobStatus | 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(DispatchJob.status == self.status)
|
|
||||||
if self.organization_id is not None:
|
|
||||||
clauses.append(DispatchJob.organization_id == self.organization_id)
|
|
||||||
if self.hub_id is not None:
|
|
||||||
clauses.append(DispatchJob.hub_id == self.hub_id)
|
|
||||||
if self.q:
|
|
||||||
term = f"%{self.q.strip()}%"
|
|
||||||
clauses.append(
|
|
||||||
or_(
|
|
||||||
DispatchJob.code.ilike(term),
|
|
||||||
DispatchJob.external_order_ref.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 JOB_SORT else "created_at"
|
|
||||||
column = getattr(DispatchJob, sort_key)
|
|
||||||
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
|
||||||
return stmt.order_by(order)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class JobAssignmentListSpec:
|
|
||||||
dispatch_job_id: UUID | None = None
|
|
||||||
driver_id: UUID | None = None
|
|
||||||
status: JobAssignmentStatus | None = None
|
|
||||||
sort_by: str = "created_at"
|
|
||||||
sort_dir: str = "desc"
|
|
||||||
|
|
||||||
def filter_clauses(self) -> list[ColumnElement]:
|
|
||||||
clauses: list[ColumnElement] = []
|
|
||||||
if self.dispatch_job_id is not None:
|
|
||||||
clauses.append(JobAssignment.dispatch_job_id == self.dispatch_job_id)
|
|
||||||
if self.driver_id is not None:
|
|
||||||
clauses.append(JobAssignment.driver_id == self.driver_id)
|
|
||||||
if self.status is not None:
|
|
||||||
clauses.append(JobAssignment.status == self.status)
|
|
||||||
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 ASSIGN_SORT else "created_at"
|
|
||||||
column = getattr(JobAssignment, sort_key)
|
|
||||||
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
|
||||||
return stmt.order_by(order)
|
|
||||||
@ -1,103 +0,0 @@
|
|||||||
"""Fleet query specifications — Phase 10.2."""
|
|
||||||
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.fleet import Fleet, Vehicle, VehicleType
|
|
||||||
from app.models.types import FleetStatus, VehicleStatus
|
|
||||||
|
|
||||||
FLEET_SORT = frozenset({"created_at", "updated_at", "code", "name", "status"})
|
|
||||||
VT_SORT = frozenset({"created_at", "updated_at", "code", "name"})
|
|
||||||
VEHICLE_SORT = frozenset({"created_at", "updated_at", "code", "status"})
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class FleetListSpec:
|
|
||||||
status: FleetStatus | 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(Fleet.status == self.status)
|
|
||||||
if self.organization_id is not None:
|
|
||||||
clauses.append(Fleet.organization_id == self.organization_id)
|
|
||||||
if self.hub_id is not None:
|
|
||||||
clauses.append(Fleet.hub_id == self.hub_id)
|
|
||||||
if self.q:
|
|
||||||
term = f"%{self.q.strip()}%"
|
|
||||||
clauses.append(or_(Fleet.code.ilike(term), Fleet.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 FLEET_SORT else "created_at"
|
|
||||||
column = getattr(Fleet, sort_key)
|
|
||||||
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
|
||||||
return stmt.order_by(order)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class VehicleTypeListSpec:
|
|
||||||
q: str | None = None
|
|
||||||
sort_by: str = "created_at"
|
|
||||||
sort_dir: str = "desc"
|
|
||||||
|
|
||||||
def filter_clauses(self) -> list[ColumnElement]:
|
|
||||||
clauses: list[ColumnElement] = []
|
|
||||||
if self.q:
|
|
||||||
term = f"%{self.q.strip()}%"
|
|
||||||
clauses.append(
|
|
||||||
or_(VehicleType.code.ilike(term), VehicleType.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 VT_SORT else "created_at"
|
|
||||||
column = getattr(VehicleType, sort_key)
|
|
||||||
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
|
||||||
return stmt.order_by(order)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class VehicleListSpec:
|
|
||||||
fleet_id: UUID | None = None
|
|
||||||
status: VehicleStatus | 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.fleet_id is not None:
|
|
||||||
clauses.append(Vehicle.fleet_id == self.fleet_id)
|
|
||||||
if self.status is not None:
|
|
||||||
clauses.append(Vehicle.status == self.status)
|
|
||||||
if self.q:
|
|
||||||
term = f"%{self.q.strip()}%"
|
|
||||||
clauses.append(
|
|
||||||
or_(Vehicle.code.ilike(term), Vehicle.plate_number.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 VEHICLE_SORT else "created_at"
|
|
||||||
column = getattr(Vehicle, sort_key)
|
|
||||||
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
|
||||||
return stmt.order_by(order)
|
|
||||||
@ -1,108 +0,0 @@
|
|||||||
"""Pricing query specifications — Phase 10.4."""
|
|
||||||
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.pricing import CapabilityBundle, CapabilityDefinition, PricingRule
|
|
||||||
from app.models.types import CapabilityBundleStatus, CapabilityStatus, PricingRuleStatus
|
|
||||||
|
|
||||||
RULE_SORT = frozenset({"created_at", "updated_at", "code", "name", "status"})
|
|
||||||
CAP_SORT = frozenset({"created_at", "updated_at", "code", "name", "status"})
|
|
||||||
BUNDLE_SORT = frozenset({"created_at", "updated_at", "code", "name", "status"})
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class PricingRuleListSpec:
|
|
||||||
status: PricingRuleStatus | None = None
|
|
||||||
organization_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(PricingRule.status == self.status)
|
|
||||||
if self.organization_id is not None:
|
|
||||||
clauses.append(PricingRule.organization_id == self.organization_id)
|
|
||||||
if self.q:
|
|
||||||
term = f"%{self.q.strip()}%"
|
|
||||||
clauses.append(
|
|
||||||
or_(PricingRule.code.ilike(term), PricingRule.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 RULE_SORT else "created_at"
|
|
||||||
column = getattr(PricingRule, sort_key)
|
|
||||||
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
|
||||||
return stmt.order_by(order)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class CapabilityListSpec:
|
|
||||||
status: CapabilityStatus | 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(CapabilityDefinition.status == self.status)
|
|
||||||
if self.q:
|
|
||||||
term = f"%{self.q.strip()}%"
|
|
||||||
clauses.append(
|
|
||||||
or_(
|
|
||||||
CapabilityDefinition.code.ilike(term),
|
|
||||||
CapabilityDefinition.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 CAP_SORT else "created_at"
|
|
||||||
column = getattr(CapabilityDefinition, sort_key)
|
|
||||||
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
|
||||||
return stmt.order_by(order)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class BundleListSpec:
|
|
||||||
status: CapabilityBundleStatus | 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(CapabilityBundle.status == self.status)
|
|
||||||
if self.q:
|
|
||||||
term = f"%{self.q.strip()}%"
|
|
||||||
clauses.append(
|
|
||||||
or_(
|
|
||||||
CapabilityBundle.code.ilike(term),
|
|
||||||
CapabilityBundle.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 BUNDLE_SORT else "created_at"
|
|
||||||
column = getattr(CapabilityBundle, sort_key)
|
|
||||||
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
|
||||||
return stmt.order_by(order)
|
|
||||||
@ -1,71 +0,0 @@
|
|||||||
"""Routing query specifications — Phase 10.6."""
|
|
||||||
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.routing import OptimizationRun, RoutePlan
|
|
||||||
from app.models.types import OptimizationRunStatus, RoutePlanStatus
|
|
||||||
|
|
||||||
ROUTE_SORT = frozenset({"created_at", "updated_at", "code", "status"})
|
|
||||||
OPT_SORT = frozenset({"created_at", "updated_at", "status"})
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class RoutePlanListSpec:
|
|
||||||
status: RoutePlanStatus | None = None
|
|
||||||
organization_id: UUID | None = None
|
|
||||||
driver_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(RoutePlan.status == self.status)
|
|
||||||
if self.organization_id is not None:
|
|
||||||
clauses.append(RoutePlan.organization_id == self.organization_id)
|
|
||||||
if self.driver_id is not None:
|
|
||||||
clauses.append(RoutePlan.driver_id == self.driver_id)
|
|
||||||
if self.q:
|
|
||||||
term = f"%{self.q.strip()}%"
|
|
||||||
clauses.append(RoutePlan.code.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 ROUTE_SORT else "created_at"
|
|
||||||
column = getattr(RoutePlan, sort_key)
|
|
||||||
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
|
||||||
return stmt.order_by(order)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class OptimizationRunListSpec:
|
|
||||||
route_plan_id: UUID | None = None
|
|
||||||
status: OptimizationRunStatus | None = None
|
|
||||||
sort_by: str = "created_at"
|
|
||||||
sort_dir: str = "desc"
|
|
||||||
|
|
||||||
def filter_clauses(self) -> list[ColumnElement]:
|
|
||||||
clauses: list[ColumnElement] = []
|
|
||||||
if self.route_plan_id is not None:
|
|
||||||
clauses.append(OptimizationRun.route_plan_id == self.route_plan_id)
|
|
||||||
if self.status is not None:
|
|
||||||
clauses.append(OptimizationRun.status == self.status)
|
|
||||||
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 OPT_SORT else "created_at"
|
|
||||||
column = getattr(OptimizationRun, sort_key)
|
|
||||||
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
|
||||||
return stmt.order_by(order)
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
"""Settlement query specifications — Phase 10.8."""
|
|
||||||
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.settlement import SettlementIntent
|
|
||||||
from app.models.types import SettlementIntentStatus
|
|
||||||
|
|
||||||
INTENT_SORT = frozenset({"created_at", "updated_at", "code", "status"})
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class SettlementIntentListSpec:
|
|
||||||
status: SettlementIntentStatus | None = None
|
|
||||||
organization_id: UUID | None = None
|
|
||||||
driver_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(SettlementIntent.status == self.status)
|
|
||||||
if self.organization_id is not None:
|
|
||||||
clauses.append(SettlementIntent.organization_id == self.organization_id)
|
|
||||||
if self.driver_id is not None:
|
|
||||||
clauses.append(SettlementIntent.driver_id == self.driver_id)
|
|
||||||
if self.q:
|
|
||||||
term = f"%{self.q.strip()}%"
|
|
||||||
clauses.append(SettlementIntent.code.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 INTENT_SORT else "created_at"
|
|
||||||
column = getattr(SettlementIntent, sort_key)
|
|
||||||
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
|
||||||
return stmt.order_by(order)
|
|
||||||
@ -1,99 +0,0 @@
|
|||||||
"""Tracking query specifications — Phase 10.7."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy import Select, asc, desc
|
|
||||||
from sqlalchemy.sql import ColumnElement
|
|
||||||
|
|
||||||
from app.models.tracking import CustomerTrackingToken, ProofOfDelivery, TrackingSession
|
|
||||||
from app.models.types import (
|
|
||||||
CustomerTrackingTokenStatus,
|
|
||||||
ProofOfDeliveryStatus,
|
|
||||||
TrackingSessionStatus,
|
|
||||||
)
|
|
||||||
|
|
||||||
SESSION_SORT = frozenset({"created_at", "updated_at", "status"})
|
|
||||||
POD_SORT = frozenset({"created_at", "updated_at", "status"})
|
|
||||||
TOKEN_SORT = frozenset({"created_at", "updated_at", "status"})
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class TrackingSessionListSpec:
|
|
||||||
status: TrackingSessionStatus | None = None
|
|
||||||
dispatch_job_id: UUID | None = None
|
|
||||||
driver_id: UUID | 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(TrackingSession.status == self.status)
|
|
||||||
if self.dispatch_job_id is not None:
|
|
||||||
clauses.append(TrackingSession.dispatch_job_id == self.dispatch_job_id)
|
|
||||||
if self.driver_id is not None:
|
|
||||||
clauses.append(TrackingSession.driver_id == self.driver_id)
|
|
||||||
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 SESSION_SORT else "created_at"
|
|
||||||
column = getattr(TrackingSession, sort_key)
|
|
||||||
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
|
||||||
return stmt.order_by(order)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class ProofOfDeliveryListSpec:
|
|
||||||
status: ProofOfDeliveryStatus | None = None
|
|
||||||
dispatch_job_id: UUID | 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(ProofOfDelivery.status == self.status)
|
|
||||||
if self.dispatch_job_id is not None:
|
|
||||||
clauses.append(ProofOfDelivery.dispatch_job_id == self.dispatch_job_id)
|
|
||||||
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 POD_SORT else "created_at"
|
|
||||||
column = getattr(ProofOfDelivery, sort_key)
|
|
||||||
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
|
||||||
return stmt.order_by(order)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
|
||||||
class CustomerTrackingTokenListSpec:
|
|
||||||
status: CustomerTrackingTokenStatus | None = None
|
|
||||||
dispatch_job_id: UUID | 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(CustomerTrackingToken.status == self.status)
|
|
||||||
if self.dispatch_job_id is not None:
|
|
||||||
clauses.append(
|
|
||||||
CustomerTrackingToken.dispatch_job_id == self.dispatch_job_id
|
|
||||||
)
|
|
||||||
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 TOKEN_SORT else "created_at"
|
|
||||||
column = getattr(CustomerTrackingToken, sort_key)
|
|
||||||
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
|
||||||
return stmt.order_by(order)
|
|
||||||
@ -15,18 +15,16 @@ async def test_health_capabilities_metrics(client):
|
|||||||
caps = await client.get("/capabilities")
|
caps = await client.get("/capabilities")
|
||||||
assert caps.status_code == 200
|
assert caps.status_code == 200
|
||||||
c = caps.json()
|
c = caps.json()
|
||||||
assert c["phase"] == "10.8"
|
assert c["phase"] == "10.1"
|
||||||
assert c["commercial_product"] == "Torbat Driver"
|
assert c["commercial_product"] == "Torbat Driver"
|
||||||
assert c["features"]["foundation"] is True
|
assert c["features"]["foundation"] is True
|
||||||
assert c["features"]["drivers"] is True
|
assert c["features"]["drivers"] is True
|
||||||
assert c["features"]["dispatch_engine"] is True
|
assert c["features"]["dispatch_engine"] is False
|
||||||
assert c["features"]["settlement"] is True
|
|
||||||
assert c["independence"]["integration_mode"] == "api_and_events_only"
|
assert c["independence"]["integration_mode"] == "api_and_events_only"
|
||||||
assert c["independence"]["no_journal_entries_in_delivery_db"] is True
|
|
||||||
|
|
||||||
metrics = await client.get("/metrics")
|
metrics = await client.get("/metrics")
|
||||||
assert metrics.status_code == 200
|
assert metrics.status_code == 200
|
||||||
assert metrics.json()["phase"] == "10.8"
|
assert metrics.json()["phase"] == "10.1"
|
||||||
|
|
||||||
|
|
||||||
async def test_foundation_flow_and_events(client):
|
async def test_foundation_flow_and_events(client):
|
||||||
|
|||||||
@ -32,36 +32,6 @@ FOUNDATION_MODELS = [
|
|||||||
models.DeliveryAuditLog,
|
models.DeliveryAuditLog,
|
||||||
]
|
]
|
||||||
|
|
||||||
PHASE_TABLES = {
|
|
||||||
"drivers",
|
|
||||||
"driver_credentials",
|
|
||||||
"driver_documents",
|
|
||||||
"driver_lifecycle_events",
|
|
||||||
"fleets",
|
|
||||||
"vehicle_types",
|
|
||||||
"vehicles",
|
|
||||||
"vehicle_assignments",
|
|
||||||
"driver_availabilities",
|
|
||||||
"shifts",
|
|
||||||
"shift_assignments",
|
|
||||||
"working_zones",
|
|
||||||
"pricing_rules",
|
|
||||||
"capability_definitions",
|
|
||||||
"capability_bundles",
|
|
||||||
"dispatch_jobs",
|
|
||||||
"job_assignments",
|
|
||||||
"route_plans",
|
|
||||||
"route_stops",
|
|
||||||
"optimization_runs",
|
|
||||||
"tracking_sessions",
|
|
||||||
"tracking_points",
|
|
||||||
"proof_of_delivery",
|
|
||||||
"customer_tracking_tokens",
|
|
||||||
"settlement_intents",
|
|
||||||
"settlement_lines",
|
|
||||||
"outbox_events",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def test_all_models_have_tenant_id():
|
def test_all_models_have_tenant_id():
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
@ -82,55 +52,59 @@ def test_foundation_aggregates_are_independent():
|
|||||||
assert not model.__mapper__.relationships
|
assert not model.__mapper__.relationships
|
||||||
|
|
||||||
|
|
||||||
def test_phase_tables_registered():
|
def test_no_dispatch_engine_tables():
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
import app.models # noqa: F401
|
import app.models # noqa: F401
|
||||||
|
|
||||||
names = set(Base.metadata.tables.keys())
|
names = set(Base.metadata.tables.keys())
|
||||||
assert PHASE_TABLES.issubset(names)
|
assert "drivers" in names
|
||||||
assert "journal_entries" not 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():
|
def test_permissions_defined():
|
||||||
from app.permissions.definitions import ALL_PERMISSIONS, PERMISSION_PREFIXES
|
from app.permissions.definitions import ALL_PERMISSIONS, PERMISSION_PREFIXES
|
||||||
|
|
||||||
assert "delivery.view" in ALL_PERMISSIONS
|
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.create" in ALL_PERMISSIONS
|
||||||
assert "delivery.fleets.create" in ALL_PERMISSIONS
|
assert "delivery.drivers.activate" in ALL_PERMISSIONS
|
||||||
assert "delivery.vehicle_types.view" in ALL_PERMISSIONS
|
assert "delivery.drivers.credentials.manage" in ALL_PERMISSIONS
|
||||||
assert "delivery.vehicles.assignments.manage" in ALL_PERMISSIONS
|
assert "delivery.dispatch.manage" in ALL_PERMISSIONS
|
||||||
assert "delivery.availability.manage" in ALL_PERMISSIONS
|
|
||||||
assert "delivery.shifts.create" in ALL_PERMISSIONS
|
|
||||||
assert "delivery.working_zones.view" in ALL_PERMISSIONS
|
|
||||||
assert "delivery.pricing_rules.create" in ALL_PERMISSIONS
|
|
||||||
assert "delivery.capabilities.view" in ALL_PERMISSIONS
|
|
||||||
assert "delivery.bundles.manage" in ALL_PERMISSIONS
|
|
||||||
assert "delivery.dispatch.jobs.status" in ALL_PERMISSIONS
|
|
||||||
assert "delivery.routes.stops.manage" in ALL_PERMISSIONS
|
|
||||||
assert "delivery.optimization.runs.create" in ALL_PERMISSIONS
|
|
||||||
assert "delivery.tracking.sessions.view" in ALL_PERMISSIONS
|
|
||||||
assert "delivery.proof_of_delivery.create" in ALL_PERMISSIONS
|
|
||||||
assert "delivery.customer_tracking.revoke" in ALL_PERMISSIONS
|
|
||||||
assert "delivery.settlements.status" in ALL_PERMISSIONS
|
|
||||||
assert len(ALL_PERMISSIONS) >= 100
|
|
||||||
for prefix in PERMISSION_PREFIXES:
|
for prefix in PERMISSION_PREFIXES:
|
||||||
assert any(
|
assert any(p.startswith(prefix.rstrip(".")) or p.startswith(prefix) for p in ALL_PERMISSIONS)
|
||||||
p.startswith(prefix.rstrip(".")) or p.startswith(prefix)
|
|
||||||
for p in ALL_PERMISSIONS
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_events_defined():
|
def test_events_defined():
|
||||||
from app.events.types import DeliveryEventType
|
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_CREATED.value == "delivery.driver.created"
|
||||||
assert DeliveryEventType.FLEET_CREATED.value == "delivery.fleet.created"
|
assert DeliveryEventType.DRIVER_ACTIVATED.value == "delivery.driver.activated"
|
||||||
assert DeliveryEventType.SHIFT_CREATED.value == "delivery.shift.created"
|
assert DeliveryEventType.DRIVER_STATUS_CHANGED.value == "delivery.driver.status_changed"
|
||||||
assert DeliveryEventType.PRICING_RULE_CREATED.value == "delivery.pricing_rule.created"
|
|
||||||
assert DeliveryEventType.DISPATCH_JOB_CREATED.value == "delivery.dispatch_job.created"
|
|
||||||
assert DeliveryEventType.ROUTE_PLAN_CREATED.value == "delivery.route_plan.created"
|
|
||||||
assert DeliveryEventType.TRACKING_SESSION_STARTED.value == "delivery.tracking_session.started"
|
|
||||||
assert DeliveryEventType.SETTLEMENT_INTENT_CREATED.value == "delivery.settlement_intent.created"
|
|
||||||
|
|
||||||
|
|
||||||
def test_platform_provider_contracts_exist():
|
def test_platform_provider_contracts_exist():
|
||||||
@ -148,15 +122,15 @@ def test_platform_provider_contracts_exist():
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert AccountingProvider is not None
|
assert AccountingProvider is not None
|
||||||
|
assert CommunicationProvider is not None
|
||||||
assert RoutingEngineProvider is not None
|
assert RoutingEngineProvider is not None
|
||||||
assert FleetProvider is not None
|
assert FleetProvider is not None
|
||||||
assert StorageProvider is not None
|
|
||||||
assert IdentityProvider is not None
|
|
||||||
assert CRMProvider is not None
|
|
||||||
assert CommunicationProvider is not None
|
|
||||||
assert NotificationProvider is not None
|
|
||||||
assert LoyaltyProvider 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 AIProvider is not None
|
||||||
|
assert IdentityProvider is not None
|
||||||
|
|
||||||
|
|
||||||
def test_no_forbidden_service_imports():
|
def test_no_forbidden_service_imports():
|
||||||
@ -185,10 +159,8 @@ def test_api_ownership_is_delivery_only():
|
|||||||
prefixes = {getattr(r, "path", "") for r in api_router.routes}
|
prefixes = {getattr(r, "path", "") for r in api_router.routes}
|
||||||
joined = " ".join(sorted(prefixes))
|
joined = " ".join(sorted(prefixes))
|
||||||
assert "/organizations" in joined or any("/organizations" in p for p in 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)
|
assert "/drivers" in joined or any("/drivers" in p for p in prefixes)
|
||||||
assert "/fleets" in joined or any("/fleets" in p for p in prefixes)
|
|
||||||
assert "/dispatch/jobs" in joined or any("/dispatch/jobs" in p for p in prefixes)
|
|
||||||
assert "/settlements" in joined or any("/settlements" in p for p in prefixes)
|
|
||||||
forbidden = ("accounting", "crm", "loyalty", "notification", "automation", "sports")
|
forbidden = ("accounting", "crm", "loyalty", "notification", "automation", "sports")
|
||||||
for name in forbidden:
|
for name in forbidden:
|
||||||
assert not re.search(rf"(^|/){re.escape(name)}(/|$|\s)", joined), name
|
assert not re.search(rf"(^|/){re.escape(name)}(/|$|\s)", joined), name
|
||||||
|
|||||||
@ -20,51 +20,17 @@ EXPECTED_TABLES = {
|
|||||||
"driver_documents",
|
"driver_documents",
|
||||||
"driver_lifecycle_events",
|
"driver_lifecycle_events",
|
||||||
"outbox_events",
|
"outbox_events",
|
||||||
"fleets",
|
|
||||||
"vehicle_types",
|
|
||||||
"vehicles",
|
|
||||||
"vehicle_assignments",
|
|
||||||
"driver_availabilities",
|
|
||||||
"shifts",
|
|
||||||
"shift_assignments",
|
|
||||||
"working_zones",
|
|
||||||
"pricing_rules",
|
|
||||||
"capability_definitions",
|
|
||||||
"capability_bundles",
|
|
||||||
"dispatch_jobs",
|
|
||||||
"job_assignments",
|
|
||||||
"route_plans",
|
|
||||||
"route_stops",
|
|
||||||
"optimization_runs",
|
|
||||||
"tracking_sessions",
|
|
||||||
"tracking_points",
|
|
||||||
"proof_of_delivery",
|
|
||||||
"customer_tracking_tokens",
|
|
||||||
"settlement_intents",
|
|
||||||
"settlement_lines",
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_alembic_revision_exists():
|
def test_alembic_revision_exists():
|
||||||
versions = Path(__file__).resolve().parents[2] / "alembic" / "versions"
|
versions = Path(__file__).resolve().parents[2] / "alembic" / "versions"
|
||||||
assert list(versions.glob("0001_initial*.py")), "missing 0001_initial migration"
|
files = list(versions.glob("0001_initial*.py"))
|
||||||
assert list(versions.glob("0002_phase_101_drivers*.py")), "missing 0002 migration"
|
assert files, "missing 0001_initial migration"
|
||||||
assert list(versions.glob("0003_phase_102_fleet*.py")), "missing 0003 migration"
|
phase101 = list(versions.glob("0002_phase_101_drivers*.py"))
|
||||||
assert list(versions.glob("0004_phase_103_availability*.py")), "missing 0004 migration"
|
assert phase101, "missing 0002_phase_101_drivers migration"
|
||||||
assert list(versions.glob("0005_phase_104_pricing*.py")), "missing 0005 migration"
|
|
||||||
assert list(versions.glob("0006_phase_105_dispatch*.py")), "missing 0006 migration"
|
|
||||||
assert list(versions.glob("0007_phase_106_routing*.py")), "missing 0007 migration"
|
|
||||||
assert list(versions.glob("0008_phase_107_108_tracking_settlement*.py")), (
|
|
||||||
"missing 0008 migration"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_metadata_has_all_tables():
|
def test_metadata_has_foundation_tables():
|
||||||
names = set(Base.metadata.tables.keys())
|
names = set(Base.metadata.tables.keys())
|
||||||
assert EXPECTED_TABLES.issubset(names)
|
assert EXPECTED_TABLES.issubset(names)
|
||||||
|
|
||||||
|
|
||||||
def test_no_journal_entry_table():
|
|
||||||
names = set(Base.metadata.tables.keys())
|
|
||||||
assert "journal_entries" not in names
|
|
||||||
assert "JournalEntry".lower() not in {n.lower() for n in names}
|
|
||||||
|
|||||||
@ -1,93 +0,0 @@
|
|||||||
"""Phase 10.2 — Fleet & Vehicle tests."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.events.publisher import get_event_publisher
|
|
||||||
from app.tests.conftest import TENANT_A, tenant_headers
|
|
||||||
|
|
||||||
|
|
||||||
async def _org(client, code="ORG_F102"):
|
|
||||||
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 _vehicle_type(client, code="VT1"):
|
|
||||||
resp = await client.post(
|
|
||||||
"/api/v1/vehicle-types",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={"code": code, "name": f"Type {code}"},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 201, resp.text
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_fleet_vehicle_crud_and_assignment(client):
|
|
||||||
org = await _org(client)
|
|
||||||
vt = await _vehicle_type(client, "VAN")
|
|
||||||
|
|
||||||
fleet = await client.post(
|
|
||||||
"/api/v1/fleets",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={
|
|
||||||
"organization_id": org["id"],
|
|
||||||
"code": "FL1",
|
|
||||||
"name": "Main Fleet",
|
|
||||||
"status": "active",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert fleet.status_code == 201, fleet.text
|
|
||||||
fleet_body = fleet.json()
|
|
||||||
|
|
||||||
vehicle = await client.post(
|
|
||||||
"/api/v1/vehicles",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={
|
|
||||||
"fleet_id": fleet_body["id"],
|
|
||||||
"vehicle_type_id": vt["id"],
|
|
||||||
"code": "VH1",
|
|
||||||
"plate_number": "12A345-11",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert vehicle.status_code == 201, vehicle.text
|
|
||||||
vid = vehicle.json()["id"]
|
|
||||||
|
|
||||||
driver = await client.post(
|
|
||||||
"/api/v1/drivers",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={
|
|
||||||
"organization_id": org["id"],
|
|
||||||
"code": "DRV_F102",
|
|
||||||
"display_name": "Fleet Driver",
|
|
||||||
"status": "active",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert driver.status_code == 201
|
|
||||||
did = driver.json()["id"]
|
|
||||||
|
|
||||||
assign = await client.post(
|
|
||||||
f"/api/v1/vehicles/{vid}/assignments",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={"driver_id": did},
|
|
||||||
)
|
|
||||||
assert assign.status_code == 201, assign.text
|
|
||||||
assert assign.json()["status"] == "active"
|
|
||||||
|
|
||||||
listed = await client.get(
|
|
||||||
"/api/v1/vehicles",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
params={"fleet_id": fleet_body["id"]},
|
|
||||||
)
|
|
||||||
assert listed.status_code == 200
|
|
||||||
assert listed.json()["total"] == 1
|
|
||||||
|
|
||||||
events = {e.event_type for e in get_event_publisher().published}
|
|
||||||
assert "delivery.fleet.created" in events
|
|
||||||
assert "delivery.vehicle.created" in events
|
|
||||||
assert "delivery.vehicle_assignment.created" in events
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
"""Phase 10.3 — Availability, Shifts & Working Zones tests."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta, timezone
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.events.publisher import get_event_publisher
|
|
||||||
from app.tests.conftest import TENANT_A, tenant_headers
|
|
||||||
|
|
||||||
|
|
||||||
async def _org(client, code="ORG_A103"):
|
|
||||||
resp = await client.post(
|
|
||||||
"/api/v1/organizations",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={"code": code, "name": code, "status": "active"},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 201
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_availability_shift_zone_flow(client):
|
|
||||||
org = await _org(client)
|
|
||||||
driver = await client.post(
|
|
||||||
"/api/v1/drivers",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={
|
|
||||||
"organization_id": org["id"],
|
|
||||||
"code": "DRV_A103",
|
|
||||||
"display_name": "Shift Driver",
|
|
||||||
"status": "active",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert driver.status_code == 201
|
|
||||||
did = driver.json()["id"]
|
|
||||||
|
|
||||||
avail = await client.put(
|
|
||||||
f"/api/v1/drivers/{did}/availability",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={"status": "online"},
|
|
||||||
)
|
|
||||||
assert avail.status_code == 200, avail.text
|
|
||||||
assert avail.json()["status"] == "online"
|
|
||||||
|
|
||||||
start = datetime.now(timezone.utc)
|
|
||||||
end = start + timedelta(hours=8)
|
|
||||||
shift = await client.post(
|
|
||||||
"/api/v1/shifts",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={
|
|
||||||
"organization_id": org["id"],
|
|
||||||
"code": "SH1",
|
|
||||||
"name": "Morning",
|
|
||||||
"starts_at": start.isoformat(),
|
|
||||||
"ends_at": end.isoformat(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert shift.status_code == 201, shift.text
|
|
||||||
sid = shift.json()["id"]
|
|
||||||
|
|
||||||
assign = await client.post(
|
|
||||||
f"/api/v1/shifts/{sid}/assignments",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={"driver_id": did},
|
|
||||||
)
|
|
||||||
assert assign.status_code == 201
|
|
||||||
|
|
||||||
zone = await client.post(
|
|
||||||
"/api/v1/working-zones",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={
|
|
||||||
"organization_id": org["id"],
|
|
||||||
"code": "ZN1",
|
|
||||||
"name": "North Zone",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert zone.status_code == 201, zone.text
|
|
||||||
|
|
||||||
events = {e.event_type for e in get_event_publisher().published}
|
|
||||||
assert "delivery.driver_availability.updated" in events
|
|
||||||
assert "delivery.shift.created" in events
|
|
||||||
assert "delivery.working_zone.created" in events
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
"""Phase 10.4 — Pricing, Capabilities & Bundles tests."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.events.publisher import get_event_publisher
|
|
||||||
from app.tests.conftest import TENANT_A, tenant_headers
|
|
||||||
|
|
||||||
|
|
||||||
async def _org(client):
|
|
||||||
resp = await client.post(
|
|
||||||
"/api/v1/organizations",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={"code": "ORG_P104", "name": "Pricing Org", "status": "active"},
|
|
||||||
)
|
|
||||||
assert resp.status_code == 201
|
|
||||||
return resp.json()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_pricing_capability_bundle_flow(client):
|
|
||||||
org = await _org(client)
|
|
||||||
|
|
||||||
rule = await client.post(
|
|
||||||
"/api/v1/pricing-rules",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={
|
|
||||||
"organization_id": org["id"],
|
|
||||||
"code": "PR1",
|
|
||||||
"name": "Base Delivery",
|
|
||||||
"base_amount": "50000",
|
|
||||||
"currency_code": "IRR",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert rule.status_code == 201, rule.text
|
|
||||||
rid = rule.json()["id"]
|
|
||||||
|
|
||||||
cap = await client.post(
|
|
||||||
"/api/v1/capabilities",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={"code": "COLD", "name": "Cold Chain"},
|
|
||||||
)
|
|
||||||
assert cap.status_code == 201
|
|
||||||
|
|
||||||
bundle = await client.post(
|
|
||||||
"/api/v1/bundles",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={
|
|
||||||
"code": "BND1",
|
|
||||||
"name": "Cold Bundle",
|
|
||||||
"capability_codes": ["COLD"],
|
|
||||||
"pricing_rule_id": rid,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert bundle.status_code == 201, bundle.text
|
|
||||||
|
|
||||||
listed = await client.get(
|
|
||||||
"/api/v1/pricing-rules",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
)
|
|
||||||
assert listed.status_code == 200
|
|
||||||
assert listed.json()["total"] >= 1
|
|
||||||
|
|
||||||
events = {e.event_type for e in get_event_publisher().published}
|
|
||||||
assert "delivery.pricing_rule.created" in events
|
|
||||||
assert "delivery.capability.created" in events
|
|
||||||
assert "delivery.bundle.created" in events
|
|
||||||
@ -1,73 +0,0 @@
|
|||||||
"""Phase 10.5 — Dispatch Engine tests."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.events.publisher import get_event_publisher
|
|
||||||
from app.models.types import DispatchJobAction
|
|
||||||
from app.tests.conftest import TENANT_A, tenant_headers
|
|
||||||
from app.validators.dispatch import ensure_dispatch_job_transition
|
|
||||||
|
|
||||||
|
|
||||||
def test_dispatch_transition_matrix():
|
|
||||||
from app.models.types import DispatchJobStatus
|
|
||||||
|
|
||||||
ensure_dispatch_job_transition(
|
|
||||||
action=DispatchJobAction.ASSIGN, current=DispatchJobStatus.PENDING
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_dispatch_job_lifecycle(client):
|
|
||||||
org = await client.post(
|
|
||||||
"/api/v1/organizations",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={"code": "ORG_D105", "name": "Dispatch Org", "status": "active"},
|
|
||||||
)
|
|
||||||
assert org.status_code == 201
|
|
||||||
org_id = org.json()["id"]
|
|
||||||
|
|
||||||
job = await client.post(
|
|
||||||
"/api/v1/dispatch/jobs",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={
|
|
||||||
"organization_id": org_id,
|
|
||||||
"code": "JOB1",
|
|
||||||
"external_order_ref": "ORD-100",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert job.status_code == 201, job.text
|
|
||||||
jid = job.json()["id"]
|
|
||||||
version = job.json()["version"]
|
|
||||||
|
|
||||||
assigned = await client.post(
|
|
||||||
f"/api/v1/dispatch/jobs/{jid}/status",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={"action": "assign", "version": version},
|
|
||||||
)
|
|
||||||
assert assigned.status_code == 200
|
|
||||||
assert assigned.json()["status"] == "assigned"
|
|
||||||
|
|
||||||
driver = await client.post(
|
|
||||||
"/api/v1/drivers",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={
|
|
||||||
"organization_id": org_id,
|
|
||||||
"code": "DRV_D105",
|
|
||||||
"display_name": "Dispatch Driver",
|
|
||||||
"status": "active",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert driver.status_code == 201
|
|
||||||
|
|
||||||
assignment = await client.post(
|
|
||||||
"/api/v1/dispatch/assignments",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={"dispatch_job_id": jid, "driver_id": driver.json()["id"]},
|
|
||||||
)
|
|
||||||
assert assignment.status_code == 201
|
|
||||||
|
|
||||||
events = {e.event_type for e in get_event_publisher().published}
|
|
||||||
assert "delivery.dispatch_job.created" in events
|
|
||||||
assert "delivery.dispatch_job.status_changed" in events
|
|
||||||
assert "delivery.job_assignment.created" in events
|
|
||||||
@ -1,46 +0,0 @@
|
|||||||
"""Phase 10.6 — Routing & Optimization tests."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.events.publisher import get_event_publisher
|
|
||||||
from app.tests.conftest import TENANT_A, tenant_headers
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_route_plan_and_optimization(client):
|
|
||||||
org = await client.post(
|
|
||||||
"/api/v1/organizations",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={"code": "ORG_R106", "name": "Route Org", "status": "active"},
|
|
||||||
)
|
|
||||||
assert org.status_code == 201
|
|
||||||
org_id = org.json()["id"]
|
|
||||||
|
|
||||||
route = await client.post(
|
|
||||||
"/api/v1/routes",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={"organization_id": org_id, "code": "RT1"},
|
|
||||||
)
|
|
||||||
assert route.status_code == 201, route.text
|
|
||||||
pid = route.json()["id"]
|
|
||||||
|
|
||||||
stop = await client.post(
|
|
||||||
f"/api/v1/routes/{pid}/stops",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={"sequence": 0, "address_ref": "addr://1"},
|
|
||||||
)
|
|
||||||
assert stop.status_code == 201
|
|
||||||
|
|
||||||
run = await client.post(
|
|
||||||
"/api/v1/optimization/runs",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={"route_plan_id": pid, "routing_engine_ref": "engine://default"},
|
|
||||||
)
|
|
||||||
assert run.status_code == 201, run.text
|
|
||||||
assert run.json()["status"] == "completed"
|
|
||||||
|
|
||||||
events = {e.event_type for e in get_event_publisher().published}
|
|
||||||
assert "delivery.route_plan.created" in events
|
|
||||||
assert "delivery.route_stop.added" in events
|
|
||||||
assert "delivery.optimization_run.completed" in events
|
|
||||||
@ -1,73 +0,0 @@
|
|||||||
"""Phase 10.7 — Tracking & Proof of Delivery tests."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.events.publisher import get_event_publisher
|
|
||||||
from app.tests.conftest import TENANT_A, tenant_headers
|
|
||||||
|
|
||||||
|
|
||||||
async def _dispatch_job(client):
|
|
||||||
org = await client.post(
|
|
||||||
"/api/v1/organizations",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={"code": "ORG_T107", "name": "Track Org", "status": "active"},
|
|
||||||
)
|
|
||||||
assert org.status_code == 201
|
|
||||||
job = await client.post(
|
|
||||||
"/api/v1/dispatch/jobs",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={"organization_id": org.json()["id"], "code": "TJ1"},
|
|
||||||
)
|
|
||||||
assert job.status_code == 201
|
|
||||||
return job.json()["id"]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_tracking_pod_and_customer_token(client):
|
|
||||||
jid = await _dispatch_job(client)
|
|
||||||
|
|
||||||
session = await client.post(
|
|
||||||
"/api/v1/tracking/sessions",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={"dispatch_job_id": jid},
|
|
||||||
)
|
|
||||||
assert session.status_code == 201, session.text
|
|
||||||
sid = session.json()["id"]
|
|
||||||
|
|
||||||
point = await client.post(
|
|
||||||
f"/api/v1/tracking/sessions/{sid}/points",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={
|
|
||||||
"latitude": 35.6892,
|
|
||||||
"longitude": 51.389,
|
|
||||||
"recorded_at": datetime.now(timezone.utc).isoformat(),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert point.status_code == 201
|
|
||||||
|
|
||||||
pod = await client.post(
|
|
||||||
"/api/v1/proof-of-delivery",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={
|
|
||||||
"dispatch_job_id": jid,
|
|
||||||
"signature_file_ref": "storage://pod/sig-1",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert pod.status_code == 201, pod.text
|
|
||||||
|
|
||||||
token = await client.post(
|
|
||||||
"/api/v1/customer-tracking/tokens",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={"dispatch_job_id": jid},
|
|
||||||
)
|
|
||||||
assert token.status_code == 201, token.text
|
|
||||||
assert token.json()["token"]
|
|
||||||
|
|
||||||
events = {e.event_type for e in get_event_publisher().published}
|
|
||||||
assert "delivery.tracking_session.started" in events
|
|
||||||
assert "delivery.tracking_point.recorded" in events
|
|
||||||
assert "delivery.proof_of_delivery.captured" in events
|
|
||||||
assert "delivery.customer_tracking_token.created" in events
|
|
||||||
@ -1,80 +0,0 @@
|
|||||||
"""Phase 10.8 — Settlement tests (AccountingProvider refs only)."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
from app.core.database import Base
|
|
||||||
from app.events.publisher import get_event_publisher
|
|
||||||
import app.models # noqa: F401
|
|
||||||
from app.tests.conftest import TENANT_A, tenant_headers
|
|
||||||
|
|
||||||
|
|
||||||
def test_no_journal_entry_table_in_delivery_db():
|
|
||||||
names = set(Base.metadata.tables.keys())
|
|
||||||
forbidden = {
|
|
||||||
"journal_entries",
|
|
||||||
"journal_entry",
|
|
||||||
"accounting_journal_entries",
|
|
||||||
"accounting_journals",
|
|
||||||
}
|
|
||||||
assert names.isdisjoint(forbidden)
|
|
||||||
for name in names:
|
|
||||||
assert "journal" not in name.lower()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_settlement_intent_via_accounting_provider_ref(client):
|
|
||||||
org = await client.post(
|
|
||||||
"/api/v1/organizations",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={"code": "ORG_S108", "name": "Settlement Org", "status": "active"},
|
|
||||||
)
|
|
||||||
assert org.status_code == 201
|
|
||||||
org_id = org.json()["id"]
|
|
||||||
|
|
||||||
intent = await client.post(
|
|
||||||
"/api/v1/settlements",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={
|
|
||||||
"organization_id": org_id,
|
|
||||||
"code": "SET1",
|
|
||||||
"total_amount": "120000",
|
|
||||||
"currency_code": "IRR",
|
|
||||||
},
|
|
||||||
)
|
|
||||||
assert intent.status_code == 201, intent.text
|
|
||||||
iid = intent.json()["id"]
|
|
||||||
version = intent.json()["version"]
|
|
||||||
assert intent.json()["external_settlement_ref"] is None
|
|
||||||
|
|
||||||
line = await client.post(
|
|
||||||
f"/api/v1/settlements/{iid}/lines",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={"kind": "delivery_fee", "amount": "100000", "currency_code": "IRR"},
|
|
||||||
)
|
|
||||||
assert line.status_code == 201
|
|
||||||
|
|
||||||
submitted = await client.post(
|
|
||||||
f"/api/v1/settlements/{iid}/submit",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={"version": version},
|
|
||||||
)
|
|
||||||
assert submitted.status_code == 200
|
|
||||||
assert submitted.json()["status"] == "submitted"
|
|
||||||
version = submitted.json()["version"]
|
|
||||||
|
|
||||||
settled = await client.post(
|
|
||||||
f"/api/v1/settlements/{iid}/settle",
|
|
||||||
headers=tenant_headers(TENANT_A),
|
|
||||||
json={"version": version},
|
|
||||||
)
|
|
||||||
assert settled.status_code == 200
|
|
||||||
assert settled.json()["status"] == "settled"
|
|
||||||
assert settled.json()["external_settlement_ref"] is not None
|
|
||||||
assert settled.json()["external_settlement_ref"].startswith("acct://")
|
|
||||||
|
|
||||||
events = {e.event_type for e in get_event_publisher().published}
|
|
||||||
assert "delivery.settlement_intent.created" in events
|
|
||||||
assert "delivery.settlement_intent.submitted" in events
|
|
||||||
assert "delivery.settlement_intent.settled" in events
|
|
||||||
assert "delivery.settlement_line.added" in events
|
|
||||||
@ -1,36 +0,0 @@
|
|||||||
"""Availability validators — Phase 10.3."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from shared.exceptions import AppError
|
|
||||||
|
|
||||||
from app.validators.foundation import validate_non_empty
|
|
||||||
|
|
||||||
|
|
||||||
def validate_shift_window(*, starts_at: datetime, ends_at: datetime) -> None:
|
|
||||||
if ends_at <= starts_at:
|
|
||||||
raise AppError(
|
|
||||||
"زمان پایان شیفت باید بعد از زمان شروع باشد",
|
|
||||||
status_code=422,
|
|
||||||
error_code="invalid_shift_window",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_availability_window(
|
|
||||||
*, effective_from: datetime | None, effective_until: datetime | None
|
|
||||||
) -> None:
|
|
||||||
if effective_from and effective_until and effective_until <= effective_from:
|
|
||||||
raise AppError(
|
|
||||||
"بازه دسترسی نامعتبر است",
|
|
||||||
status_code=422,
|
|
||||||
error_code="invalid_availability_window",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_shift_name(name: str) -> str:
|
|
||||||
return validate_non_empty(name, "name")
|
|
||||||
|
|
||||||
|
|
||||||
def validate_zone_name(name: str) -> str:
|
|
||||||
return validate_non_empty(name, "name")
|
|
||||||
@ -1,84 +0,0 @@
|
|||||||
"""Dispatch validators — Phase 10.5."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from shared.exceptions import AppError
|
|
||||||
|
|
||||||
from app.models.types import DispatchJobAction, DispatchJobStatus
|
|
||||||
|
|
||||||
ALLOWED_TRANSITIONS: dict[DispatchJobAction, set[DispatchJobStatus]] = {
|
|
||||||
DispatchJobAction.ASSIGN: {DispatchJobStatus.PENDING},
|
|
||||||
DispatchJobAction.START: {DispatchJobStatus.ASSIGNED},
|
|
||||||
DispatchJobAction.COMPLETE: {
|
|
||||||
DispatchJobStatus.ASSIGNED,
|
|
||||||
DispatchJobStatus.IN_PROGRESS,
|
|
||||||
},
|
|
||||||
DispatchJobAction.CANCEL: {
|
|
||||||
DispatchJobStatus.PENDING,
|
|
||||||
DispatchJobStatus.ASSIGNED,
|
|
||||||
DispatchJobStatus.IN_PROGRESS,
|
|
||||||
},
|
|
||||||
DispatchJobAction.FAIL: {
|
|
||||||
DispatchJobStatus.PENDING,
|
|
||||||
DispatchJobStatus.ASSIGNED,
|
|
||||||
DispatchJobStatus.IN_PROGRESS,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
TARGET_STATUS: dict[DispatchJobAction, DispatchJobStatus] = {
|
|
||||||
DispatchJobAction.ASSIGN: DispatchJobStatus.ASSIGNED,
|
|
||||||
DispatchJobAction.START: DispatchJobStatus.IN_PROGRESS,
|
|
||||||
DispatchJobAction.COMPLETE: DispatchJobStatus.COMPLETED,
|
|
||||||
DispatchJobAction.CANCEL: DispatchJobStatus.CANCELLED,
|
|
||||||
DispatchJobAction.FAIL: DispatchJobStatus.FAILED,
|
|
||||||
}
|
|
||||||
|
|
||||||
TERMINAL = frozenset(
|
|
||||||
{DispatchJobStatus.COMPLETED, DispatchJobStatus.CANCELLED, DispatchJobStatus.FAILED}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_dispatch_job_transition(
|
|
||||||
*, action: DispatchJobAction, current: DispatchJobStatus
|
|
||||||
) -> None:
|
|
||||||
if current in TERMINAL:
|
|
||||||
raise AppError(
|
|
||||||
"کار ارسال در وضعیت پایانی است",
|
|
||||||
status_code=409,
|
|
||||||
error_code="dispatch_job_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_dispatch_job_transition",
|
|
||||||
details={
|
|
||||||
"from_status": current.value,
|
|
||||||
"action": action.value,
|
|
||||||
"allowed_from": sorted(s.value for s in allowed),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def target_status_for(action: DispatchJobAction) -> DispatchJobStatus:
|
|
||||||
return TARGET_STATUS[action]
|
|
||||||
|
|
||||||
|
|
||||||
def validate_dispatch_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
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
"""Fleet validators — Phase 10.2."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from app.validators.foundation import validate_non_empty
|
|
||||||
|
|
||||||
|
|
||||||
def validate_fleet_name(name: str) -> str:
|
|
||||||
return validate_non_empty(name, "name")
|
|
||||||
|
|
||||||
|
|
||||||
def validate_vehicle_type_name(name: str) -> str:
|
|
||||||
return validate_non_empty(name, "name")
|
|
||||||
|
|
||||||
|
|
||||||
def validate_plate_number(plate: str | None) -> str | None:
|
|
||||||
if plate is None or not str(plate).strip():
|
|
||||||
return None
|
|
||||||
value = str(plate).strip()
|
|
||||||
if len(value) > 32:
|
|
||||||
from shared.exceptions import AppError
|
|
||||||
|
|
||||||
raise AppError(
|
|
||||||
"شماره پلاک بیش از حد طولانی است",
|
|
||||||
status_code=422,
|
|
||||||
error_code="plate_too_long",
|
|
||||||
)
|
|
||||||
return value
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
"""Pricing validators — Phase 10.4."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
from shared.exceptions import AppError
|
|
||||||
|
|
||||||
from app.validators.foundation import validate_currency_code, validate_non_empty
|
|
||||||
|
|
||||||
|
|
||||||
def validate_pricing_name(name: str) -> str:
|
|
||||||
return validate_non_empty(name, "name")
|
|
||||||
|
|
||||||
|
|
||||||
def validate_base_amount(amount: Decimal) -> Decimal:
|
|
||||||
if amount <= 0:
|
|
||||||
raise AppError(
|
|
||||||
"مبلغ پایه باید بزرگتر از صفر باشد",
|
|
||||||
status_code=422,
|
|
||||||
error_code="invalid_base_amount",
|
|
||||||
)
|
|
||||||
return amount
|
|
||||||
|
|
||||||
|
|
||||||
def validate_line_amount(amount: Decimal) -> Decimal:
|
|
||||||
return validate_base_amount(amount)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_capability_name(name: str) -> str:
|
|
||||||
return validate_non_empty(name, "name")
|
|
||||||
|
|
||||||
|
|
||||||
def validate_bundle_name(name: str) -> str:
|
|
||||||
return validate_non_empty(name, "name")
|
|
||||||
|
|
||||||
|
|
||||||
def validate_currency(currency_code: str) -> str:
|
|
||||||
return validate_currency_code(currency_code)
|
|
||||||
@ -1,25 +0,0 @@
|
|||||||
"""Routing validators — Phase 10.6."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from shared.exceptions import AppError
|
|
||||||
|
|
||||||
|
|
||||||
def validate_route_code_present(code: str) -> str:
|
|
||||||
code = str(code).strip()
|
|
||||||
if not code:
|
|
||||||
raise AppError(
|
|
||||||
"کد مسیر الزامی است",
|
|
||||||
status_code=422,
|
|
||||||
error_code="route_code_required",
|
|
||||||
)
|
|
||||||
return code
|
|
||||||
|
|
||||||
|
|
||||||
def validate_stop_sequence(sequence: int) -> int:
|
|
||||||
if sequence < 0:
|
|
||||||
raise AppError(
|
|
||||||
"ترتیب توقف نامعتبر است",
|
|
||||||
status_code=422,
|
|
||||||
error_code="invalid_stop_sequence",
|
|
||||||
)
|
|
||||||
return sequence
|
|
||||||
@ -1,66 +0,0 @@
|
|||||||
"""Settlement validators — Phase 10.8."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from decimal import Decimal
|
|
||||||
|
|
||||||
from shared.exceptions import AppError
|
|
||||||
|
|
||||||
from app.models.types import SettlementIntentStatus
|
|
||||||
|
|
||||||
ALLOWED_TRANSITIONS: dict[str, set[SettlementIntentStatus]] = {
|
|
||||||
"submit": {SettlementIntentStatus.DRAFT},
|
|
||||||
"settle": {SettlementIntentStatus.SUBMITTED},
|
|
||||||
"fail": {SettlementIntentStatus.SUBMITTED},
|
|
||||||
"cancel": {SettlementIntentStatus.DRAFT, SettlementIntentStatus.PENDING},
|
|
||||||
}
|
|
||||||
|
|
||||||
TARGET_STATUS: dict[str, SettlementIntentStatus] = {
|
|
||||||
"submit": SettlementIntentStatus.SUBMITTED,
|
|
||||||
"settle": SettlementIntentStatus.SETTLED,
|
|
||||||
"fail": SettlementIntentStatus.FAILED,
|
|
||||||
"cancel": SettlementIntentStatus.CANCELLED,
|
|
||||||
}
|
|
||||||
|
|
||||||
TERMINAL = frozenset(
|
|
||||||
{
|
|
||||||
SettlementIntentStatus.SETTLED,
|
|
||||||
SettlementIntentStatus.FAILED,
|
|
||||||
SettlementIntentStatus.CANCELLED,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def ensure_settlement_transition(*, action: str, current: SettlementIntentStatus) -> None:
|
|
||||||
if current in TERMINAL:
|
|
||||||
raise AppError(
|
|
||||||
"تسویه در وضعیت پایانی است",
|
|
||||||
status_code=409,
|
|
||||||
error_code="settlement_terminal",
|
|
||||||
details={"status": current.value, "action": action},
|
|
||||||
)
|
|
||||||
allowed = ALLOWED_TRANSITIONS.get(action, set())
|
|
||||||
if current not in allowed:
|
|
||||||
raise AppError(
|
|
||||||
"انتقال وضعیت تسویه مجاز نیست",
|
|
||||||
status_code=409,
|
|
||||||
error_code="invalid_settlement_transition",
|
|
||||||
details={
|
|
||||||
"from_status": current.value,
|
|
||||||
"action": action,
|
|
||||||
"allowed_from": sorted(s.value for s in allowed),
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def target_settlement_status(action: str) -> SettlementIntentStatus:
|
|
||||||
return TARGET_STATUS[action]
|
|
||||||
|
|
||||||
|
|
||||||
def validate_settlement_amount(amount: Decimal) -> Decimal:
|
|
||||||
if amount <= 0:
|
|
||||||
raise AppError(
|
|
||||||
"مبلغ تسویه باید بزرگتر از صفر باشد",
|
|
||||||
status_code=422,
|
|
||||||
error_code="invalid_settlement_amount",
|
|
||||||
)
|
|
||||||
return amount
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
"""Tracking validators — Phase 10.7."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from shared.exceptions import AppError
|
|
||||||
|
|
||||||
|
|
||||||
def validate_coordinates(*, latitude: float, longitude: float) -> None:
|
|
||||||
if not (-90 <= latitude <= 90):
|
|
||||||
raise AppError(
|
|
||||||
"عرض جغرافیایی نامعتبر است",
|
|
||||||
status_code=422,
|
|
||||||
error_code="invalid_latitude",
|
|
||||||
)
|
|
||||||
if not (-180 <= longitude <= 180):
|
|
||||||
raise AppError(
|
|
||||||
"طول جغرافیایی نامعتبر است",
|
|
||||||
status_code=422,
|
|
||||||
error_code="invalid_longitude",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def validate_pod_refs(
|
|
||||||
*, signature_file_ref: str | None, photo_file_ref: str | None
|
|
||||||
) -> None:
|
|
||||||
if not signature_file_ref and not photo_file_ref:
|
|
||||||
raise AppError(
|
|
||||||
"حداقل یک مرجع فایل برای اثبات تحویل الزامی است",
|
|
||||||
status_code=422,
|
|
||||||
error_code="pod_file_ref_required",
|
|
||||||
)
|
|
||||||
@ -1,38 +0,0 @@
|
|||||||
"""Build all delivery phase 10.2-10.8 files."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
|
||||||
sys.path.insert(0, str(ROOT))
|
|
||||||
|
|
||||||
FILES: dict[str, str] = {}
|
|
||||||
|
|
||||||
|
|
||||||
def register(rel: str, content: str) -> None:
|
|
||||||
FILES[rel] = content.rstrip() + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
_register_repositories()
|
|
||||||
_register_specifications()
|
|
||||||
_register_validators()
|
|
||||||
_register_policies()
|
|
||||||
_register_services()
|
|
||||||
_register_commands_queries()
|
|
||||||
_register_api()
|
|
||||||
_register_migrations()
|
|
||||||
_register_tests()
|
|
||||||
_register_updates()
|
|
||||||
|
|
||||||
for rel, content in sorted(FILES.items()):
|
|
||||||
path = ROOT / rel
|
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
|
||||||
path.write_text(content, encoding="utf-8")
|
|
||||||
print(f"wrote {rel}")
|
|
||||||
print(f"total: {len(FILES)} files")
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user