Compare commits

...

2 Commits

Author SHA1 Message Date
Mortezakoohjani
0eec9f729b feat(experience): deploy Experience frontend FE-11.0-11.5 portal
Commit completed Torbat Pages admin frontend module, BFF proxy, routes, and validation artifacts for official platform deploy.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 21:01:50 +03:30
Mortezakoohjani
72077908f1 feat(delivery): deploy backend phases 10.2-10.8 through settlement
Ship fleet, availability, pricing, dispatch, routing, tracking, and settlement on delivery-service 0.10.8.0 with migrations and phase tests green.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-27 21:01:23 +03:30
363 changed files with 17186 additions and 152 deletions

View File

@ -0,0 +1,29 @@
"""Phase 10.2 — Fleet & Vehicle Types schema (additive)."""
from alembic import op
from app.core.database import Base
import app.models # noqa: F401
revision = "0003_phase_102_fleet"
down_revision = "0002_phase_101_drivers"
branch_labels = None
depends_on = None
_NEW_TABLES = (
"fleets",
"vehicle_types",
"vehicles",
"vehicle_assignments",
)
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
for name in reversed(_NEW_TABLES):
table = Base.metadata.tables.get(name)
if table is not None:
table.drop(bind=bind, checkfirst=True)

View File

@ -0,0 +1,29 @@
"""Phase 10.3 — Availability, Shifts & Working Zones schema (additive)."""
from alembic import op
from app.core.database import Base
import app.models # noqa: F401
revision = "0004_phase_103_availability"
down_revision = "0003_phase_102_fleet"
branch_labels = None
depends_on = None
_NEW_TABLES = (
"driver_availabilities",
"shifts",
"shift_assignments",
"working_zones",
)
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
for name in reversed(_NEW_TABLES):
table = Base.metadata.tables.get(name)
if table is not None:
table.drop(bind=bind, checkfirst=True)

View File

@ -0,0 +1,28 @@
"""Phase 10.4 — Pricing, Capabilities & Bundles schema (additive)."""
from alembic import op
from app.core.database import Base
import app.models # noqa: F401
revision = "0005_phase_104_pricing"
down_revision = "0004_phase_103_availability"
branch_labels = None
depends_on = None
_NEW_TABLES = (
"pricing_rules",
"capability_definitions",
"capability_bundles",
)
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
for name in reversed(_NEW_TABLES):
table = Base.metadata.tables.get(name)
if table is not None:
table.drop(bind=bind, checkfirst=True)

View File

@ -0,0 +1,27 @@
"""Phase 10.5 — Dispatch Engine schema (additive)."""
from alembic import op
from app.core.database import Base
import app.models # noqa: F401
revision = "0006_phase_105_dispatch"
down_revision = "0005_phase_104_pricing"
branch_labels = None
depends_on = None
_NEW_TABLES = (
"dispatch_jobs",
"job_assignments",
)
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
for name in reversed(_NEW_TABLES):
table = Base.metadata.tables.get(name)
if table is not None:
table.drop(bind=bind, checkfirst=True)

View File

@ -0,0 +1,28 @@
"""Phase 10.6 — Routing & Optimization schema (additive)."""
from alembic import op
from app.core.database import Base
import app.models # noqa: F401
revision = "0007_phase_106_routing"
down_revision = "0006_phase_105_dispatch"
branch_labels = None
depends_on = None
_NEW_TABLES = (
"route_plans",
"route_stops",
"optimization_runs",
)
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
for name in reversed(_NEW_TABLES):
table = Base.metadata.tables.get(name)
if table is not None:
table.drop(bind=bind, checkfirst=True)

View File

@ -0,0 +1,31 @@
"""Phase 10.7+10.8 — Tracking, POD & Settlement schema (additive)."""
from alembic import op
from app.core.database import Base
import app.models # noqa: F401
revision = "0008_phase_107_108_tracking_settlement"
down_revision = "0007_phase_106_routing"
branch_labels = None
depends_on = None
_NEW_TABLES = (
"tracking_sessions",
"tracking_points",
"proof_of_delivery",
"customer_tracking_tokens",
"settlement_intents",
"settlement_lines",
)
def upgrade():
bind = op.get_bind()
Base.metadata.create_all(bind=bind)
def downgrade():
bind = op.get_bind()
for name in reversed(_NEW_TABLES):
table = Base.metadata.tables.get(name)
if table is not None:
table.drop(bind=bind, checkfirst=True)

View File

@ -1 +1 @@
__version__ = "0.10.1.0"
__version__ = "0.10.8.0"

View File

@ -2,14 +2,31 @@ from fastapi import APIRouter
from app.api.v1 import (
audit,
availability,
bundles,
capabilities,
configurations,
customer_tracking,
dispatch_assignments,
dispatch_jobs,
drivers,
external_providers,
fleets,
hubs,
optimization,
organizations,
permissions,
pricing_rules,
proof_of_delivery,
routes,
routing_engines,
settings,
settlements,
shifts,
tracking,
vehicle_types,
vehicles,
working_zones,
)
api_router = APIRouter()
@ -31,6 +48,51 @@ api_router.include_router(
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
api_router.include_router(audit.router, prefix="/audit", tags=["audit"])
api_router.include_router(drivers.router, prefix="/drivers", tags=["drivers"])
api_router.include_router(
availability.router, prefix="/drivers", tags=["driver-availability"]
)
api_router.include_router(fleets.router, prefix="/fleets", tags=["fleets"])
api_router.include_router(
vehicle_types.router, prefix="/vehicle-types", tags=["vehicle-types"]
)
api_router.include_router(vehicles.router, prefix="/vehicles", tags=["vehicles"])
api_router.include_router(shifts.router, prefix="/shifts", tags=["shifts"])
api_router.include_router(
working_zones.router, prefix="/working-zones", tags=["working-zones"]
)
api_router.include_router(
pricing_rules.router, prefix="/pricing-rules", tags=["pricing-rules"]
)
api_router.include_router(
capabilities.router, prefix="/capabilities", tags=["capabilities"]
)
api_router.include_router(bundles.router, prefix="/bundles", tags=["bundles"])
api_router.include_router(
dispatch_jobs.router, prefix="/dispatch/jobs", tags=["dispatch-jobs"]
)
api_router.include_router(
dispatch_assignments.router,
prefix="/dispatch/assignments",
tags=["dispatch-assignments"],
)
api_router.include_router(routes.router, prefix="/routes", tags=["routes"])
api_router.include_router(
optimization.router, prefix="/optimization", tags=["optimization"]
)
api_router.include_router(tracking.router, prefix="/tracking", tags=["tracking"])
api_router.include_router(
proof_of_delivery.router,
prefix="/proof-of-delivery",
tags=["proof-of-delivery"],
)
api_router.include_router(
customer_tracking.router,
prefix="/customer-tracking",
tags=["customer-tracking"],
)
api_router.include_router(
settlements.router, prefix="/settlements", tags=["settlements"]
)
api_router.include_router(
permissions.router, prefix="/permissions", tags=["permissions"]
)

View File

@ -0,0 +1,40 @@
"""Driver availability APIs — Phase 10.3."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, require_tenant
from app.api.permissions import require_permissions
from app.commands.availability import AvailabilityCommands
from app.permissions.definitions import AVAILABILITY_MANAGE, AVAILABILITY_VIEW
from app.queries.availability import AvailabilityQueries
from app.schemas.availability import DriverAvailabilityRead, DriverAvailabilityUpsert
from shared.security import CurrentUser
router = APIRouter()
@router.get("/{driver_id}/availability", response_model=DriverAvailabilityRead)
async def get_availability(
driver_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(AVAILABILITY_VIEW)),
):
return await AvailabilityQueries(db).get_availability(tenant_id, driver_id)
@router.put("/{driver_id}/availability", response_model=DriverAvailabilityRead)
async def upsert_availability(
driver_id: UUID,
body: DriverAvailabilityUpsert,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(AVAILABILITY_MANAGE)),
):
return await AvailabilityCommands(db).upsert_availability(
tenant_id, driver_id, body, actor=user
)

View File

@ -0,0 +1,100 @@
"""Capability bundle APIs — Phase 10.4."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.pricing import PricingCommands
from app.models.types import CapabilityBundleStatus
from app.permissions.definitions import (
BUNDLES_CREATE,
BUNDLES_DELETE,
BUNDLES_UPDATE,
BUNDLES_VIEW,
)
from app.queries.pricing import PricingQueries
from app.schemas.pricing import (
BundleCreate,
BundleListResponse,
BundleRead,
BundleUpdate,
)
from app.specifications.pricing import BundleListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: CapabilityBundleStatus | None = Query(default=None, alias="status"),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> BundleListSpec:
return BundleListSpec(
status=status_filter, q=q, sort_by=sort_by, sort_dir=sort_dir.lower()
)
@router.post("", response_model=BundleRead, status_code=status.HTTP_201_CREATED)
async def create_bundle(
body: BundleCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(BUNDLES_CREATE)),
):
return await PricingCommands(db).create_bundle(tenant_id, body, actor=user)
@router.get("", response_model=BundleListResponse)
async def list_bundles(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: BundleListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(BUNDLES_VIEW)),
):
items, total = await PricingQueries(db).list_bundles(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return BundleListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{bundle_id}", response_model=BundleRead)
async def get_bundle(
bundle_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(BUNDLES_VIEW)),
):
return await PricingQueries(db).get_bundle(tenant_id, bundle_id)
@router.patch("/{bundle_id}", response_model=BundleRead)
async def update_bundle(
bundle_id: UUID,
body: BundleUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(BUNDLES_UPDATE)),
):
return await PricingCommands(db).update_bundle(
tenant_id, bundle_id, body, actor=user
)
@router.post("/{bundle_id}/delete", response_model=BundleRead)
async def delete_bundle(
bundle_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(BUNDLES_DELETE)),
):
return await PricingCommands(db).delete_bundle(tenant_id, bundle_id, actor=user)

View File

@ -0,0 +1,100 @@
"""Capability APIs — Phase 10.4."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.pricing import PricingCommands
from app.models.types import CapabilityStatus
from app.permissions.definitions import (
CAPABILITIES_CREATE,
CAPABILITIES_DELETE,
CAPABILITIES_UPDATE,
CAPABILITIES_VIEW,
)
from app.queries.pricing import PricingQueries
from app.schemas.pricing import (
CapabilityCreate,
CapabilityListResponse,
CapabilityRead,
CapabilityUpdate,
)
from app.specifications.pricing import CapabilityListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: CapabilityStatus | None = Query(default=None, alias="status"),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> CapabilityListSpec:
return CapabilityListSpec(
status=status_filter, q=q, sort_by=sort_by, sort_dir=sort_dir.lower()
)
@router.post("", response_model=CapabilityRead, status_code=status.HTTP_201_CREATED)
async def create_capability(
body: CapabilityCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(CAPABILITIES_CREATE)),
):
return await PricingCommands(db).create_capability(tenant_id, body, actor=user)
@router.get("", response_model=CapabilityListResponse)
async def list_capabilities(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: CapabilityListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(CAPABILITIES_VIEW)),
):
items, total = await PricingQueries(db).list_capabilities(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return CapabilityListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{cap_id}", response_model=CapabilityRead)
async def get_capability(
cap_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(CAPABILITIES_VIEW)),
):
return await PricingQueries(db).get_capability(tenant_id, cap_id)
@router.patch("/{cap_id}", response_model=CapabilityRead)
async def update_capability(
cap_id: UUID,
body: CapabilityUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(CAPABILITIES_UPDATE)),
):
return await PricingCommands(db).update_capability(
tenant_id, cap_id, body, actor=user
)
@router.post("/{cap_id}/delete", response_model=CapabilityRead)
async def delete_capability(
cap_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(CAPABILITIES_DELETE)),
):
return await PricingCommands(db).delete_capability(tenant_id, cap_id, actor=user)

View File

@ -0,0 +1,88 @@
"""Customer tracking token APIs — Phase 10.7."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.tracking import TrackingCommands
from app.models.types import CustomerTrackingTokenStatus
from app.permissions.definitions import (
CUSTOMER_TRACKING_CREATE,
CUSTOMER_TRACKING_REVOKE,
CUSTOMER_TRACKING_VIEW,
)
from app.queries.tracking import TrackingQueries
from app.schemas.tracking import (
CustomerTrackingTokenCreate,
CustomerTrackingTokenListResponse,
CustomerTrackingTokenRead,
)
from app.specifications.tracking import CustomerTrackingTokenListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: CustomerTrackingTokenStatus | None = Query(default=None, alias="status"),
dispatch_job_id: UUID | None = Query(default=None),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> CustomerTrackingTokenListSpec:
return CustomerTrackingTokenListSpec(
status=status_filter,
dispatch_job_id=dispatch_job_id,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("/tokens", response_model=CustomerTrackingTokenRead, status_code=status.HTTP_201_CREATED)
async def create_token(
body: CustomerTrackingTokenCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(CUSTOMER_TRACKING_CREATE)),
):
return await TrackingCommands(db).create_token(tenant_id, body, actor=user)
@router.get("/tokens", response_model=CustomerTrackingTokenListResponse)
async def list_tokens(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: CustomerTrackingTokenListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(CUSTOMER_TRACKING_VIEW)),
):
items, total = await TrackingQueries(db).list_tokens(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return CustomerTrackingTokenListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/tokens/{token_id}", response_model=CustomerTrackingTokenRead)
async def get_token(
token_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(CUSTOMER_TRACKING_VIEW)),
):
return await TrackingQueries(db).get_token(tenant_id, token_id)
@router.post("/tokens/{token_id}/revoke", response_model=CustomerTrackingTokenRead)
async def revoke_token(
token_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(CUSTOMER_TRACKING_REVOKE)),
):
return await TrackingCommands(db).revoke_token(tenant_id, token_id, actor=user)

View File

@ -0,0 +1,94 @@
"""Dispatch assignment APIs — Phase 10.5."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.dispatch import DispatchCommands
from app.models.types import JobAssignmentStatus
from app.permissions.definitions import (
DISPATCH_ASSIGNMENTS_CREATE,
DISPATCH_ASSIGNMENTS_UPDATE,
DISPATCH_ASSIGNMENTS_VIEW,
)
from app.queries.dispatch import DispatchQueries
from app.schemas.dispatch import (
JobAssignmentCreate,
JobAssignmentListResponse,
JobAssignmentRead,
JobAssignmentUpdate,
)
from app.specifications.dispatch import JobAssignmentListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
dispatch_job_id: UUID | None = Query(default=None),
driver_id: UUID | None = Query(default=None),
status_filter: JobAssignmentStatus | None = Query(default=None, alias="status"),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> JobAssignmentListSpec:
return JobAssignmentListSpec(
dispatch_job_id=dispatch_job_id,
driver_id=driver_id,
status=status_filter,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("", response_model=JobAssignmentRead, status_code=status.HTTP_201_CREATED)
async def create_assignment(
body: JobAssignmentCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DISPATCH_ASSIGNMENTS_CREATE)),
):
return await DispatchCommands(db).create_assignment(tenant_id, body, actor=user)
@router.get("", response_model=JobAssignmentListResponse)
async def list_assignments(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: JobAssignmentListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(DISPATCH_ASSIGNMENTS_VIEW)),
):
items, total = await DispatchQueries(db).list_assignments(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return JobAssignmentListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{assignment_id}", response_model=JobAssignmentRead)
async def get_assignment(
assignment_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(DISPATCH_ASSIGNMENTS_VIEW)),
):
return await DispatchQueries(db).get_assignment(tenant_id, assignment_id)
@router.patch("/{assignment_id}", response_model=JobAssignmentRead)
async def update_assignment(
assignment_id: UUID,
body: JobAssignmentUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DISPATCH_ASSIGNMENTS_UPDATE)),
):
return await DispatchCommands(db).update_assignment(
tenant_id, assignment_id, body, actor=user
)

View File

@ -0,0 +1,120 @@
"""Dispatch job APIs — Phase 10.5."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.dispatch import DispatchCommands
from app.models.types import DispatchJobStatus
from app.permissions.definitions import (
DISPATCH_JOBS_CREATE,
DISPATCH_JOBS_DELETE,
DISPATCH_JOBS_STATUS,
DISPATCH_JOBS_UPDATE,
DISPATCH_JOBS_VIEW,
)
from app.queries.dispatch import DispatchQueries
from app.schemas.dispatch import (
DispatchJobCreate,
DispatchJobListResponse,
DispatchJobRead,
DispatchJobStatusRequest,
DispatchJobUpdate,
)
from app.specifications.dispatch import DispatchJobListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: DispatchJobStatus | None = Query(default=None, alias="status"),
organization_id: UUID | None = Query(default=None),
hub_id: UUID | None = Query(default=None),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> DispatchJobListSpec:
return DispatchJobListSpec(
status=status_filter,
organization_id=organization_id,
hub_id=hub_id,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("", response_model=DispatchJobRead, status_code=status.HTTP_201_CREATED)
async def create_dispatch_job(
body: DispatchJobCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_CREATE)),
):
return await DispatchCommands(db).create_job(tenant_id, body, actor=user)
@router.get("", response_model=DispatchJobListResponse)
async def list_dispatch_jobs(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: DispatchJobListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_VIEW)),
):
items, total = await DispatchQueries(db).list_jobs(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return DispatchJobListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{job_id}", response_model=DispatchJobRead)
async def get_dispatch_job(
job_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_VIEW)),
):
return await DispatchQueries(db).get_job(tenant_id, job_id)
@router.patch("/{job_id}", response_model=DispatchJobRead)
async def update_dispatch_job(
job_id: UUID,
body: DispatchJobUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_UPDATE)),
):
return await DispatchCommands(db).update_job(tenant_id, job_id, body, actor=user)
@router.post("/{job_id}/status", response_model=DispatchJobRead)
async def apply_dispatch_status(
job_id: UUID,
body: DispatchJobStatusRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_STATUS)),
):
return await DispatchCommands(db).apply_status(
tenant_id, job_id, body, actor=user
)
@router.post("/{job_id}/delete", response_model=DispatchJobRead)
async def delete_dispatch_job(
job_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_DELETE)),
):
return await DispatchCommands(db).delete_job(tenant_id, job_id, actor=user)

View File

@ -0,0 +1,102 @@
"""Fleet management APIs — Phase 10.2."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.fleet import FleetCommands
from app.models.types import FleetStatus, VehicleStatus
from app.permissions.definitions import (
FLEETS_CREATE,
FLEETS_DELETE,
FLEETS_UPDATE,
FLEETS_VIEW,
)
from app.queries.fleet import FleetQueries
from app.schemas.fleet import FleetCreate, FleetListResponse, FleetRead, FleetUpdate
from app.specifications.fleet import FLEET_SORT, FleetListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: FleetStatus | None = Query(default=None, alias="status"),
organization_id: UUID | None = Query(default=None),
hub_id: UUID | None = Query(default=None),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> FleetListSpec:
if sort_by not in FLEET_SORT:
sort_by = "created_at"
return FleetListSpec(
status=status_filter,
organization_id=organization_id,
hub_id=hub_id,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("", response_model=FleetRead, status_code=status.HTTP_201_CREATED)
async def create_fleet(
body: FleetCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(FLEETS_CREATE)),
):
return await FleetCommands(db).create_fleet(tenant_id, body, actor=user)
@router.get("", response_model=FleetListResponse)
async def list_fleets(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: FleetListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(FLEETS_VIEW)),
):
items, total = await FleetQueries(db).list_fleets(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return FleetListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{fleet_id}", response_model=FleetRead)
async def get_fleet(
fleet_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(FLEETS_VIEW)),
):
return await FleetQueries(db).get_fleet(tenant_id, fleet_id)
@router.patch("/{fleet_id}", response_model=FleetRead)
async def update_fleet(
fleet_id: UUID,
body: FleetUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(FLEETS_UPDATE)),
):
return await FleetCommands(db).update_fleet(tenant_id, fleet_id, body, actor=user)
@router.post("/{fleet_id}/delete", response_model=FleetRead)
async def delete_fleet(
fleet_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(FLEETS_DELETE)),
):
return await FleetCommands(db).delete_fleet(tenant_id, fleet_id, actor=user)

View File

@ -20,7 +20,7 @@ async def capabilities():
return {
"service": settings.service_name,
"version": __version__,
"phase": "10.1",
"phase": "10.8",
"commercial_product": "Torbat Driver",
"features": {
"foundation": True,
@ -33,12 +33,23 @@ async def capabilities():
"driver_lifecycle": True,
"driver_credentials": True,
"driver_documents": True,
"fleet": False,
"dispatch_engine": False,
"routing_engine": False,
"tracking": False,
"proof_of_delivery": False,
"settlement": False,
"fleet": True,
"vehicle_types": True,
"vehicles": True,
"vehicle_assignments": True,
"driver_availability": True,
"shifts": True,
"working_zones": True,
"pricing_rules": True,
"capabilities_catalog": True,
"capability_bundles": True,
"dispatch_engine": True,
"routing_engine": True,
"optimization_runs": True,
"tracking": True,
"proof_of_delivery": True,
"customer_tracking": True,
"settlement": True,
"ai": False,
},
"independence": {
@ -56,31 +67,34 @@ async def capabilities():
"pharmacy",
],
"integration_mode": "api_and_events_only",
"accounting_mode": "settlement_refs_only",
"no_journal_entries_in_delivery_db": True,
},
}
@router.get("/metrics")
async def metrics():
"""Phase 10.1 metrics discovery — driver counters reserved for ops wiring."""
"""Phase 10.8 metrics discovery — tenant-scoped counters."""
return {
"service": settings.service_name,
"version": __version__,
"phase": "10.1",
"phase": "10.8",
"metrics": {
"organizations": "tenant_scoped",
"hubs": "tenant_scoped",
"external_providers": "tenant_scoped",
"routing_engines": "tenant_scoped",
"configurations": "tenant_scoped",
"drivers": "tenant_scoped",
"driver_credentials": "tenant_scoped",
"driver_documents": "tenant_scoped",
"driver_lifecycle_events": "tenant_scoped",
"fleets": "tenant_scoped",
"vehicles": "tenant_scoped",
"shifts": "tenant_scoped",
"pricing_rules": "tenant_scoped",
"dispatch_jobs": "tenant_scoped",
"route_plans": "tenant_scoped",
"optimization_runs": "tenant_scoped",
"tracking_sessions": "tenant_scoped",
"proof_of_delivery": "tenant_scoped",
"settlement_intents": "tenant_scoped",
"outbox_events": "tenant_scoped",
"dispatch_jobs": 0,
"active_routes": 0,
"tracking_sessions": 0,
},
"note": "Driver aggregates live; dispatch/routing/tracking counters reserved for later phases",
"note": "Full delivery platform through phase 10.8; settlement uses AccountingProvider refs only",
}

View File

@ -0,0 +1,74 @@
"""Optimization run APIs — Phase 10.6."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.routing import RoutingCommands
from app.models.types import OptimizationRunStatus
from app.permissions.definitions import OPTIMIZATION_RUNS_CREATE, OPTIMIZATION_RUNS_VIEW
from app.queries.routing import RoutingQueries
from app.schemas.routing import (
OptimizationRunCreate,
OptimizationRunListResponse,
OptimizationRunRead,
)
from app.specifications.routing import OptimizationRunListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
route_plan_id: UUID | None = Query(default=None),
status_filter: OptimizationRunStatus | None = Query(default=None, alias="status"),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> OptimizationRunListSpec:
return OptimizationRunListSpec(
route_plan_id=route_plan_id,
status=status_filter,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("/runs", response_model=OptimizationRunRead, status_code=status.HTTP_201_CREATED)
async def start_optimization(
body: OptimizationRunCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(OPTIMIZATION_RUNS_CREATE)),
):
return await RoutingCommands(db).start_optimization(tenant_id, body, actor=user)
@router.get("/runs", response_model=OptimizationRunListResponse)
async def list_optimization_runs(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: OptimizationRunListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(OPTIMIZATION_RUNS_VIEW)),
):
items, total = await RoutingQueries(db).list_optimization_runs(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return OptimizationRunListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/runs/{run_id}", response_model=OptimizationRunRead)
async def get_optimization_run(
run_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(OPTIMIZATION_RUNS_VIEW)),
):
return await RoutingQueries(db).get_optimization_run(tenant_id, run_id)

View File

@ -0,0 +1,103 @@
"""Pricing rule APIs — Phase 10.4."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.pricing import PricingCommands
from app.models.types import PricingRuleStatus
from app.permissions.definitions import (
PRICING_RULES_CREATE,
PRICING_RULES_DELETE,
PRICING_RULES_UPDATE,
PRICING_RULES_VIEW,
)
from app.queries.pricing import PricingQueries
from app.schemas.pricing import (
PricingRuleCreate,
PricingRuleListResponse,
PricingRuleRead,
PricingRuleUpdate,
)
from app.specifications.pricing import PricingRuleListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: PricingRuleStatus | None = Query(default=None, alias="status"),
organization_id: UUID | None = Query(default=None),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> PricingRuleListSpec:
return PricingRuleListSpec(
status=status_filter,
organization_id=organization_id,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("", response_model=PricingRuleRead, status_code=status.HTTP_201_CREATED)
async def create_pricing_rule(
body: PricingRuleCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(PRICING_RULES_CREATE)),
):
return await PricingCommands(db).create_rule(tenant_id, body, actor=user)
@router.get("", response_model=PricingRuleListResponse)
async def list_pricing_rules(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: PricingRuleListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(PRICING_RULES_VIEW)),
):
items, total = await PricingQueries(db).list_rules(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return PricingRuleListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{rule_id}", response_model=PricingRuleRead)
async def get_pricing_rule(
rule_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(PRICING_RULES_VIEW)),
):
return await PricingQueries(db).get_rule(tenant_id, rule_id)
@router.patch("/{rule_id}", response_model=PricingRuleRead)
async def update_pricing_rule(
rule_id: UUID,
body: PricingRuleUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(PRICING_RULES_UPDATE)),
):
return await PricingCommands(db).update_rule(tenant_id, rule_id, body, actor=user)
@router.post("/{rule_id}/delete", response_model=PricingRuleRead)
async def delete_pricing_rule(
rule_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(PRICING_RULES_DELETE)),
):
return await PricingCommands(db).delete_rule(tenant_id, rule_id, actor=user)

View File

@ -0,0 +1,90 @@
"""Proof of delivery APIs — Phase 10.7."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.tracking import TrackingCommands
from app.models.types import ProofOfDeliveryStatus
from app.permissions.definitions import (
PROOF_OF_DELIVERY_CREATE,
PROOF_OF_DELIVERY_UPDATE,
PROOF_OF_DELIVERY_VIEW,
)
from app.queries.tracking import TrackingQueries
from app.schemas.tracking import (
ProofOfDeliveryCreate,
ProofOfDeliveryListResponse,
ProofOfDeliveryRead,
ProofOfDeliveryUpdate,
)
from app.specifications.tracking import ProofOfDeliveryListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: ProofOfDeliveryStatus | None = Query(default=None, alias="status"),
dispatch_job_id: UUID | None = Query(default=None),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> ProofOfDeliveryListSpec:
return ProofOfDeliveryListSpec(
status=status_filter,
dispatch_job_id=dispatch_job_id,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("", response_model=ProofOfDeliveryRead, status_code=status.HTTP_201_CREATED)
async def capture_pod(
body: ProofOfDeliveryCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(PROOF_OF_DELIVERY_CREATE)),
):
return await TrackingCommands(db).capture_pod(tenant_id, body, actor=user)
@router.get("", response_model=ProofOfDeliveryListResponse)
async def list_pods(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: ProofOfDeliveryListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(PROOF_OF_DELIVERY_VIEW)),
):
items, total = await TrackingQueries(db).list_pods(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return ProofOfDeliveryListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{pod_id}", response_model=ProofOfDeliveryRead)
async def get_pod(
pod_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(PROOF_OF_DELIVERY_VIEW)),
):
return await TrackingQueries(db).get_pod(tenant_id, pod_id)
@router.patch("/{pod_id}", response_model=ProofOfDeliveryRead)
async def update_pod(
pod_id: UUID,
body: ProofOfDeliveryUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(PROOF_OF_DELIVERY_UPDATE)),
):
return await TrackingCommands(db).update_pod(tenant_id, pod_id, body, actor=user)

View File

@ -0,0 +1,134 @@
"""Route plan APIs — Phase 10.6."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.routing import RoutingCommands
from app.models.types import RoutePlanStatus
from app.permissions.definitions import (
ROUTES_CREATE,
ROUTES_DELETE,
ROUTES_STOPS_MANAGE,
ROUTES_STOPS_VIEW,
ROUTES_UPDATE,
ROUTES_VIEW,
)
from app.queries.routing import RoutingQueries
from app.schemas.routing import (
RoutePlanCreate,
RoutePlanListResponse,
RoutePlanRead,
RoutePlanUpdate,
RouteStopCreate,
RouteStopRead,
)
from app.specifications.routing import RoutePlanListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: RoutePlanStatus | None = Query(default=None, alias="status"),
organization_id: UUID | None = Query(default=None),
driver_id: UUID | None = Query(default=None),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> RoutePlanListSpec:
return RoutePlanListSpec(
status=status_filter,
organization_id=organization_id,
driver_id=driver_id,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("", response_model=RoutePlanRead, status_code=status.HTTP_201_CREATED)
async def create_route(
body: RoutePlanCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(ROUTES_CREATE)),
):
return await RoutingCommands(db).create_route(tenant_id, body, actor=user)
@router.get("", response_model=RoutePlanListResponse)
async def list_routes(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: RoutePlanListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(ROUTES_VIEW)),
):
items, total = await RoutingQueries(db).list_routes(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return RoutePlanListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{plan_id}", response_model=RoutePlanRead)
async def get_route(
plan_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(ROUTES_VIEW)),
):
return await RoutingQueries(db).get_route(tenant_id, plan_id)
@router.patch("/{plan_id}", response_model=RoutePlanRead)
async def update_route(
plan_id: UUID,
body: RoutePlanUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(ROUTES_UPDATE)),
):
return await RoutingCommands(db).update_route(tenant_id, plan_id, body, actor=user)
@router.post("/{plan_id}/delete", response_model=RoutePlanRead)
async def delete_route(
plan_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(ROUTES_DELETE)),
):
return await RoutingCommands(db).delete_route(tenant_id, plan_id, actor=user)
@router.post(
"/{plan_id}/stops",
response_model=RouteStopRead,
status_code=status.HTTP_201_CREATED,
)
async def add_stop(
plan_id: UUID,
body: RouteStopCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(ROUTES_STOPS_MANAGE)),
):
return await RoutingCommands(db).add_stop(tenant_id, plan_id, body, actor=user)
@router.get("/{plan_id}/stops", response_model=list[RouteStopRead])
async def list_stops(
plan_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(ROUTES_STOPS_VIEW)),
):
return await RoutingQueries(db).list_stops(tenant_id, plan_id)

View File

@ -0,0 +1,169 @@
"""Settlement APIs — Phase 10.8."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.settlement import SettlementCommands
from app.models.types import SettlementIntentStatus
from app.permissions.definitions import (
SETTLEMENTS_CREATE,
SETTLEMENTS_LINES_MANAGE,
SETTLEMENTS_LINES_VIEW,
SETTLEMENTS_STATUS,
SETTLEMENTS_UPDATE,
SETTLEMENTS_VIEW,
)
from app.queries.settlement import SettlementQueries
from app.schemas.settlement import (
SettlementActionRequest,
SettlementIntentCreate,
SettlementIntentListResponse,
SettlementIntentRead,
SettlementIntentUpdate,
SettlementLineCreate,
SettlementLineRead,
)
from app.specifications.settlement import SettlementIntentListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: SettlementIntentStatus | None = Query(default=None, alias="status"),
organization_id: UUID | None = Query(default=None),
driver_id: UUID | None = Query(default=None),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> SettlementIntentListSpec:
return SettlementIntentListSpec(
status=status_filter,
organization_id=organization_id,
driver_id=driver_id,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("", response_model=SettlementIntentRead, status_code=status.HTTP_201_CREATED)
async def create_settlement(
body: SettlementIntentCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_CREATE)),
):
return await SettlementCommands(db).create(tenant_id, body, actor=user)
@router.get("", response_model=SettlementIntentListResponse)
async def list_settlements(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: SettlementIntentListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(SETTLEMENTS_VIEW)),
):
items, total = await SettlementQueries(db).list(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return SettlementIntentListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{intent_id}", response_model=SettlementIntentRead)
async def get_settlement(
intent_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(SETTLEMENTS_VIEW)),
):
return await SettlementQueries(db).get(tenant_id, intent_id)
@router.patch("/{intent_id}", response_model=SettlementIntentRead)
async def update_settlement(
intent_id: UUID,
body: SettlementIntentUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_UPDATE)),
):
return await SettlementCommands(db).update(tenant_id, intent_id, body, actor=user)
@router.post("/{intent_id}/submit", response_model=SettlementIntentRead)
async def submit_settlement(
intent_id: UUID,
body: SettlementActionRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_STATUS)),
):
return await SettlementCommands(db).submit(tenant_id, intent_id, body, actor=user)
@router.post("/{intent_id}/settle", response_model=SettlementIntentRead)
async def settle_settlement(
intent_id: UUID,
body: SettlementActionRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_STATUS)),
):
return await SettlementCommands(db).settle(tenant_id, intent_id, body, actor=user)
@router.post("/{intent_id}/fail", response_model=SettlementIntentRead)
async def fail_settlement(
intent_id: UUID,
body: SettlementActionRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_STATUS)),
):
return await SettlementCommands(db).fail(tenant_id, intent_id, body, actor=user)
@router.post("/{intent_id}/cancel", response_model=SettlementIntentRead)
async def cancel_settlement(
intent_id: UUID,
body: SettlementActionRequest,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_STATUS)),
):
return await SettlementCommands(db).cancel(tenant_id, intent_id, body, actor=user)
@router.post(
"/{intent_id}/lines",
response_model=SettlementLineRead,
status_code=status.HTTP_201_CREATED,
)
async def add_settlement_line(
intent_id: UUID,
body: SettlementLineCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_LINES_MANAGE)),
):
return await SettlementCommands(db).add_line(tenant_id, intent_id, body, actor=user)
@router.get("/{intent_id}/lines", response_model=list[SettlementLineRead])
async def list_settlement_lines(
intent_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(SETTLEMENTS_LINES_VIEW)),
):
return await SettlementQueries(db).lines(tenant_id, intent_id)

View File

@ -0,0 +1,156 @@
"""Shift APIs — Phase 10.3."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.availability import AvailabilityCommands
from app.models.types import ShiftStatus
from app.permissions.definitions import (
SHIFTS_ASSIGNMENTS_MANAGE,
SHIFTS_ASSIGNMENTS_VIEW,
SHIFTS_CREATE,
SHIFTS_DELETE,
SHIFTS_UPDATE,
SHIFTS_VIEW,
)
from app.queries.availability import AvailabilityQueries
from app.schemas.availability import (
ShiftAssignmentCreate,
ShiftAssignmentRead,
ShiftAssignmentUpdate,
ShiftCreate,
ShiftListResponse,
ShiftRead,
ShiftUpdate,
)
from app.specifications.availability import ShiftListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: ShiftStatus | None = Query(default=None, alias="status"),
organization_id: UUID | None = Query(default=None),
hub_id: UUID | None = Query(default=None),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="starts_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> ShiftListSpec:
return ShiftListSpec(
status=status_filter,
organization_id=organization_id,
hub_id=hub_id,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("", response_model=ShiftRead, status_code=status.HTTP_201_CREATED)
async def create_shift(
body: ShiftCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SHIFTS_CREATE)),
):
return await AvailabilityCommands(db).create_shift(tenant_id, body, actor=user)
@router.get("", response_model=ShiftListResponse)
async def list_shifts(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: ShiftListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(SHIFTS_VIEW)),
):
items, total = await AvailabilityQueries(db).list_shifts(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return ShiftListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{shift_id}", response_model=ShiftRead)
async def get_shift(
shift_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(SHIFTS_VIEW)),
):
return await AvailabilityQueries(db).get_shift(tenant_id, shift_id)
@router.patch("/{shift_id}", response_model=ShiftRead)
async def update_shift(
shift_id: UUID,
body: ShiftUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SHIFTS_UPDATE)),
):
return await AvailabilityCommands(db).update_shift(
tenant_id, shift_id, body, actor=user
)
@router.post("/{shift_id}/delete", response_model=ShiftRead)
async def delete_shift(
shift_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SHIFTS_DELETE)),
):
return await AvailabilityCommands(db).delete_shift(tenant_id, shift_id, actor=user)
@router.post(
"/{shift_id}/assignments",
response_model=ShiftAssignmentRead,
status_code=status.HTTP_201_CREATED,
)
async def assign_shift(
shift_id: UUID,
body: ShiftAssignmentCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SHIFTS_ASSIGNMENTS_MANAGE)),
):
return await AvailabilityCommands(db).assign_shift(
tenant_id, shift_id, body, actor=user
)
@router.get("/{shift_id}/assignments", response_model=list[ShiftAssignmentRead])
async def list_shift_assignments(
shift_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(SHIFTS_ASSIGNMENTS_VIEW)),
):
return await AvailabilityQueries(db).list_shift_assignments(tenant_id, shift_id)
@router.patch(
"/{shift_id}/assignments/{assignment_id}",
response_model=ShiftAssignmentRead,
)
async def update_shift_assignment(
shift_id: UUID,
assignment_id: UUID,
body: ShiftAssignmentUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(SHIFTS_ASSIGNMENTS_MANAGE)),
):
return await AvailabilityCommands(db).update_shift_assignment(
tenant_id, shift_id, assignment_id, body, actor=user
)

View File

@ -0,0 +1,126 @@
"""Tracking session APIs — Phase 10.7."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.tracking import TrackingCommands
from app.models.types import TrackingSessionStatus
from app.permissions.definitions import (
TRACKING_POINTS_MANAGE,
TRACKING_POINTS_VIEW,
TRACKING_SESSIONS_CREATE,
TRACKING_SESSIONS_UPDATE,
TRACKING_SESSIONS_VIEW,
)
from app.queries.tracking import TrackingQueries
from app.schemas.tracking import (
TrackingPointCreate,
TrackingPointRead,
TrackingSessionCreate,
TrackingSessionListResponse,
TrackingSessionRead,
TrackingSessionUpdate,
)
from app.specifications.tracking import TrackingSessionListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: TrackingSessionStatus | None = Query(default=None, alias="status"),
dispatch_job_id: UUID | None = Query(default=None),
driver_id: UUID | None = Query(default=None),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> TrackingSessionListSpec:
return TrackingSessionListSpec(
status=status_filter,
dispatch_job_id=dispatch_job_id,
driver_id=driver_id,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("/sessions", response_model=TrackingSessionRead, status_code=status.HTTP_201_CREATED)
async def start_session(
body: TrackingSessionCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(TRACKING_SESSIONS_CREATE)),
):
return await TrackingCommands(db).start_session(tenant_id, body, actor=user)
@router.get("/sessions", response_model=TrackingSessionListResponse)
async def list_sessions(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: TrackingSessionListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(TRACKING_SESSIONS_VIEW)),
):
items, total = await TrackingQueries(db).list_sessions(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return TrackingSessionListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/sessions/{session_id}", response_model=TrackingSessionRead)
async def get_session(
session_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(TRACKING_SESSIONS_VIEW)),
):
return await TrackingQueries(db).get_session(tenant_id, session_id)
@router.patch("/sessions/{session_id}", response_model=TrackingSessionRead)
async def update_session(
session_id: UUID,
body: TrackingSessionUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(TRACKING_SESSIONS_UPDATE)),
):
return await TrackingCommands(db).update_session(
tenant_id, session_id, body, actor=user
)
@router.post(
"/sessions/{session_id}/points",
response_model=TrackingPointRead,
status_code=status.HTTP_201_CREATED,
)
async def record_point(
session_id: UUID,
body: TrackingPointCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(TRACKING_POINTS_MANAGE)),
):
return await TrackingCommands(db).record_point(
tenant_id, session_id, body, actor=user
)
@router.get("/sessions/{session_id}/points", response_model=list[TrackingPointRead])
async def list_points(
session_id: UUID,
limit: int = Query(default=500, ge=1, le=2000),
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(TRACKING_POINTS_VIEW)),
):
return await TrackingQueries(db).list_points(tenant_id, session_id, limit=limit)

View File

@ -0,0 +1,96 @@
"""Vehicle type APIs — Phase 10.2."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.fleet import FleetCommands
from app.permissions.definitions import (
VEHICLE_TYPES_CREATE,
VEHICLE_TYPES_DELETE,
VEHICLE_TYPES_UPDATE,
VEHICLE_TYPES_VIEW,
)
from app.queries.fleet import FleetQueries
from app.schemas.fleet import (
VehicleTypeCreate,
VehicleTypeListResponse,
VehicleTypeRead,
VehicleTypeUpdate,
)
from app.specifications.fleet import VehicleTypeListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> VehicleTypeListSpec:
return VehicleTypeListSpec(q=q, sort_by=sort_by, sort_dir=sort_dir.lower())
@router.post("", response_model=VehicleTypeRead, status_code=status.HTTP_201_CREATED)
async def create_vehicle_type(
body: VehicleTypeCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(VEHICLE_TYPES_CREATE)),
):
return await FleetCommands(db).create_vehicle_type(tenant_id, body, actor=user)
@router.get("", response_model=VehicleTypeListResponse)
async def list_vehicle_types(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: VehicleTypeListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(VEHICLE_TYPES_VIEW)),
):
items, total = await FleetQueries(db).list_vehicle_types(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return VehicleTypeListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{type_id}", response_model=VehicleTypeRead)
async def get_vehicle_type(
type_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(VEHICLE_TYPES_VIEW)),
):
return await FleetQueries(db).get_vehicle_type(tenant_id, type_id)
@router.patch("/{type_id}", response_model=VehicleTypeRead)
async def update_vehicle_type(
type_id: UUID,
body: VehicleTypeUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(VEHICLE_TYPES_UPDATE)),
):
return await FleetCommands(db).update_vehicle_type(
tenant_id, type_id, body, actor=user
)
@router.post("/{type_id}/delete", response_model=VehicleTypeRead)
async def delete_vehicle_type(
type_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(VEHICLE_TYPES_DELETE)),
):
return await FleetCommands(db).delete_vehicle_type(tenant_id, type_id, actor=user)

View File

@ -0,0 +1,154 @@
"""Vehicle APIs — Phase 10.2."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.fleet import FleetCommands
from app.models.types import VehicleStatus
from app.permissions.definitions import (
VEHICLES_ASSIGNMENTS_MANAGE,
VEHICLES_ASSIGNMENTS_VIEW,
VEHICLES_CREATE,
VEHICLES_DELETE,
VEHICLES_UPDATE,
VEHICLES_VIEW,
)
from app.queries.fleet import FleetQueries
from app.schemas.fleet import (
VehicleAssignmentCreate,
VehicleAssignmentRead,
VehicleAssignmentUpdate,
VehicleCreate,
VehicleListResponse,
VehicleRead,
VehicleUpdate,
)
from app.specifications.fleet import VehicleListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
fleet_id: UUID | None = Query(default=None),
status_filter: VehicleStatus | None = Query(default=None, alias="status"),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> VehicleListSpec:
return VehicleListSpec(
fleet_id=fleet_id,
status=status_filter,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("", response_model=VehicleRead, status_code=status.HTTP_201_CREATED)
async def create_vehicle(
body: VehicleCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(VEHICLES_CREATE)),
):
return await FleetCommands(db).create_vehicle(tenant_id, body, actor=user)
@router.get("", response_model=VehicleListResponse)
async def list_vehicles(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: VehicleListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(VEHICLES_VIEW)),
):
items, total = await FleetQueries(db).list_vehicles(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return VehicleListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{vehicle_id}", response_model=VehicleRead)
async def get_vehicle(
vehicle_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(VEHICLES_VIEW)),
):
return await FleetQueries(db).get_vehicle(tenant_id, vehicle_id)
@router.patch("/{vehicle_id}", response_model=VehicleRead)
async def update_vehicle(
vehicle_id: UUID,
body: VehicleUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(VEHICLES_UPDATE)),
):
return await FleetCommands(db).update_vehicle(
tenant_id, vehicle_id, body, actor=user
)
@router.post("/{vehicle_id}/delete", response_model=VehicleRead)
async def delete_vehicle(
vehicle_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(VEHICLES_DELETE)),
):
return await FleetCommands(db).delete_vehicle(tenant_id, vehicle_id, actor=user)
@router.post(
"/{vehicle_id}/assignments",
response_model=VehicleAssignmentRead,
status_code=status.HTTP_201_CREATED,
)
async def create_assignment(
vehicle_id: UUID,
body: VehicleAssignmentCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(VEHICLES_ASSIGNMENTS_MANAGE)),
):
return await FleetCommands(db).create_assignment(
tenant_id, vehicle_id, body, actor=user
)
@router.get("/{vehicle_id}/assignments", response_model=list[VehicleAssignmentRead])
async def list_assignments(
vehicle_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(VEHICLES_ASSIGNMENTS_VIEW)),
):
return await FleetQueries(db).list_assignments(tenant_id, vehicle_id)
@router.patch(
"/{vehicle_id}/assignments/{assignment_id}",
response_model=VehicleAssignmentRead,
)
async def update_assignment(
vehicle_id: UUID,
assignment_id: UUID,
body: VehicleAssignmentUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(VEHICLES_ASSIGNMENTS_MANAGE)),
):
return await FleetCommands(db).update_assignment(
tenant_id, vehicle_id, assignment_id, body, actor=user
)

View File

@ -0,0 +1,105 @@
"""Working zone APIs — Phase 10.3."""
from __future__ import annotations
from uuid import UUID
from fastapi import APIRouter, Depends, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_db, get_pagination, require_tenant
from app.api.permissions import require_permissions
from app.commands.availability import AvailabilityCommands
from app.models.types import WorkingZoneStatus
from app.permissions.definitions import (
WORKING_ZONES_CREATE,
WORKING_ZONES_DELETE,
WORKING_ZONES_UPDATE,
WORKING_ZONES_VIEW,
)
from app.queries.availability import AvailabilityQueries
from app.schemas.availability import (
WorkingZoneCreate,
WorkingZoneListResponse,
WorkingZoneRead,
WorkingZoneUpdate,
)
from app.specifications.availability import WorkingZoneListSpec
from shared.pagination import PaginationParams
from shared.security import CurrentUser
router = APIRouter()
def _list_spec(
status_filter: WorkingZoneStatus | None = Query(default=None, alias="status"),
organization_id: UUID | None = Query(default=None),
q: str | None = Query(default=None, max_length=100),
sort_by: str = Query(default="created_at"),
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
) -> WorkingZoneListSpec:
return WorkingZoneListSpec(
status=status_filter,
organization_id=organization_id,
q=q,
sort_by=sort_by,
sort_dir=sort_dir.lower(),
)
@router.post("", response_model=WorkingZoneRead, status_code=status.HTTP_201_CREATED)
async def create_zone(
body: WorkingZoneCreate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(WORKING_ZONES_CREATE)),
):
return await AvailabilityCommands(db).create_zone(tenant_id, body, actor=user)
@router.get("", response_model=WorkingZoneListResponse)
async def list_zones(
tenant_id: UUID = Depends(require_tenant),
pagination: PaginationParams = Depends(get_pagination),
spec: WorkingZoneListSpec = Depends(_list_spec),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(WORKING_ZONES_VIEW)),
):
items, total = await AvailabilityQueries(db).list_zones(
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
)
return WorkingZoneListResponse(
items=items, total=total, page=pagination.page, page_size=pagination.page_size
)
@router.get("/{zone_id}", response_model=WorkingZoneRead)
async def get_zone(
zone_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
_user: CurrentUser = Depends(require_permissions(WORKING_ZONES_VIEW)),
):
return await AvailabilityQueries(db).get_zone(tenant_id, zone_id)
@router.patch("/{zone_id}", response_model=WorkingZoneRead)
async def update_zone(
zone_id: UUID,
body: WorkingZoneUpdate,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(WORKING_ZONES_UPDATE)),
):
return await AvailabilityCommands(db).update_zone(
tenant_id, zone_id, body, actor=user
)
@router.post("/{zone_id}/delete", response_model=WorkingZoneRead)
async def delete_zone(
zone_id: UUID,
tenant_id: UUID = Depends(require_tenant),
db: AsyncSession = Depends(get_db),
user: CurrentUser = Depends(require_permissions(WORKING_ZONES_DELETE)),
):
return await AvailabilityCommands(db).delete_zone(tenant_id, zone_id, actor=user)

View File

@ -0,0 +1,90 @@
"""Availability command handlers — Phase 10.3."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.availability import (
DriverAvailabilityUpsert,
ShiftAssignmentCreate,
ShiftAssignmentUpdate,
ShiftCreate,
ShiftUpdate,
WorkingZoneCreate,
WorkingZoneUpdate,
)
from app.services.availability import AvailabilityService, ShiftService, WorkingZoneService
from shared.security import CurrentUser
class AvailabilityCommands:
def __init__(self, session: AsyncSession) -> None:
self.availability = AvailabilityService(session)
self.shifts = ShiftService(session)
self.zones = WorkingZoneService(session)
async def upsert_availability(
self,
tenant_id: UUID,
driver_id: UUID,
body: DriverAvailabilityUpsert,
*,
actor: CurrentUser | None,
):
return await self.availability.upsert_for_driver(
tenant_id, driver_id, body, actor=actor
)
async def create_shift(
self, tenant_id: UUID, body: ShiftCreate, *, actor: CurrentUser | None
):
return await self.shifts.create(tenant_id, body, actor=actor)
async def update_shift(
self, tenant_id: UUID, shift_id: UUID, body: ShiftUpdate, *, actor: CurrentUser | None
):
return await self.shifts.update(tenant_id, shift_id, body, actor=actor)
async def delete_shift(
self, tenant_id: UUID, shift_id: UUID, *, actor: CurrentUser | None
):
return await self.shifts.soft_delete(tenant_id, shift_id, actor=actor)
async def assign_shift(
self,
tenant_id: UUID,
shift_id: UUID,
body: ShiftAssignmentCreate,
*,
actor: CurrentUser | None,
):
return await self.shifts.assign_driver(tenant_id, shift_id, body, actor=actor)
async def update_shift_assignment(
self,
tenant_id: UUID,
shift_id: UUID,
assignment_id: UUID,
body: ShiftAssignmentUpdate,
*,
actor: CurrentUser | None,
):
return await self.shifts.update_assignment(
tenant_id, shift_id, assignment_id, body, actor=actor
)
async def create_zone(
self, tenant_id: UUID, body: WorkingZoneCreate, *, actor: CurrentUser | None
):
return await self.zones.create(tenant_id, body, actor=actor)
async def update_zone(
self, tenant_id: UUID, zone_id: UUID, body: WorkingZoneUpdate, *, actor: CurrentUser | None
):
return await self.zones.update(tenant_id, zone_id, body, actor=actor)
async def delete_zone(
self, tenant_id: UUID, zone_id: UUID, *, actor: CurrentUser | None
):
return await self.zones.soft_delete(tenant_id, zone_id, actor=actor)

View File

@ -0,0 +1,62 @@
"""Dispatch command handlers — Phase 10.5."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.dispatch import (
DispatchJobCreate,
DispatchJobStatusRequest,
DispatchJobUpdate,
JobAssignmentCreate,
JobAssignmentUpdate,
)
from app.services.dispatch import DispatchJobService, JobAssignmentService
from shared.security import CurrentUser
class DispatchCommands:
def __init__(self, session: AsyncSession) -> None:
self.jobs = DispatchJobService(session)
self.assignments = JobAssignmentService(session)
async def create_job(
self, tenant_id: UUID, body: DispatchJobCreate, *, actor: CurrentUser | None
):
return await self.jobs.create(tenant_id, body, actor=actor)
async def update_job(
self, tenant_id: UUID, job_id: UUID, body: DispatchJobUpdate, *, actor: CurrentUser | None
):
return await self.jobs.update(tenant_id, job_id, body, actor=actor)
async def apply_status(
self,
tenant_id: UUID,
job_id: UUID,
body: DispatchJobStatusRequest,
*,
actor: CurrentUser | None,
):
return await self.jobs.apply_status(tenant_id, job_id, body, actor=actor)
async def delete_job(
self, tenant_id: UUID, job_id: UUID, *, actor: CurrentUser | None
):
return await self.jobs.soft_delete(tenant_id, job_id, actor=actor)
async def create_assignment(
self, tenant_id: UUID, body: JobAssignmentCreate, *, actor: CurrentUser | None
):
return await self.assignments.create(tenant_id, body, actor=actor)
async def update_assignment(
self,
tenant_id: UUID,
assignment_id: UUID,
body: JobAssignmentUpdate,
*,
actor: CurrentUser | None,
):
return await self.assignments.update(tenant_id, assignment_id, body, actor=actor)

View File

@ -0,0 +1,92 @@
"""Fleet command handlers — Phase 10.2."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.fleet import (
FleetCreate,
FleetUpdate,
VehicleAssignmentCreate,
VehicleAssignmentUpdate,
VehicleCreate,
VehicleTypeCreate,
VehicleTypeUpdate,
VehicleUpdate,
)
from app.services.fleet import FleetService, VehicleService, VehicleTypeService
from shared.security import CurrentUser
class FleetCommands:
def __init__(self, session: AsyncSession) -> None:
self.fleets = FleetService(session)
self.types = VehicleTypeService(session)
self.vehicles = VehicleService(session)
async def create_fleet(self, tenant_id: UUID, body: FleetCreate, *, actor: CurrentUser | None):
return await self.fleets.create(tenant_id, body, actor=actor)
async def update_fleet(
self, tenant_id: UUID, fleet_id: UUID, body: FleetUpdate, *, actor: CurrentUser | None
):
return await self.fleets.update(tenant_id, fleet_id, body, actor=actor)
async def delete_fleet(self, tenant_id: UUID, fleet_id: UUID, *, actor: CurrentUser | None):
return await self.fleets.soft_delete(tenant_id, fleet_id, actor=actor)
async def create_vehicle_type(
self, tenant_id: UUID, body: VehicleTypeCreate, *, actor: CurrentUser | None
):
return await self.types.create(tenant_id, body, actor=actor)
async def update_vehicle_type(
self, tenant_id: UUID, type_id: UUID, body: VehicleTypeUpdate, *, actor: CurrentUser | None
):
return await self.types.update(tenant_id, type_id, body, actor=actor)
async def delete_vehicle_type(
self, tenant_id: UUID, type_id: UUID, *, actor: CurrentUser | None
):
return await self.types.soft_delete(tenant_id, type_id, actor=actor)
async def create_vehicle(
self, tenant_id: UUID, body: VehicleCreate, *, actor: CurrentUser | None
):
return await self.vehicles.create(tenant_id, body, actor=actor)
async def update_vehicle(
self, tenant_id: UUID, vehicle_id: UUID, body: VehicleUpdate, *, actor: CurrentUser | None
):
return await self.vehicles.update(tenant_id, vehicle_id, body, actor=actor)
async def delete_vehicle(
self, tenant_id: UUID, vehicle_id: UUID, *, actor: CurrentUser | None
):
return await self.vehicles.soft_delete(tenant_id, vehicle_id, actor=actor)
async def create_assignment(
self,
tenant_id: UUID,
vehicle_id: UUID,
body: VehicleAssignmentCreate,
*,
actor: CurrentUser | None,
):
return await self.vehicles.create_assignment(
tenant_id, vehicle_id, body, actor=actor
)
async def update_assignment(
self,
tenant_id: UUID,
vehicle_id: UUID,
assignment_id: UUID,
body: VehicleAssignmentUpdate,
*,
actor: CurrentUser | None,
):
return await self.vehicles.update_assignment(
tenant_id, vehicle_id, assignment_id, body, actor=actor
)

View File

@ -0,0 +1,69 @@
"""Pricing command handlers — Phase 10.4."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.pricing import (
BundleCreate,
BundleUpdate,
CapabilityCreate,
CapabilityUpdate,
PricingRuleCreate,
PricingRuleUpdate,
)
from app.services.pricing import BundleService, CapabilityService, PricingRuleService
from shared.security import CurrentUser
class PricingCommands:
def __init__(self, session: AsyncSession) -> None:
self.rules = PricingRuleService(session)
self.capabilities = CapabilityService(session)
self.bundles = BundleService(session)
async def create_rule(
self, tenant_id: UUID, body: PricingRuleCreate, *, actor: CurrentUser | None
):
return await self.rules.create(tenant_id, body, actor=actor)
async def update_rule(
self, tenant_id: UUID, rule_id: UUID, body: PricingRuleUpdate, *, actor: CurrentUser | None
):
return await self.rules.update(tenant_id, rule_id, body, actor=actor)
async def delete_rule(
self, tenant_id: UUID, rule_id: UUID, *, actor: CurrentUser | None
):
return await self.rules.soft_delete(tenant_id, rule_id, actor=actor)
async def create_capability(
self, tenant_id: UUID, body: CapabilityCreate, *, actor: CurrentUser | None
):
return await self.capabilities.create(tenant_id, body, actor=actor)
async def update_capability(
self, tenant_id: UUID, cap_id: UUID, body: CapabilityUpdate, *, actor: CurrentUser | None
):
return await self.capabilities.update(tenant_id, cap_id, body, actor=actor)
async def delete_capability(
self, tenant_id: UUID, cap_id: UUID, *, actor: CurrentUser | None
):
return await self.capabilities.soft_delete(tenant_id, cap_id, actor=actor)
async def create_bundle(
self, tenant_id: UUID, body: BundleCreate, *, actor: CurrentUser | None
):
return await self.bundles.create(tenant_id, body, actor=actor)
async def update_bundle(
self, tenant_id: UUID, bundle_id: UUID, body: BundleUpdate, *, actor: CurrentUser | None
):
return await self.bundles.update(tenant_id, bundle_id, body, actor=actor)
async def delete_bundle(
self, tenant_id: UUID, bundle_id: UUID, *, actor: CurrentUser | None
):
return await self.bundles.soft_delete(tenant_id, bundle_id, actor=actor)

View File

@ -0,0 +1,51 @@
"""Routing command handlers — Phase 10.6."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.routing import (
OptimizationRunCreate,
RoutePlanCreate,
RoutePlanUpdate,
RouteStopCreate,
)
from app.services.routing import OptimizationRunService, RoutePlanService
from shared.security import CurrentUser
class RoutingCommands:
def __init__(self, session: AsyncSession) -> None:
self.routes = RoutePlanService(session)
self.optimization = OptimizationRunService(session)
async def create_route(
self, tenant_id: UUID, body: RoutePlanCreate, *, actor: CurrentUser | None
):
return await self.routes.create(tenant_id, body, actor=actor)
async def update_route(
self, tenant_id: UUID, plan_id: UUID, body: RoutePlanUpdate, *, actor: CurrentUser | None
):
return await self.routes.update(tenant_id, plan_id, body, actor=actor)
async def delete_route(
self, tenant_id: UUID, plan_id: UUID, *, actor: CurrentUser | None
):
return await self.routes.soft_delete(tenant_id, plan_id, actor=actor)
async def add_stop(
self,
tenant_id: UUID,
plan_id: UUID,
body: RouteStopCreate,
*,
actor: CurrentUser | None,
):
return await self.routes.add_stop(tenant_id, plan_id, body, actor=actor)
async def start_optimization(
self, tenant_id: UUID, body: OptimizationRunCreate, *, actor: CurrentUser | None
):
return await self.optimization.start(tenant_id, body, actor=actor)

View File

@ -0,0 +1,85 @@
"""Settlement command handlers — Phase 10.8."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.settlement import (
SettlementActionRequest,
SettlementIntentCreate,
SettlementIntentUpdate,
SettlementLineCreate,
)
from app.services.settlement import SettlementService
from shared.security import CurrentUser
class SettlementCommands:
def __init__(self, session: AsyncSession) -> None:
self.service = SettlementService(session)
async def create(
self, tenant_id: UUID, body: SettlementIntentCreate, *, actor: CurrentUser | None
):
return await self.service.create(tenant_id, body, actor=actor)
async def update(
self,
tenant_id: UUID,
intent_id: UUID,
body: SettlementIntentUpdate,
*,
actor: CurrentUser | None,
):
return await self.service.update(tenant_id, intent_id, body, actor=actor)
async def submit(
self,
tenant_id: UUID,
intent_id: UUID,
body: SettlementActionRequest,
*,
actor: CurrentUser | None,
):
return await self.service.submit(tenant_id, intent_id, body, actor=actor)
async def settle(
self,
tenant_id: UUID,
intent_id: UUID,
body: SettlementActionRequest,
*,
actor: CurrentUser | None,
):
return await self.service.settle(tenant_id, intent_id, body, actor=actor)
async def fail(
self,
tenant_id: UUID,
intent_id: UUID,
body: SettlementActionRequest,
*,
actor: CurrentUser | None,
):
return await self.service.fail(tenant_id, intent_id, body, actor=actor)
async def cancel(
self,
tenant_id: UUID,
intent_id: UUID,
body: SettlementActionRequest,
*,
actor: CurrentUser | None,
):
return await self.service.cancel(tenant_id, intent_id, body, actor=actor)
async def add_line(
self,
tenant_id: UUID,
intent_id: UUID,
body: SettlementLineCreate,
*,
actor: CurrentUser | None,
):
return await self.service.add_line(tenant_id, intent_id, body, actor=actor)

View File

@ -0,0 +1,84 @@
"""Tracking command handlers — Phase 10.7."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.schemas.tracking import (
CustomerTrackingTokenCreate,
ProofOfDeliveryCreate,
ProofOfDeliveryUpdate,
TrackingPointCreate,
TrackingSessionCreate,
TrackingSessionUpdate,
)
from app.services.tracking import (
CustomerTrackingTokenService,
ProofOfDeliveryService,
TrackingSessionService,
)
from shared.security import CurrentUser
class TrackingCommands:
def __init__(self, session: AsyncSession) -> None:
self.sessions = TrackingSessionService(session)
self.pod = ProofOfDeliveryService(session)
self.tokens = CustomerTrackingTokenService(session)
async def start_session(
self, tenant_id: UUID, body: TrackingSessionCreate, *, actor: CurrentUser | None
):
return await self.sessions.start(tenant_id, body, actor=actor)
async def update_session(
self,
tenant_id: UUID,
session_id: UUID,
body: TrackingSessionUpdate,
*,
actor: CurrentUser | None,
):
return await self.sessions.update(tenant_id, session_id, body, actor=actor)
async def record_point(
self,
tenant_id: UUID,
session_id: UUID,
body: TrackingPointCreate,
*,
actor: CurrentUser | None,
):
return await self.sessions.record_point(
tenant_id, session_id, body, actor=actor
)
async def capture_pod(
self, tenant_id: UUID, body: ProofOfDeliveryCreate, *, actor: CurrentUser | None
):
return await self.pod.capture(tenant_id, body, actor=actor)
async def update_pod(
self,
tenant_id: UUID,
pod_id: UUID,
body: ProofOfDeliveryUpdate,
*,
actor: CurrentUser | None,
):
return await self.pod.update(tenant_id, pod_id, body, actor=actor)
async def create_token(
self,
tenant_id: UUID,
body: CustomerTrackingTokenCreate,
*,
actor: CurrentUser | None,
):
return await self.tokens.create(tenant_id, body, actor=actor)
async def revoke_token(
self, tenant_id: UUID, token_id: UUID, *, actor: CurrentUser | None
):
return await self.tokens.revoke(tenant_id, token_id, actor=actor)

View File

@ -31,3 +31,75 @@ class DeliveryEventType(str, enum.Enum):
DRIVER_CREDENTIAL_ADDED = "delivery.driver.credential_added"
DRIVER_DOCUMENT_ATTACHED = "delivery.driver.document_attached"
DRIVER_STATUS_CHANGED = "delivery.driver.status_changed"
# Phase 10.2 — Fleet & Vehicle Types
FLEET_CREATED = "delivery.fleet.created"
FLEET_UPDATED = "delivery.fleet.updated"
FLEET_DELETED = "delivery.fleet.deleted"
VEHICLE_TYPE_CREATED = "delivery.vehicle_type.created"
VEHICLE_TYPE_UPDATED = "delivery.vehicle_type.updated"
VEHICLE_TYPE_DELETED = "delivery.vehicle_type.deleted"
VEHICLE_CREATED = "delivery.vehicle.created"
VEHICLE_UPDATED = "delivery.vehicle.updated"
VEHICLE_DELETED = "delivery.vehicle.deleted"
VEHICLE_ASSIGNMENT_CREATED = "delivery.vehicle_assignment.created"
VEHICLE_ASSIGNMENT_UPDATED = "delivery.vehicle_assignment.updated"
VEHICLE_ASSIGNMENT_ENDED = "delivery.vehicle_assignment.ended"
# Phase 10.3 — Availability, Shifts & Working Zones
DRIVER_AVAILABILITY_UPDATED = "delivery.driver_availability.updated"
SHIFT_CREATED = "delivery.shift.created"
SHIFT_UPDATED = "delivery.shift.updated"
SHIFT_DELETED = "delivery.shift.deleted"
SHIFT_ASSIGNMENT_CREATED = "delivery.shift_assignment.created"
SHIFT_ASSIGNMENT_UPDATED = "delivery.shift_assignment.updated"
WORKING_ZONE_CREATED = "delivery.working_zone.created"
WORKING_ZONE_UPDATED = "delivery.working_zone.updated"
WORKING_ZONE_DELETED = "delivery.working_zone.deleted"
# Phase 10.4 — Pricing, Capabilities & Bundles
PRICING_RULE_CREATED = "delivery.pricing_rule.created"
PRICING_RULE_UPDATED = "delivery.pricing_rule.updated"
PRICING_RULE_DELETED = "delivery.pricing_rule.deleted"
CAPABILITY_CREATED = "delivery.capability.created"
CAPABILITY_UPDATED = "delivery.capability.updated"
CAPABILITY_DELETED = "delivery.capability.deleted"
BUNDLE_CREATED = "delivery.bundle.created"
BUNDLE_UPDATED = "delivery.bundle.updated"
BUNDLE_DELETED = "delivery.bundle.deleted"
# Phase 10.5 — Dispatch Engine
DISPATCH_JOB_CREATED = "delivery.dispatch_job.created"
DISPATCH_JOB_UPDATED = "delivery.dispatch_job.updated"
DISPATCH_JOB_STATUS_CHANGED = "delivery.dispatch_job.status_changed"
DISPATCH_JOB_DELETED = "delivery.dispatch_job.deleted"
JOB_ASSIGNMENT_CREATED = "delivery.job_assignment.created"
JOB_ASSIGNMENT_UPDATED = "delivery.job_assignment.updated"
JOB_ASSIGNMENT_STATUS_CHANGED = "delivery.job_assignment.status_changed"
# Phase 10.6 — Routing & Optimization
ROUTE_PLAN_CREATED = "delivery.route_plan.created"
ROUTE_PLAN_UPDATED = "delivery.route_plan.updated"
ROUTE_PLAN_DELETED = "delivery.route_plan.deleted"
ROUTE_STOP_ADDED = "delivery.route_stop.added"
OPTIMIZATION_RUN_STARTED = "delivery.optimization_run.started"
OPTIMIZATION_RUN_COMPLETED = "delivery.optimization_run.completed"
OPTIMIZATION_RUN_FAILED = "delivery.optimization_run.failed"
# Phase 10.7 — Tracking & Proof of Delivery
TRACKING_SESSION_STARTED = "delivery.tracking_session.started"
TRACKING_SESSION_UPDATED = "delivery.tracking_session.updated"
TRACKING_SESSION_ENDED = "delivery.tracking_session.ended"
TRACKING_POINT_RECORDED = "delivery.tracking_point.recorded"
PROOF_OF_DELIVERY_CAPTURED = "delivery.proof_of_delivery.captured"
PROOF_OF_DELIVERY_UPDATED = "delivery.proof_of_delivery.updated"
CUSTOMER_TRACKING_TOKEN_CREATED = "delivery.customer_tracking_token.created"
CUSTOMER_TRACKING_TOKEN_REVOKED = "delivery.customer_tracking_token.revoked"
# Phase 10.8 — Settlement
SETTLEMENT_INTENT_CREATED = "delivery.settlement_intent.created"
SETTLEMENT_INTENT_UPDATED = "delivery.settlement_intent.updated"
SETTLEMENT_INTENT_SUBMITTED = "delivery.settlement_intent.submitted"
SETTLEMENT_INTENT_SETTLED = "delivery.settlement_intent.settled"
SETTLEMENT_INTENT_FAILED = "delivery.settlement_intent.failed"
SETTLEMENT_LINE_ADDED = "delivery.settlement_line.added"

View File

@ -1,10 +1,18 @@
"""Import all models for Alembic metadata discovery."""
from app.models.availability import ( # noqa: F401
DriverAvailability,
Shift,
ShiftAssignment,
WorkingZone,
)
from app.models.dispatch import DispatchJob, JobAssignment # noqa: F401
from app.models.drivers import ( # noqa: F401
Driver,
DriverCredential,
DriverDocument,
DriverLifecycleEvent,
)
from app.models.fleet import Fleet, Vehicle, VehicleAssignment, VehicleType # noqa: F401
from app.models.foundation import ( # noqa: F401
DeliveryAuditLog,
DeliveryConfiguration,
@ -17,3 +25,16 @@ from app.models.foundation import ( # noqa: F401
RoutingEngineRegistration,
)
from app.models.outbox import OutboxEvent # noqa: F401
from app.models.pricing import ( # noqa: F401
CapabilityBundle,
CapabilityDefinition,
PricingRule,
)
from app.models.routing import OptimizationRun, RoutePlan, RouteStop # noqa: F401
from app.models.settlement import SettlementIntent, SettlementLine # noqa: F401
from app.models.tracking import ( # noqa: F401
CustomerTrackingToken,
ProofOfDelivery,
TrackingPoint,
TrackingSession,
)

View File

@ -0,0 +1,142 @@
"""Availability, shifts & working zones — Phase 10.3."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Index, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.core.database import Base
from app.models.base import (
ActorAuditMixin,
OptimisticLockMixin,
SoftDeleteMixin,
TenantMixin,
TimestampMixin,
UUIDPrimaryKeyMixin,
)
from app.models.types import (
DriverAvailabilityStatus,
GUID,
ShiftAssignmentStatus,
ShiftStatus,
WorkingZoneStatus,
)
class DriverAvailability(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "driver_availabilities"
driver_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
status: Mapped[DriverAvailabilityStatus] = mapped_column(
default=DriverAvailabilityStatus.OFFLINE, nullable=False
)
effective_from: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
effective_until: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
Index("ix_driver_availabilities_driver", "tenant_id", "driver_id"),
Index("ix_driver_availabilities_tenant_deleted", "tenant_id", "is_deleted"),
)
class Shift(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "shifts"
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
hub_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[ShiftStatus] = mapped_column(
default=ShiftStatus.DRAFT, nullable=False
)
starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
ends_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_shifts_tenant_code"),
Index("ix_shifts_tenant_status", "tenant_id", "status"),
Index("ix_shifts_tenant_deleted", "tenant_id", "is_deleted"),
)
class ShiftAssignment(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
):
__tablename__ = "shift_assignments"
shift_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
driver_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
status: Mapped[ShiftAssignmentStatus] = mapped_column(
default=ShiftAssignmentStatus.ASSIGNED, nullable=False
)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id",
"shift_id",
"driver_id",
name="uq_shift_assignments_tenant_shift_driver",
),
Index("ix_shift_assignments_shift", "tenant_id", "shift_id"),
Index("ix_shift_assignments_driver", "tenant_id", "driver_id"),
)
class WorkingZone(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "working_zones"
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[WorkingZoneStatus] = mapped_column(
default=WorkingZoneStatus.ACTIVE, nullable=False
)
geo_boundary_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_working_zones_tenant_code"),
Index("ix_working_zones_tenant_status", "tenant_id", "status"),
Index("ix_working_zones_tenant_deleted", "tenant_id", "is_deleted"),
)

View File

@ -0,0 +1,89 @@
"""Dispatch engine aggregates — Phase 10.5."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Index, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.core.database import Base
from app.models.base import (
ActorAuditMixin,
OptimisticLockMixin,
SoftDeleteMixin,
TenantMixin,
TimestampMixin,
UUIDPrimaryKeyMixin,
)
from app.models.types import DispatchJobStatus, GUID, JobAssignmentStatus
class DispatchJob(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""Vertical job refs only — no deep order ownership."""
__tablename__ = "dispatch_jobs"
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
hub_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
code: Mapped[str] = mapped_column(String(50), nullable=False)
status: Mapped[DispatchJobStatus] = mapped_column(
default=DispatchJobStatus.PENDING, nullable=False
)
external_order_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
external_source: Mapped[str | None] = mapped_column(String(100), nullable=True)
priority: Mapped[int] = mapped_column(default=0, nullable=False)
pickup_address_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
dropoff_address_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
scheduled_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
status_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_dispatch_jobs_tenant_code"),
Index("ix_dispatch_jobs_tenant_status", "tenant_id", "status"),
Index("ix_dispatch_jobs_external_ref", "tenant_id", "external_order_ref"),
Index("ix_dispatch_jobs_tenant_deleted", "tenant_id", "is_deleted"),
)
class JobAssignment(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "job_assignments"
dispatch_job_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
driver_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
vehicle_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
status: Mapped[JobAssignmentStatus] = mapped_column(
default=JobAssignmentStatus.PENDING, nullable=False
)
assigned_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
Index("ix_job_assignments_job", "tenant_id", "dispatch_job_id"),
Index("ix_job_assignments_driver", "tenant_id", "driver_id"),
Index("ix_job_assignments_tenant_status", "tenant_id", "status"),
Index("ix_job_assignments_tenant_deleted", "tenant_id", "is_deleted"),
)

View File

@ -0,0 +1,126 @@
"""Fleet & Vehicle aggregates — Phase 10.2."""
from __future__ import annotations
import uuid
from sqlalchemy import Index, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.core.database import Base
from app.models.base import (
ActorAuditMixin,
OptimisticLockMixin,
SoftDeleteMixin,
TenantMixin,
TimestampMixin,
UUIDPrimaryKeyMixin,
)
from app.models.types import FleetStatus, GUID, VehicleAssignmentStatus, VehicleStatus
class Fleet(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "fleets"
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
hub_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[FleetStatus] = mapped_column(default=FleetStatus.DRAFT, nullable=False)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_fleets_tenant_code"),
Index("ix_fleets_tenant_status", "tenant_id", "status"),
Index("ix_fleets_org", "tenant_id", "organization_id"),
Index("ix_fleets_tenant_deleted", "tenant_id", "is_deleted"),
)
class VehicleType(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "vehicle_types"
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
capacity_kg: Mapped[float | None] = mapped_column(nullable=True)
capacity_volume: Mapped[float | None] = mapped_column(nullable=True)
capability_tags: Mapped[list | None] = mapped_column(JSON, nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_vehicle_types_tenant_code"),
Index("ix_vehicle_types_tenant_deleted", "tenant_id", "is_deleted"),
)
class Vehicle(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "vehicles"
fleet_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
vehicle_type_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
code: Mapped[str] = mapped_column(String(50), nullable=False)
plate_number: Mapped[str | None] = mapped_column(String(32), nullable=True)
status: Mapped[VehicleStatus] = mapped_column(
default=VehicleStatus.AVAILABLE, nullable=False
)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_vehicles_tenant_code"),
Index("ix_vehicles_fleet", "tenant_id", "fleet_id"),
Index("ix_vehicles_tenant_status", "tenant_id", "status"),
Index("ix_vehicles_tenant_deleted", "tenant_id", "is_deleted"),
)
class VehicleAssignment(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
):
"""Shell assignment linking vehicles to drivers — no dispatch engine."""
__tablename__ = "vehicle_assignments"
vehicle_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
driver_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
status: Mapped[VehicleAssignmentStatus] = mapped_column(
default=VehicleAssignmentStatus.ACTIVE, nullable=False
)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
Index("ix_vehicle_assignments_vehicle", "tenant_id", "vehicle_id"),
Index("ix_vehicle_assignments_driver", "tenant_id", "driver_id"),
Index("ix_vehicle_assignments_tenant_deleted", "tenant_id", "is_deleted"),
)

View File

@ -0,0 +1,108 @@
"""Pricing, capabilities & bundles — Phase 10.4."""
from __future__ import annotations
import uuid
from decimal import Decimal
from sqlalchemy import Index, Numeric, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.core.database import Base
from app.models.base import (
ActorAuditMixin,
OptimisticLockMixin,
SoftDeleteMixin,
TenantMixin,
TimestampMixin,
UUIDPrimaryKeyMixin,
)
from app.models.types import (
CapabilityBundleStatus,
CapabilityStatus,
GUID,
PricingRuleStatus,
)
class PricingRule(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "pricing_rules"
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[PricingRuleStatus] = mapped_column(
default=PricingRuleStatus.DRAFT, nullable=False
)
base_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
currency_code: Mapped[str] = mapped_column(String(3), nullable=False, default="IRR")
rule_config: Mapped[dict | None] = mapped_column(JSON, nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_pricing_rules_tenant_code"),
Index("ix_pricing_rules_tenant_status", "tenant_id", "status"),
Index("ix_pricing_rules_tenant_deleted", "tenant_id", "is_deleted"),
)
class CapabilityDefinition(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "capability_definitions"
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
status: Mapped[CapabilityStatus] = mapped_column(
default=CapabilityStatus.ACTIVE, nullable=False
)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint(
"tenant_id", "code", name="uq_capability_definitions_tenant_code"
),
Index("ix_capability_definitions_tenant_deleted", "tenant_id", "is_deleted"),
)
class CapabilityBundle(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "capability_bundles"
code: Mapped[str] = mapped_column(String(50), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
status: Mapped[CapabilityBundleStatus] = mapped_column(
default=CapabilityBundleStatus.DRAFT, nullable=False
)
capability_codes: Mapped[list | None] = mapped_column(JSON, nullable=True)
pricing_rule_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_capability_bundles_tenant_code"),
Index("ix_capability_bundles_tenant_status", "tenant_id", "status"),
Index("ix_capability_bundles_tenant_deleted", "tenant_id", "is_deleted"),
)

View File

@ -0,0 +1,110 @@
"""Routing & optimization aggregates — Phase 10.6."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Index, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.core.database import Base
from app.models.base import (
ActorAuditMixin,
OptimisticLockMixin,
SoftDeleteMixin,
TenantMixin,
TimestampMixin,
UUIDPrimaryKeyMixin,
)
from app.models.types import GUID, OptimizationRunStatus, RoutePlanStatus, RouteStopStatus
class RoutePlan(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "route_plans"
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
code: Mapped[str] = mapped_column(String(50), nullable=False)
status: Mapped[RoutePlanStatus] = mapped_column(
default=RoutePlanStatus.DRAFT, nullable=False
)
driver_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
dispatch_job_ids: Mapped[list | None] = mapped_column(JSON, nullable=True)
routing_engine_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
planned_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_route_plans_tenant_code"),
Index("ix_route_plans_tenant_status", "tenant_id", "status"),
Index("ix_route_plans_tenant_deleted", "tenant_id", "is_deleted"),
)
class RouteStop(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
):
__tablename__ = "route_stops"
route_plan_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
status: Mapped[RouteStopStatus] = mapped_column(
default=RouteStopStatus.PENDING, nullable=False
)
address_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
external_order_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
Index("ix_route_stops_plan", "tenant_id", "route_plan_id"),
Index("ix_route_stops_tenant_deleted", "tenant_id", "is_deleted"),
)
class OptimizationRun(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
):
"""External routing via RoutingEngineProvider protocol refs only."""
__tablename__ = "optimization_runs"
route_plan_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
status: Mapped[OptimizationRunStatus] = mapped_column(
default=OptimizationRunStatus.PENDING, nullable=False
)
routing_engine_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
request_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
result_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
started_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
completed_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
__table_args__ = (
Index("ix_optimization_runs_tenant_status", "tenant_id", "status"),
Index("ix_optimization_runs_plan", "tenant_id", "route_plan_id"),
)

View File

@ -0,0 +1,78 @@
"""Settlement aggregates — Phase 10.8.
Uses AccountingProvider protocol refs only NO journal entries in delivery_db.
"""
from __future__ import annotations
import uuid
from decimal import Decimal
from sqlalchemy import Index, Numeric, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.core.database import Base
from app.models.base import (
ActorAuditMixin,
OptimisticLockMixin,
SoftDeleteMixin,
TenantMixin,
TimestampMixin,
UUIDPrimaryKeyMixin,
)
from app.models.types import GUID, SettlementIntentStatus, SettlementLineKind
class SettlementIntent(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "settlement_intents"
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
code: Mapped[str] = mapped_column(String(50), nullable=False)
status: Mapped[SettlementIntentStatus] = mapped_column(
default=SettlementIntentStatus.DRAFT, nullable=False
)
driver_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
dispatch_job_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
external_settlement_ref: Mapped[str | None] = mapped_column(
String(255), nullable=True
)
total_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
currency_code: Mapped[str] = mapped_column(String(3), nullable=False, default="IRR")
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "code", name="uq_settlement_intents_tenant_code"),
Index("ix_settlement_intents_tenant_status", "tenant_id", "status"),
Index("ix_settlement_intents_tenant_deleted", "tenant_id", "is_deleted"),
)
class SettlementLine(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
):
__tablename__ = "settlement_lines"
settlement_intent_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
kind: Mapped[SettlementLineKind] = mapped_column(nullable=False)
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
currency_code: Mapped[str] = mapped_column(String(3), nullable=False, default="IRR")
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
Index("ix_settlement_lines_intent", "tenant_id", "settlement_intent_id"),
)

View File

@ -0,0 +1,138 @@
"""Tracking & proof of delivery — Phase 10.7."""
from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import DateTime, Float, Index, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.types import JSON
from app.core.database import Base
from app.models.base import (
ActorAuditMixin,
OptimisticLockMixin,
SoftDeleteMixin,
TenantMixin,
TimestampMixin,
UUIDPrimaryKeyMixin,
)
from app.models.types import (
CustomerTrackingTokenStatus,
GUID,
ProofOfDeliveryStatus,
TrackingSessionStatus,
)
class TrackingSession(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
__tablename__ = "tracking_sessions"
dispatch_job_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
driver_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
status: Mapped[TrackingSessionStatus] = mapped_column(
default=TrackingSessionStatus.ACTIVE, nullable=False
)
started_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
ended_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
Index("ix_tracking_sessions_tenant_status", "tenant_id", "status"),
Index("ix_tracking_sessions_job", "tenant_id", "dispatch_job_id"),
Index("ix_tracking_sessions_tenant_deleted", "tenant_id", "is_deleted"),
)
class TrackingPoint(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
):
__tablename__ = "tracking_points"
tracking_session_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
latitude: Mapped[float] = mapped_column(Float, nullable=False)
longitude: Mapped[float] = mapped_column(Float, nullable=False)
recorded_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False
)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
Index("ix_tracking_points_session", "tenant_id", "tracking_session_id"),
Index("ix_tracking_points_recorded", "tenant_id", "recorded_at"),
)
class ProofOfDelivery(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
OptimisticLockMixin,
):
"""Storage file refs for POD only — no blob ownership."""
__tablename__ = "proof_of_delivery"
dispatch_job_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
status: Mapped[ProofOfDeliveryStatus] = mapped_column(
default=ProofOfDeliveryStatus.PENDING, nullable=False
)
signature_file_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
photo_file_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
delivery_code: Mapped[str | None] = mapped_column(String(50), nullable=True)
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
captured_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
Index("ix_proof_of_delivery_job", "tenant_id", "dispatch_job_id"),
Index("ix_proof_of_delivery_tenant_status", "tenant_id", "status"),
Index("ix_proof_of_delivery_tenant_deleted", "tenant_id", "is_deleted"),
)
class CustomerTrackingToken(
Base,
UUIDPrimaryKeyMixin,
TenantMixin,
TimestampMixin,
SoftDeleteMixin,
ActorAuditMixin,
):
__tablename__ = "customer_tracking_tokens"
dispatch_job_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
token: Mapped[str] = mapped_column(String(128), nullable=False)
status: Mapped[CustomerTrackingTokenStatus] = mapped_column(
default=CustomerTrackingTokenStatus.ACTIVE, nullable=False
)
expires_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
__table_args__ = (
UniqueConstraint("tenant_id", "token", name="uq_customer_tracking_tokens_token"),
Index("ix_customer_tracking_tokens_job", "tenant_id", "dispatch_job_id"),
Index("ix_customer_tracking_tokens_tenant_status", "tenant_id", "status"),
)

View File

@ -116,3 +116,162 @@ class DriverDocumentKind(str, enum.Enum):
CONTRACT = "contract"
PHOTO = "photo"
OTHER = "other"
# Phase 10.2 — Fleet & Vehicle Types
class FleetStatus(str, enum.Enum):
DRAFT = "draft"
ACTIVE = "active"
INACTIVE = "inactive"
ARCHIVED = "archived"
class VehicleStatus(str, enum.Enum):
AVAILABLE = "available"
ASSIGNED = "assigned"
MAINTENANCE = "maintenance"
INACTIVE = "inactive"
ARCHIVED = "archived"
class VehicleAssignmentStatus(str, enum.Enum):
ACTIVE = "active"
ENDED = "ended"
CANCELLED = "cancelled"
# Phase 10.3 — Availability, Shifts & Working Zones
class DriverAvailabilityStatus(str, enum.Enum):
ONLINE = "online"
OFFLINE = "offline"
BUSY = "busy"
BREAK = "break"
class ShiftStatus(str, enum.Enum):
DRAFT = "draft"
SCHEDULED = "scheduled"
ACTIVE = "active"
COMPLETED = "completed"
CANCELLED = "cancelled"
class ShiftAssignmentStatus(str, enum.Enum):
ASSIGNED = "assigned"
CHECKED_IN = "checked_in"
COMPLETED = "completed"
CANCELLED = "cancelled"
class WorkingZoneStatus(str, enum.Enum):
ACTIVE = "active"
INACTIVE = "inactive"
# Phase 10.4 — Pricing, Capabilities & Bundles
class PricingRuleStatus(str, enum.Enum):
DRAFT = "draft"
ACTIVE = "active"
INACTIVE = "inactive"
ARCHIVED = "archived"
class CapabilityStatus(str, enum.Enum):
ACTIVE = "active"
INACTIVE = "inactive"
class CapabilityBundleStatus(str, enum.Enum):
DRAFT = "draft"
ACTIVE = "active"
INACTIVE = "inactive"
ARCHIVED = "archived"
# Phase 10.5 — Dispatch Engine
class DispatchJobStatus(str, enum.Enum):
PENDING = "pending"
ASSIGNED = "assigned"
IN_PROGRESS = "in_progress"
COMPLETED = "completed"
CANCELLED = "cancelled"
FAILED = "failed"
class DispatchJobAction(str, enum.Enum):
ASSIGN = "assign"
START = "start"
COMPLETE = "complete"
CANCEL = "cancel"
FAIL = "fail"
class JobAssignmentStatus(str, enum.Enum):
PENDING = "pending"
ACCEPTED = "accepted"
REJECTED = "rejected"
ACTIVE = "active"
COMPLETED = "completed"
CANCELLED = "cancelled"
# Phase 10.6 — Routing & Optimization
class RoutePlanStatus(str, enum.Enum):
DRAFT = "draft"
PLANNED = "planned"
ACTIVE = "active"
COMPLETED = "completed"
CANCELLED = "cancelled"
class RouteStopStatus(str, enum.Enum):
PENDING = "pending"
ARRIVED = "arrived"
COMPLETED = "completed"
SKIPPED = "skipped"
class OptimizationRunStatus(str, enum.Enum):
PENDING = "pending"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
# Phase 10.7 — Tracking & Proof of Delivery
class TrackingSessionStatus(str, enum.Enum):
ACTIVE = "active"
PAUSED = "paused"
COMPLETED = "completed"
CANCELLED = "cancelled"
class ProofOfDeliveryStatus(str, enum.Enum):
PENDING = "pending"
CAPTURED = "captured"
VERIFIED = "verified"
REJECTED = "rejected"
class CustomerTrackingTokenStatus(str, enum.Enum):
ACTIVE = "active"
EXPIRED = "expired"
REVOKED = "revoked"
# Phase 10.8 — Settlement
class SettlementIntentStatus(str, enum.Enum):
DRAFT = "draft"
PENDING = "pending"
SUBMITTED = "submitted"
SETTLED = "settled"
FAILED = "failed"
CANCELLED = "cancelled"
class SettlementLineKind(str, enum.Enum):
DELIVERY_FEE = "delivery_fee"
TIP = "tip"
SURCHARGE = "surcharge"
ADJUSTMENT = "adjustment"
OTHER = "other"

View File

@ -1,4 +1,4 @@
"""Delivery permission definitions — Phase 10.0 foundation + 10.1 drivers."""
"""Delivery permission definitions — Phase 10.010.8."""
from __future__ import annotations
DELIVERY_VIEW = "delivery.view"
@ -58,18 +58,134 @@ DRIVERS_DOCUMENTS_VIEW = "delivery.drivers.documents.view"
DRIVERS_DOCUMENTS_MANAGE = "delivery.drivers.documents.manage"
DRIVERS_MANAGE = "delivery.drivers.manage"
# Planned trees (registered for discovery; enforced in later phases)
FLEET_VIEW = "delivery.fleet.view"
FLEET_MANAGE = "delivery.fleet.manage"
# Phase 10.2 — Fleet & Vehicles
FLEETS_VIEW = "delivery.fleets.view"
FLEETS_CREATE = "delivery.fleets.create"
FLEETS_UPDATE = "delivery.fleets.update"
FLEETS_DELETE = "delivery.fleets.delete"
FLEETS_MANAGE = "delivery.fleets.manage"
VEHICLE_TYPES_VIEW = "delivery.vehicle_types.view"
VEHICLE_TYPES_CREATE = "delivery.vehicle_types.create"
VEHICLE_TYPES_UPDATE = "delivery.vehicle_types.update"
VEHICLE_TYPES_DELETE = "delivery.vehicle_types.delete"
VEHICLE_TYPES_MANAGE = "delivery.vehicle_types.manage"
VEHICLES_VIEW = "delivery.vehicles.view"
VEHICLES_CREATE = "delivery.vehicles.create"
VEHICLES_UPDATE = "delivery.vehicles.update"
VEHICLES_DELETE = "delivery.vehicles.delete"
VEHICLES_ASSIGNMENTS_VIEW = "delivery.vehicles.assignments.view"
VEHICLES_ASSIGNMENTS_MANAGE = "delivery.vehicles.assignments.manage"
VEHICLES_MANAGE = "delivery.vehicles.manage"
# Phase 10.3 — Availability, Shifts & Working Zones
AVAILABILITY_VIEW = "delivery.availability.view"
AVAILABILITY_MANAGE = "delivery.availability.manage"
SHIFTS_VIEW = "delivery.shifts.view"
SHIFTS_CREATE = "delivery.shifts.create"
SHIFTS_UPDATE = "delivery.shifts.update"
SHIFTS_DELETE = "delivery.shifts.delete"
SHIFTS_ASSIGNMENTS_VIEW = "delivery.shifts.assignments.view"
SHIFTS_ASSIGNMENTS_MANAGE = "delivery.shifts.assignments.manage"
SHIFTS_MANAGE = "delivery.shifts.manage"
WORKING_ZONES_VIEW = "delivery.working_zones.view"
WORKING_ZONES_CREATE = "delivery.working_zones.create"
WORKING_ZONES_UPDATE = "delivery.working_zones.update"
WORKING_ZONES_DELETE = "delivery.working_zones.delete"
WORKING_ZONES_MANAGE = "delivery.working_zones.manage"
# Phase 10.4 — Pricing, Capabilities & Bundles
PRICING_RULES_VIEW = "delivery.pricing_rules.view"
PRICING_RULES_CREATE = "delivery.pricing_rules.create"
PRICING_RULES_UPDATE = "delivery.pricing_rules.update"
PRICING_RULES_DELETE = "delivery.pricing_rules.delete"
PRICING_RULES_MANAGE = "delivery.pricing_rules.manage"
CAPABILITIES_VIEW = "delivery.capabilities.view"
CAPABILITIES_CREATE = "delivery.capabilities.create"
CAPABILITIES_UPDATE = "delivery.capabilities.update"
CAPABILITIES_DELETE = "delivery.capabilities.delete"
CAPABILITIES_MANAGE = "delivery.capabilities.manage"
BUNDLES_VIEW = "delivery.bundles.view"
BUNDLES_CREATE = "delivery.bundles.create"
BUNDLES_UPDATE = "delivery.bundles.update"
BUNDLES_DELETE = "delivery.bundles.delete"
BUNDLES_MANAGE = "delivery.bundles.manage"
# Phase 10.5 — Dispatch Engine
DISPATCH_JOBS_VIEW = "delivery.dispatch.jobs.view"
DISPATCH_JOBS_CREATE = "delivery.dispatch.jobs.create"
DISPATCH_JOBS_UPDATE = "delivery.dispatch.jobs.update"
DISPATCH_JOBS_DELETE = "delivery.dispatch.jobs.delete"
DISPATCH_JOBS_STATUS = "delivery.dispatch.jobs.status"
DISPATCH_JOBS_MANAGE = "delivery.dispatch.jobs.manage"
DISPATCH_ASSIGNMENTS_VIEW = "delivery.dispatch.assignments.view"
DISPATCH_ASSIGNMENTS_CREATE = "delivery.dispatch.assignments.create"
DISPATCH_ASSIGNMENTS_UPDATE = "delivery.dispatch.assignments.update"
DISPATCH_ASSIGNMENTS_MANAGE = "delivery.dispatch.assignments.manage"
DISPATCH_VIEW = "delivery.dispatch.view"
DISPATCH_MANAGE = "delivery.dispatch.manage"
# Phase 10.6 — Routing & Optimization
ROUTES_VIEW = "delivery.routes.view"
ROUTES_CREATE = "delivery.routes.create"
ROUTES_UPDATE = "delivery.routes.update"
ROUTES_DELETE = "delivery.routes.delete"
ROUTES_STOPS_VIEW = "delivery.routes.stops.view"
ROUTES_STOPS_MANAGE = "delivery.routes.stops.manage"
ROUTES_MANAGE = "delivery.routes.manage"
OPTIMIZATION_RUNS_VIEW = "delivery.optimization.runs.view"
OPTIMIZATION_RUNS_CREATE = "delivery.optimization.runs.create"
OPTIMIZATION_RUNS_MANAGE = "delivery.optimization.runs.manage"
ROUTING_VIEW = "delivery.routing.view"
ROUTING_MANAGE = "delivery.routing.manage"
# Phase 10.7 — Tracking & Proof of Delivery
TRACKING_SESSIONS_VIEW = "delivery.tracking.sessions.view"
TRACKING_SESSIONS_CREATE = "delivery.tracking.sessions.create"
TRACKING_SESSIONS_UPDATE = "delivery.tracking.sessions.update"
TRACKING_SESSIONS_MANAGE = "delivery.tracking.sessions.manage"
TRACKING_POINTS_VIEW = "delivery.tracking.points.view"
TRACKING_POINTS_MANAGE = "delivery.tracking.points.manage"
PROOF_OF_DELIVERY_VIEW = "delivery.proof_of_delivery.view"
PROOF_OF_DELIVERY_CREATE = "delivery.proof_of_delivery.create"
PROOF_OF_DELIVERY_UPDATE = "delivery.proof_of_delivery.update"
PROOF_OF_DELIVERY_MANAGE = "delivery.proof_of_delivery.manage"
CUSTOMER_TRACKING_VIEW = "delivery.customer_tracking.view"
CUSTOMER_TRACKING_CREATE = "delivery.customer_tracking.create"
CUSTOMER_TRACKING_REVOKE = "delivery.customer_tracking.revoke"
CUSTOMER_TRACKING_MANAGE = "delivery.customer_tracking.manage"
TRACKING_VIEW = "delivery.tracking.view"
TRACKING_MANAGE = "delivery.tracking.manage"
# Phase 10.8 — Settlement
SETTLEMENTS_VIEW = "delivery.settlements.view"
SETTLEMENTS_CREATE = "delivery.settlements.create"
SETTLEMENTS_UPDATE = "delivery.settlements.update"
SETTLEMENTS_STATUS = "delivery.settlements.status"
SETTLEMENTS_LINES_VIEW = "delivery.settlements.lines.view"
SETTLEMENTS_LINES_MANAGE = "delivery.settlements.lines.manage"
SETTLEMENTS_MANAGE = "delivery.settlements.manage"
SETTLEMENT_VIEW = "delivery.settlement.view"
SETTLEMENT_MANAGE = "delivery.settlement.manage"
# Legacy aliases
FLEET_VIEW = FLEETS_VIEW
FLEET_MANAGE = FLEETS_MANAGE
ALL_PERMISSIONS: list[str] = [
DELIVERY_VIEW,
DELIVERY_MANAGE,
@ -118,16 +234,103 @@ ALL_PERMISSIONS: list[str] = [
DRIVERS_DOCUMENTS_VIEW,
DRIVERS_DOCUMENTS_MANAGE,
DRIVERS_MANAGE,
FLEET_VIEW,
FLEET_MANAGE,
FLEETS_VIEW,
FLEETS_CREATE,
FLEETS_UPDATE,
FLEETS_DELETE,
FLEETS_MANAGE,
VEHICLE_TYPES_VIEW,
VEHICLE_TYPES_CREATE,
VEHICLE_TYPES_UPDATE,
VEHICLE_TYPES_DELETE,
VEHICLE_TYPES_MANAGE,
VEHICLES_VIEW,
VEHICLES_CREATE,
VEHICLES_UPDATE,
VEHICLES_DELETE,
VEHICLES_ASSIGNMENTS_VIEW,
VEHICLES_ASSIGNMENTS_MANAGE,
VEHICLES_MANAGE,
AVAILABILITY_VIEW,
AVAILABILITY_MANAGE,
SHIFTS_VIEW,
SHIFTS_CREATE,
SHIFTS_UPDATE,
SHIFTS_DELETE,
SHIFTS_ASSIGNMENTS_VIEW,
SHIFTS_ASSIGNMENTS_MANAGE,
SHIFTS_MANAGE,
WORKING_ZONES_VIEW,
WORKING_ZONES_CREATE,
WORKING_ZONES_UPDATE,
WORKING_ZONES_DELETE,
WORKING_ZONES_MANAGE,
PRICING_RULES_VIEW,
PRICING_RULES_CREATE,
PRICING_RULES_UPDATE,
PRICING_RULES_DELETE,
PRICING_RULES_MANAGE,
CAPABILITIES_VIEW,
CAPABILITIES_CREATE,
CAPABILITIES_UPDATE,
CAPABILITIES_DELETE,
CAPABILITIES_MANAGE,
BUNDLES_VIEW,
BUNDLES_CREATE,
BUNDLES_UPDATE,
BUNDLES_DELETE,
BUNDLES_MANAGE,
DISPATCH_JOBS_VIEW,
DISPATCH_JOBS_CREATE,
DISPATCH_JOBS_UPDATE,
DISPATCH_JOBS_DELETE,
DISPATCH_JOBS_STATUS,
DISPATCH_JOBS_MANAGE,
DISPATCH_ASSIGNMENTS_VIEW,
DISPATCH_ASSIGNMENTS_CREATE,
DISPATCH_ASSIGNMENTS_UPDATE,
DISPATCH_ASSIGNMENTS_MANAGE,
DISPATCH_VIEW,
DISPATCH_MANAGE,
ROUTES_VIEW,
ROUTES_CREATE,
ROUTES_UPDATE,
ROUTES_DELETE,
ROUTES_STOPS_VIEW,
ROUTES_STOPS_MANAGE,
ROUTES_MANAGE,
OPTIMIZATION_RUNS_VIEW,
OPTIMIZATION_RUNS_CREATE,
OPTIMIZATION_RUNS_MANAGE,
ROUTING_VIEW,
ROUTING_MANAGE,
TRACKING_SESSIONS_VIEW,
TRACKING_SESSIONS_CREATE,
TRACKING_SESSIONS_UPDATE,
TRACKING_SESSIONS_MANAGE,
TRACKING_POINTS_VIEW,
TRACKING_POINTS_MANAGE,
PROOF_OF_DELIVERY_VIEW,
PROOF_OF_DELIVERY_CREATE,
PROOF_OF_DELIVERY_UPDATE,
PROOF_OF_DELIVERY_MANAGE,
CUSTOMER_TRACKING_VIEW,
CUSTOMER_TRACKING_CREATE,
CUSTOMER_TRACKING_REVOKE,
CUSTOMER_TRACKING_MANAGE,
TRACKING_VIEW,
TRACKING_MANAGE,
SETTLEMENTS_VIEW,
SETTLEMENTS_CREATE,
SETTLEMENTS_UPDATE,
SETTLEMENTS_STATUS,
SETTLEMENTS_LINES_VIEW,
SETTLEMENTS_LINES_MANAGE,
SETTLEMENTS_MANAGE,
SETTLEMENT_VIEW,
SETTLEMENT_MANAGE,
FLEET_VIEW,
FLEET_MANAGE,
]
PERMISSION_PREFIXES: tuple[str, ...] = (
@ -140,9 +343,23 @@ PERMISSION_PREFIXES: tuple[str, ...] = (
"delivery.settings.",
"delivery.audit.",
"delivery.drivers.",
"delivery.fleet.",
"delivery.fleets.",
"delivery.vehicle_types.",
"delivery.vehicles.",
"delivery.availability.",
"delivery.shifts.",
"delivery.working_zones.",
"delivery.pricing_rules.",
"delivery.capabilities.",
"delivery.bundles.",
"delivery.dispatch.",
"delivery.routing.",
"delivery.routes.",
"delivery.optimization.",
"delivery.tracking.",
"delivery.proof_of_delivery.",
"delivery.customer_tracking.",
"delivery.settlements.",
"delivery.fleet.",
"delivery.routing.",
"delivery.settlement.",
)

View File

@ -0,0 +1,28 @@
"""Dispatch domain policies — Phase 10.5."""
from __future__ import annotations
from app.models.types import DispatchJobAction, DispatchJobStatus
from app.validators.dispatch import (
ensure_dispatch_job_transition,
target_status_for,
validate_dispatch_reason,
)
class DispatchJobPolicy:
REQUIRES_REASON = frozenset(
{DispatchJobAction.CANCEL, DispatchJobAction.FAIL}
)
def assert_transition(
self,
*,
action: DispatchJobAction,
current: DispatchJobStatus,
reason: str | None = None,
) -> tuple[DispatchJobStatus, str | None]:
ensure_dispatch_job_transition(action=action, current=current)
cleaned = validate_dispatch_reason(
reason, required=action in self.REQUIRES_REASON
)
return target_status_for(action), cleaned

View File

@ -0,0 +1,26 @@
"""Settlement domain policies — Phase 10.8."""
from __future__ import annotations
from app.models.types import SettlementIntentStatus
from app.validators.dispatch import validate_dispatch_reason
from app.validators.settlement import (
ensure_settlement_transition,
target_settlement_status,
)
class SettlementIntentPolicy:
REQUIRES_REASON = frozenset({"fail", "cancel"})
def assert_transition(
self,
*,
action: str,
current: SettlementIntentStatus,
reason: str | None = None,
) -> tuple[SettlementIntentStatus, str | None]:
ensure_settlement_transition(action=action, current=current)
cleaned = validate_dispatch_reason(
reason, required=action in self.REQUIRES_REASON
)
return target_settlement_status(action), cleaned

View File

@ -0,0 +1,38 @@
"""Availability query handlers — Phase 10.3."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.availability import AvailabilityService, ShiftService, WorkingZoneService
from app.specifications.availability import ShiftListSpec, WorkingZoneListSpec
class AvailabilityQueries:
def __init__(self, session: AsyncSession) -> None:
self.availability = AvailabilityService(session)
self.shifts = ShiftService(session)
self.zones = WorkingZoneService(session)
async def get_availability(self, tenant_id: UUID, driver_id: UUID):
return await self.availability.get_for_driver(tenant_id, driver_id)
async def get_shift(self, tenant_id: UUID, shift_id: UUID):
return await self.shifts.get(tenant_id, shift_id)
async def list_shifts(
self, tenant_id: UUID, *, offset: int, limit: int, spec: ShiftListSpec | None
):
return await self.shifts.list(tenant_id, offset=offset, limit=limit, spec=spec)
async def list_shift_assignments(self, tenant_id: UUID, shift_id: UUID):
return await self.shifts.list_assignments(tenant_id, shift_id)
async def get_zone(self, tenant_id: UUID, zone_id: UUID):
return await self.zones.get(tenant_id, zone_id)
async def list_zones(
self, tenant_id: UUID, *, offset: int, limit: int, spec: WorkingZoneListSpec | None
):
return await self.zones.list(tenant_id, offset=offset, limit=limit, spec=spec)

View File

@ -0,0 +1,38 @@
"""Dispatch query handlers — Phase 10.5."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.dispatch import DispatchJobService, JobAssignmentService
from app.specifications.dispatch import DispatchJobListSpec, JobAssignmentListSpec
class DispatchQueries:
def __init__(self, session: AsyncSession) -> None:
self.jobs = DispatchJobService(session)
self.assignments = JobAssignmentService(session)
async def get_job(self, tenant_id: UUID, job_id: UUID):
return await self.jobs.get(tenant_id, job_id)
async def list_jobs(
self, tenant_id: UUID, *, offset: int, limit: int, spec: DispatchJobListSpec | None
):
return await self.jobs.list(tenant_id, offset=offset, limit=limit, spec=spec)
async def get_assignment(self, tenant_id: UUID, assignment_id: UUID):
return await self.assignments.get(tenant_id, assignment_id)
async def list_assignments(
self,
tenant_id: UUID,
*,
offset: int,
limit: int,
spec: JobAssignmentListSpec | None,
):
return await self.assignments.list(
tenant_id, offset=offset, limit=limit, spec=spec
)

View File

@ -0,0 +1,43 @@
"""Fleet query handlers — Phase 10.2."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.fleet import FleetService, VehicleService, VehicleTypeService
from app.specifications.fleet import FleetListSpec, VehicleListSpec, VehicleTypeListSpec
class FleetQueries:
def __init__(self, session: AsyncSession) -> None:
self.fleets = FleetService(session)
self.types = VehicleTypeService(session)
self.vehicles = VehicleService(session)
async def get_fleet(self, tenant_id: UUID, fleet_id: UUID):
return await self.fleets.get(tenant_id, fleet_id)
async def list_fleets(
self, tenant_id: UUID, *, offset: int, limit: int, spec: FleetListSpec | None
):
return await self.fleets.list(tenant_id, offset=offset, limit=limit, spec=spec)
async def get_vehicle_type(self, tenant_id: UUID, type_id: UUID):
return await self.types.get(tenant_id, type_id)
async def list_vehicle_types(
self, tenant_id: UUID, *, offset: int, limit: int, spec: VehicleTypeListSpec | None
):
return await self.types.list(tenant_id, offset=offset, limit=limit, spec=spec)
async def get_vehicle(self, tenant_id: UUID, vehicle_id: UUID):
return await self.vehicles.get(tenant_id, vehicle_id)
async def list_vehicles(
self, tenant_id: UUID, *, offset: int, limit: int, spec: VehicleListSpec | None
):
return await self.vehicles.list(tenant_id, offset=offset, limit=limit, spec=spec)
async def list_assignments(self, tenant_id: UUID, vehicle_id: UUID):
return await self.vehicles.list_assignments(tenant_id, vehicle_id)

View File

@ -0,0 +1,44 @@
"""Pricing query handlers — Phase 10.4."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.pricing import BundleService, CapabilityService, PricingRuleService
from app.specifications.pricing import (
BundleListSpec,
CapabilityListSpec,
PricingRuleListSpec,
)
class PricingQueries:
def __init__(self, session: AsyncSession) -> None:
self.rules = PricingRuleService(session)
self.capabilities = CapabilityService(session)
self.bundles = BundleService(session)
async def get_rule(self, tenant_id: UUID, rule_id: UUID):
return await self.rules.get(tenant_id, rule_id)
async def list_rules(
self, tenant_id: UUID, *, offset: int, limit: int, spec: PricingRuleListSpec | None
):
return await self.rules.list(tenant_id, offset=offset, limit=limit, spec=spec)
async def get_capability(self, tenant_id: UUID, cap_id: UUID):
return await self.capabilities.get(tenant_id, cap_id)
async def list_capabilities(
self, tenant_id: UUID, *, offset: int, limit: int, spec: CapabilityListSpec | None
):
return await self.capabilities.list(tenant_id, offset=offset, limit=limit, spec=spec)
async def get_bundle(self, tenant_id: UUID, bundle_id: UUID):
return await self.bundles.get(tenant_id, bundle_id)
async def list_bundles(
self, tenant_id: UUID, *, offset: int, limit: int, spec: BundleListSpec | None
):
return await self.bundles.list(tenant_id, offset=offset, limit=limit, spec=spec)

View File

@ -0,0 +1,41 @@
"""Routing query handlers — Phase 10.6."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.routing import OptimizationRunService, RoutePlanService
from app.specifications.routing import OptimizationRunListSpec, RoutePlanListSpec
class RoutingQueries:
def __init__(self, session: AsyncSession) -> None:
self.routes = RoutePlanService(session)
self.optimization = OptimizationRunService(session)
async def get_route(self, tenant_id: UUID, plan_id: UUID):
return await self.routes.get(tenant_id, plan_id)
async def list_routes(
self, tenant_id: UUID, *, offset: int, limit: int, spec: RoutePlanListSpec | None
):
return await self.routes.list(tenant_id, offset=offset, limit=limit, spec=spec)
async def list_stops(self, tenant_id: UUID, plan_id: UUID):
return await self.routes.list_stops(tenant_id, plan_id)
async def get_optimization_run(self, tenant_id: UUID, run_id: UUID):
return await self.optimization.get(tenant_id, run_id)
async def list_optimization_runs(
self,
tenant_id: UUID,
*,
offset: int,
limit: int,
spec: OptimizationRunListSpec | None,
):
return await self.optimization.list(
tenant_id, offset=offset, limit=limit, spec=spec
)

View File

@ -0,0 +1,32 @@
"""Settlement query handlers — Phase 10.8."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.settlement import SettlementService
from app.specifications.settlement import SettlementIntentListSpec
class SettlementQueries:
def __init__(self, session: AsyncSession) -> None:
self.service = SettlementService(session)
async def get(self, tenant_id: UUID, intent_id: UUID):
return await self.service.get(tenant_id, intent_id)
async def list(
self,
tenant_id: UUID,
*,
offset: int,
limit: int,
spec: SettlementIntentListSpec | None,
):
return await self.service.list(
tenant_id, offset=offset, limit=limit, spec=spec
)
async def lines(self, tenant_id: UUID, intent_id: UUID):
return await self.service.list_lines(tenant_id, intent_id)

View File

@ -0,0 +1,68 @@
"""Tracking query handlers — Phase 10.7."""
from __future__ import annotations
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from app.services.tracking import (
CustomerTrackingTokenService,
ProofOfDeliveryService,
TrackingSessionService,
)
from app.specifications.tracking import (
CustomerTrackingTokenListSpec,
ProofOfDeliveryListSpec,
TrackingSessionListSpec,
)
class TrackingQueries:
def __init__(self, session: AsyncSession) -> None:
self.sessions = TrackingSessionService(session)
self.pod = ProofOfDeliveryService(session)
self.tokens = CustomerTrackingTokenService(session)
async def get_session(self, tenant_id: UUID, session_id: UUID):
return await self.sessions.get(tenant_id, session_id)
async def list_sessions(
self,
tenant_id: UUID,
*,
offset: int,
limit: int,
spec: TrackingSessionListSpec | None,
):
return await self.sessions.list(
tenant_id, offset=offset, limit=limit, spec=spec
)
async def list_points(self, tenant_id: UUID, session_id: UUID, *, limit: int = 500):
return await self.sessions.list_points(tenant_id, session_id, limit=limit)
async def get_pod(self, tenant_id: UUID, pod_id: UUID):
return await self.pod.get(tenant_id, pod_id)
async def list_pods(
self,
tenant_id: UUID,
*,
offset: int,
limit: int,
spec: ProofOfDeliveryListSpec | None,
):
return await self.pod.list(tenant_id, offset=offset, limit=limit, spec=spec)
async def get_token(self, tenant_id: UUID, token_id: UUID):
return await self.tokens.get(tenant_id, token_id)
async def list_tokens(
self,
tenant_id: UUID,
*,
offset: int,
limit: int,
spec: CustomerTrackingTokenListSpec | None,
):
return await self.tokens.list(tenant_id, offset=offset, limit=limit, spec=spec)

View File

@ -0,0 +1,145 @@
"""Availability repositories — Phase 10.3."""
from __future__ import annotations
from typing import Sequence
from uuid import UUID
from sqlalchemy import func, select
from app.models.availability import (
DriverAvailability,
Shift,
ShiftAssignment,
WorkingZone,
)
from app.repositories.base import TenantBaseRepository
from app.specifications.availability import ShiftListSpec, WorkingZoneListSpec
class DriverAvailabilityRepository(TenantBaseRepository[DriverAvailability]):
model = DriverAvailability
async def get_for_driver(
self, tenant_id: UUID, driver_id: UUID
) -> DriverAvailability | None:
stmt = (
select(self.model)
.where(
self.model.tenant_id == tenant_id,
self.model.driver_id == driver_id,
self.model.is_deleted.is_(False),
)
.order_by(self.model.updated_at.desc())
.limit(1)
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
class ShiftRepository(TenantBaseRepository[Shift]):
model = Shift
async def get_by_code(self, tenant_id: UUID, code: str) -> Shift | None:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.code == code,
self.model.is_deleted.is_(False),
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def list_filtered(
self,
tenant_id: UUID,
*,
offset: int = 0,
limit: int = 20,
spec: ShiftListSpec | None = None,
) -> Sequence[Shift]:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.is_deleted.is_(False),
)
if spec is not None:
stmt = spec.apply(stmt)
else:
stmt = stmt.order_by(self.model.starts_at.desc())
stmt = stmt.offset(offset).limit(limit)
result = await self.session.execute(stmt)
return result.scalars().all()
async def count_filtered(
self, tenant_id: UUID, *, spec: ShiftListSpec | None = None
) -> int:
stmt = select(func.count()).select_from(self.model).where(
self.model.tenant_id == tenant_id,
self.model.is_deleted.is_(False),
)
if spec is not None:
clauses = spec.filter_clauses()
if clauses:
stmt = stmt.where(*clauses)
result = await self.session.execute(stmt)
return int(result.scalar_one())
class ShiftAssignmentRepository(TenantBaseRepository[ShiftAssignment]):
model = ShiftAssignment
async def list_for_shift(
self, tenant_id: UUID, shift_id: UUID
) -> Sequence[ShiftAssignment]:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.shift_id == shift_id,
self.model.is_deleted.is_(False),
)
result = await self.session.execute(stmt)
return result.scalars().all()
class WorkingZoneRepository(TenantBaseRepository[WorkingZone]):
model = WorkingZone
async def get_by_code(self, tenant_id: UUID, code: str) -> WorkingZone | None:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.code == code,
self.model.is_deleted.is_(False),
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def list_filtered(
self,
tenant_id: UUID,
*,
offset: int = 0,
limit: int = 20,
spec: WorkingZoneListSpec | None = None,
) -> Sequence[WorkingZone]:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.is_deleted.is_(False),
)
if spec is not None:
stmt = spec.apply(stmt)
else:
stmt = stmt.order_by(self.model.created_at.desc())
stmt = stmt.offset(offset).limit(limit)
result = await self.session.execute(stmt)
return result.scalars().all()
async def count_filtered(
self, tenant_id: UUID, *, spec: WorkingZoneListSpec | None = None
) -> int:
stmt = select(func.count()).select_from(self.model).where(
self.model.tenant_id == tenant_id,
self.model.is_deleted.is_(False),
)
if spec is not None:
clauses = spec.filter_clauses()
if clauses:
stmt = stmt.where(*clauses)
result = await self.session.execute(stmt)
return int(result.scalar_one())

View File

@ -0,0 +1,107 @@
"""Dispatch repositories — Phase 10.5."""
from __future__ import annotations
from typing import Sequence
from uuid import UUID
from sqlalchemy import func, select
from app.models.dispatch import DispatchJob, JobAssignment
from app.repositories.base import TenantBaseRepository
from app.specifications.dispatch import DispatchJobListSpec, JobAssignmentListSpec
class DispatchJobRepository(TenantBaseRepository[DispatchJob]):
model = DispatchJob
async def get_by_code(self, tenant_id: UUID, code: str) -> DispatchJob | None:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.code == code,
self.model.is_deleted.is_(False),
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def list_filtered(
self,
tenant_id: UUID,
*,
offset: int = 0,
limit: int = 20,
spec: DispatchJobListSpec | None = None,
) -> Sequence[DispatchJob]:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.is_deleted.is_(False),
)
if spec is not None:
stmt = spec.apply(stmt)
else:
stmt = stmt.order_by(self.model.created_at.desc())
stmt = stmt.offset(offset).limit(limit)
result = await self.session.execute(stmt)
return result.scalars().all()
async def count_filtered(
self, tenant_id: UUID, *, spec: DispatchJobListSpec | None = None
) -> int:
stmt = select(func.count()).select_from(self.model).where(
self.model.tenant_id == tenant_id,
self.model.is_deleted.is_(False),
)
if spec is not None:
clauses = spec.filter_clauses()
if clauses:
stmt = stmt.where(*clauses)
result = await self.session.execute(stmt)
return int(result.scalar_one())
class JobAssignmentRepository(TenantBaseRepository[JobAssignment]):
model = JobAssignment
async def list_filtered(
self,
tenant_id: UUID,
*,
offset: int = 0,
limit: int = 20,
spec: JobAssignmentListSpec | None = None,
) -> Sequence[JobAssignment]:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.is_deleted.is_(False),
)
if spec is not None:
stmt = spec.apply(stmt)
else:
stmt = stmt.order_by(self.model.created_at.desc())
stmt = stmt.offset(offset).limit(limit)
result = await self.session.execute(stmt)
return result.scalars().all()
async def count_filtered(
self, tenant_id: UUID, *, spec: JobAssignmentListSpec | None = None
) -> int:
stmt = select(func.count()).select_from(self.model).where(
self.model.tenant_id == tenant_id,
self.model.is_deleted.is_(False),
)
if spec is not None:
clauses = spec.filter_clauses()
if clauses:
stmt = stmt.where(*clauses)
result = await self.session.execute(stmt)
return int(result.scalar_one())
async def list_for_job(
self, tenant_id: UUID, dispatch_job_id: UUID
) -> Sequence[JobAssignment]:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.dispatch_job_id == dispatch_job_id,
self.model.is_deleted.is_(False),
)
result = await self.session.execute(stmt)
return result.scalars().all()

View File

@ -0,0 +1,184 @@
"""Fleet repositories — Phase 10.2."""
from __future__ import annotations
from typing import Sequence
from uuid import UUID
from sqlalchemy import func, select
from app.models.fleet import Fleet, Vehicle, VehicleAssignment, VehicleType
from app.models.types import VehicleAssignmentStatus
from app.repositories.base import TenantBaseRepository
from app.specifications.fleet import FleetListSpec, VehicleListSpec, VehicleTypeListSpec
class FleetRepository(TenantBaseRepository[Fleet]):
model = Fleet
async def get_by_code(self, tenant_id: UUID, code: str) -> Fleet | None:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.code == code,
self.model.is_deleted.is_(False),
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def list_filtered(
self,
tenant_id: UUID,
*,
offset: int = 0,
limit: int = 20,
spec: FleetListSpec | None = None,
) -> Sequence[Fleet]:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.is_deleted.is_(False),
)
if spec is not None:
stmt = spec.apply(stmt)
else:
stmt = stmt.order_by(self.model.created_at.desc())
stmt = stmt.offset(offset).limit(limit)
result = await self.session.execute(stmt)
return result.scalars().all()
async def count_filtered(
self, tenant_id: UUID, *, spec: FleetListSpec | None = None
) -> int:
stmt = select(func.count()).select_from(self.model).where(
self.model.tenant_id == tenant_id,
self.model.is_deleted.is_(False),
)
if spec is not None:
clauses = spec.filter_clauses()
if clauses:
stmt = stmt.where(*clauses)
result = await self.session.execute(stmt)
return int(result.scalar_one())
class VehicleTypeRepository(TenantBaseRepository[VehicleType]):
model = VehicleType
async def get_by_code(self, tenant_id: UUID, code: str) -> VehicleType | None:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.code == code,
self.model.is_deleted.is_(False),
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def list_filtered(
self,
tenant_id: UUID,
*,
offset: int = 0,
limit: int = 20,
spec: VehicleTypeListSpec | None = None,
) -> Sequence[VehicleType]:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.is_deleted.is_(False),
)
if spec is not None:
stmt = spec.apply(stmt)
else:
stmt = stmt.order_by(self.model.created_at.desc())
stmt = stmt.offset(offset).limit(limit)
result = await self.session.execute(stmt)
return result.scalars().all()
async def count_filtered(
self, tenant_id: UUID, *, spec: VehicleTypeListSpec | None = None
) -> int:
stmt = select(func.count()).select_from(self.model).where(
self.model.tenant_id == tenant_id,
self.model.is_deleted.is_(False),
)
if spec is not None:
clauses = spec.filter_clauses()
if clauses:
stmt = stmt.where(*clauses)
result = await self.session.execute(stmt)
return int(result.scalar_one())
class VehicleRepository(TenantBaseRepository[Vehicle]):
model = Vehicle
async def get_by_code(self, tenant_id: UUID, code: str) -> Vehicle | None:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.code == code,
self.model.is_deleted.is_(False),
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()
async def list_filtered(
self,
tenant_id: UUID,
*,
offset: int = 0,
limit: int = 20,
spec: VehicleListSpec | None = None,
) -> Sequence[Vehicle]:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.is_deleted.is_(False),
)
if spec is not None:
stmt = spec.apply(stmt)
else:
stmt = stmt.order_by(self.model.created_at.desc())
stmt = stmt.offset(offset).limit(limit)
result = await self.session.execute(stmt)
return result.scalars().all()
async def count_filtered(
self, tenant_id: UUID, *, spec: VehicleListSpec | None = None
) -> int:
stmt = select(func.count()).select_from(self.model).where(
self.model.tenant_id == tenant_id,
self.model.is_deleted.is_(False),
)
if spec is not None:
clauses = spec.filter_clauses()
if clauses:
stmt = stmt.where(*clauses)
result = await self.session.execute(stmt)
return int(result.scalar_one())
class VehicleAssignmentRepository(TenantBaseRepository[VehicleAssignment]):
model = VehicleAssignment
async def list_for_vehicle(
self, tenant_id: UUID, vehicle_id: UUID
) -> Sequence[VehicleAssignment]:
stmt = (
select(self.model)
.where(
self.model.tenant_id == tenant_id,
self.model.vehicle_id == vehicle_id,
self.model.is_deleted.is_(False),
)
.order_by(self.model.created_at.desc())
)
result = await self.session.execute(stmt)
return result.scalars().all()
async def get_active_for_vehicle(
self, tenant_id: UUID, vehicle_id: UUID
) -> VehicleAssignment | None:
stmt = select(self.model).where(
self.model.tenant_id == tenant_id,
self.model.vehicle_id == vehicle_id,
self.model.status == VehicleAssignmentStatus.ACTIVE,
self.model.is_deleted.is_(False),
)
result = await self.session.execute(stmt)
return result.scalar_one_or_none()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -15,16 +15,18 @@ async def test_health_capabilities_metrics(client):
caps = await client.get("/capabilities")
assert caps.status_code == 200
c = caps.json()
assert c["phase"] == "10.1"
assert c["phase"] == "10.8"
assert c["commercial_product"] == "Torbat Driver"
assert c["features"]["foundation"] is True
assert c["features"]["drivers"] is True
assert c["features"]["dispatch_engine"] is False
assert c["features"]["dispatch_engine"] is True
assert c["features"]["settlement"] is True
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")
assert metrics.status_code == 200
assert metrics.json()["phase"] == "10.1"
assert metrics.json()["phase"] == "10.8"
async def test_foundation_flow_and_events(client):

View File

@ -32,6 +32,36 @@ FOUNDATION_MODELS = [
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():
from app.core.database import Base
@ -52,59 +82,55 @@ def test_foundation_aggregates_are_independent():
assert not model.__mapper__.relationships
def test_no_dispatch_engine_tables():
def test_phase_tables_registered():
from app.core.database import Base
import app.models # noqa: F401
names = set(Base.metadata.tables.keys())
assert "drivers" in names
assert "driver_credentials" in names
assert "driver_documents" in names
assert "driver_lifecycle_events" in names
assert "outbox_events" in names
forbidden = {
"dispatch_jobs",
"routes",
"tracking_sessions",
"vehicles",
"fleets",
"proof_of_delivery",
}
assert forbidden.isdisjoint(names)
assert PHASE_TABLES.issubset(names)
assert "journal_entries" not in names
def test_permissions_defined():
from app.permissions.definitions import ALL_PERMISSIONS, PERMISSION_PREFIXES
assert "delivery.view" in ALL_PERMISSIONS
assert "delivery.organizations.create" in ALL_PERMISSIONS
assert "delivery.external_providers.manage" in ALL_PERMISSIONS
assert "delivery.routing_engines.view" in ALL_PERMISSIONS
assert "delivery.audit.view" in ALL_PERMISSIONS
assert "delivery.drivers.create" in ALL_PERMISSIONS
assert "delivery.drivers.activate" in ALL_PERMISSIONS
assert "delivery.drivers.credentials.manage" in ALL_PERMISSIONS
assert "delivery.dispatch.manage" in ALL_PERMISSIONS
assert "delivery.fleets.create" in ALL_PERMISSIONS
assert "delivery.vehicle_types.view" in ALL_PERMISSIONS
assert "delivery.vehicles.assignments.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:
assert any(p.startswith(prefix.rstrip(".")) or p.startswith(prefix) for p in ALL_PERMISSIONS)
assert any(
p.startswith(prefix.rstrip(".")) or p.startswith(prefix)
for p in ALL_PERMISSIONS
)
def test_events_defined():
from app.events.types import DeliveryEventType
assert DeliveryEventType.ORGANIZATION_CREATED.value == "delivery.organization.created"
assert DeliveryEventType.HUB_CREATED.value == "delivery.hub.created"
assert (
DeliveryEventType.EXTERNAL_PROVIDER_REGISTERED.value
== "delivery.external_provider.registered"
)
assert (
DeliveryEventType.ROUTING_ENGINE_REGISTERED.value
== "delivery.routing_engine.registered"
)
assert DeliveryEventType.DRIVER_CREATED.value == "delivery.driver.created"
assert DeliveryEventType.DRIVER_ACTIVATED.value == "delivery.driver.activated"
assert DeliveryEventType.DRIVER_STATUS_CHANGED.value == "delivery.driver.status_changed"
assert DeliveryEventType.FLEET_CREATED.value == "delivery.fleet.created"
assert DeliveryEventType.SHIFT_CREATED.value == "delivery.shift.created"
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():
@ -122,15 +148,15 @@ def test_platform_provider_contracts_exist():
)
assert AccountingProvider is not None
assert CommunicationProvider is not None
assert RoutingEngineProvider is not None
assert FleetProvider is not None
assert LoyaltyProvider is not None
assert CRMProvider is not None
assert NotificationProvider is not None
assert StorageProvider is not None
assert AIProvider is not None
assert IdentityProvider is not None
assert CRMProvider is not None
assert CommunicationProvider is not None
assert NotificationProvider is not None
assert LoyaltyProvider is not None
assert AIProvider is not None
def test_no_forbidden_service_imports():
@ -159,8 +185,10 @@ def test_api_ownership_is_delivery_only():
prefixes = {getattr(r, "path", "") for r in api_router.routes}
joined = " ".join(sorted(prefixes))
assert "/organizations" in joined or any("/organizations" in p for p in prefixes)
assert "/hubs" in joined or any("/hubs" in p for p in prefixes)
assert "/drivers" in joined or any("/drivers" in p for p in prefixes)
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")
for name in forbidden:
assert not re.search(rf"(^|/){re.escape(name)}(/|$|\s)", joined), name

View File

@ -20,17 +20,51 @@ EXPECTED_TABLES = {
"driver_documents",
"driver_lifecycle_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():
versions = Path(__file__).resolve().parents[2] / "alembic" / "versions"
files = list(versions.glob("0001_initial*.py"))
assert files, "missing 0001_initial migration"
phase101 = list(versions.glob("0002_phase_101_drivers*.py"))
assert phase101, "missing 0002_phase_101_drivers migration"
assert list(versions.glob("0001_initial*.py")), "missing 0001_initial migration"
assert list(versions.glob("0002_phase_101_drivers*.py")), "missing 0002 migration"
assert list(versions.glob("0003_phase_102_fleet*.py")), "missing 0003 migration"
assert list(versions.glob("0004_phase_103_availability*.py")), "missing 0004 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_foundation_tables():
def test_metadata_has_all_tables():
names = set(Base.metadata.tables.keys())
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}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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