Compare commits
No commits in common. "0eec9f729b4a9381cd3f62633756fc0e629577da" and "4451b32a33d01151124d62a47c1e30d8ec1d8067" have entirely different histories.
0eec9f729b
...
4451b32a33
@ -1,29 +0,0 @@
|
||||
"""Phase 10.2 — Fleet & Vehicle Types schema (additive)."""
|
||||
from alembic import op
|
||||
from app.core.database import Base
|
||||
import app.models # noqa: F401
|
||||
|
||||
revision = "0003_phase_102_fleet"
|
||||
down_revision = "0002_phase_101_drivers"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_NEW_TABLES = (
|
||||
"fleets",
|
||||
"vehicle_types",
|
||||
"vehicles",
|
||||
"vehicle_assignments",
|
||||
)
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
Base.metadata.create_all(bind=bind)
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
for name in reversed(_NEW_TABLES):
|
||||
table = Base.metadata.tables.get(name)
|
||||
if table is not None:
|
||||
table.drop(bind=bind, checkfirst=True)
|
||||
@ -1,29 +0,0 @@
|
||||
"""Phase 10.3 — Availability, Shifts & Working Zones schema (additive)."""
|
||||
from alembic import op
|
||||
from app.core.database import Base
|
||||
import app.models # noqa: F401
|
||||
|
||||
revision = "0004_phase_103_availability"
|
||||
down_revision = "0003_phase_102_fleet"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_NEW_TABLES = (
|
||||
"driver_availabilities",
|
||||
"shifts",
|
||||
"shift_assignments",
|
||||
"working_zones",
|
||||
)
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
Base.metadata.create_all(bind=bind)
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
for name in reversed(_NEW_TABLES):
|
||||
table = Base.metadata.tables.get(name)
|
||||
if table is not None:
|
||||
table.drop(bind=bind, checkfirst=True)
|
||||
@ -1,28 +0,0 @@
|
||||
"""Phase 10.4 — Pricing, Capabilities & Bundles schema (additive)."""
|
||||
from alembic import op
|
||||
from app.core.database import Base
|
||||
import app.models # noqa: F401
|
||||
|
||||
revision = "0005_phase_104_pricing"
|
||||
down_revision = "0004_phase_103_availability"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_NEW_TABLES = (
|
||||
"pricing_rules",
|
||||
"capability_definitions",
|
||||
"capability_bundles",
|
||||
)
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
Base.metadata.create_all(bind=bind)
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
for name in reversed(_NEW_TABLES):
|
||||
table = Base.metadata.tables.get(name)
|
||||
if table is not None:
|
||||
table.drop(bind=bind, checkfirst=True)
|
||||
@ -1,27 +0,0 @@
|
||||
"""Phase 10.5 — Dispatch Engine schema (additive)."""
|
||||
from alembic import op
|
||||
from app.core.database import Base
|
||||
import app.models # noqa: F401
|
||||
|
||||
revision = "0006_phase_105_dispatch"
|
||||
down_revision = "0005_phase_104_pricing"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_NEW_TABLES = (
|
||||
"dispatch_jobs",
|
||||
"job_assignments",
|
||||
)
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
Base.metadata.create_all(bind=bind)
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
for name in reversed(_NEW_TABLES):
|
||||
table = Base.metadata.tables.get(name)
|
||||
if table is not None:
|
||||
table.drop(bind=bind, checkfirst=True)
|
||||
@ -1,28 +0,0 @@
|
||||
"""Phase 10.6 — Routing & Optimization schema (additive)."""
|
||||
from alembic import op
|
||||
from app.core.database import Base
|
||||
import app.models # noqa: F401
|
||||
|
||||
revision = "0007_phase_106_routing"
|
||||
down_revision = "0006_phase_105_dispatch"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_NEW_TABLES = (
|
||||
"route_plans",
|
||||
"route_stops",
|
||||
"optimization_runs",
|
||||
)
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
Base.metadata.create_all(bind=bind)
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
for name in reversed(_NEW_TABLES):
|
||||
table = Base.metadata.tables.get(name)
|
||||
if table is not None:
|
||||
table.drop(bind=bind, checkfirst=True)
|
||||
@ -1,31 +0,0 @@
|
||||
"""Phase 10.7+10.8 — Tracking, POD & Settlement schema (additive)."""
|
||||
from alembic import op
|
||||
from app.core.database import Base
|
||||
import app.models # noqa: F401
|
||||
|
||||
revision = "0008_phase_107_108_tracking_settlement"
|
||||
down_revision = "0007_phase_106_routing"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_NEW_TABLES = (
|
||||
"tracking_sessions",
|
||||
"tracking_points",
|
||||
"proof_of_delivery",
|
||||
"customer_tracking_tokens",
|
||||
"settlement_intents",
|
||||
"settlement_lines",
|
||||
)
|
||||
|
||||
|
||||
def upgrade():
|
||||
bind = op.get_bind()
|
||||
Base.metadata.create_all(bind=bind)
|
||||
|
||||
|
||||
def downgrade():
|
||||
bind = op.get_bind()
|
||||
for name in reversed(_NEW_TABLES):
|
||||
table = Base.metadata.tables.get(name)
|
||||
if table is not None:
|
||||
table.drop(bind=bind, checkfirst=True)
|
||||
@ -1 +1 @@
|
||||
__version__ = "0.10.8.0"
|
||||
__version__ = "0.10.1.0"
|
||||
|
||||
@ -2,31 +2,14 @@ from fastapi import APIRouter
|
||||
|
||||
from app.api.v1 import (
|
||||
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()
|
||||
@ -48,51 +31,6 @@ 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"]
|
||||
)
|
||||
|
||||
@ -1,40 +0,0 @@
|
||||
"""Driver availability APIs — Phase 10.3."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.commands.availability import AvailabilityCommands
|
||||
from app.permissions.definitions import AVAILABILITY_MANAGE, AVAILABILITY_VIEW
|
||||
from app.queries.availability import AvailabilityQueries
|
||||
from app.schemas.availability import DriverAvailabilityRead, DriverAvailabilityUpsert
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/{driver_id}/availability", response_model=DriverAvailabilityRead)
|
||||
async def get_availability(
|
||||
driver_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(AVAILABILITY_VIEW)),
|
||||
):
|
||||
return await AvailabilityQueries(db).get_availability(tenant_id, driver_id)
|
||||
|
||||
|
||||
@router.put("/{driver_id}/availability", response_model=DriverAvailabilityRead)
|
||||
async def upsert_availability(
|
||||
driver_id: UUID,
|
||||
body: DriverAvailabilityUpsert,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(AVAILABILITY_MANAGE)),
|
||||
):
|
||||
return await AvailabilityCommands(db).upsert_availability(
|
||||
tenant_id, driver_id, body, actor=user
|
||||
)
|
||||
@ -1,100 +0,0 @@
|
||||
"""Capability bundle APIs — Phase 10.4."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.commands.pricing import PricingCommands
|
||||
from app.models.types import CapabilityBundleStatus
|
||||
from app.permissions.definitions import (
|
||||
BUNDLES_CREATE,
|
||||
BUNDLES_DELETE,
|
||||
BUNDLES_UPDATE,
|
||||
BUNDLES_VIEW,
|
||||
)
|
||||
from app.queries.pricing import PricingQueries
|
||||
from app.schemas.pricing import (
|
||||
BundleCreate,
|
||||
BundleListResponse,
|
||||
BundleRead,
|
||||
BundleUpdate,
|
||||
)
|
||||
from app.specifications.pricing import BundleListSpec
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _list_spec(
|
||||
status_filter: CapabilityBundleStatus | None = Query(default=None, alias="status"),
|
||||
q: str | None = Query(default=None, max_length=100),
|
||||
sort_by: str = Query(default="created_at"),
|
||||
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
||||
) -> BundleListSpec:
|
||||
return BundleListSpec(
|
||||
status=status_filter, q=q, sort_by=sort_by, sort_dir=sort_dir.lower()
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=BundleRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_bundle(
|
||||
body: BundleCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(BUNDLES_CREATE)),
|
||||
):
|
||||
return await PricingCommands(db).create_bundle(tenant_id, body, actor=user)
|
||||
|
||||
|
||||
@router.get("", response_model=BundleListResponse)
|
||||
async def list_bundles(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
spec: BundleListSpec = Depends(_list_spec),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(BUNDLES_VIEW)),
|
||||
):
|
||||
items, total = await PricingQueries(db).list_bundles(
|
||||
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
||||
)
|
||||
return BundleListResponse(
|
||||
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{bundle_id}", response_model=BundleRead)
|
||||
async def get_bundle(
|
||||
bundle_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(BUNDLES_VIEW)),
|
||||
):
|
||||
return await PricingQueries(db).get_bundle(tenant_id, bundle_id)
|
||||
|
||||
|
||||
@router.patch("/{bundle_id}", response_model=BundleRead)
|
||||
async def update_bundle(
|
||||
bundle_id: UUID,
|
||||
body: BundleUpdate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(BUNDLES_UPDATE)),
|
||||
):
|
||||
return await PricingCommands(db).update_bundle(
|
||||
tenant_id, bundle_id, body, actor=user
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{bundle_id}/delete", response_model=BundleRead)
|
||||
async def delete_bundle(
|
||||
bundle_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(BUNDLES_DELETE)),
|
||||
):
|
||||
return await PricingCommands(db).delete_bundle(tenant_id, bundle_id, actor=user)
|
||||
@ -1,100 +0,0 @@
|
||||
"""Capability APIs — Phase 10.4."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.commands.pricing import PricingCommands
|
||||
from app.models.types import CapabilityStatus
|
||||
from app.permissions.definitions import (
|
||||
CAPABILITIES_CREATE,
|
||||
CAPABILITIES_DELETE,
|
||||
CAPABILITIES_UPDATE,
|
||||
CAPABILITIES_VIEW,
|
||||
)
|
||||
from app.queries.pricing import PricingQueries
|
||||
from app.schemas.pricing import (
|
||||
CapabilityCreate,
|
||||
CapabilityListResponse,
|
||||
CapabilityRead,
|
||||
CapabilityUpdate,
|
||||
)
|
||||
from app.specifications.pricing import CapabilityListSpec
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _list_spec(
|
||||
status_filter: CapabilityStatus | None = Query(default=None, alias="status"),
|
||||
q: str | None = Query(default=None, max_length=100),
|
||||
sort_by: str = Query(default="created_at"),
|
||||
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
||||
) -> CapabilityListSpec:
|
||||
return CapabilityListSpec(
|
||||
status=status_filter, q=q, sort_by=sort_by, sort_dir=sort_dir.lower()
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=CapabilityRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_capability(
|
||||
body: CapabilityCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(CAPABILITIES_CREATE)),
|
||||
):
|
||||
return await PricingCommands(db).create_capability(tenant_id, body, actor=user)
|
||||
|
||||
|
||||
@router.get("", response_model=CapabilityListResponse)
|
||||
async def list_capabilities(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
spec: CapabilityListSpec = Depends(_list_spec),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(CAPABILITIES_VIEW)),
|
||||
):
|
||||
items, total = await PricingQueries(db).list_capabilities(
|
||||
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
||||
)
|
||||
return CapabilityListResponse(
|
||||
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{cap_id}", response_model=CapabilityRead)
|
||||
async def get_capability(
|
||||
cap_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(CAPABILITIES_VIEW)),
|
||||
):
|
||||
return await PricingQueries(db).get_capability(tenant_id, cap_id)
|
||||
|
||||
|
||||
@router.patch("/{cap_id}", response_model=CapabilityRead)
|
||||
async def update_capability(
|
||||
cap_id: UUID,
|
||||
body: CapabilityUpdate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(CAPABILITIES_UPDATE)),
|
||||
):
|
||||
return await PricingCommands(db).update_capability(
|
||||
tenant_id, cap_id, body, actor=user
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{cap_id}/delete", response_model=CapabilityRead)
|
||||
async def delete_capability(
|
||||
cap_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(CAPABILITIES_DELETE)),
|
||||
):
|
||||
return await PricingCommands(db).delete_capability(tenant_id, cap_id, actor=user)
|
||||
@ -1,88 +0,0 @@
|
||||
"""Customer tracking token APIs — Phase 10.7."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.commands.tracking import TrackingCommands
|
||||
from app.models.types import CustomerTrackingTokenStatus
|
||||
from app.permissions.definitions import (
|
||||
CUSTOMER_TRACKING_CREATE,
|
||||
CUSTOMER_TRACKING_REVOKE,
|
||||
CUSTOMER_TRACKING_VIEW,
|
||||
)
|
||||
from app.queries.tracking import TrackingQueries
|
||||
from app.schemas.tracking import (
|
||||
CustomerTrackingTokenCreate,
|
||||
CustomerTrackingTokenListResponse,
|
||||
CustomerTrackingTokenRead,
|
||||
)
|
||||
from app.specifications.tracking import CustomerTrackingTokenListSpec
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _list_spec(
|
||||
status_filter: CustomerTrackingTokenStatus | None = Query(default=None, alias="status"),
|
||||
dispatch_job_id: UUID | None = Query(default=None),
|
||||
sort_by: str = Query(default="created_at"),
|
||||
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
||||
) -> CustomerTrackingTokenListSpec:
|
||||
return CustomerTrackingTokenListSpec(
|
||||
status=status_filter,
|
||||
dispatch_job_id=dispatch_job_id,
|
||||
sort_by=sort_by,
|
||||
sort_dir=sort_dir.lower(),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/tokens", response_model=CustomerTrackingTokenRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_token(
|
||||
body: CustomerTrackingTokenCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(CUSTOMER_TRACKING_CREATE)),
|
||||
):
|
||||
return await TrackingCommands(db).create_token(tenant_id, body, actor=user)
|
||||
|
||||
|
||||
@router.get("/tokens", response_model=CustomerTrackingTokenListResponse)
|
||||
async def list_tokens(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
spec: CustomerTrackingTokenListSpec = Depends(_list_spec),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(CUSTOMER_TRACKING_VIEW)),
|
||||
):
|
||||
items, total = await TrackingQueries(db).list_tokens(
|
||||
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
||||
)
|
||||
return CustomerTrackingTokenListResponse(
|
||||
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
||||
)
|
||||
|
||||
|
||||
@router.get("/tokens/{token_id}", response_model=CustomerTrackingTokenRead)
|
||||
async def get_token(
|
||||
token_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(CUSTOMER_TRACKING_VIEW)),
|
||||
):
|
||||
return await TrackingQueries(db).get_token(tenant_id, token_id)
|
||||
|
||||
|
||||
@router.post("/tokens/{token_id}/revoke", response_model=CustomerTrackingTokenRead)
|
||||
async def revoke_token(
|
||||
token_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(CUSTOMER_TRACKING_REVOKE)),
|
||||
):
|
||||
return await TrackingCommands(db).revoke_token(tenant_id, token_id, actor=user)
|
||||
@ -1,94 +0,0 @@
|
||||
"""Dispatch assignment APIs — Phase 10.5."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.commands.dispatch import DispatchCommands
|
||||
from app.models.types import JobAssignmentStatus
|
||||
from app.permissions.definitions import (
|
||||
DISPATCH_ASSIGNMENTS_CREATE,
|
||||
DISPATCH_ASSIGNMENTS_UPDATE,
|
||||
DISPATCH_ASSIGNMENTS_VIEW,
|
||||
)
|
||||
from app.queries.dispatch import DispatchQueries
|
||||
from app.schemas.dispatch import (
|
||||
JobAssignmentCreate,
|
||||
JobAssignmentListResponse,
|
||||
JobAssignmentRead,
|
||||
JobAssignmentUpdate,
|
||||
)
|
||||
from app.specifications.dispatch import JobAssignmentListSpec
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _list_spec(
|
||||
dispatch_job_id: UUID | None = Query(default=None),
|
||||
driver_id: UUID | None = Query(default=None),
|
||||
status_filter: JobAssignmentStatus | None = Query(default=None, alias="status"),
|
||||
sort_by: str = Query(default="created_at"),
|
||||
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
||||
) -> JobAssignmentListSpec:
|
||||
return JobAssignmentListSpec(
|
||||
dispatch_job_id=dispatch_job_id,
|
||||
driver_id=driver_id,
|
||||
status=status_filter,
|
||||
sort_by=sort_by,
|
||||
sort_dir=sort_dir.lower(),
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=JobAssignmentRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_assignment(
|
||||
body: JobAssignmentCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(DISPATCH_ASSIGNMENTS_CREATE)),
|
||||
):
|
||||
return await DispatchCommands(db).create_assignment(tenant_id, body, actor=user)
|
||||
|
||||
|
||||
@router.get("", response_model=JobAssignmentListResponse)
|
||||
async def list_assignments(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
spec: JobAssignmentListSpec = Depends(_list_spec),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(DISPATCH_ASSIGNMENTS_VIEW)),
|
||||
):
|
||||
items, total = await DispatchQueries(db).list_assignments(
|
||||
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
||||
)
|
||||
return JobAssignmentListResponse(
|
||||
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{assignment_id}", response_model=JobAssignmentRead)
|
||||
async def get_assignment(
|
||||
assignment_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(DISPATCH_ASSIGNMENTS_VIEW)),
|
||||
):
|
||||
return await DispatchQueries(db).get_assignment(tenant_id, assignment_id)
|
||||
|
||||
|
||||
@router.patch("/{assignment_id}", response_model=JobAssignmentRead)
|
||||
async def update_assignment(
|
||||
assignment_id: UUID,
|
||||
body: JobAssignmentUpdate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(DISPATCH_ASSIGNMENTS_UPDATE)),
|
||||
):
|
||||
return await DispatchCommands(db).update_assignment(
|
||||
tenant_id, assignment_id, body, actor=user
|
||||
)
|
||||
@ -1,120 +0,0 @@
|
||||
"""Dispatch job APIs — Phase 10.5."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.commands.dispatch import DispatchCommands
|
||||
from app.models.types import DispatchJobStatus
|
||||
from app.permissions.definitions import (
|
||||
DISPATCH_JOBS_CREATE,
|
||||
DISPATCH_JOBS_DELETE,
|
||||
DISPATCH_JOBS_STATUS,
|
||||
DISPATCH_JOBS_UPDATE,
|
||||
DISPATCH_JOBS_VIEW,
|
||||
)
|
||||
from app.queries.dispatch import DispatchQueries
|
||||
from app.schemas.dispatch import (
|
||||
DispatchJobCreate,
|
||||
DispatchJobListResponse,
|
||||
DispatchJobRead,
|
||||
DispatchJobStatusRequest,
|
||||
DispatchJobUpdate,
|
||||
)
|
||||
from app.specifications.dispatch import DispatchJobListSpec
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _list_spec(
|
||||
status_filter: DispatchJobStatus | None = Query(default=None, alias="status"),
|
||||
organization_id: UUID | None = Query(default=None),
|
||||
hub_id: UUID | None = Query(default=None),
|
||||
q: str | None = Query(default=None, max_length=100),
|
||||
sort_by: str = Query(default="created_at"),
|
||||
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
||||
) -> DispatchJobListSpec:
|
||||
return DispatchJobListSpec(
|
||||
status=status_filter,
|
||||
organization_id=organization_id,
|
||||
hub_id=hub_id,
|
||||
q=q,
|
||||
sort_by=sort_by,
|
||||
sort_dir=sort_dir.lower(),
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=DispatchJobRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_dispatch_job(
|
||||
body: DispatchJobCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_CREATE)),
|
||||
):
|
||||
return await DispatchCommands(db).create_job(tenant_id, body, actor=user)
|
||||
|
||||
|
||||
@router.get("", response_model=DispatchJobListResponse)
|
||||
async def list_dispatch_jobs(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
spec: DispatchJobListSpec = Depends(_list_spec),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_VIEW)),
|
||||
):
|
||||
items, total = await DispatchQueries(db).list_jobs(
|
||||
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
||||
)
|
||||
return DispatchJobListResponse(
|
||||
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{job_id}", response_model=DispatchJobRead)
|
||||
async def get_dispatch_job(
|
||||
job_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_VIEW)),
|
||||
):
|
||||
return await DispatchQueries(db).get_job(tenant_id, job_id)
|
||||
|
||||
|
||||
@router.patch("/{job_id}", response_model=DispatchJobRead)
|
||||
async def update_dispatch_job(
|
||||
job_id: UUID,
|
||||
body: DispatchJobUpdate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_UPDATE)),
|
||||
):
|
||||
return await DispatchCommands(db).update_job(tenant_id, job_id, body, actor=user)
|
||||
|
||||
|
||||
@router.post("/{job_id}/status", response_model=DispatchJobRead)
|
||||
async def apply_dispatch_status(
|
||||
job_id: UUID,
|
||||
body: DispatchJobStatusRequest,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_STATUS)),
|
||||
):
|
||||
return await DispatchCommands(db).apply_status(
|
||||
tenant_id, job_id, body, actor=user
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{job_id}/delete", response_model=DispatchJobRead)
|
||||
async def delete_dispatch_job(
|
||||
job_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_DELETE)),
|
||||
):
|
||||
return await DispatchCommands(db).delete_job(tenant_id, job_id, actor=user)
|
||||
@ -1,102 +0,0 @@
|
||||
"""Fleet management APIs — Phase 10.2."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.commands.fleet import FleetCommands
|
||||
from app.models.types import FleetStatus, VehicleStatus
|
||||
from app.permissions.definitions import (
|
||||
FLEETS_CREATE,
|
||||
FLEETS_DELETE,
|
||||
FLEETS_UPDATE,
|
||||
FLEETS_VIEW,
|
||||
)
|
||||
from app.queries.fleet import FleetQueries
|
||||
from app.schemas.fleet import FleetCreate, FleetListResponse, FleetRead, FleetUpdate
|
||||
from app.specifications.fleet import FLEET_SORT, FleetListSpec
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _list_spec(
|
||||
status_filter: FleetStatus | None = Query(default=None, alias="status"),
|
||||
organization_id: UUID | None = Query(default=None),
|
||||
hub_id: UUID | None = Query(default=None),
|
||||
q: str | None = Query(default=None, max_length=100),
|
||||
sort_by: str = Query(default="created_at"),
|
||||
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
||||
) -> FleetListSpec:
|
||||
if sort_by not in FLEET_SORT:
|
||||
sort_by = "created_at"
|
||||
return FleetListSpec(
|
||||
status=status_filter,
|
||||
organization_id=organization_id,
|
||||
hub_id=hub_id,
|
||||
q=q,
|
||||
sort_by=sort_by,
|
||||
sort_dir=sort_dir.lower(),
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=FleetRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_fleet(
|
||||
body: FleetCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(FLEETS_CREATE)),
|
||||
):
|
||||
return await FleetCommands(db).create_fleet(tenant_id, body, actor=user)
|
||||
|
||||
|
||||
@router.get("", response_model=FleetListResponse)
|
||||
async def list_fleets(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
spec: FleetListSpec = Depends(_list_spec),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(FLEETS_VIEW)),
|
||||
):
|
||||
items, total = await FleetQueries(db).list_fleets(
|
||||
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
||||
)
|
||||
return FleetListResponse(
|
||||
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{fleet_id}", response_model=FleetRead)
|
||||
async def get_fleet(
|
||||
fleet_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(FLEETS_VIEW)),
|
||||
):
|
||||
return await FleetQueries(db).get_fleet(tenant_id, fleet_id)
|
||||
|
||||
|
||||
@router.patch("/{fleet_id}", response_model=FleetRead)
|
||||
async def update_fleet(
|
||||
fleet_id: UUID,
|
||||
body: FleetUpdate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(FLEETS_UPDATE)),
|
||||
):
|
||||
return await FleetCommands(db).update_fleet(tenant_id, fleet_id, body, actor=user)
|
||||
|
||||
|
||||
@router.post("/{fleet_id}/delete", response_model=FleetRead)
|
||||
async def delete_fleet(
|
||||
fleet_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(FLEETS_DELETE)),
|
||||
):
|
||||
return await FleetCommands(db).delete_fleet(tenant_id, fleet_id, actor=user)
|
||||
@ -20,7 +20,7 @@ async def capabilities():
|
||||
return {
|
||||
"service": settings.service_name,
|
||||
"version": __version__,
|
||||
"phase": "10.8",
|
||||
"phase": "10.1",
|
||||
"commercial_product": "Torbat Driver",
|
||||
"features": {
|
||||
"foundation": True,
|
||||
@ -33,23 +33,12 @@ async def capabilities():
|
||||
"driver_lifecycle": True,
|
||||
"driver_credentials": True,
|
||||
"driver_documents": True,
|
||||
"fleet": True,
|
||||
"vehicle_types": True,
|
||||
"vehicles": True,
|
||||
"vehicle_assignments": True,
|
||||
"driver_availability": True,
|
||||
"shifts": True,
|
||||
"working_zones": True,
|
||||
"pricing_rules": True,
|
||||
"capabilities_catalog": True,
|
||||
"capability_bundles": True,
|
||||
"dispatch_engine": True,
|
||||
"routing_engine": True,
|
||||
"optimization_runs": True,
|
||||
"tracking": True,
|
||||
"proof_of_delivery": True,
|
||||
"customer_tracking": True,
|
||||
"settlement": True,
|
||||
"fleet": False,
|
||||
"dispatch_engine": False,
|
||||
"routing_engine": False,
|
||||
"tracking": False,
|
||||
"proof_of_delivery": False,
|
||||
"settlement": False,
|
||||
"ai": False,
|
||||
},
|
||||
"independence": {
|
||||
@ -67,34 +56,31 @@ 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.8 metrics discovery — tenant-scoped counters."""
|
||||
"""Phase 10.1 metrics discovery — driver counters reserved for ops wiring."""
|
||||
return {
|
||||
"service": settings.service_name,
|
||||
"version": __version__,
|
||||
"phase": "10.8",
|
||||
"phase": "10.1",
|
||||
"metrics": {
|
||||
"organizations": "tenant_scoped",
|
||||
"hubs": "tenant_scoped",
|
||||
"external_providers": "tenant_scoped",
|
||||
"routing_engines": "tenant_scoped",
|
||||
"configurations": "tenant_scoped",
|
||||
"drivers": "tenant_scoped",
|
||||
"fleets": "tenant_scoped",
|
||||
"vehicles": "tenant_scoped",
|
||||
"shifts": "tenant_scoped",
|
||||
"pricing_rules": "tenant_scoped",
|
||||
"dispatch_jobs": "tenant_scoped",
|
||||
"route_plans": "tenant_scoped",
|
||||
"optimization_runs": "tenant_scoped",
|
||||
"tracking_sessions": "tenant_scoped",
|
||||
"proof_of_delivery": "tenant_scoped",
|
||||
"settlement_intents": "tenant_scoped",
|
||||
"driver_credentials": "tenant_scoped",
|
||||
"driver_documents": "tenant_scoped",
|
||||
"driver_lifecycle_events": "tenant_scoped",
|
||||
"outbox_events": "tenant_scoped",
|
||||
"dispatch_jobs": 0,
|
||||
"active_routes": 0,
|
||||
"tracking_sessions": 0,
|
||||
},
|
||||
"note": "Full delivery platform through phase 10.8; settlement uses AccountingProvider refs only",
|
||||
"note": "Driver aggregates live; dispatch/routing/tracking counters reserved for later phases",
|
||||
}
|
||||
|
||||
@ -1,74 +0,0 @@
|
||||
"""Optimization run APIs — Phase 10.6."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.commands.routing import RoutingCommands
|
||||
from app.models.types import OptimizationRunStatus
|
||||
from app.permissions.definitions import OPTIMIZATION_RUNS_CREATE, OPTIMIZATION_RUNS_VIEW
|
||||
from app.queries.routing import RoutingQueries
|
||||
from app.schemas.routing import (
|
||||
OptimizationRunCreate,
|
||||
OptimizationRunListResponse,
|
||||
OptimizationRunRead,
|
||||
)
|
||||
from app.specifications.routing import OptimizationRunListSpec
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _list_spec(
|
||||
route_plan_id: UUID | None = Query(default=None),
|
||||
status_filter: OptimizationRunStatus | None = Query(default=None, alias="status"),
|
||||
sort_by: str = Query(default="created_at"),
|
||||
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
||||
) -> OptimizationRunListSpec:
|
||||
return OptimizationRunListSpec(
|
||||
route_plan_id=route_plan_id,
|
||||
status=status_filter,
|
||||
sort_by=sort_by,
|
||||
sort_dir=sort_dir.lower(),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/runs", response_model=OptimizationRunRead, status_code=status.HTTP_201_CREATED)
|
||||
async def start_optimization(
|
||||
body: OptimizationRunCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(OPTIMIZATION_RUNS_CREATE)),
|
||||
):
|
||||
return await RoutingCommands(db).start_optimization(tenant_id, body, actor=user)
|
||||
|
||||
|
||||
@router.get("/runs", response_model=OptimizationRunListResponse)
|
||||
async def list_optimization_runs(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
spec: OptimizationRunListSpec = Depends(_list_spec),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(OPTIMIZATION_RUNS_VIEW)),
|
||||
):
|
||||
items, total = await RoutingQueries(db).list_optimization_runs(
|
||||
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
||||
)
|
||||
return OptimizationRunListResponse(
|
||||
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
||||
)
|
||||
|
||||
|
||||
@router.get("/runs/{run_id}", response_model=OptimizationRunRead)
|
||||
async def get_optimization_run(
|
||||
run_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(OPTIMIZATION_RUNS_VIEW)),
|
||||
):
|
||||
return await RoutingQueries(db).get_optimization_run(tenant_id, run_id)
|
||||
@ -1,103 +0,0 @@
|
||||
"""Pricing rule APIs — Phase 10.4."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.commands.pricing import PricingCommands
|
||||
from app.models.types import PricingRuleStatus
|
||||
from app.permissions.definitions import (
|
||||
PRICING_RULES_CREATE,
|
||||
PRICING_RULES_DELETE,
|
||||
PRICING_RULES_UPDATE,
|
||||
PRICING_RULES_VIEW,
|
||||
)
|
||||
from app.queries.pricing import PricingQueries
|
||||
from app.schemas.pricing import (
|
||||
PricingRuleCreate,
|
||||
PricingRuleListResponse,
|
||||
PricingRuleRead,
|
||||
PricingRuleUpdate,
|
||||
)
|
||||
from app.specifications.pricing import PricingRuleListSpec
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _list_spec(
|
||||
status_filter: PricingRuleStatus | None = Query(default=None, alias="status"),
|
||||
organization_id: UUID | None = Query(default=None),
|
||||
q: str | None = Query(default=None, max_length=100),
|
||||
sort_by: str = Query(default="created_at"),
|
||||
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
||||
) -> PricingRuleListSpec:
|
||||
return PricingRuleListSpec(
|
||||
status=status_filter,
|
||||
organization_id=organization_id,
|
||||
q=q,
|
||||
sort_by=sort_by,
|
||||
sort_dir=sort_dir.lower(),
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=PricingRuleRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_pricing_rule(
|
||||
body: PricingRuleCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(PRICING_RULES_CREATE)),
|
||||
):
|
||||
return await PricingCommands(db).create_rule(tenant_id, body, actor=user)
|
||||
|
||||
|
||||
@router.get("", response_model=PricingRuleListResponse)
|
||||
async def list_pricing_rules(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
spec: PricingRuleListSpec = Depends(_list_spec),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(PRICING_RULES_VIEW)),
|
||||
):
|
||||
items, total = await PricingQueries(db).list_rules(
|
||||
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
||||
)
|
||||
return PricingRuleListResponse(
|
||||
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{rule_id}", response_model=PricingRuleRead)
|
||||
async def get_pricing_rule(
|
||||
rule_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(PRICING_RULES_VIEW)),
|
||||
):
|
||||
return await PricingQueries(db).get_rule(tenant_id, rule_id)
|
||||
|
||||
|
||||
@router.patch("/{rule_id}", response_model=PricingRuleRead)
|
||||
async def update_pricing_rule(
|
||||
rule_id: UUID,
|
||||
body: PricingRuleUpdate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(PRICING_RULES_UPDATE)),
|
||||
):
|
||||
return await PricingCommands(db).update_rule(tenant_id, rule_id, body, actor=user)
|
||||
|
||||
|
||||
@router.post("/{rule_id}/delete", response_model=PricingRuleRead)
|
||||
async def delete_pricing_rule(
|
||||
rule_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(PRICING_RULES_DELETE)),
|
||||
):
|
||||
return await PricingCommands(db).delete_rule(tenant_id, rule_id, actor=user)
|
||||
@ -1,90 +0,0 @@
|
||||
"""Proof of delivery APIs — Phase 10.7."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.commands.tracking import TrackingCommands
|
||||
from app.models.types import ProofOfDeliveryStatus
|
||||
from app.permissions.definitions import (
|
||||
PROOF_OF_DELIVERY_CREATE,
|
||||
PROOF_OF_DELIVERY_UPDATE,
|
||||
PROOF_OF_DELIVERY_VIEW,
|
||||
)
|
||||
from app.queries.tracking import TrackingQueries
|
||||
from app.schemas.tracking import (
|
||||
ProofOfDeliveryCreate,
|
||||
ProofOfDeliveryListResponse,
|
||||
ProofOfDeliveryRead,
|
||||
ProofOfDeliveryUpdate,
|
||||
)
|
||||
from app.specifications.tracking import ProofOfDeliveryListSpec
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _list_spec(
|
||||
status_filter: ProofOfDeliveryStatus | None = Query(default=None, alias="status"),
|
||||
dispatch_job_id: UUID | None = Query(default=None),
|
||||
sort_by: str = Query(default="created_at"),
|
||||
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
||||
) -> ProofOfDeliveryListSpec:
|
||||
return ProofOfDeliveryListSpec(
|
||||
status=status_filter,
|
||||
dispatch_job_id=dispatch_job_id,
|
||||
sort_by=sort_by,
|
||||
sort_dir=sort_dir.lower(),
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=ProofOfDeliveryRead, status_code=status.HTTP_201_CREATED)
|
||||
async def capture_pod(
|
||||
body: ProofOfDeliveryCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(PROOF_OF_DELIVERY_CREATE)),
|
||||
):
|
||||
return await TrackingCommands(db).capture_pod(tenant_id, body, actor=user)
|
||||
|
||||
|
||||
@router.get("", response_model=ProofOfDeliveryListResponse)
|
||||
async def list_pods(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
spec: ProofOfDeliveryListSpec = Depends(_list_spec),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(PROOF_OF_DELIVERY_VIEW)),
|
||||
):
|
||||
items, total = await TrackingQueries(db).list_pods(
|
||||
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
||||
)
|
||||
return ProofOfDeliveryListResponse(
|
||||
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{pod_id}", response_model=ProofOfDeliveryRead)
|
||||
async def get_pod(
|
||||
pod_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(PROOF_OF_DELIVERY_VIEW)),
|
||||
):
|
||||
return await TrackingQueries(db).get_pod(tenant_id, pod_id)
|
||||
|
||||
|
||||
@router.patch("/{pod_id}", response_model=ProofOfDeliveryRead)
|
||||
async def update_pod(
|
||||
pod_id: UUID,
|
||||
body: ProofOfDeliveryUpdate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(PROOF_OF_DELIVERY_UPDATE)),
|
||||
):
|
||||
return await TrackingCommands(db).update_pod(tenant_id, pod_id, body, actor=user)
|
||||
@ -1,134 +0,0 @@
|
||||
"""Route plan APIs — Phase 10.6."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.commands.routing import RoutingCommands
|
||||
from app.models.types import RoutePlanStatus
|
||||
from app.permissions.definitions import (
|
||||
ROUTES_CREATE,
|
||||
ROUTES_DELETE,
|
||||
ROUTES_STOPS_MANAGE,
|
||||
ROUTES_STOPS_VIEW,
|
||||
ROUTES_UPDATE,
|
||||
ROUTES_VIEW,
|
||||
)
|
||||
from app.queries.routing import RoutingQueries
|
||||
from app.schemas.routing import (
|
||||
RoutePlanCreate,
|
||||
RoutePlanListResponse,
|
||||
RoutePlanRead,
|
||||
RoutePlanUpdate,
|
||||
RouteStopCreate,
|
||||
RouteStopRead,
|
||||
)
|
||||
from app.specifications.routing import RoutePlanListSpec
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _list_spec(
|
||||
status_filter: RoutePlanStatus | None = Query(default=None, alias="status"),
|
||||
organization_id: UUID | None = Query(default=None),
|
||||
driver_id: UUID | None = Query(default=None),
|
||||
q: str | None = Query(default=None, max_length=100),
|
||||
sort_by: str = Query(default="created_at"),
|
||||
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
||||
) -> RoutePlanListSpec:
|
||||
return RoutePlanListSpec(
|
||||
status=status_filter,
|
||||
organization_id=organization_id,
|
||||
driver_id=driver_id,
|
||||
q=q,
|
||||
sort_by=sort_by,
|
||||
sort_dir=sort_dir.lower(),
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=RoutePlanRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_route(
|
||||
body: RoutePlanCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(ROUTES_CREATE)),
|
||||
):
|
||||
return await RoutingCommands(db).create_route(tenant_id, body, actor=user)
|
||||
|
||||
|
||||
@router.get("", response_model=RoutePlanListResponse)
|
||||
async def list_routes(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
spec: RoutePlanListSpec = Depends(_list_spec),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(ROUTES_VIEW)),
|
||||
):
|
||||
items, total = await RoutingQueries(db).list_routes(
|
||||
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
||||
)
|
||||
return RoutePlanListResponse(
|
||||
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{plan_id}", response_model=RoutePlanRead)
|
||||
async def get_route(
|
||||
plan_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(ROUTES_VIEW)),
|
||||
):
|
||||
return await RoutingQueries(db).get_route(tenant_id, plan_id)
|
||||
|
||||
|
||||
@router.patch("/{plan_id}", response_model=RoutePlanRead)
|
||||
async def update_route(
|
||||
plan_id: UUID,
|
||||
body: RoutePlanUpdate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(ROUTES_UPDATE)),
|
||||
):
|
||||
return await RoutingCommands(db).update_route(tenant_id, plan_id, body, actor=user)
|
||||
|
||||
|
||||
@router.post("/{plan_id}/delete", response_model=RoutePlanRead)
|
||||
async def delete_route(
|
||||
plan_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(ROUTES_DELETE)),
|
||||
):
|
||||
return await RoutingCommands(db).delete_route(tenant_id, plan_id, actor=user)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{plan_id}/stops",
|
||||
response_model=RouteStopRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def add_stop(
|
||||
plan_id: UUID,
|
||||
body: RouteStopCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(ROUTES_STOPS_MANAGE)),
|
||||
):
|
||||
return await RoutingCommands(db).add_stop(tenant_id, plan_id, body, actor=user)
|
||||
|
||||
|
||||
@router.get("/{plan_id}/stops", response_model=list[RouteStopRead])
|
||||
async def list_stops(
|
||||
plan_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(ROUTES_STOPS_VIEW)),
|
||||
):
|
||||
return await RoutingQueries(db).list_stops(tenant_id, plan_id)
|
||||
@ -1,169 +0,0 @@
|
||||
"""Settlement APIs — Phase 10.8."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.commands.settlement import SettlementCommands
|
||||
from app.models.types import SettlementIntentStatus
|
||||
from app.permissions.definitions import (
|
||||
SETTLEMENTS_CREATE,
|
||||
SETTLEMENTS_LINES_MANAGE,
|
||||
SETTLEMENTS_LINES_VIEW,
|
||||
SETTLEMENTS_STATUS,
|
||||
SETTLEMENTS_UPDATE,
|
||||
SETTLEMENTS_VIEW,
|
||||
)
|
||||
from app.queries.settlement import SettlementQueries
|
||||
from app.schemas.settlement import (
|
||||
SettlementActionRequest,
|
||||
SettlementIntentCreate,
|
||||
SettlementIntentListResponse,
|
||||
SettlementIntentRead,
|
||||
SettlementIntentUpdate,
|
||||
SettlementLineCreate,
|
||||
SettlementLineRead,
|
||||
)
|
||||
from app.specifications.settlement import SettlementIntentListSpec
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _list_spec(
|
||||
status_filter: SettlementIntentStatus | None = Query(default=None, alias="status"),
|
||||
organization_id: UUID | None = Query(default=None),
|
||||
driver_id: UUID | None = Query(default=None),
|
||||
q: str | None = Query(default=None, max_length=100),
|
||||
sort_by: str = Query(default="created_at"),
|
||||
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
||||
) -> SettlementIntentListSpec:
|
||||
return SettlementIntentListSpec(
|
||||
status=status_filter,
|
||||
organization_id=organization_id,
|
||||
driver_id=driver_id,
|
||||
q=q,
|
||||
sort_by=sort_by,
|
||||
sort_dir=sort_dir.lower(),
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=SettlementIntentRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_settlement(
|
||||
body: SettlementIntentCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_CREATE)),
|
||||
):
|
||||
return await SettlementCommands(db).create(tenant_id, body, actor=user)
|
||||
|
||||
|
||||
@router.get("", response_model=SettlementIntentListResponse)
|
||||
async def list_settlements(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
spec: SettlementIntentListSpec = Depends(_list_spec),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(SETTLEMENTS_VIEW)),
|
||||
):
|
||||
items, total = await SettlementQueries(db).list(
|
||||
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
||||
)
|
||||
return SettlementIntentListResponse(
|
||||
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{intent_id}", response_model=SettlementIntentRead)
|
||||
async def get_settlement(
|
||||
intent_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(SETTLEMENTS_VIEW)),
|
||||
):
|
||||
return await SettlementQueries(db).get(tenant_id, intent_id)
|
||||
|
||||
|
||||
@router.patch("/{intent_id}", response_model=SettlementIntentRead)
|
||||
async def update_settlement(
|
||||
intent_id: UUID,
|
||||
body: SettlementIntentUpdate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_UPDATE)),
|
||||
):
|
||||
return await SettlementCommands(db).update(tenant_id, intent_id, body, actor=user)
|
||||
|
||||
|
||||
@router.post("/{intent_id}/submit", response_model=SettlementIntentRead)
|
||||
async def submit_settlement(
|
||||
intent_id: UUID,
|
||||
body: SettlementActionRequest,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_STATUS)),
|
||||
):
|
||||
return await SettlementCommands(db).submit(tenant_id, intent_id, body, actor=user)
|
||||
|
||||
|
||||
@router.post("/{intent_id}/settle", response_model=SettlementIntentRead)
|
||||
async def settle_settlement(
|
||||
intent_id: UUID,
|
||||
body: SettlementActionRequest,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_STATUS)),
|
||||
):
|
||||
return await SettlementCommands(db).settle(tenant_id, intent_id, body, actor=user)
|
||||
|
||||
|
||||
@router.post("/{intent_id}/fail", response_model=SettlementIntentRead)
|
||||
async def fail_settlement(
|
||||
intent_id: UUID,
|
||||
body: SettlementActionRequest,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_STATUS)),
|
||||
):
|
||||
return await SettlementCommands(db).fail(tenant_id, intent_id, body, actor=user)
|
||||
|
||||
|
||||
@router.post("/{intent_id}/cancel", response_model=SettlementIntentRead)
|
||||
async def cancel_settlement(
|
||||
intent_id: UUID,
|
||||
body: SettlementActionRequest,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_STATUS)),
|
||||
):
|
||||
return await SettlementCommands(db).cancel(tenant_id, intent_id, body, actor=user)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{intent_id}/lines",
|
||||
response_model=SettlementLineRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def add_settlement_line(
|
||||
intent_id: UUID,
|
||||
body: SettlementLineCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_LINES_MANAGE)),
|
||||
):
|
||||
return await SettlementCommands(db).add_line(tenant_id, intent_id, body, actor=user)
|
||||
|
||||
|
||||
@router.get("/{intent_id}/lines", response_model=list[SettlementLineRead])
|
||||
async def list_settlement_lines(
|
||||
intent_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(SETTLEMENTS_LINES_VIEW)),
|
||||
):
|
||||
return await SettlementQueries(db).lines(tenant_id, intent_id)
|
||||
@ -1,156 +0,0 @@
|
||||
"""Shift APIs — Phase 10.3."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.commands.availability import AvailabilityCommands
|
||||
from app.models.types import ShiftStatus
|
||||
from app.permissions.definitions import (
|
||||
SHIFTS_ASSIGNMENTS_MANAGE,
|
||||
SHIFTS_ASSIGNMENTS_VIEW,
|
||||
SHIFTS_CREATE,
|
||||
SHIFTS_DELETE,
|
||||
SHIFTS_UPDATE,
|
||||
SHIFTS_VIEW,
|
||||
)
|
||||
from app.queries.availability import AvailabilityQueries
|
||||
from app.schemas.availability import (
|
||||
ShiftAssignmentCreate,
|
||||
ShiftAssignmentRead,
|
||||
ShiftAssignmentUpdate,
|
||||
ShiftCreate,
|
||||
ShiftListResponse,
|
||||
ShiftRead,
|
||||
ShiftUpdate,
|
||||
)
|
||||
from app.specifications.availability import ShiftListSpec
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _list_spec(
|
||||
status_filter: ShiftStatus | None = Query(default=None, alias="status"),
|
||||
organization_id: UUID | None = Query(default=None),
|
||||
hub_id: UUID | None = Query(default=None),
|
||||
q: str | None = Query(default=None, max_length=100),
|
||||
sort_by: str = Query(default="starts_at"),
|
||||
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
||||
) -> ShiftListSpec:
|
||||
return ShiftListSpec(
|
||||
status=status_filter,
|
||||
organization_id=organization_id,
|
||||
hub_id=hub_id,
|
||||
q=q,
|
||||
sort_by=sort_by,
|
||||
sort_dir=sort_dir.lower(),
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=ShiftRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_shift(
|
||||
body: ShiftCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(SHIFTS_CREATE)),
|
||||
):
|
||||
return await AvailabilityCommands(db).create_shift(tenant_id, body, actor=user)
|
||||
|
||||
|
||||
@router.get("", response_model=ShiftListResponse)
|
||||
async def list_shifts(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
spec: ShiftListSpec = Depends(_list_spec),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(SHIFTS_VIEW)),
|
||||
):
|
||||
items, total = await AvailabilityQueries(db).list_shifts(
|
||||
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
||||
)
|
||||
return ShiftListResponse(
|
||||
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{shift_id}", response_model=ShiftRead)
|
||||
async def get_shift(
|
||||
shift_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(SHIFTS_VIEW)),
|
||||
):
|
||||
return await AvailabilityQueries(db).get_shift(tenant_id, shift_id)
|
||||
|
||||
|
||||
@router.patch("/{shift_id}", response_model=ShiftRead)
|
||||
async def update_shift(
|
||||
shift_id: UUID,
|
||||
body: ShiftUpdate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(SHIFTS_UPDATE)),
|
||||
):
|
||||
return await AvailabilityCommands(db).update_shift(
|
||||
tenant_id, shift_id, body, actor=user
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{shift_id}/delete", response_model=ShiftRead)
|
||||
async def delete_shift(
|
||||
shift_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(SHIFTS_DELETE)),
|
||||
):
|
||||
return await AvailabilityCommands(db).delete_shift(tenant_id, shift_id, actor=user)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{shift_id}/assignments",
|
||||
response_model=ShiftAssignmentRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def assign_shift(
|
||||
shift_id: UUID,
|
||||
body: ShiftAssignmentCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(SHIFTS_ASSIGNMENTS_MANAGE)),
|
||||
):
|
||||
return await AvailabilityCommands(db).assign_shift(
|
||||
tenant_id, shift_id, body, actor=user
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{shift_id}/assignments", response_model=list[ShiftAssignmentRead])
|
||||
async def list_shift_assignments(
|
||||
shift_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(SHIFTS_ASSIGNMENTS_VIEW)),
|
||||
):
|
||||
return await AvailabilityQueries(db).list_shift_assignments(tenant_id, shift_id)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{shift_id}/assignments/{assignment_id}",
|
||||
response_model=ShiftAssignmentRead,
|
||||
)
|
||||
async def update_shift_assignment(
|
||||
shift_id: UUID,
|
||||
assignment_id: UUID,
|
||||
body: ShiftAssignmentUpdate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(SHIFTS_ASSIGNMENTS_MANAGE)),
|
||||
):
|
||||
return await AvailabilityCommands(db).update_shift_assignment(
|
||||
tenant_id, shift_id, assignment_id, body, actor=user
|
||||
)
|
||||
@ -1,126 +0,0 @@
|
||||
"""Tracking session APIs — Phase 10.7."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.commands.tracking import TrackingCommands
|
||||
from app.models.types import TrackingSessionStatus
|
||||
from app.permissions.definitions import (
|
||||
TRACKING_POINTS_MANAGE,
|
||||
TRACKING_POINTS_VIEW,
|
||||
TRACKING_SESSIONS_CREATE,
|
||||
TRACKING_SESSIONS_UPDATE,
|
||||
TRACKING_SESSIONS_VIEW,
|
||||
)
|
||||
from app.queries.tracking import TrackingQueries
|
||||
from app.schemas.tracking import (
|
||||
TrackingPointCreate,
|
||||
TrackingPointRead,
|
||||
TrackingSessionCreate,
|
||||
TrackingSessionListResponse,
|
||||
TrackingSessionRead,
|
||||
TrackingSessionUpdate,
|
||||
)
|
||||
from app.specifications.tracking import TrackingSessionListSpec
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _list_spec(
|
||||
status_filter: TrackingSessionStatus | None = Query(default=None, alias="status"),
|
||||
dispatch_job_id: UUID | None = Query(default=None),
|
||||
driver_id: UUID | None = Query(default=None),
|
||||
sort_by: str = Query(default="created_at"),
|
||||
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
||||
) -> TrackingSessionListSpec:
|
||||
return TrackingSessionListSpec(
|
||||
status=status_filter,
|
||||
dispatch_job_id=dispatch_job_id,
|
||||
driver_id=driver_id,
|
||||
sort_by=sort_by,
|
||||
sort_dir=sort_dir.lower(),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/sessions", response_model=TrackingSessionRead, status_code=status.HTTP_201_CREATED)
|
||||
async def start_session(
|
||||
body: TrackingSessionCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(TRACKING_SESSIONS_CREATE)),
|
||||
):
|
||||
return await TrackingCommands(db).start_session(tenant_id, body, actor=user)
|
||||
|
||||
|
||||
@router.get("/sessions", response_model=TrackingSessionListResponse)
|
||||
async def list_sessions(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
spec: TrackingSessionListSpec = Depends(_list_spec),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(TRACKING_SESSIONS_VIEW)),
|
||||
):
|
||||
items, total = await TrackingQueries(db).list_sessions(
|
||||
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
||||
)
|
||||
return TrackingSessionListResponse(
|
||||
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}", response_model=TrackingSessionRead)
|
||||
async def get_session(
|
||||
session_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(TRACKING_SESSIONS_VIEW)),
|
||||
):
|
||||
return await TrackingQueries(db).get_session(tenant_id, session_id)
|
||||
|
||||
|
||||
@router.patch("/sessions/{session_id}", response_model=TrackingSessionRead)
|
||||
async def update_session(
|
||||
session_id: UUID,
|
||||
body: TrackingSessionUpdate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(TRACKING_SESSIONS_UPDATE)),
|
||||
):
|
||||
return await TrackingCommands(db).update_session(
|
||||
tenant_id, session_id, body, actor=user
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sessions/{session_id}/points",
|
||||
response_model=TrackingPointRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def record_point(
|
||||
session_id: UUID,
|
||||
body: TrackingPointCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(TRACKING_POINTS_MANAGE)),
|
||||
):
|
||||
return await TrackingCommands(db).record_point(
|
||||
tenant_id, session_id, body, actor=user
|
||||
)
|
||||
|
||||
|
||||
@router.get("/sessions/{session_id}/points", response_model=list[TrackingPointRead])
|
||||
async def list_points(
|
||||
session_id: UUID,
|
||||
limit: int = Query(default=500, ge=1, le=2000),
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(TRACKING_POINTS_VIEW)),
|
||||
):
|
||||
return await TrackingQueries(db).list_points(tenant_id, session_id, limit=limit)
|
||||
@ -1,96 +0,0 @@
|
||||
"""Vehicle type APIs — Phase 10.2."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.commands.fleet import FleetCommands
|
||||
from app.permissions.definitions import (
|
||||
VEHICLE_TYPES_CREATE,
|
||||
VEHICLE_TYPES_DELETE,
|
||||
VEHICLE_TYPES_UPDATE,
|
||||
VEHICLE_TYPES_VIEW,
|
||||
)
|
||||
from app.queries.fleet import FleetQueries
|
||||
from app.schemas.fleet import (
|
||||
VehicleTypeCreate,
|
||||
VehicleTypeListResponse,
|
||||
VehicleTypeRead,
|
||||
VehicleTypeUpdate,
|
||||
)
|
||||
from app.specifications.fleet import VehicleTypeListSpec
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _list_spec(
|
||||
q: str | None = Query(default=None, max_length=100),
|
||||
sort_by: str = Query(default="created_at"),
|
||||
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
||||
) -> VehicleTypeListSpec:
|
||||
return VehicleTypeListSpec(q=q, sort_by=sort_by, sort_dir=sort_dir.lower())
|
||||
|
||||
|
||||
@router.post("", response_model=VehicleTypeRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_vehicle_type(
|
||||
body: VehicleTypeCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(VEHICLE_TYPES_CREATE)),
|
||||
):
|
||||
return await FleetCommands(db).create_vehicle_type(tenant_id, body, actor=user)
|
||||
|
||||
|
||||
@router.get("", response_model=VehicleTypeListResponse)
|
||||
async def list_vehicle_types(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
spec: VehicleTypeListSpec = Depends(_list_spec),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(VEHICLE_TYPES_VIEW)),
|
||||
):
|
||||
items, total = await FleetQueries(db).list_vehicle_types(
|
||||
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
||||
)
|
||||
return VehicleTypeListResponse(
|
||||
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{type_id}", response_model=VehicleTypeRead)
|
||||
async def get_vehicle_type(
|
||||
type_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(VEHICLE_TYPES_VIEW)),
|
||||
):
|
||||
return await FleetQueries(db).get_vehicle_type(tenant_id, type_id)
|
||||
|
||||
|
||||
@router.patch("/{type_id}", response_model=VehicleTypeRead)
|
||||
async def update_vehicle_type(
|
||||
type_id: UUID,
|
||||
body: VehicleTypeUpdate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(VEHICLE_TYPES_UPDATE)),
|
||||
):
|
||||
return await FleetCommands(db).update_vehicle_type(
|
||||
tenant_id, type_id, body, actor=user
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{type_id}/delete", response_model=VehicleTypeRead)
|
||||
async def delete_vehicle_type(
|
||||
type_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(VEHICLE_TYPES_DELETE)),
|
||||
):
|
||||
return await FleetCommands(db).delete_vehicle_type(tenant_id, type_id, actor=user)
|
||||
@ -1,154 +0,0 @@
|
||||
"""Vehicle APIs — Phase 10.2."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.commands.fleet import FleetCommands
|
||||
from app.models.types import VehicleStatus
|
||||
from app.permissions.definitions import (
|
||||
VEHICLES_ASSIGNMENTS_MANAGE,
|
||||
VEHICLES_ASSIGNMENTS_VIEW,
|
||||
VEHICLES_CREATE,
|
||||
VEHICLES_DELETE,
|
||||
VEHICLES_UPDATE,
|
||||
VEHICLES_VIEW,
|
||||
)
|
||||
from app.queries.fleet import FleetQueries
|
||||
from app.schemas.fleet import (
|
||||
VehicleAssignmentCreate,
|
||||
VehicleAssignmentRead,
|
||||
VehicleAssignmentUpdate,
|
||||
VehicleCreate,
|
||||
VehicleListResponse,
|
||||
VehicleRead,
|
||||
VehicleUpdate,
|
||||
)
|
||||
from app.specifications.fleet import VehicleListSpec
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _list_spec(
|
||||
fleet_id: UUID | None = Query(default=None),
|
||||
status_filter: VehicleStatus | None = Query(default=None, alias="status"),
|
||||
q: str | None = Query(default=None, max_length=100),
|
||||
sort_by: str = Query(default="created_at"),
|
||||
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
||||
) -> VehicleListSpec:
|
||||
return VehicleListSpec(
|
||||
fleet_id=fleet_id,
|
||||
status=status_filter,
|
||||
q=q,
|
||||
sort_by=sort_by,
|
||||
sort_dir=sort_dir.lower(),
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=VehicleRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_vehicle(
|
||||
body: VehicleCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(VEHICLES_CREATE)),
|
||||
):
|
||||
return await FleetCommands(db).create_vehicle(tenant_id, body, actor=user)
|
||||
|
||||
|
||||
@router.get("", response_model=VehicleListResponse)
|
||||
async def list_vehicles(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
spec: VehicleListSpec = Depends(_list_spec),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(VEHICLES_VIEW)),
|
||||
):
|
||||
items, total = await FleetQueries(db).list_vehicles(
|
||||
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
||||
)
|
||||
return VehicleListResponse(
|
||||
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{vehicle_id}", response_model=VehicleRead)
|
||||
async def get_vehicle(
|
||||
vehicle_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(VEHICLES_VIEW)),
|
||||
):
|
||||
return await FleetQueries(db).get_vehicle(tenant_id, vehicle_id)
|
||||
|
||||
|
||||
@router.patch("/{vehicle_id}", response_model=VehicleRead)
|
||||
async def update_vehicle(
|
||||
vehicle_id: UUID,
|
||||
body: VehicleUpdate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(VEHICLES_UPDATE)),
|
||||
):
|
||||
return await FleetCommands(db).update_vehicle(
|
||||
tenant_id, vehicle_id, body, actor=user
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{vehicle_id}/delete", response_model=VehicleRead)
|
||||
async def delete_vehicle(
|
||||
vehicle_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(VEHICLES_DELETE)),
|
||||
):
|
||||
return await FleetCommands(db).delete_vehicle(tenant_id, vehicle_id, actor=user)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{vehicle_id}/assignments",
|
||||
response_model=VehicleAssignmentRead,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_assignment(
|
||||
vehicle_id: UUID,
|
||||
body: VehicleAssignmentCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(VEHICLES_ASSIGNMENTS_MANAGE)),
|
||||
):
|
||||
return await FleetCommands(db).create_assignment(
|
||||
tenant_id, vehicle_id, body, actor=user
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{vehicle_id}/assignments", response_model=list[VehicleAssignmentRead])
|
||||
async def list_assignments(
|
||||
vehicle_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(VEHICLES_ASSIGNMENTS_VIEW)),
|
||||
):
|
||||
return await FleetQueries(db).list_assignments(tenant_id, vehicle_id)
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{vehicle_id}/assignments/{assignment_id}",
|
||||
response_model=VehicleAssignmentRead,
|
||||
)
|
||||
async def update_assignment(
|
||||
vehicle_id: UUID,
|
||||
assignment_id: UUID,
|
||||
body: VehicleAssignmentUpdate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(VEHICLES_ASSIGNMENTS_MANAGE)),
|
||||
):
|
||||
return await FleetCommands(db).update_assignment(
|
||||
tenant_id, vehicle_id, assignment_id, body, actor=user
|
||||
)
|
||||
@ -1,105 +0,0 @@
|
||||
"""Working zone APIs — Phase 10.3."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.api.deps import get_db, get_pagination, require_tenant
|
||||
from app.api.permissions import require_permissions
|
||||
from app.commands.availability import AvailabilityCommands
|
||||
from app.models.types import WorkingZoneStatus
|
||||
from app.permissions.definitions import (
|
||||
WORKING_ZONES_CREATE,
|
||||
WORKING_ZONES_DELETE,
|
||||
WORKING_ZONES_UPDATE,
|
||||
WORKING_ZONES_VIEW,
|
||||
)
|
||||
from app.queries.availability import AvailabilityQueries
|
||||
from app.schemas.availability import (
|
||||
WorkingZoneCreate,
|
||||
WorkingZoneListResponse,
|
||||
WorkingZoneRead,
|
||||
WorkingZoneUpdate,
|
||||
)
|
||||
from app.specifications.availability import WorkingZoneListSpec
|
||||
from shared.pagination import PaginationParams
|
||||
from shared.security import CurrentUser
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _list_spec(
|
||||
status_filter: WorkingZoneStatus | None = Query(default=None, alias="status"),
|
||||
organization_id: UUID | None = Query(default=None),
|
||||
q: str | None = Query(default=None, max_length=100),
|
||||
sort_by: str = Query(default="created_at"),
|
||||
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
||||
) -> WorkingZoneListSpec:
|
||||
return WorkingZoneListSpec(
|
||||
status=status_filter,
|
||||
organization_id=organization_id,
|
||||
q=q,
|
||||
sort_by=sort_by,
|
||||
sort_dir=sort_dir.lower(),
|
||||
)
|
||||
|
||||
|
||||
@router.post("", response_model=WorkingZoneRead, status_code=status.HTTP_201_CREATED)
|
||||
async def create_zone(
|
||||
body: WorkingZoneCreate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(WORKING_ZONES_CREATE)),
|
||||
):
|
||||
return await AvailabilityCommands(db).create_zone(tenant_id, body, actor=user)
|
||||
|
||||
|
||||
@router.get("", response_model=WorkingZoneListResponse)
|
||||
async def list_zones(
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
pagination: PaginationParams = Depends(get_pagination),
|
||||
spec: WorkingZoneListSpec = Depends(_list_spec),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(WORKING_ZONES_VIEW)),
|
||||
):
|
||||
items, total = await AvailabilityQueries(db).list_zones(
|
||||
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
||||
)
|
||||
return WorkingZoneListResponse(
|
||||
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
||||
)
|
||||
|
||||
|
||||
@router.get("/{zone_id}", response_model=WorkingZoneRead)
|
||||
async def get_zone(
|
||||
zone_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
_user: CurrentUser = Depends(require_permissions(WORKING_ZONES_VIEW)),
|
||||
):
|
||||
return await AvailabilityQueries(db).get_zone(tenant_id, zone_id)
|
||||
|
||||
|
||||
@router.patch("/{zone_id}", response_model=WorkingZoneRead)
|
||||
async def update_zone(
|
||||
zone_id: UUID,
|
||||
body: WorkingZoneUpdate,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(WORKING_ZONES_UPDATE)),
|
||||
):
|
||||
return await AvailabilityCommands(db).update_zone(
|
||||
tenant_id, zone_id, body, actor=user
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{zone_id}/delete", response_model=WorkingZoneRead)
|
||||
async def delete_zone(
|
||||
zone_id: UUID,
|
||||
tenant_id: UUID = Depends(require_tenant),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
user: CurrentUser = Depends(require_permissions(WORKING_ZONES_DELETE)),
|
||||
):
|
||||
return await AvailabilityCommands(db).delete_zone(tenant_id, zone_id, actor=user)
|
||||
@ -1,90 +0,0 @@
|
||||
"""Availability command handlers — Phase 10.3."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.schemas.availability import (
|
||||
DriverAvailabilityUpsert,
|
||||
ShiftAssignmentCreate,
|
||||
ShiftAssignmentUpdate,
|
||||
ShiftCreate,
|
||||
ShiftUpdate,
|
||||
WorkingZoneCreate,
|
||||
WorkingZoneUpdate,
|
||||
)
|
||||
from app.services.availability import AvailabilityService, ShiftService, WorkingZoneService
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
class AvailabilityCommands:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.availability = AvailabilityService(session)
|
||||
self.shifts = ShiftService(session)
|
||||
self.zones = WorkingZoneService(session)
|
||||
|
||||
async def upsert_availability(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
driver_id: UUID,
|
||||
body: DriverAvailabilityUpsert,
|
||||
*,
|
||||
actor: CurrentUser | None,
|
||||
):
|
||||
return await self.availability.upsert_for_driver(
|
||||
tenant_id, driver_id, body, actor=actor
|
||||
)
|
||||
|
||||
async def create_shift(
|
||||
self, tenant_id: UUID, body: ShiftCreate, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.shifts.create(tenant_id, body, actor=actor)
|
||||
|
||||
async def update_shift(
|
||||
self, tenant_id: UUID, shift_id: UUID, body: ShiftUpdate, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.shifts.update(tenant_id, shift_id, body, actor=actor)
|
||||
|
||||
async def delete_shift(
|
||||
self, tenant_id: UUID, shift_id: UUID, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.shifts.soft_delete(tenant_id, shift_id, actor=actor)
|
||||
|
||||
async def assign_shift(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
shift_id: UUID,
|
||||
body: ShiftAssignmentCreate,
|
||||
*,
|
||||
actor: CurrentUser | None,
|
||||
):
|
||||
return await self.shifts.assign_driver(tenant_id, shift_id, body, actor=actor)
|
||||
|
||||
async def update_shift_assignment(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
shift_id: UUID,
|
||||
assignment_id: UUID,
|
||||
body: ShiftAssignmentUpdate,
|
||||
*,
|
||||
actor: CurrentUser | None,
|
||||
):
|
||||
return await self.shifts.update_assignment(
|
||||
tenant_id, shift_id, assignment_id, body, actor=actor
|
||||
)
|
||||
|
||||
async def create_zone(
|
||||
self, tenant_id: UUID, body: WorkingZoneCreate, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.zones.create(tenant_id, body, actor=actor)
|
||||
|
||||
async def update_zone(
|
||||
self, tenant_id: UUID, zone_id: UUID, body: WorkingZoneUpdate, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.zones.update(tenant_id, zone_id, body, actor=actor)
|
||||
|
||||
async def delete_zone(
|
||||
self, tenant_id: UUID, zone_id: UUID, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.zones.soft_delete(tenant_id, zone_id, actor=actor)
|
||||
@ -1,62 +0,0 @@
|
||||
"""Dispatch command handlers — Phase 10.5."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.schemas.dispatch import (
|
||||
DispatchJobCreate,
|
||||
DispatchJobStatusRequest,
|
||||
DispatchJobUpdate,
|
||||
JobAssignmentCreate,
|
||||
JobAssignmentUpdate,
|
||||
)
|
||||
from app.services.dispatch import DispatchJobService, JobAssignmentService
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
class DispatchCommands:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.jobs = DispatchJobService(session)
|
||||
self.assignments = JobAssignmentService(session)
|
||||
|
||||
async def create_job(
|
||||
self, tenant_id: UUID, body: DispatchJobCreate, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.jobs.create(tenant_id, body, actor=actor)
|
||||
|
||||
async def update_job(
|
||||
self, tenant_id: UUID, job_id: UUID, body: DispatchJobUpdate, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.jobs.update(tenant_id, job_id, body, actor=actor)
|
||||
|
||||
async def apply_status(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
job_id: UUID,
|
||||
body: DispatchJobStatusRequest,
|
||||
*,
|
||||
actor: CurrentUser | None,
|
||||
):
|
||||
return await self.jobs.apply_status(tenant_id, job_id, body, actor=actor)
|
||||
|
||||
async def delete_job(
|
||||
self, tenant_id: UUID, job_id: UUID, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.jobs.soft_delete(tenant_id, job_id, actor=actor)
|
||||
|
||||
async def create_assignment(
|
||||
self, tenant_id: UUID, body: JobAssignmentCreate, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.assignments.create(tenant_id, body, actor=actor)
|
||||
|
||||
async def update_assignment(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
assignment_id: UUID,
|
||||
body: JobAssignmentUpdate,
|
||||
*,
|
||||
actor: CurrentUser | None,
|
||||
):
|
||||
return await self.assignments.update(tenant_id, assignment_id, body, actor=actor)
|
||||
@ -1,92 +0,0 @@
|
||||
"""Fleet command handlers — Phase 10.2."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.schemas.fleet import (
|
||||
FleetCreate,
|
||||
FleetUpdate,
|
||||
VehicleAssignmentCreate,
|
||||
VehicleAssignmentUpdate,
|
||||
VehicleCreate,
|
||||
VehicleTypeCreate,
|
||||
VehicleTypeUpdate,
|
||||
VehicleUpdate,
|
||||
)
|
||||
from app.services.fleet import FleetService, VehicleService, VehicleTypeService
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
class FleetCommands:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.fleets = FleetService(session)
|
||||
self.types = VehicleTypeService(session)
|
||||
self.vehicles = VehicleService(session)
|
||||
|
||||
async def create_fleet(self, tenant_id: UUID, body: FleetCreate, *, actor: CurrentUser | None):
|
||||
return await self.fleets.create(tenant_id, body, actor=actor)
|
||||
|
||||
async def update_fleet(
|
||||
self, tenant_id: UUID, fleet_id: UUID, body: FleetUpdate, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.fleets.update(tenant_id, fleet_id, body, actor=actor)
|
||||
|
||||
async def delete_fleet(self, tenant_id: UUID, fleet_id: UUID, *, actor: CurrentUser | None):
|
||||
return await self.fleets.soft_delete(tenant_id, fleet_id, actor=actor)
|
||||
|
||||
async def create_vehicle_type(
|
||||
self, tenant_id: UUID, body: VehicleTypeCreate, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.types.create(tenant_id, body, actor=actor)
|
||||
|
||||
async def update_vehicle_type(
|
||||
self, tenant_id: UUID, type_id: UUID, body: VehicleTypeUpdate, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.types.update(tenant_id, type_id, body, actor=actor)
|
||||
|
||||
async def delete_vehicle_type(
|
||||
self, tenant_id: UUID, type_id: UUID, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.types.soft_delete(tenant_id, type_id, actor=actor)
|
||||
|
||||
async def create_vehicle(
|
||||
self, tenant_id: UUID, body: VehicleCreate, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.vehicles.create(tenant_id, body, actor=actor)
|
||||
|
||||
async def update_vehicle(
|
||||
self, tenant_id: UUID, vehicle_id: UUID, body: VehicleUpdate, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.vehicles.update(tenant_id, vehicle_id, body, actor=actor)
|
||||
|
||||
async def delete_vehicle(
|
||||
self, tenant_id: UUID, vehicle_id: UUID, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.vehicles.soft_delete(tenant_id, vehicle_id, actor=actor)
|
||||
|
||||
async def create_assignment(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
vehicle_id: UUID,
|
||||
body: VehicleAssignmentCreate,
|
||||
*,
|
||||
actor: CurrentUser | None,
|
||||
):
|
||||
return await self.vehicles.create_assignment(
|
||||
tenant_id, vehicle_id, body, actor=actor
|
||||
)
|
||||
|
||||
async def update_assignment(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
vehicle_id: UUID,
|
||||
assignment_id: UUID,
|
||||
body: VehicleAssignmentUpdate,
|
||||
*,
|
||||
actor: CurrentUser | None,
|
||||
):
|
||||
return await self.vehicles.update_assignment(
|
||||
tenant_id, vehicle_id, assignment_id, body, actor=actor
|
||||
)
|
||||
@ -1,69 +0,0 @@
|
||||
"""Pricing command handlers — Phase 10.4."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.schemas.pricing import (
|
||||
BundleCreate,
|
||||
BundleUpdate,
|
||||
CapabilityCreate,
|
||||
CapabilityUpdate,
|
||||
PricingRuleCreate,
|
||||
PricingRuleUpdate,
|
||||
)
|
||||
from app.services.pricing import BundleService, CapabilityService, PricingRuleService
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
class PricingCommands:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.rules = PricingRuleService(session)
|
||||
self.capabilities = CapabilityService(session)
|
||||
self.bundles = BundleService(session)
|
||||
|
||||
async def create_rule(
|
||||
self, tenant_id: UUID, body: PricingRuleCreate, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.rules.create(tenant_id, body, actor=actor)
|
||||
|
||||
async def update_rule(
|
||||
self, tenant_id: UUID, rule_id: UUID, body: PricingRuleUpdate, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.rules.update(tenant_id, rule_id, body, actor=actor)
|
||||
|
||||
async def delete_rule(
|
||||
self, tenant_id: UUID, rule_id: UUID, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.rules.soft_delete(tenant_id, rule_id, actor=actor)
|
||||
|
||||
async def create_capability(
|
||||
self, tenant_id: UUID, body: CapabilityCreate, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.capabilities.create(tenant_id, body, actor=actor)
|
||||
|
||||
async def update_capability(
|
||||
self, tenant_id: UUID, cap_id: UUID, body: CapabilityUpdate, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.capabilities.update(tenant_id, cap_id, body, actor=actor)
|
||||
|
||||
async def delete_capability(
|
||||
self, tenant_id: UUID, cap_id: UUID, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.capabilities.soft_delete(tenant_id, cap_id, actor=actor)
|
||||
|
||||
async def create_bundle(
|
||||
self, tenant_id: UUID, body: BundleCreate, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.bundles.create(tenant_id, body, actor=actor)
|
||||
|
||||
async def update_bundle(
|
||||
self, tenant_id: UUID, bundle_id: UUID, body: BundleUpdate, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.bundles.update(tenant_id, bundle_id, body, actor=actor)
|
||||
|
||||
async def delete_bundle(
|
||||
self, tenant_id: UUID, bundle_id: UUID, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.bundles.soft_delete(tenant_id, bundle_id, actor=actor)
|
||||
@ -1,51 +0,0 @@
|
||||
"""Routing command handlers — Phase 10.6."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.schemas.routing import (
|
||||
OptimizationRunCreate,
|
||||
RoutePlanCreate,
|
||||
RoutePlanUpdate,
|
||||
RouteStopCreate,
|
||||
)
|
||||
from app.services.routing import OptimizationRunService, RoutePlanService
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
class RoutingCommands:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.routes = RoutePlanService(session)
|
||||
self.optimization = OptimizationRunService(session)
|
||||
|
||||
async def create_route(
|
||||
self, tenant_id: UUID, body: RoutePlanCreate, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.routes.create(tenant_id, body, actor=actor)
|
||||
|
||||
async def update_route(
|
||||
self, tenant_id: UUID, plan_id: UUID, body: RoutePlanUpdate, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.routes.update(tenant_id, plan_id, body, actor=actor)
|
||||
|
||||
async def delete_route(
|
||||
self, tenant_id: UUID, plan_id: UUID, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.routes.soft_delete(tenant_id, plan_id, actor=actor)
|
||||
|
||||
async def add_stop(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
plan_id: UUID,
|
||||
body: RouteStopCreate,
|
||||
*,
|
||||
actor: CurrentUser | None,
|
||||
):
|
||||
return await self.routes.add_stop(tenant_id, plan_id, body, actor=actor)
|
||||
|
||||
async def start_optimization(
|
||||
self, tenant_id: UUID, body: OptimizationRunCreate, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.optimization.start(tenant_id, body, actor=actor)
|
||||
@ -1,85 +0,0 @@
|
||||
"""Settlement command handlers — Phase 10.8."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.schemas.settlement import (
|
||||
SettlementActionRequest,
|
||||
SettlementIntentCreate,
|
||||
SettlementIntentUpdate,
|
||||
SettlementLineCreate,
|
||||
)
|
||||
from app.services.settlement import SettlementService
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
class SettlementCommands:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.service = SettlementService(session)
|
||||
|
||||
async def create(
|
||||
self, tenant_id: UUID, body: SettlementIntentCreate, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.service.create(tenant_id, body, actor=actor)
|
||||
|
||||
async def update(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
intent_id: UUID,
|
||||
body: SettlementIntentUpdate,
|
||||
*,
|
||||
actor: CurrentUser | None,
|
||||
):
|
||||
return await self.service.update(tenant_id, intent_id, body, actor=actor)
|
||||
|
||||
async def submit(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
intent_id: UUID,
|
||||
body: SettlementActionRequest,
|
||||
*,
|
||||
actor: CurrentUser | None,
|
||||
):
|
||||
return await self.service.submit(tenant_id, intent_id, body, actor=actor)
|
||||
|
||||
async def settle(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
intent_id: UUID,
|
||||
body: SettlementActionRequest,
|
||||
*,
|
||||
actor: CurrentUser | None,
|
||||
):
|
||||
return await self.service.settle(tenant_id, intent_id, body, actor=actor)
|
||||
|
||||
async def fail(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
intent_id: UUID,
|
||||
body: SettlementActionRequest,
|
||||
*,
|
||||
actor: CurrentUser | None,
|
||||
):
|
||||
return await self.service.fail(tenant_id, intent_id, body, actor=actor)
|
||||
|
||||
async def cancel(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
intent_id: UUID,
|
||||
body: SettlementActionRequest,
|
||||
*,
|
||||
actor: CurrentUser | None,
|
||||
):
|
||||
return await self.service.cancel(tenant_id, intent_id, body, actor=actor)
|
||||
|
||||
async def add_line(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
intent_id: UUID,
|
||||
body: SettlementLineCreate,
|
||||
*,
|
||||
actor: CurrentUser | None,
|
||||
):
|
||||
return await self.service.add_line(tenant_id, intent_id, body, actor=actor)
|
||||
@ -1,84 +0,0 @@
|
||||
"""Tracking command handlers — Phase 10.7."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.schemas.tracking import (
|
||||
CustomerTrackingTokenCreate,
|
||||
ProofOfDeliveryCreate,
|
||||
ProofOfDeliveryUpdate,
|
||||
TrackingPointCreate,
|
||||
TrackingSessionCreate,
|
||||
TrackingSessionUpdate,
|
||||
)
|
||||
from app.services.tracking import (
|
||||
CustomerTrackingTokenService,
|
||||
ProofOfDeliveryService,
|
||||
TrackingSessionService,
|
||||
)
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
class TrackingCommands:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.sessions = TrackingSessionService(session)
|
||||
self.pod = ProofOfDeliveryService(session)
|
||||
self.tokens = CustomerTrackingTokenService(session)
|
||||
|
||||
async def start_session(
|
||||
self, tenant_id: UUID, body: TrackingSessionCreate, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.sessions.start(tenant_id, body, actor=actor)
|
||||
|
||||
async def update_session(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
session_id: UUID,
|
||||
body: TrackingSessionUpdate,
|
||||
*,
|
||||
actor: CurrentUser | None,
|
||||
):
|
||||
return await self.sessions.update(tenant_id, session_id, body, actor=actor)
|
||||
|
||||
async def record_point(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
session_id: UUID,
|
||||
body: TrackingPointCreate,
|
||||
*,
|
||||
actor: CurrentUser | None,
|
||||
):
|
||||
return await self.sessions.record_point(
|
||||
tenant_id, session_id, body, actor=actor
|
||||
)
|
||||
|
||||
async def capture_pod(
|
||||
self, tenant_id: UUID, body: ProofOfDeliveryCreate, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.pod.capture(tenant_id, body, actor=actor)
|
||||
|
||||
async def update_pod(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
pod_id: UUID,
|
||||
body: ProofOfDeliveryUpdate,
|
||||
*,
|
||||
actor: CurrentUser | None,
|
||||
):
|
||||
return await self.pod.update(tenant_id, pod_id, body, actor=actor)
|
||||
|
||||
async def create_token(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
body: CustomerTrackingTokenCreate,
|
||||
*,
|
||||
actor: CurrentUser | None,
|
||||
):
|
||||
return await self.tokens.create(tenant_id, body, actor=actor)
|
||||
|
||||
async def revoke_token(
|
||||
self, tenant_id: UUID, token_id: UUID, *, actor: CurrentUser | None
|
||||
):
|
||||
return await self.tokens.revoke(tenant_id, token_id, actor=actor)
|
||||
@ -31,75 +31,3 @@ class DeliveryEventType(str, enum.Enum):
|
||||
DRIVER_CREDENTIAL_ADDED = "delivery.driver.credential_added"
|
||||
DRIVER_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"
|
||||
|
||||
@ -1,18 +1,10 @@
|
||||
"""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,
|
||||
@ -25,16 +17,3 @@ 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,
|
||||
)
|
||||
|
||||
@ -1,142 +0,0 @@
|
||||
"""Availability, shifts & working zones — Phase 10.3."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Index, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import (
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
SoftDeleteMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
UUIDPrimaryKeyMixin,
|
||||
)
|
||||
from app.models.types import (
|
||||
DriverAvailabilityStatus,
|
||||
GUID,
|
||||
ShiftAssignmentStatus,
|
||||
ShiftStatus,
|
||||
WorkingZoneStatus,
|
||||
)
|
||||
|
||||
|
||||
class DriverAvailability(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
):
|
||||
__tablename__ = "driver_availabilities"
|
||||
|
||||
driver_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
status: Mapped[DriverAvailabilityStatus] = mapped_column(
|
||||
default=DriverAvailabilityStatus.OFFLINE, nullable=False
|
||||
)
|
||||
effective_from: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
effective_until: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_driver_availabilities_driver", "tenant_id", "driver_id"),
|
||||
Index("ix_driver_availabilities_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class Shift(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
):
|
||||
__tablename__ = "shifts"
|
||||
|
||||
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
hub_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
status: Mapped[ShiftStatus] = mapped_column(
|
||||
default=ShiftStatus.DRAFT, nullable=False
|
||||
)
|
||||
starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
ends_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_shifts_tenant_code"),
|
||||
Index("ix_shifts_tenant_status", "tenant_id", "status"),
|
||||
Index("ix_shifts_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class ShiftAssignment(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "shift_assignments"
|
||||
|
||||
shift_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
driver_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
status: Mapped[ShiftAssignmentStatus] = mapped_column(
|
||||
default=ShiftAssignmentStatus.ASSIGNED, nullable=False
|
||||
)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id",
|
||||
"shift_id",
|
||||
"driver_id",
|
||||
name="uq_shift_assignments_tenant_shift_driver",
|
||||
),
|
||||
Index("ix_shift_assignments_shift", "tenant_id", "shift_id"),
|
||||
Index("ix_shift_assignments_driver", "tenant_id", "driver_id"),
|
||||
)
|
||||
|
||||
|
||||
class WorkingZone(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
):
|
||||
__tablename__ = "working_zones"
|
||||
|
||||
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
status: Mapped[WorkingZoneStatus] = mapped_column(
|
||||
default=WorkingZoneStatus.ACTIVE, nullable=False
|
||||
)
|
||||
geo_boundary_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_working_zones_tenant_code"),
|
||||
Index("ix_working_zones_tenant_status", "tenant_id", "status"),
|
||||
Index("ix_working_zones_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
@ -1,89 +0,0 @@
|
||||
"""Dispatch engine aggregates — Phase 10.5."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Index, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import (
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
SoftDeleteMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
UUIDPrimaryKeyMixin,
|
||||
)
|
||||
from app.models.types import DispatchJobStatus, GUID, JobAssignmentStatus
|
||||
|
||||
|
||||
class DispatchJob(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
):
|
||||
"""Vertical job refs only — no deep order ownership."""
|
||||
|
||||
__tablename__ = "dispatch_jobs"
|
||||
|
||||
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
hub_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
status: Mapped[DispatchJobStatus] = mapped_column(
|
||||
default=DispatchJobStatus.PENDING, nullable=False
|
||||
)
|
||||
external_order_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
external_source: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
priority: Mapped[int] = mapped_column(default=0, nullable=False)
|
||||
pickup_address_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
dropoff_address_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
scheduled_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
status_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_dispatch_jobs_tenant_code"),
|
||||
Index("ix_dispatch_jobs_tenant_status", "tenant_id", "status"),
|
||||
Index("ix_dispatch_jobs_external_ref", "tenant_id", "external_order_ref"),
|
||||
Index("ix_dispatch_jobs_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class JobAssignment(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
):
|
||||
__tablename__ = "job_assignments"
|
||||
|
||||
dispatch_job_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
driver_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
vehicle_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
status: Mapped[JobAssignmentStatus] = mapped_column(
|
||||
default=JobAssignmentStatus.PENDING, nullable=False
|
||||
)
|
||||
assigned_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_job_assignments_job", "tenant_id", "dispatch_job_id"),
|
||||
Index("ix_job_assignments_driver", "tenant_id", "driver_id"),
|
||||
Index("ix_job_assignments_tenant_status", "tenant_id", "status"),
|
||||
Index("ix_job_assignments_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
@ -1,126 +0,0 @@
|
||||
"""Fleet & Vehicle aggregates — Phase 10.2."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import Index, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import (
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
SoftDeleteMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
UUIDPrimaryKeyMixin,
|
||||
)
|
||||
from app.models.types import FleetStatus, GUID, VehicleAssignmentStatus, VehicleStatus
|
||||
|
||||
|
||||
class Fleet(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
):
|
||||
__tablename__ = "fleets"
|
||||
|
||||
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
hub_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[FleetStatus] = mapped_column(default=FleetStatus.DRAFT, nullable=False)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_fleets_tenant_code"),
|
||||
Index("ix_fleets_tenant_status", "tenant_id", "status"),
|
||||
Index("ix_fleets_org", "tenant_id", "organization_id"),
|
||||
Index("ix_fleets_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class VehicleType(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
):
|
||||
__tablename__ = "vehicle_types"
|
||||
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
capacity_kg: Mapped[float | None] = mapped_column(nullable=True)
|
||||
capacity_volume: Mapped[float | None] = mapped_column(nullable=True)
|
||||
capability_tags: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_vehicle_types_tenant_code"),
|
||||
Index("ix_vehicle_types_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class Vehicle(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
):
|
||||
__tablename__ = "vehicles"
|
||||
|
||||
fleet_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
vehicle_type_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
plate_number: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
status: Mapped[VehicleStatus] = mapped_column(
|
||||
default=VehicleStatus.AVAILABLE, nullable=False
|
||||
)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_vehicles_tenant_code"),
|
||||
Index("ix_vehicles_fleet", "tenant_id", "fleet_id"),
|
||||
Index("ix_vehicles_tenant_status", "tenant_id", "status"),
|
||||
Index("ix_vehicles_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class VehicleAssignment(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
"""Shell assignment linking vehicles to drivers — no dispatch engine."""
|
||||
|
||||
__tablename__ = "vehicle_assignments"
|
||||
|
||||
vehicle_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
driver_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
status: Mapped[VehicleAssignmentStatus] = mapped_column(
|
||||
default=VehicleAssignmentStatus.ACTIVE, nullable=False
|
||||
)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_vehicle_assignments_vehicle", "tenant_id", "vehicle_id"),
|
||||
Index("ix_vehicle_assignments_driver", "tenant_id", "driver_id"),
|
||||
Index("ix_vehicle_assignments_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
@ -1,108 +0,0 @@
|
||||
"""Pricing, capabilities & bundles — Phase 10.4."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Index, Numeric, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import (
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
SoftDeleteMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
UUIDPrimaryKeyMixin,
|
||||
)
|
||||
from app.models.types import (
|
||||
CapabilityBundleStatus,
|
||||
CapabilityStatus,
|
||||
GUID,
|
||||
PricingRuleStatus,
|
||||
)
|
||||
|
||||
|
||||
class PricingRule(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
):
|
||||
__tablename__ = "pricing_rules"
|
||||
|
||||
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
status: Mapped[PricingRuleStatus] = mapped_column(
|
||||
default=PricingRuleStatus.DRAFT, nullable=False
|
||||
)
|
||||
base_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||
currency_code: Mapped[str] = mapped_column(String(3), nullable=False, default="IRR")
|
||||
rule_config: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_pricing_rules_tenant_code"),
|
||||
Index("ix_pricing_rules_tenant_status", "tenant_id", "status"),
|
||||
Index("ix_pricing_rules_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class CapabilityDefinition(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
):
|
||||
__tablename__ = "capability_definitions"
|
||||
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[CapabilityStatus] = mapped_column(
|
||||
default=CapabilityStatus.ACTIVE, nullable=False
|
||||
)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint(
|
||||
"tenant_id", "code", name="uq_capability_definitions_tenant_code"
|
||||
),
|
||||
Index("ix_capability_definitions_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class CapabilityBundle(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
):
|
||||
__tablename__ = "capability_bundles"
|
||||
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
status: Mapped[CapabilityBundleStatus] = mapped_column(
|
||||
default=CapabilityBundleStatus.DRAFT, nullable=False
|
||||
)
|
||||
capability_codes: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
pricing_rule_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_capability_bundles_tenant_code"),
|
||||
Index("ix_capability_bundles_tenant_status", "tenant_id", "status"),
|
||||
Index("ix_capability_bundles_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
@ -1,110 +0,0 @@
|
||||
"""Routing & optimization aggregates — Phase 10.6."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Index, Integer, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import (
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
SoftDeleteMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
UUIDPrimaryKeyMixin,
|
||||
)
|
||||
from app.models.types import GUID, OptimizationRunStatus, RoutePlanStatus, RouteStopStatus
|
||||
|
||||
|
||||
class RoutePlan(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
):
|
||||
__tablename__ = "route_plans"
|
||||
|
||||
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
status: Mapped[RoutePlanStatus] = mapped_column(
|
||||
default=RoutePlanStatus.DRAFT, nullable=False
|
||||
)
|
||||
driver_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
dispatch_job_ids: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||
routing_engine_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
planned_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_route_plans_tenant_code"),
|
||||
Index("ix_route_plans_tenant_status", "tenant_id", "status"),
|
||||
Index("ix_route_plans_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class RouteStop(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "route_stops"
|
||||
|
||||
route_plan_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
status: Mapped[RouteStopStatus] = mapped_column(
|
||||
default=RouteStopStatus.PENDING, nullable=False
|
||||
)
|
||||
address_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
external_order_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_route_stops_plan", "tenant_id", "route_plan_id"),
|
||||
Index("ix_route_stops_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class OptimizationRun(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
"""External routing via RoutingEngineProvider protocol refs only."""
|
||||
|
||||
__tablename__ = "optimization_runs"
|
||||
|
||||
route_plan_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
status: Mapped[OptimizationRunStatus] = mapped_column(
|
||||
default=OptimizationRunStatus.PENDING, nullable=False
|
||||
)
|
||||
routing_engine_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
request_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
result_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
started_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_optimization_runs_tenant_status", "tenant_id", "status"),
|
||||
Index("ix_optimization_runs_plan", "tenant_id", "route_plan_id"),
|
||||
)
|
||||
@ -1,78 +0,0 @@
|
||||
"""Settlement aggregates — Phase 10.8.
|
||||
|
||||
Uses AccountingProvider protocol refs only — NO journal entries in delivery_db.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Index, Numeric, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import (
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
SoftDeleteMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
UUIDPrimaryKeyMixin,
|
||||
)
|
||||
from app.models.types import GUID, SettlementIntentStatus, SettlementLineKind
|
||||
|
||||
|
||||
class SettlementIntent(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
):
|
||||
__tablename__ = "settlement_intents"
|
||||
|
||||
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||
status: Mapped[SettlementIntentStatus] = mapped_column(
|
||||
default=SettlementIntentStatus.DRAFT, nullable=False
|
||||
)
|
||||
driver_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
dispatch_job_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
external_settlement_ref: Mapped[str | None] = mapped_column(
|
||||
String(255), nullable=True
|
||||
)
|
||||
total_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||
currency_code: Mapped[str] = mapped_column(String(3), nullable=False, default="IRR")
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "code", name="uq_settlement_intents_tenant_code"),
|
||||
Index("ix_settlement_intents_tenant_status", "tenant_id", "status"),
|
||||
Index("ix_settlement_intents_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class SettlementLine(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "settlement_lines"
|
||||
|
||||
settlement_intent_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
kind: Mapped[SettlementLineKind] = mapped_column(nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||
currency_code: Mapped[str] = mapped_column(String(3), nullable=False, default="IRR")
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_settlement_lines_intent", "tenant_id", "settlement_intent_id"),
|
||||
)
|
||||
@ -1,138 +0,0 @@
|
||||
"""Tracking & proof of delivery — Phase 10.7."""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, Float, Index, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
from sqlalchemy.types import JSON
|
||||
|
||||
from app.core.database import Base
|
||||
from app.models.base import (
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
SoftDeleteMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
UUIDPrimaryKeyMixin,
|
||||
)
|
||||
from app.models.types import (
|
||||
CustomerTrackingTokenStatus,
|
||||
GUID,
|
||||
ProofOfDeliveryStatus,
|
||||
TrackingSessionStatus,
|
||||
)
|
||||
|
||||
|
||||
class TrackingSession(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
):
|
||||
__tablename__ = "tracking_sessions"
|
||||
|
||||
dispatch_job_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
driver_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||
status: Mapped[TrackingSessionStatus] = mapped_column(
|
||||
default=TrackingSessionStatus.ACTIVE, nullable=False
|
||||
)
|
||||
started_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
ended_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_tracking_sessions_tenant_status", "tenant_id", "status"),
|
||||
Index("ix_tracking_sessions_job", "tenant_id", "dispatch_job_id"),
|
||||
Index("ix_tracking_sessions_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class TrackingPoint(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
):
|
||||
__tablename__ = "tracking_points"
|
||||
|
||||
tracking_session_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
latitude: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
longitude: Mapped[float] = mapped_column(Float, nullable=False)
|
||||
recorded_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_tracking_points_session", "tenant_id", "tracking_session_id"),
|
||||
Index("ix_tracking_points_recorded", "tenant_id", "recorded_at"),
|
||||
)
|
||||
|
||||
|
||||
class ProofOfDelivery(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
OptimisticLockMixin,
|
||||
):
|
||||
"""Storage file refs for POD only — no blob ownership."""
|
||||
|
||||
__tablename__ = "proof_of_delivery"
|
||||
|
||||
dispatch_job_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
status: Mapped[ProofOfDeliveryStatus] = mapped_column(
|
||||
default=ProofOfDeliveryStatus.PENDING, nullable=False
|
||||
)
|
||||
signature_file_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
photo_file_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
delivery_code: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
captured_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_proof_of_delivery_job", "tenant_id", "dispatch_job_id"),
|
||||
Index("ix_proof_of_delivery_tenant_status", "tenant_id", "status"),
|
||||
Index("ix_proof_of_delivery_tenant_deleted", "tenant_id", "is_deleted"),
|
||||
)
|
||||
|
||||
|
||||
class CustomerTrackingToken(
|
||||
Base,
|
||||
UUIDPrimaryKeyMixin,
|
||||
TenantMixin,
|
||||
TimestampMixin,
|
||||
SoftDeleteMixin,
|
||||
ActorAuditMixin,
|
||||
):
|
||||
__tablename__ = "customer_tracking_tokens"
|
||||
|
||||
dispatch_job_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||
token: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
status: Mapped[CustomerTrackingTokenStatus] = mapped_column(
|
||||
default=CustomerTrackingTokenStatus.ACTIVE, nullable=False
|
||||
)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
__table_args__ = (
|
||||
UniqueConstraint("tenant_id", "token", name="uq_customer_tracking_tokens_token"),
|
||||
Index("ix_customer_tracking_tokens_job", "tenant_id", "dispatch_job_id"),
|
||||
Index("ix_customer_tracking_tokens_tenant_status", "tenant_id", "status"),
|
||||
)
|
||||
@ -116,162 +116,3 @@ class DriverDocumentKind(str, enum.Enum):
|
||||
CONTRACT = "contract"
|
||||
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"
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Delivery permission definitions — Phase 10.0–10.8."""
|
||||
"""Delivery permission definitions — Phase 10.0 foundation + 10.1 drivers."""
|
||||
from __future__ import annotations
|
||||
|
||||
DELIVERY_VIEW = "delivery.view"
|
||||
@ -58,134 +58,18 @@ DRIVERS_DOCUMENTS_VIEW = "delivery.drivers.documents.view"
|
||||
DRIVERS_DOCUMENTS_MANAGE = "delivery.drivers.documents.manage"
|
||||
DRIVERS_MANAGE = "delivery.drivers.manage"
|
||||
|
||||
# Phase 10.2 — Fleet & Vehicles
|
||||
FLEETS_VIEW = "delivery.fleets.view"
|
||||
FLEETS_CREATE = "delivery.fleets.create"
|
||||
FLEETS_UPDATE = "delivery.fleets.update"
|
||||
FLEETS_DELETE = "delivery.fleets.delete"
|
||||
FLEETS_MANAGE = "delivery.fleets.manage"
|
||||
|
||||
VEHICLE_TYPES_VIEW = "delivery.vehicle_types.view"
|
||||
VEHICLE_TYPES_CREATE = "delivery.vehicle_types.create"
|
||||
VEHICLE_TYPES_UPDATE = "delivery.vehicle_types.update"
|
||||
VEHICLE_TYPES_DELETE = "delivery.vehicle_types.delete"
|
||||
VEHICLE_TYPES_MANAGE = "delivery.vehicle_types.manage"
|
||||
|
||||
VEHICLES_VIEW = "delivery.vehicles.view"
|
||||
VEHICLES_CREATE = "delivery.vehicles.create"
|
||||
VEHICLES_UPDATE = "delivery.vehicles.update"
|
||||
VEHICLES_DELETE = "delivery.vehicles.delete"
|
||||
VEHICLES_ASSIGNMENTS_VIEW = "delivery.vehicles.assignments.view"
|
||||
VEHICLES_ASSIGNMENTS_MANAGE = "delivery.vehicles.assignments.manage"
|
||||
VEHICLES_MANAGE = "delivery.vehicles.manage"
|
||||
|
||||
# Phase 10.3 — Availability, Shifts & Working Zones
|
||||
AVAILABILITY_VIEW = "delivery.availability.view"
|
||||
AVAILABILITY_MANAGE = "delivery.availability.manage"
|
||||
|
||||
SHIFTS_VIEW = "delivery.shifts.view"
|
||||
SHIFTS_CREATE = "delivery.shifts.create"
|
||||
SHIFTS_UPDATE = "delivery.shifts.update"
|
||||
SHIFTS_DELETE = "delivery.shifts.delete"
|
||||
SHIFTS_ASSIGNMENTS_VIEW = "delivery.shifts.assignments.view"
|
||||
SHIFTS_ASSIGNMENTS_MANAGE = "delivery.shifts.assignments.manage"
|
||||
SHIFTS_MANAGE = "delivery.shifts.manage"
|
||||
|
||||
WORKING_ZONES_VIEW = "delivery.working_zones.view"
|
||||
WORKING_ZONES_CREATE = "delivery.working_zones.create"
|
||||
WORKING_ZONES_UPDATE = "delivery.working_zones.update"
|
||||
WORKING_ZONES_DELETE = "delivery.working_zones.delete"
|
||||
WORKING_ZONES_MANAGE = "delivery.working_zones.manage"
|
||||
|
||||
# Phase 10.4 — Pricing, Capabilities & Bundles
|
||||
PRICING_RULES_VIEW = "delivery.pricing_rules.view"
|
||||
PRICING_RULES_CREATE = "delivery.pricing_rules.create"
|
||||
PRICING_RULES_UPDATE = "delivery.pricing_rules.update"
|
||||
PRICING_RULES_DELETE = "delivery.pricing_rules.delete"
|
||||
PRICING_RULES_MANAGE = "delivery.pricing_rules.manage"
|
||||
|
||||
CAPABILITIES_VIEW = "delivery.capabilities.view"
|
||||
CAPABILITIES_CREATE = "delivery.capabilities.create"
|
||||
CAPABILITIES_UPDATE = "delivery.capabilities.update"
|
||||
CAPABILITIES_DELETE = "delivery.capabilities.delete"
|
||||
CAPABILITIES_MANAGE = "delivery.capabilities.manage"
|
||||
|
||||
BUNDLES_VIEW = "delivery.bundles.view"
|
||||
BUNDLES_CREATE = "delivery.bundles.create"
|
||||
BUNDLES_UPDATE = "delivery.bundles.update"
|
||||
BUNDLES_DELETE = "delivery.bundles.delete"
|
||||
BUNDLES_MANAGE = "delivery.bundles.manage"
|
||||
|
||||
# Phase 10.5 — Dispatch Engine
|
||||
DISPATCH_JOBS_VIEW = "delivery.dispatch.jobs.view"
|
||||
DISPATCH_JOBS_CREATE = "delivery.dispatch.jobs.create"
|
||||
DISPATCH_JOBS_UPDATE = "delivery.dispatch.jobs.update"
|
||||
DISPATCH_JOBS_DELETE = "delivery.dispatch.jobs.delete"
|
||||
DISPATCH_JOBS_STATUS = "delivery.dispatch.jobs.status"
|
||||
DISPATCH_JOBS_MANAGE = "delivery.dispatch.jobs.manage"
|
||||
|
||||
DISPATCH_ASSIGNMENTS_VIEW = "delivery.dispatch.assignments.view"
|
||||
DISPATCH_ASSIGNMENTS_CREATE = "delivery.dispatch.assignments.create"
|
||||
DISPATCH_ASSIGNMENTS_UPDATE = "delivery.dispatch.assignments.update"
|
||||
DISPATCH_ASSIGNMENTS_MANAGE = "delivery.dispatch.assignments.manage"
|
||||
|
||||
# Planned trees (registered for discovery; enforced in later phases)
|
||||
FLEET_VIEW = "delivery.fleet.view"
|
||||
FLEET_MANAGE = "delivery.fleet.manage"
|
||||
DISPATCH_VIEW = "delivery.dispatch.view"
|
||||
DISPATCH_MANAGE = "delivery.dispatch.manage"
|
||||
|
||||
# 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,
|
||||
@ -234,103 +118,16 @@ ALL_PERMISSIONS: list[str] = [
|
||||
DRIVERS_DOCUMENTS_VIEW,
|
||||
DRIVERS_DOCUMENTS_MANAGE,
|
||||
DRIVERS_MANAGE,
|
||||
FLEETS_VIEW,
|
||||
FLEETS_CREATE,
|
||||
FLEETS_UPDATE,
|
||||
FLEETS_DELETE,
|
||||
FLEETS_MANAGE,
|
||||
VEHICLE_TYPES_VIEW,
|
||||
VEHICLE_TYPES_CREATE,
|
||||
VEHICLE_TYPES_UPDATE,
|
||||
VEHICLE_TYPES_DELETE,
|
||||
VEHICLE_TYPES_MANAGE,
|
||||
VEHICLES_VIEW,
|
||||
VEHICLES_CREATE,
|
||||
VEHICLES_UPDATE,
|
||||
VEHICLES_DELETE,
|
||||
VEHICLES_ASSIGNMENTS_VIEW,
|
||||
VEHICLES_ASSIGNMENTS_MANAGE,
|
||||
VEHICLES_MANAGE,
|
||||
AVAILABILITY_VIEW,
|
||||
AVAILABILITY_MANAGE,
|
||||
SHIFTS_VIEW,
|
||||
SHIFTS_CREATE,
|
||||
SHIFTS_UPDATE,
|
||||
SHIFTS_DELETE,
|
||||
SHIFTS_ASSIGNMENTS_VIEW,
|
||||
SHIFTS_ASSIGNMENTS_MANAGE,
|
||||
SHIFTS_MANAGE,
|
||||
WORKING_ZONES_VIEW,
|
||||
WORKING_ZONES_CREATE,
|
||||
WORKING_ZONES_UPDATE,
|
||||
WORKING_ZONES_DELETE,
|
||||
WORKING_ZONES_MANAGE,
|
||||
PRICING_RULES_VIEW,
|
||||
PRICING_RULES_CREATE,
|
||||
PRICING_RULES_UPDATE,
|
||||
PRICING_RULES_DELETE,
|
||||
PRICING_RULES_MANAGE,
|
||||
CAPABILITIES_VIEW,
|
||||
CAPABILITIES_CREATE,
|
||||
CAPABILITIES_UPDATE,
|
||||
CAPABILITIES_DELETE,
|
||||
CAPABILITIES_MANAGE,
|
||||
BUNDLES_VIEW,
|
||||
BUNDLES_CREATE,
|
||||
BUNDLES_UPDATE,
|
||||
BUNDLES_DELETE,
|
||||
BUNDLES_MANAGE,
|
||||
DISPATCH_JOBS_VIEW,
|
||||
DISPATCH_JOBS_CREATE,
|
||||
DISPATCH_JOBS_UPDATE,
|
||||
DISPATCH_JOBS_DELETE,
|
||||
DISPATCH_JOBS_STATUS,
|
||||
DISPATCH_JOBS_MANAGE,
|
||||
DISPATCH_ASSIGNMENTS_VIEW,
|
||||
DISPATCH_ASSIGNMENTS_CREATE,
|
||||
DISPATCH_ASSIGNMENTS_UPDATE,
|
||||
DISPATCH_ASSIGNMENTS_MANAGE,
|
||||
DISPATCH_VIEW,
|
||||
DISPATCH_MANAGE,
|
||||
ROUTES_VIEW,
|
||||
ROUTES_CREATE,
|
||||
ROUTES_UPDATE,
|
||||
ROUTES_DELETE,
|
||||
ROUTES_STOPS_VIEW,
|
||||
ROUTES_STOPS_MANAGE,
|
||||
ROUTES_MANAGE,
|
||||
OPTIMIZATION_RUNS_VIEW,
|
||||
OPTIMIZATION_RUNS_CREATE,
|
||||
OPTIMIZATION_RUNS_MANAGE,
|
||||
ROUTING_VIEW,
|
||||
ROUTING_MANAGE,
|
||||
TRACKING_SESSIONS_VIEW,
|
||||
TRACKING_SESSIONS_CREATE,
|
||||
TRACKING_SESSIONS_UPDATE,
|
||||
TRACKING_SESSIONS_MANAGE,
|
||||
TRACKING_POINTS_VIEW,
|
||||
TRACKING_POINTS_MANAGE,
|
||||
PROOF_OF_DELIVERY_VIEW,
|
||||
PROOF_OF_DELIVERY_CREATE,
|
||||
PROOF_OF_DELIVERY_UPDATE,
|
||||
PROOF_OF_DELIVERY_MANAGE,
|
||||
CUSTOMER_TRACKING_VIEW,
|
||||
CUSTOMER_TRACKING_CREATE,
|
||||
CUSTOMER_TRACKING_REVOKE,
|
||||
CUSTOMER_TRACKING_MANAGE,
|
||||
TRACKING_VIEW,
|
||||
TRACKING_MANAGE,
|
||||
SETTLEMENTS_VIEW,
|
||||
SETTLEMENTS_CREATE,
|
||||
SETTLEMENTS_UPDATE,
|
||||
SETTLEMENTS_STATUS,
|
||||
SETTLEMENTS_LINES_VIEW,
|
||||
SETTLEMENTS_LINES_MANAGE,
|
||||
SETTLEMENTS_MANAGE,
|
||||
SETTLEMENT_VIEW,
|
||||
SETTLEMENT_MANAGE,
|
||||
FLEET_VIEW,
|
||||
FLEET_MANAGE,
|
||||
DISPATCH_VIEW,
|
||||
DISPATCH_MANAGE,
|
||||
ROUTING_VIEW,
|
||||
ROUTING_MANAGE,
|
||||
TRACKING_VIEW,
|
||||
TRACKING_MANAGE,
|
||||
SETTLEMENT_VIEW,
|
||||
SETTLEMENT_MANAGE,
|
||||
]
|
||||
|
||||
PERMISSION_PREFIXES: tuple[str, ...] = (
|
||||
@ -343,23 +140,9 @@ PERMISSION_PREFIXES: tuple[str, ...] = (
|
||||
"delivery.settings.",
|
||||
"delivery.audit.",
|
||||
"delivery.drivers.",
|
||||
"delivery.fleets.",
|
||||
"delivery.vehicle_types.",
|
||||
"delivery.vehicles.",
|
||||
"delivery.availability.",
|
||||
"delivery.shifts.",
|
||||
"delivery.working_zones.",
|
||||
"delivery.pricing_rules.",
|
||||
"delivery.capabilities.",
|
||||
"delivery.bundles.",
|
||||
"delivery.dispatch.",
|
||||
"delivery.routes.",
|
||||
"delivery.optimization.",
|
||||
"delivery.tracking.",
|
||||
"delivery.proof_of_delivery.",
|
||||
"delivery.customer_tracking.",
|
||||
"delivery.settlements.",
|
||||
"delivery.fleet.",
|
||||
"delivery.dispatch.",
|
||||
"delivery.routing.",
|
||||
"delivery.tracking.",
|
||||
"delivery.settlement.",
|
||||
)
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
"""Dispatch domain policies — Phase 10.5."""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.models.types import DispatchJobAction, DispatchJobStatus
|
||||
from app.validators.dispatch import (
|
||||
ensure_dispatch_job_transition,
|
||||
target_status_for,
|
||||
validate_dispatch_reason,
|
||||
)
|
||||
|
||||
|
||||
class DispatchJobPolicy:
|
||||
REQUIRES_REASON = frozenset(
|
||||
{DispatchJobAction.CANCEL, DispatchJobAction.FAIL}
|
||||
)
|
||||
|
||||
def assert_transition(
|
||||
self,
|
||||
*,
|
||||
action: DispatchJobAction,
|
||||
current: DispatchJobStatus,
|
||||
reason: str | None = None,
|
||||
) -> tuple[DispatchJobStatus, str | None]:
|
||||
ensure_dispatch_job_transition(action=action, current=current)
|
||||
cleaned = validate_dispatch_reason(
|
||||
reason, required=action in self.REQUIRES_REASON
|
||||
)
|
||||
return target_status_for(action), cleaned
|
||||
@ -1,26 +0,0 @@
|
||||
"""Settlement domain policies — Phase 10.8."""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.models.types import SettlementIntentStatus
|
||||
from app.validators.dispatch import validate_dispatch_reason
|
||||
from app.validators.settlement import (
|
||||
ensure_settlement_transition,
|
||||
target_settlement_status,
|
||||
)
|
||||
|
||||
|
||||
class SettlementIntentPolicy:
|
||||
REQUIRES_REASON = frozenset({"fail", "cancel"})
|
||||
|
||||
def assert_transition(
|
||||
self,
|
||||
*,
|
||||
action: str,
|
||||
current: SettlementIntentStatus,
|
||||
reason: str | None = None,
|
||||
) -> tuple[SettlementIntentStatus, str | None]:
|
||||
ensure_settlement_transition(action=action, current=current)
|
||||
cleaned = validate_dispatch_reason(
|
||||
reason, required=action in self.REQUIRES_REASON
|
||||
)
|
||||
return target_settlement_status(action), cleaned
|
||||
@ -1,38 +0,0 @@
|
||||
"""Availability query handlers — Phase 10.3."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.availability import AvailabilityService, ShiftService, WorkingZoneService
|
||||
from app.specifications.availability import ShiftListSpec, WorkingZoneListSpec
|
||||
|
||||
|
||||
class AvailabilityQueries:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.availability = AvailabilityService(session)
|
||||
self.shifts = ShiftService(session)
|
||||
self.zones = WorkingZoneService(session)
|
||||
|
||||
async def get_availability(self, tenant_id: UUID, driver_id: UUID):
|
||||
return await self.availability.get_for_driver(tenant_id, driver_id)
|
||||
|
||||
async def get_shift(self, tenant_id: UUID, shift_id: UUID):
|
||||
return await self.shifts.get(tenant_id, shift_id)
|
||||
|
||||
async def list_shifts(
|
||||
self, tenant_id: UUID, *, offset: int, limit: int, spec: ShiftListSpec | None
|
||||
):
|
||||
return await self.shifts.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
||||
|
||||
async def list_shift_assignments(self, tenant_id: UUID, shift_id: UUID):
|
||||
return await self.shifts.list_assignments(tenant_id, shift_id)
|
||||
|
||||
async def get_zone(self, tenant_id: UUID, zone_id: UUID):
|
||||
return await self.zones.get(tenant_id, zone_id)
|
||||
|
||||
async def list_zones(
|
||||
self, tenant_id: UUID, *, offset: int, limit: int, spec: WorkingZoneListSpec | None
|
||||
):
|
||||
return await self.zones.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
||||
@ -1,38 +0,0 @@
|
||||
"""Dispatch query handlers — Phase 10.5."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.dispatch import DispatchJobService, JobAssignmentService
|
||||
from app.specifications.dispatch import DispatchJobListSpec, JobAssignmentListSpec
|
||||
|
||||
|
||||
class DispatchQueries:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.jobs = DispatchJobService(session)
|
||||
self.assignments = JobAssignmentService(session)
|
||||
|
||||
async def get_job(self, tenant_id: UUID, job_id: UUID):
|
||||
return await self.jobs.get(tenant_id, job_id)
|
||||
|
||||
async def list_jobs(
|
||||
self, tenant_id: UUID, *, offset: int, limit: int, spec: DispatchJobListSpec | None
|
||||
):
|
||||
return await self.jobs.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
||||
|
||||
async def get_assignment(self, tenant_id: UUID, assignment_id: UUID):
|
||||
return await self.assignments.get(tenant_id, assignment_id)
|
||||
|
||||
async def list_assignments(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
spec: JobAssignmentListSpec | None,
|
||||
):
|
||||
return await self.assignments.list(
|
||||
tenant_id, offset=offset, limit=limit, spec=spec
|
||||
)
|
||||
@ -1,43 +0,0 @@
|
||||
"""Fleet query handlers — Phase 10.2."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.fleet import FleetService, VehicleService, VehicleTypeService
|
||||
from app.specifications.fleet import FleetListSpec, VehicleListSpec, VehicleTypeListSpec
|
||||
|
||||
|
||||
class FleetQueries:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.fleets = FleetService(session)
|
||||
self.types = VehicleTypeService(session)
|
||||
self.vehicles = VehicleService(session)
|
||||
|
||||
async def get_fleet(self, tenant_id: UUID, fleet_id: UUID):
|
||||
return await self.fleets.get(tenant_id, fleet_id)
|
||||
|
||||
async def list_fleets(
|
||||
self, tenant_id: UUID, *, offset: int, limit: int, spec: FleetListSpec | None
|
||||
):
|
||||
return await self.fleets.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
||||
|
||||
async def get_vehicle_type(self, tenant_id: UUID, type_id: UUID):
|
||||
return await self.types.get(tenant_id, type_id)
|
||||
|
||||
async def list_vehicle_types(
|
||||
self, tenant_id: UUID, *, offset: int, limit: int, spec: VehicleTypeListSpec | None
|
||||
):
|
||||
return await self.types.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
||||
|
||||
async def get_vehicle(self, tenant_id: UUID, vehicle_id: UUID):
|
||||
return await self.vehicles.get(tenant_id, vehicle_id)
|
||||
|
||||
async def list_vehicles(
|
||||
self, tenant_id: UUID, *, offset: int, limit: int, spec: VehicleListSpec | None
|
||||
):
|
||||
return await self.vehicles.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
||||
|
||||
async def list_assignments(self, tenant_id: UUID, vehicle_id: UUID):
|
||||
return await self.vehicles.list_assignments(tenant_id, vehicle_id)
|
||||
@ -1,44 +0,0 @@
|
||||
"""Pricing query handlers — Phase 10.4."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.pricing import BundleService, CapabilityService, PricingRuleService
|
||||
from app.specifications.pricing import (
|
||||
BundleListSpec,
|
||||
CapabilityListSpec,
|
||||
PricingRuleListSpec,
|
||||
)
|
||||
|
||||
|
||||
class PricingQueries:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.rules = PricingRuleService(session)
|
||||
self.capabilities = CapabilityService(session)
|
||||
self.bundles = BundleService(session)
|
||||
|
||||
async def get_rule(self, tenant_id: UUID, rule_id: UUID):
|
||||
return await self.rules.get(tenant_id, rule_id)
|
||||
|
||||
async def list_rules(
|
||||
self, tenant_id: UUID, *, offset: int, limit: int, spec: PricingRuleListSpec | None
|
||||
):
|
||||
return await self.rules.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
||||
|
||||
async def get_capability(self, tenant_id: UUID, cap_id: UUID):
|
||||
return await self.capabilities.get(tenant_id, cap_id)
|
||||
|
||||
async def list_capabilities(
|
||||
self, tenant_id: UUID, *, offset: int, limit: int, spec: CapabilityListSpec | None
|
||||
):
|
||||
return await self.capabilities.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
||||
|
||||
async def get_bundle(self, tenant_id: UUID, bundle_id: UUID):
|
||||
return await self.bundles.get(tenant_id, bundle_id)
|
||||
|
||||
async def list_bundles(
|
||||
self, tenant_id: UUID, *, offset: int, limit: int, spec: BundleListSpec | None
|
||||
):
|
||||
return await self.bundles.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
||||
@ -1,41 +0,0 @@
|
||||
"""Routing query handlers — Phase 10.6."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.routing import OptimizationRunService, RoutePlanService
|
||||
from app.specifications.routing import OptimizationRunListSpec, RoutePlanListSpec
|
||||
|
||||
|
||||
class RoutingQueries:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.routes = RoutePlanService(session)
|
||||
self.optimization = OptimizationRunService(session)
|
||||
|
||||
async def get_route(self, tenant_id: UUID, plan_id: UUID):
|
||||
return await self.routes.get(tenant_id, plan_id)
|
||||
|
||||
async def list_routes(
|
||||
self, tenant_id: UUID, *, offset: int, limit: int, spec: RoutePlanListSpec | None
|
||||
):
|
||||
return await self.routes.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
||||
|
||||
async def list_stops(self, tenant_id: UUID, plan_id: UUID):
|
||||
return await self.routes.list_stops(tenant_id, plan_id)
|
||||
|
||||
async def get_optimization_run(self, tenant_id: UUID, run_id: UUID):
|
||||
return await self.optimization.get(tenant_id, run_id)
|
||||
|
||||
async def list_optimization_runs(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
spec: OptimizationRunListSpec | None,
|
||||
):
|
||||
return await self.optimization.list(
|
||||
tenant_id, offset=offset, limit=limit, spec=spec
|
||||
)
|
||||
@ -1,32 +0,0 @@
|
||||
"""Settlement query handlers — Phase 10.8."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.settlement import SettlementService
|
||||
from app.specifications.settlement import SettlementIntentListSpec
|
||||
|
||||
|
||||
class SettlementQueries:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.service = SettlementService(session)
|
||||
|
||||
async def get(self, tenant_id: UUID, intent_id: UUID):
|
||||
return await self.service.get(tenant_id, intent_id)
|
||||
|
||||
async def list(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
spec: SettlementIntentListSpec | None,
|
||||
):
|
||||
return await self.service.list(
|
||||
tenant_id, offset=offset, limit=limit, spec=spec
|
||||
)
|
||||
|
||||
async def lines(self, tenant_id: UUID, intent_id: UUID):
|
||||
return await self.service.list_lines(tenant_id, intent_id)
|
||||
@ -1,68 +0,0 @@
|
||||
"""Tracking query handlers — Phase 10.7."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.services.tracking import (
|
||||
CustomerTrackingTokenService,
|
||||
ProofOfDeliveryService,
|
||||
TrackingSessionService,
|
||||
)
|
||||
from app.specifications.tracking import (
|
||||
CustomerTrackingTokenListSpec,
|
||||
ProofOfDeliveryListSpec,
|
||||
TrackingSessionListSpec,
|
||||
)
|
||||
|
||||
|
||||
class TrackingQueries:
|
||||
def __init__(self, session: AsyncSession) -> None:
|
||||
self.sessions = TrackingSessionService(session)
|
||||
self.pod = ProofOfDeliveryService(session)
|
||||
self.tokens = CustomerTrackingTokenService(session)
|
||||
|
||||
async def get_session(self, tenant_id: UUID, session_id: UUID):
|
||||
return await self.sessions.get(tenant_id, session_id)
|
||||
|
||||
async def list_sessions(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
spec: TrackingSessionListSpec | None,
|
||||
):
|
||||
return await self.sessions.list(
|
||||
tenant_id, offset=offset, limit=limit, spec=spec
|
||||
)
|
||||
|
||||
async def list_points(self, tenant_id: UUID, session_id: UUID, *, limit: int = 500):
|
||||
return await self.sessions.list_points(tenant_id, session_id, limit=limit)
|
||||
|
||||
async def get_pod(self, tenant_id: UUID, pod_id: UUID):
|
||||
return await self.pod.get(tenant_id, pod_id)
|
||||
|
||||
async def list_pods(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
spec: ProofOfDeliveryListSpec | None,
|
||||
):
|
||||
return await self.pod.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
||||
|
||||
async def get_token(self, tenant_id: UUID, token_id: UUID):
|
||||
return await self.tokens.get(tenant_id, token_id)
|
||||
|
||||
async def list_tokens(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
offset: int,
|
||||
limit: int,
|
||||
spec: CustomerTrackingTokenListSpec | None,
|
||||
):
|
||||
return await self.tokens.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
||||
@ -1,145 +0,0 @@
|
||||
"""Availability repositories — Phase 10.3."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.models.availability import (
|
||||
DriverAvailability,
|
||||
Shift,
|
||||
ShiftAssignment,
|
||||
WorkingZone,
|
||||
)
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
from app.specifications.availability import ShiftListSpec, WorkingZoneListSpec
|
||||
|
||||
|
||||
class DriverAvailabilityRepository(TenantBaseRepository[DriverAvailability]):
|
||||
model = DriverAvailability
|
||||
|
||||
async def get_for_driver(
|
||||
self, tenant_id: UUID, driver_id: UUID
|
||||
) -> DriverAvailability | None:
|
||||
stmt = (
|
||||
select(self.model)
|
||||
.where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.driver_id == driver_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(self.model.updated_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
class ShiftRepository(TenantBaseRepository[Shift]):
|
||||
model = Shift
|
||||
|
||||
async def get_by_code(self, tenant_id: UUID, code: str) -> Shift | None:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.code == code,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_filtered(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
spec: ShiftListSpec | None = None,
|
||||
) -> Sequence[Shift]:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
if spec is not None:
|
||||
stmt = spec.apply(stmt)
|
||||
else:
|
||||
stmt = stmt.order_by(self.model.starts_at.desc())
|
||||
stmt = stmt.offset(offset).limit(limit)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def count_filtered(
|
||||
self, tenant_id: UUID, *, spec: ShiftListSpec | None = None
|
||||
) -> int:
|
||||
stmt = select(func.count()).select_from(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
if spec is not None:
|
||||
clauses = spec.filter_clauses()
|
||||
if clauses:
|
||||
stmt = stmt.where(*clauses)
|
||||
result = await self.session.execute(stmt)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
class ShiftAssignmentRepository(TenantBaseRepository[ShiftAssignment]):
|
||||
model = ShiftAssignment
|
||||
|
||||
async def list_for_shift(
|
||||
self, tenant_id: UUID, shift_id: UUID
|
||||
) -> Sequence[ShiftAssignment]:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.shift_id == shift_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
class WorkingZoneRepository(TenantBaseRepository[WorkingZone]):
|
||||
model = WorkingZone
|
||||
|
||||
async def get_by_code(self, tenant_id: UUID, code: str) -> WorkingZone | None:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.code == code,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_filtered(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
spec: WorkingZoneListSpec | None = None,
|
||||
) -> Sequence[WorkingZone]:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
if spec is not None:
|
||||
stmt = spec.apply(stmt)
|
||||
else:
|
||||
stmt = stmt.order_by(self.model.created_at.desc())
|
||||
stmt = stmt.offset(offset).limit(limit)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def count_filtered(
|
||||
self, tenant_id: UUID, *, spec: WorkingZoneListSpec | None = None
|
||||
) -> int:
|
||||
stmt = select(func.count()).select_from(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
if spec is not None:
|
||||
clauses = spec.filter_clauses()
|
||||
if clauses:
|
||||
stmt = stmt.where(*clauses)
|
||||
result = await self.session.execute(stmt)
|
||||
return int(result.scalar_one())
|
||||
@ -1,107 +0,0 @@
|
||||
"""Dispatch repositories — Phase 10.5."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.models.dispatch import DispatchJob, JobAssignment
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
from app.specifications.dispatch import DispatchJobListSpec, JobAssignmentListSpec
|
||||
|
||||
|
||||
class DispatchJobRepository(TenantBaseRepository[DispatchJob]):
|
||||
model = DispatchJob
|
||||
|
||||
async def get_by_code(self, tenant_id: UUID, code: str) -> DispatchJob | None:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.code == code,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_filtered(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
spec: DispatchJobListSpec | None = None,
|
||||
) -> Sequence[DispatchJob]:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
if spec is not None:
|
||||
stmt = spec.apply(stmt)
|
||||
else:
|
||||
stmt = stmt.order_by(self.model.created_at.desc())
|
||||
stmt = stmt.offset(offset).limit(limit)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def count_filtered(
|
||||
self, tenant_id: UUID, *, spec: DispatchJobListSpec | None = None
|
||||
) -> int:
|
||||
stmt = select(func.count()).select_from(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
if spec is not None:
|
||||
clauses = spec.filter_clauses()
|
||||
if clauses:
|
||||
stmt = stmt.where(*clauses)
|
||||
result = await self.session.execute(stmt)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
class JobAssignmentRepository(TenantBaseRepository[JobAssignment]):
|
||||
model = JobAssignment
|
||||
|
||||
async def list_filtered(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
spec: JobAssignmentListSpec | None = None,
|
||||
) -> Sequence[JobAssignment]:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
if spec is not None:
|
||||
stmt = spec.apply(stmt)
|
||||
else:
|
||||
stmt = stmt.order_by(self.model.created_at.desc())
|
||||
stmt = stmt.offset(offset).limit(limit)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def count_filtered(
|
||||
self, tenant_id: UUID, *, spec: JobAssignmentListSpec | None = None
|
||||
) -> int:
|
||||
stmt = select(func.count()).select_from(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
if spec is not None:
|
||||
clauses = spec.filter_clauses()
|
||||
if clauses:
|
||||
stmt = stmt.where(*clauses)
|
||||
result = await self.session.execute(stmt)
|
||||
return int(result.scalar_one())
|
||||
|
||||
async def list_for_job(
|
||||
self, tenant_id: UUID, dispatch_job_id: UUID
|
||||
) -> Sequence[JobAssignment]:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.dispatch_job_id == dispatch_job_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
@ -1,184 +0,0 @@
|
||||
"""Fleet repositories — Phase 10.2."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.models.fleet import Fleet, Vehicle, VehicleAssignment, VehicleType
|
||||
from app.models.types import VehicleAssignmentStatus
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
from app.specifications.fleet import FleetListSpec, VehicleListSpec, VehicleTypeListSpec
|
||||
|
||||
|
||||
class FleetRepository(TenantBaseRepository[Fleet]):
|
||||
model = Fleet
|
||||
|
||||
async def get_by_code(self, tenant_id: UUID, code: str) -> Fleet | None:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.code == code,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_filtered(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
spec: FleetListSpec | None = None,
|
||||
) -> Sequence[Fleet]:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
if spec is not None:
|
||||
stmt = spec.apply(stmt)
|
||||
else:
|
||||
stmt = stmt.order_by(self.model.created_at.desc())
|
||||
stmt = stmt.offset(offset).limit(limit)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def count_filtered(
|
||||
self, tenant_id: UUID, *, spec: FleetListSpec | None = None
|
||||
) -> int:
|
||||
stmt = select(func.count()).select_from(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
if spec is not None:
|
||||
clauses = spec.filter_clauses()
|
||||
if clauses:
|
||||
stmt = stmt.where(*clauses)
|
||||
result = await self.session.execute(stmt)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
class VehicleTypeRepository(TenantBaseRepository[VehicleType]):
|
||||
model = VehicleType
|
||||
|
||||
async def get_by_code(self, tenant_id: UUID, code: str) -> VehicleType | None:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.code == code,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_filtered(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
spec: VehicleTypeListSpec | None = None,
|
||||
) -> Sequence[VehicleType]:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
if spec is not None:
|
||||
stmt = spec.apply(stmt)
|
||||
else:
|
||||
stmt = stmt.order_by(self.model.created_at.desc())
|
||||
stmt = stmt.offset(offset).limit(limit)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def count_filtered(
|
||||
self, tenant_id: UUID, *, spec: VehicleTypeListSpec | None = None
|
||||
) -> int:
|
||||
stmt = select(func.count()).select_from(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
if spec is not None:
|
||||
clauses = spec.filter_clauses()
|
||||
if clauses:
|
||||
stmt = stmt.where(*clauses)
|
||||
result = await self.session.execute(stmt)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
class VehicleRepository(TenantBaseRepository[Vehicle]):
|
||||
model = Vehicle
|
||||
|
||||
async def get_by_code(self, tenant_id: UUID, code: str) -> Vehicle | None:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.code == code,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_filtered(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
spec: VehicleListSpec | None = None,
|
||||
) -> Sequence[Vehicle]:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
if spec is not None:
|
||||
stmt = spec.apply(stmt)
|
||||
else:
|
||||
stmt = stmt.order_by(self.model.created_at.desc())
|
||||
stmt = stmt.offset(offset).limit(limit)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def count_filtered(
|
||||
self, tenant_id: UUID, *, spec: VehicleListSpec | None = None
|
||||
) -> int:
|
||||
stmt = select(func.count()).select_from(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
if spec is not None:
|
||||
clauses = spec.filter_clauses()
|
||||
if clauses:
|
||||
stmt = stmt.where(*clauses)
|
||||
result = await self.session.execute(stmt)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
class VehicleAssignmentRepository(TenantBaseRepository[VehicleAssignment]):
|
||||
model = VehicleAssignment
|
||||
|
||||
async def list_for_vehicle(
|
||||
self, tenant_id: UUID, vehicle_id: UUID
|
||||
) -> Sequence[VehicleAssignment]:
|
||||
stmt = (
|
||||
select(self.model)
|
||||
.where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.vehicle_id == vehicle_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(self.model.created_at.desc())
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def get_active_for_vehicle(
|
||||
self, tenant_id: UUID, vehicle_id: UUID
|
||||
) -> VehicleAssignment | None:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.vehicle_id == vehicle_id,
|
||||
self.model.status == VehicleAssignmentStatus.ACTIVE,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
@ -1,158 +0,0 @@
|
||||
"""Pricing repositories — Phase 10.4."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.models.pricing import CapabilityBundle, CapabilityDefinition, PricingRule
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
from app.specifications.pricing import (
|
||||
BundleListSpec,
|
||||
CapabilityListSpec,
|
||||
PricingRuleListSpec,
|
||||
)
|
||||
|
||||
|
||||
class PricingRuleRepository(TenantBaseRepository[PricingRule]):
|
||||
model = PricingRule
|
||||
|
||||
async def get_by_code(self, tenant_id: UUID, code: str) -> PricingRule | None:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.code == code,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_filtered(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
spec: PricingRuleListSpec | None = None,
|
||||
) -> Sequence[PricingRule]:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
if spec is not None:
|
||||
stmt = spec.apply(stmt)
|
||||
else:
|
||||
stmt = stmt.order_by(self.model.created_at.desc())
|
||||
stmt = stmt.offset(offset).limit(limit)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def count_filtered(
|
||||
self, tenant_id: UUID, *, spec: PricingRuleListSpec | None = None
|
||||
) -> int:
|
||||
stmt = select(func.count()).select_from(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
if spec is not None:
|
||||
clauses = spec.filter_clauses()
|
||||
if clauses:
|
||||
stmt = stmt.where(*clauses)
|
||||
result = await self.session.execute(stmt)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
class CapabilityDefinitionRepository(TenantBaseRepository[CapabilityDefinition]):
|
||||
model = CapabilityDefinition
|
||||
|
||||
async def get_by_code(
|
||||
self, tenant_id: UUID, code: str
|
||||
) -> CapabilityDefinition | None:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.code == code,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_filtered(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
spec: CapabilityListSpec | None = None,
|
||||
) -> Sequence[CapabilityDefinition]:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
if spec is not None:
|
||||
stmt = spec.apply(stmt)
|
||||
else:
|
||||
stmt = stmt.order_by(self.model.created_at.desc())
|
||||
stmt = stmt.offset(offset).limit(limit)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def count_filtered(
|
||||
self, tenant_id: UUID, *, spec: CapabilityListSpec | None = None
|
||||
) -> int:
|
||||
stmt = select(func.count()).select_from(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
if spec is not None:
|
||||
clauses = spec.filter_clauses()
|
||||
if clauses:
|
||||
stmt = stmt.where(*clauses)
|
||||
result = await self.session.execute(stmt)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
class CapabilityBundleRepository(TenantBaseRepository[CapabilityBundle]):
|
||||
model = CapabilityBundle
|
||||
|
||||
async def get_by_code(self, tenant_id: UUID, code: str) -> CapabilityBundle | None:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.code == code,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_filtered(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
spec: BundleListSpec | None = None,
|
||||
) -> Sequence[CapabilityBundle]:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
if spec is not None:
|
||||
stmt = spec.apply(stmt)
|
||||
else:
|
||||
stmt = stmt.order_by(self.model.created_at.desc())
|
||||
stmt = stmt.offset(offset).limit(limit)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def count_filtered(
|
||||
self, tenant_id: UUID, *, spec: BundleListSpec | None = None
|
||||
) -> int:
|
||||
stmt = select(func.count()).select_from(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
if spec is not None:
|
||||
clauses = spec.filter_clauses()
|
||||
if clauses:
|
||||
stmt = stmt.where(*clauses)
|
||||
result = await self.session.execute(stmt)
|
||||
return int(result.scalar_one())
|
||||
@ -1,115 +0,0 @@
|
||||
"""Routing repositories — Phase 10.6."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.models.routing import OptimizationRun, RoutePlan, RouteStop
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
from app.specifications.routing import OptimizationRunListSpec, RoutePlanListSpec
|
||||
|
||||
|
||||
class RoutePlanRepository(TenantBaseRepository[RoutePlan]):
|
||||
model = RoutePlan
|
||||
|
||||
async def get_by_code(self, tenant_id: UUID, code: str) -> RoutePlan | None:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.code == code,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_filtered(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
spec: RoutePlanListSpec | None = None,
|
||||
) -> Sequence[RoutePlan]:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
if spec is not None:
|
||||
stmt = spec.apply(stmt)
|
||||
else:
|
||||
stmt = stmt.order_by(self.model.created_at.desc())
|
||||
stmt = stmt.offset(offset).limit(limit)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def count_filtered(
|
||||
self, tenant_id: UUID, *, spec: RoutePlanListSpec | None = None
|
||||
) -> int:
|
||||
stmt = select(func.count()).select_from(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
if spec is not None:
|
||||
clauses = spec.filter_clauses()
|
||||
if clauses:
|
||||
stmt = stmt.where(*clauses)
|
||||
result = await self.session.execute(stmt)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
class RouteStopRepository(TenantBaseRepository[RouteStop]):
|
||||
model = RouteStop
|
||||
|
||||
async def list_for_plan(
|
||||
self, tenant_id: UUID, route_plan_id: UUID
|
||||
) -> Sequence[RouteStop]:
|
||||
stmt = (
|
||||
select(self.model)
|
||||
.where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.route_plan_id == route_plan_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
.order_by(self.model.sequence.asc())
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
class OptimizationRunRepository(TenantBaseRepository[OptimizationRun]):
|
||||
model = OptimizationRun
|
||||
|
||||
async def list_filtered(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
spec: OptimizationRunListSpec | None = None,
|
||||
) -> Sequence[OptimizationRun]:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
if spec is not None:
|
||||
stmt = spec.apply(stmt)
|
||||
else:
|
||||
stmt = stmt.order_by(self.model.created_at.desc())
|
||||
stmt = stmt.offset(offset).limit(limit)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def count_filtered(
|
||||
self, tenant_id: UUID, *, spec: OptimizationRunListSpec | None = None
|
||||
) -> int:
|
||||
stmt = select(func.count()).select_from(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
if spec is not None:
|
||||
clauses = spec.filter_clauses()
|
||||
if clauses:
|
||||
stmt = stmt.where(*clauses)
|
||||
result = await self.session.execute(stmt)
|
||||
return int(result.scalar_one())
|
||||
@ -1,75 +0,0 @@
|
||||
"""Settlement repositories — Phase 10.8."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.models.settlement import SettlementIntent, SettlementLine
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
from app.specifications.settlement import SettlementIntentListSpec
|
||||
|
||||
|
||||
class SettlementIntentRepository(TenantBaseRepository[SettlementIntent]):
|
||||
model = SettlementIntent
|
||||
|
||||
async def get_by_code(
|
||||
self, tenant_id: UUID, code: str
|
||||
) -> SettlementIntent | None:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.code == code,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_filtered(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
spec: SettlementIntentListSpec | None = None,
|
||||
) -> Sequence[SettlementIntent]:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
if spec is not None:
|
||||
stmt = spec.apply(stmt)
|
||||
else:
|
||||
stmt = stmt.order_by(self.model.created_at.desc())
|
||||
stmt = stmt.offset(offset).limit(limit)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def count_filtered(
|
||||
self, tenant_id: UUID, *, spec: SettlementIntentListSpec | None = None
|
||||
) -> int:
|
||||
stmt = select(func.count()).select_from(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
if spec is not None:
|
||||
clauses = spec.filter_clauses()
|
||||
if clauses:
|
||||
stmt = stmt.where(*clauses)
|
||||
result = await self.session.execute(stmt)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
class SettlementLineRepository(TenantBaseRepository[SettlementLine]):
|
||||
model = SettlementLine
|
||||
|
||||
async def list_for_intent(
|
||||
self, tenant_id: UUID, settlement_intent_id: UUID
|
||||
) -> Sequence[SettlementLine]:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.settlement_intent_id == settlement_intent_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
@ -1,164 +0,0 @@
|
||||
"""Tracking repositories — Phase 10.7."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Sequence
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import func, select
|
||||
|
||||
from app.models.tracking import (
|
||||
CustomerTrackingToken,
|
||||
ProofOfDelivery,
|
||||
TrackingPoint,
|
||||
TrackingSession,
|
||||
)
|
||||
from app.repositories.base import TenantBaseRepository
|
||||
from app.specifications.tracking import (
|
||||
CustomerTrackingTokenListSpec,
|
||||
ProofOfDeliveryListSpec,
|
||||
TrackingSessionListSpec,
|
||||
)
|
||||
|
||||
|
||||
class TrackingSessionRepository(TenantBaseRepository[TrackingSession]):
|
||||
model = TrackingSession
|
||||
|
||||
async def list_filtered(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
spec: TrackingSessionListSpec | None = None,
|
||||
) -> Sequence[TrackingSession]:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
if spec is not None:
|
||||
stmt = spec.apply(stmt)
|
||||
else:
|
||||
stmt = stmt.order_by(self.model.created_at.desc())
|
||||
stmt = stmt.offset(offset).limit(limit)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def count_filtered(
|
||||
self, tenant_id: UUID, *, spec: TrackingSessionListSpec | None = None
|
||||
) -> int:
|
||||
stmt = select(func.count()).select_from(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
if spec is not None:
|
||||
clauses = spec.filter_clauses()
|
||||
if clauses:
|
||||
stmt = stmt.where(*clauses)
|
||||
result = await self.session.execute(stmt)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
class TrackingPointRepository(TenantBaseRepository[TrackingPoint]):
|
||||
model = TrackingPoint
|
||||
|
||||
async def list_for_session(
|
||||
self, tenant_id: UUID, tracking_session_id: UUID, *, limit: int = 500
|
||||
) -> Sequence[TrackingPoint]:
|
||||
stmt = (
|
||||
select(self.model)
|
||||
.where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.tracking_session_id == tracking_session_id,
|
||||
)
|
||||
.order_by(self.model.recorded_at.asc())
|
||||
.limit(limit)
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
|
||||
class ProofOfDeliveryRepository(TenantBaseRepository[ProofOfDelivery]):
|
||||
model = ProofOfDelivery
|
||||
|
||||
async def list_filtered(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
spec: ProofOfDeliveryListSpec | None = None,
|
||||
) -> Sequence[ProofOfDelivery]:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
if spec is not None:
|
||||
stmt = spec.apply(stmt)
|
||||
else:
|
||||
stmt = stmt.order_by(self.model.created_at.desc())
|
||||
stmt = stmt.offset(offset).limit(limit)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def count_filtered(
|
||||
self, tenant_id: UUID, *, spec: ProofOfDeliveryListSpec | None = None
|
||||
) -> int:
|
||||
stmt = select(func.count()).select_from(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
if spec is not None:
|
||||
clauses = spec.filter_clauses()
|
||||
if clauses:
|
||||
stmt = stmt.where(*clauses)
|
||||
result = await self.session.execute(stmt)
|
||||
return int(result.scalar_one())
|
||||
|
||||
|
||||
class CustomerTrackingTokenRepository(TenantBaseRepository[CustomerTrackingToken]):
|
||||
model = CustomerTrackingToken
|
||||
|
||||
async def get_by_token(
|
||||
self, tenant_id: UUID, token: str
|
||||
) -> CustomerTrackingToken | None:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.token == token,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
async def list_filtered(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
spec: CustomerTrackingTokenListSpec | None = None,
|
||||
) -> Sequence[CustomerTrackingToken]:
|
||||
stmt = select(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
if spec is not None:
|
||||
stmt = spec.apply(stmt)
|
||||
else:
|
||||
stmt = stmt.order_by(self.model.created_at.desc())
|
||||
stmt = stmt.offset(offset).limit(limit)
|
||||
result = await self.session.execute(stmt)
|
||||
return result.scalars().all()
|
||||
|
||||
async def count_filtered(
|
||||
self, tenant_id: UUID, *, spec: CustomerTrackingTokenListSpec | None = None
|
||||
) -> int:
|
||||
stmt = select(func.count()).select_from(self.model).where(
|
||||
self.model.tenant_id == tenant_id,
|
||||
self.model.is_deleted.is_(False),
|
||||
)
|
||||
if spec is not None:
|
||||
clauses = spec.filter_clauses()
|
||||
if clauses:
|
||||
stmt = stmt.where(*clauses)
|
||||
result = await self.session.execute(stmt)
|
||||
return int(result.scalar_one())
|
||||
@ -1,172 +0,0 @@
|
||||
"""Availability, shifts & working zones DTOs — Phase 10.3."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.types import (
|
||||
DriverAvailabilityStatus,
|
||||
ShiftAssignmentStatus,
|
||||
ShiftStatus,
|
||||
WorkingZoneStatus,
|
||||
)
|
||||
from app.schemas.common import ORMBase
|
||||
|
||||
_FORBID = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class DriverAvailabilityUpsert(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
status: DriverAvailabilityStatus
|
||||
effective_from: datetime | None = None
|
||||
effective_until: datetime | None = None
|
||||
notes: str | None = None
|
||||
metadata_json: dict | None = None
|
||||
version: int | None = Field(default=None, ge=1)
|
||||
|
||||
|
||||
class DriverAvailabilityRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
driver_id: UUID
|
||||
status: DriverAvailabilityStatus
|
||||
effective_from: datetime | None
|
||||
effective_until: datetime | None
|
||||
notes: str | None
|
||||
metadata_json: dict | None
|
||||
version: int
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ShiftCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
organization_id: UUID
|
||||
hub_id: UUID | None = None
|
||||
code: str = Field(max_length=50)
|
||||
name: str = Field(max_length=255)
|
||||
status: ShiftStatus = ShiftStatus.DRAFT
|
||||
starts_at: datetime
|
||||
ends_at: datetime
|
||||
metadata_json: dict | None = None
|
||||
|
||||
|
||||
class ShiftUpdate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
hub_id: UUID | None = None
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
status: ShiftStatus | None = None
|
||||
starts_at: datetime | None = None
|
||||
ends_at: datetime | None = None
|
||||
metadata_json: dict | None = None
|
||||
version: int = Field(ge=1)
|
||||
|
||||
|
||||
class ShiftRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
organization_id: UUID
|
||||
hub_id: UUID | None
|
||||
code: str
|
||||
name: str
|
||||
status: ShiftStatus
|
||||
starts_at: datetime
|
||||
ends_at: datetime
|
||||
metadata_json: dict | None
|
||||
version: int
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ShiftListResponse(BaseModel):
|
||||
items: list[ShiftRead]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class ShiftAssignmentCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
driver_id: UUID
|
||||
notes: str | None = None
|
||||
metadata_json: dict | None = None
|
||||
|
||||
|
||||
class ShiftAssignmentUpdate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
status: ShiftAssignmentStatus | None = None
|
||||
notes: str | None = None
|
||||
metadata_json: dict | None = None
|
||||
|
||||
|
||||
class ShiftAssignmentRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
shift_id: UUID
|
||||
driver_id: UUID
|
||||
status: ShiftAssignmentStatus
|
||||
notes: str | None
|
||||
metadata_json: dict | None
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class WorkingZoneCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
organization_id: UUID
|
||||
code: str = Field(max_length=50)
|
||||
name: str = Field(max_length=255)
|
||||
status: WorkingZoneStatus = WorkingZoneStatus.ACTIVE
|
||||
geo_boundary_ref: str | None = Field(default=None, max_length=255)
|
||||
metadata_json: dict | None = None
|
||||
|
||||
|
||||
class WorkingZoneUpdate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
status: WorkingZoneStatus | None = None
|
||||
geo_boundary_ref: str | None = Field(default=None, max_length=255)
|
||||
metadata_json: dict | None = None
|
||||
version: int = Field(ge=1)
|
||||
|
||||
|
||||
class WorkingZoneRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
organization_id: UUID
|
||||
code: str
|
||||
name: str
|
||||
status: WorkingZoneStatus
|
||||
geo_boundary_ref: str | None
|
||||
metadata_json: dict | None
|
||||
version: int
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class WorkingZoneListResponse(BaseModel):
|
||||
items: list[WorkingZoneRead]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
@ -1,122 +0,0 @@
|
||||
"""Dispatch engine DTOs — Phase 10.5."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.types import DispatchJobAction, DispatchJobStatus, JobAssignmentStatus
|
||||
from app.schemas.common import ORMBase
|
||||
|
||||
_FORBID = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class DispatchJobCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
organization_id: UUID
|
||||
hub_id: UUID | None = None
|
||||
code: str = Field(max_length=50)
|
||||
external_order_ref: str | None = Field(default=None, max_length=255)
|
||||
external_source: str | None = Field(default=None, max_length=100)
|
||||
priority: int = 0
|
||||
pickup_address_ref: str | None = Field(default=None, max_length=255)
|
||||
dropoff_address_ref: str | None = Field(default=None, max_length=255)
|
||||
scheduled_at: datetime | None = None
|
||||
metadata_json: dict | None = None
|
||||
|
||||
|
||||
class DispatchJobUpdate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
hub_id: UUID | None = None
|
||||
priority: int | None = None
|
||||
pickup_address_ref: str | None = Field(default=None, max_length=255)
|
||||
dropoff_address_ref: str | None = Field(default=None, max_length=255)
|
||||
scheduled_at: datetime | None = None
|
||||
metadata_json: dict | None = None
|
||||
version: int = Field(ge=1)
|
||||
|
||||
|
||||
class DispatchJobStatusRequest(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
action: DispatchJobAction
|
||||
reason: str | None = Field(default=None, max_length=500)
|
||||
version: int = Field(ge=1)
|
||||
|
||||
|
||||
class DispatchJobRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
organization_id: UUID
|
||||
hub_id: UUID | None
|
||||
code: str
|
||||
status: DispatchJobStatus
|
||||
external_order_ref: str | None
|
||||
external_source: str | None
|
||||
priority: int
|
||||
pickup_address_ref: str | None
|
||||
dropoff_address_ref: str | None
|
||||
scheduled_at: datetime | None
|
||||
status_reason: str | None
|
||||
metadata_json: dict | None
|
||||
version: int
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class DispatchJobListResponse(BaseModel):
|
||||
items: list[DispatchJobRead]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class JobAssignmentCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
dispatch_job_id: UUID
|
||||
driver_id: UUID
|
||||
vehicle_id: UUID | None = None
|
||||
notes: str | None = None
|
||||
metadata_json: dict | None = None
|
||||
|
||||
|
||||
class JobAssignmentUpdate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
status: JobAssignmentStatus | None = None
|
||||
vehicle_id: UUID | None = None
|
||||
notes: str | None = None
|
||||
metadata_json: dict | None = None
|
||||
version: int = Field(ge=1)
|
||||
|
||||
|
||||
class JobAssignmentRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
dispatch_job_id: UUID
|
||||
driver_id: UUID
|
||||
vehicle_id: UUID | None
|
||||
status: JobAssignmentStatus
|
||||
assigned_at: datetime | None
|
||||
notes: str | None
|
||||
metadata_json: dict | None
|
||||
version: int
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class JobAssignmentListResponse(BaseModel):
|
||||
items: list[JobAssignmentRead]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
@ -1,184 +0,0 @@
|
||||
"""Fleet & vehicle DTOs — Phase 10.2."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.types import FleetStatus, VehicleAssignmentStatus, VehicleStatus
|
||||
from app.schemas.common import ORMBase
|
||||
|
||||
_FORBID = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class FleetCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
organization_id: UUID
|
||||
hub_id: UUID | None = None
|
||||
code: str = Field(max_length=50)
|
||||
name: str = Field(max_length=255)
|
||||
description: str | None = None
|
||||
status: FleetStatus = FleetStatus.DRAFT
|
||||
metadata_json: dict | None = None
|
||||
|
||||
|
||||
class FleetUpdate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
hub_id: UUID | None = None
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
description: str | None = None
|
||||
status: FleetStatus | None = None
|
||||
metadata_json: dict | None = None
|
||||
version: int = Field(ge=1)
|
||||
|
||||
|
||||
class FleetRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
organization_id: UUID
|
||||
hub_id: UUID | None
|
||||
code: str
|
||||
name: str
|
||||
description: str | None
|
||||
status: FleetStatus
|
||||
metadata_json: dict | None
|
||||
version: int
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class FleetListResponse(BaseModel):
|
||||
items: list[FleetRead]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class VehicleTypeCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
code: str = Field(max_length=50)
|
||||
name: str = Field(max_length=255)
|
||||
description: str | None = None
|
||||
capacity_kg: float | None = None
|
||||
capacity_volume: float | None = None
|
||||
capability_tags: list[str] | None = None
|
||||
metadata_json: dict | None = None
|
||||
|
||||
|
||||
class VehicleTypeUpdate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
description: str | None = None
|
||||
capacity_kg: float | None = None
|
||||
capacity_volume: float | None = None
|
||||
capability_tags: list[str] | None = None
|
||||
metadata_json: dict | None = None
|
||||
version: int = Field(ge=1)
|
||||
|
||||
|
||||
class VehicleTypeRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
code: str
|
||||
name: str
|
||||
description: str | None
|
||||
capacity_kg: float | None
|
||||
capacity_volume: float | None
|
||||
capability_tags: list | None
|
||||
metadata_json: dict | None
|
||||
version: int
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class VehicleTypeListResponse(BaseModel):
|
||||
items: list[VehicleTypeRead]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class VehicleCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
fleet_id: UUID
|
||||
vehicle_type_id: UUID
|
||||
code: str = Field(max_length=50)
|
||||
plate_number: str | None = Field(default=None, max_length=32)
|
||||
status: VehicleStatus = VehicleStatus.AVAILABLE
|
||||
metadata_json: dict | None = None
|
||||
|
||||
|
||||
class VehicleUpdate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
plate_number: str | None = Field(default=None, max_length=32)
|
||||
status: VehicleStatus | None = None
|
||||
metadata_json: dict | None = None
|
||||
version: int = Field(ge=1)
|
||||
|
||||
|
||||
class VehicleRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
fleet_id: UUID
|
||||
vehicle_type_id: UUID
|
||||
code: str
|
||||
plate_number: str | None
|
||||
status: VehicleStatus
|
||||
metadata_json: dict | None
|
||||
version: int
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class VehicleListResponse(BaseModel):
|
||||
items: list[VehicleRead]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class VehicleAssignmentCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
driver_id: UUID
|
||||
notes: str | None = None
|
||||
metadata_json: dict | None = None
|
||||
|
||||
|
||||
class VehicleAssignmentUpdate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
status: VehicleAssignmentStatus | None = None
|
||||
notes: str | None = None
|
||||
metadata_json: dict | None = None
|
||||
|
||||
|
||||
class VehicleAssignmentRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
vehicle_id: UUID
|
||||
driver_id: UUID
|
||||
status: VehicleAssignmentStatus
|
||||
notes: str | None
|
||||
metadata_json: dict | None
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@ -1,153 +0,0 @@
|
||||
"""Pricing, capabilities & bundles DTOs — Phase 10.4."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.types import CapabilityBundleStatus, CapabilityStatus, PricingRuleStatus
|
||||
from app.schemas.common import ORMBase
|
||||
|
||||
_FORBID = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class PricingRuleCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
organization_id: UUID
|
||||
code: str = Field(max_length=50)
|
||||
name: str = Field(max_length=255)
|
||||
status: PricingRuleStatus = PricingRuleStatus.DRAFT
|
||||
base_amount: Decimal = Field(gt=0)
|
||||
currency_code: str = Field(default="IRR", max_length=3)
|
||||
rule_config: dict | None = None
|
||||
metadata_json: dict | None = None
|
||||
|
||||
|
||||
class PricingRuleUpdate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
status: PricingRuleStatus | None = None
|
||||
base_amount: Decimal | None = Field(default=None, gt=0)
|
||||
currency_code: str | None = Field(default=None, max_length=3)
|
||||
rule_config: dict | None = None
|
||||
metadata_json: dict | None = None
|
||||
version: int = Field(ge=1)
|
||||
|
||||
|
||||
class PricingRuleRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
organization_id: UUID
|
||||
code: str
|
||||
name: str
|
||||
status: PricingRuleStatus
|
||||
base_amount: Decimal
|
||||
currency_code: str
|
||||
rule_config: dict | None
|
||||
metadata_json: dict | None
|
||||
version: int
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class PricingRuleListResponse(BaseModel):
|
||||
items: list[PricingRuleRead]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class CapabilityCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
code: str = Field(max_length=50)
|
||||
name: str = Field(max_length=255)
|
||||
description: str | None = None
|
||||
status: CapabilityStatus = CapabilityStatus.ACTIVE
|
||||
metadata_json: dict | None = None
|
||||
|
||||
|
||||
class CapabilityUpdate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
description: str | None = None
|
||||
status: CapabilityStatus | None = None
|
||||
metadata_json: dict | None = None
|
||||
version: int = Field(ge=1)
|
||||
|
||||
|
||||
class CapabilityRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
code: str
|
||||
name: str
|
||||
description: str | None
|
||||
status: CapabilityStatus
|
||||
metadata_json: dict | None
|
||||
version: int
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class CapabilityListResponse(BaseModel):
|
||||
items: list[CapabilityRead]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class BundleCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
code: str = Field(max_length=50)
|
||||
name: str = Field(max_length=255)
|
||||
status: CapabilityBundleStatus = CapabilityBundleStatus.DRAFT
|
||||
capability_codes: list[str] | None = None
|
||||
pricing_rule_id: UUID | None = None
|
||||
metadata_json: dict | None = None
|
||||
|
||||
|
||||
class BundleUpdate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
name: str | None = Field(default=None, max_length=255)
|
||||
status: CapabilityBundleStatus | None = None
|
||||
capability_codes: list[str] | None = None
|
||||
pricing_rule_id: UUID | None = None
|
||||
metadata_json: dict | None = None
|
||||
version: int = Field(ge=1)
|
||||
|
||||
|
||||
class BundleRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
code: str
|
||||
name: str
|
||||
status: CapabilityBundleStatus
|
||||
capability_codes: list | None
|
||||
pricing_rule_id: UUID | None
|
||||
metadata_json: dict | None
|
||||
version: int
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class BundleListResponse(BaseModel):
|
||||
items: list[BundleRead]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
@ -1,120 +0,0 @@
|
||||
"""Routing & optimization DTOs — Phase 10.6."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.types import OptimizationRunStatus, RoutePlanStatus, RouteStopStatus
|
||||
from app.schemas.common import ORMBase
|
||||
|
||||
_FORBID = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class RoutePlanCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
organization_id: UUID
|
||||
code: str = Field(max_length=50)
|
||||
driver_id: UUID | None = None
|
||||
dispatch_job_ids: list[str] | None = None
|
||||
routing_engine_ref: str | None = Field(default=None, max_length=255)
|
||||
metadata_json: dict | None = None
|
||||
|
||||
|
||||
class RoutePlanUpdate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
driver_id: UUID | None = None
|
||||
status: RoutePlanStatus | None = None
|
||||
dispatch_job_ids: list[str] | None = None
|
||||
routing_engine_ref: str | None = Field(default=None, max_length=255)
|
||||
metadata_json: dict | None = None
|
||||
version: int = Field(ge=1)
|
||||
|
||||
|
||||
class RoutePlanRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
organization_id: UUID
|
||||
code: str
|
||||
status: RoutePlanStatus
|
||||
driver_id: UUID | None
|
||||
dispatch_job_ids: list | None
|
||||
routing_engine_ref: str | None
|
||||
planned_at: datetime | None
|
||||
metadata_json: dict | None
|
||||
version: int
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class RoutePlanListResponse(BaseModel):
|
||||
items: list[RoutePlanRead]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class RouteStopCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
sequence: int = Field(ge=0)
|
||||
address_ref: str | None = Field(default=None, max_length=255)
|
||||
external_order_ref: str | None = Field(default=None, max_length=255)
|
||||
notes: str | None = None
|
||||
metadata_json: dict | None = None
|
||||
|
||||
|
||||
class RouteStopRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
route_plan_id: UUID
|
||||
sequence: int
|
||||
status: RouteStopStatus
|
||||
address_ref: str | None
|
||||
external_order_ref: str | None
|
||||
notes: str | None
|
||||
metadata_json: dict | None
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class OptimizationRunCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
route_plan_id: UUID | None = None
|
||||
routing_engine_ref: str | None = Field(default=None, max_length=255)
|
||||
request_payload: dict | None = None
|
||||
|
||||
|
||||
class OptimizationRunRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
route_plan_id: UUID | None
|
||||
status: OptimizationRunStatus
|
||||
routing_engine_ref: str | None
|
||||
request_payload: dict | None
|
||||
result_payload: dict | None
|
||||
started_at: datetime | None
|
||||
completed_at: datetime | None
|
||||
error_message: str | None
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class OptimizationRunListResponse(BaseModel):
|
||||
items: list[OptimizationRunRead]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
@ -1,99 +0,0 @@
|
||||
"""Settlement DTOs — Phase 10.8."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.types import SettlementIntentStatus, SettlementLineKind
|
||||
from app.schemas.common import ORMBase
|
||||
|
||||
_FORBID = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class SettlementIntentCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
organization_id: UUID
|
||||
code: str = Field(max_length=50)
|
||||
driver_id: UUID | None = None
|
||||
dispatch_job_id: UUID | None = None
|
||||
total_amount: Decimal = Field(gt=0)
|
||||
currency_code: str = Field(default="IRR", max_length=3)
|
||||
notes: str | None = None
|
||||
metadata_json: dict | None = None
|
||||
|
||||
|
||||
class SettlementIntentUpdate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
driver_id: UUID | None = None
|
||||
dispatch_job_id: UUID | None = None
|
||||
total_amount: Decimal | None = Field(default=None, gt=0)
|
||||
currency_code: str | None = Field(default=None, max_length=3)
|
||||
notes: str | None = None
|
||||
metadata_json: dict | None = None
|
||||
version: int = Field(ge=1)
|
||||
|
||||
|
||||
class SettlementActionRequest(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
reason: str | None = Field(default=None, max_length=500)
|
||||
version: int = Field(ge=1)
|
||||
|
||||
|
||||
class SettlementIntentRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
organization_id: UUID
|
||||
code: str
|
||||
status: SettlementIntentStatus
|
||||
driver_id: UUID | None
|
||||
dispatch_job_id: UUID | None
|
||||
external_settlement_ref: str | None
|
||||
total_amount: Decimal
|
||||
currency_code: str
|
||||
notes: str | None
|
||||
metadata_json: dict | None
|
||||
version: int
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class SettlementIntentListResponse(BaseModel):
|
||||
items: list[SettlementIntentRead]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class SettlementLineCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
kind: SettlementLineKind
|
||||
description: str | None = Field(default=None, max_length=255)
|
||||
amount: Decimal = Field(gt=0)
|
||||
currency_code: str = Field(default="IRR", max_length=3)
|
||||
metadata_json: dict | None = None
|
||||
|
||||
|
||||
class SettlementLineRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
settlement_intent_id: UUID
|
||||
kind: SettlementLineKind
|
||||
description: str | None
|
||||
amount: Decimal
|
||||
currency_code: str
|
||||
metadata_json: dict | None
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
@ -1,156 +0,0 @@
|
||||
"""Tracking & proof of delivery DTOs — Phase 10.7."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from uuid import UUID
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.models.types import (
|
||||
CustomerTrackingTokenStatus,
|
||||
ProofOfDeliveryStatus,
|
||||
TrackingSessionStatus,
|
||||
)
|
||||
from app.schemas.common import ORMBase
|
||||
|
||||
_FORBID = ConfigDict(extra="forbid")
|
||||
|
||||
|
||||
class TrackingSessionCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
dispatch_job_id: UUID | None = None
|
||||
driver_id: UUID | None = None
|
||||
metadata_json: dict | None = None
|
||||
|
||||
|
||||
class TrackingSessionUpdate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
status: TrackingSessionStatus | None = None
|
||||
metadata_json: dict | None = None
|
||||
version: int = Field(ge=1)
|
||||
|
||||
|
||||
class TrackingSessionRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
dispatch_job_id: UUID | None
|
||||
driver_id: UUID | None
|
||||
status: TrackingSessionStatus
|
||||
started_at: datetime | None
|
||||
ended_at: datetime | None
|
||||
metadata_json: dict | None
|
||||
version: int
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class TrackingSessionListResponse(BaseModel):
|
||||
items: list[TrackingSessionRead]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class TrackingPointCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
latitude: float = Field(ge=-90, le=90)
|
||||
longitude: float = Field(ge=-180, le=180)
|
||||
recorded_at: datetime
|
||||
metadata_json: dict | None = None
|
||||
|
||||
|
||||
class TrackingPointRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
tracking_session_id: UUID
|
||||
latitude: float
|
||||
longitude: float
|
||||
recorded_at: datetime
|
||||
metadata_json: dict | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ProofOfDeliveryCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
dispatch_job_id: UUID
|
||||
signature_file_ref: str | None = Field(default=None, max_length=255)
|
||||
photo_file_ref: str | None = Field(default=None, max_length=255)
|
||||
delivery_code: str | None = Field(default=None, max_length=50)
|
||||
notes: str | None = None
|
||||
metadata_json: dict | None = None
|
||||
|
||||
|
||||
class ProofOfDeliveryUpdate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
status: ProofOfDeliveryStatus | None = None
|
||||
signature_file_ref: str | None = Field(default=None, max_length=255)
|
||||
photo_file_ref: str | None = Field(default=None, max_length=255)
|
||||
delivery_code: str | None = Field(default=None, max_length=50)
|
||||
notes: str | None = None
|
||||
metadata_json: dict | None = None
|
||||
version: int = Field(ge=1)
|
||||
|
||||
|
||||
class ProofOfDeliveryRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
dispatch_job_id: UUID
|
||||
status: ProofOfDeliveryStatus
|
||||
signature_file_ref: str | None
|
||||
photo_file_ref: str | None
|
||||
delivery_code: str | None
|
||||
notes: str | None
|
||||
captured_at: datetime | None
|
||||
metadata_json: dict | None
|
||||
version: int
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class ProofOfDeliveryListResponse(BaseModel):
|
||||
items: list[ProofOfDeliveryRead]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
|
||||
|
||||
class CustomerTrackingTokenCreate(BaseModel):
|
||||
model_config = _FORBID
|
||||
|
||||
dispatch_job_id: UUID
|
||||
expires_at: datetime | None = None
|
||||
metadata_json: dict | None = None
|
||||
|
||||
|
||||
class CustomerTrackingTokenRead(ORMBase):
|
||||
id: UUID
|
||||
tenant_id: UUID
|
||||
dispatch_job_id: UUID
|
||||
token: str
|
||||
status: CustomerTrackingTokenStatus
|
||||
expires_at: datetime | None
|
||||
metadata_json: dict | None
|
||||
is_deleted: bool
|
||||
created_by: str | None
|
||||
updated_by: str | None
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class CustomerTrackingTokenListResponse(BaseModel):
|
||||
items: list[CustomerTrackingTokenRead]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
@ -1,29 +0,0 @@
|
||||
"""Shared service helpers for delivery phases 10.2+."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
def actor_id(actor: CurrentUser | None) -> str | None:
|
||||
return actor.user_id if actor else None
|
||||
|
||||
|
||||
def jsonable(data: dict[str, Any]) -> dict[str, Any]:
|
||||
out: dict[str, Any] = {}
|
||||
for key, value in data.items():
|
||||
if isinstance(value, UUID):
|
||||
out[key] = str(value)
|
||||
elif isinstance(value, Decimal):
|
||||
out[key] = str(value)
|
||||
elif isinstance(value, (datetime, date)):
|
||||
out[key] = value.isoformat()
|
||||
elif hasattr(value, "value"):
|
||||
out[key] = value.value
|
||||
else:
|
||||
out[key] = value
|
||||
return out
|
||||
@ -1,395 +0,0 @@
|
||||
"""Availability application services — Phase 10.3."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.events.publisher import TransactionalEventPublisher
|
||||
from app.events.types import DeliveryEventType
|
||||
from app.models.availability import (
|
||||
DriverAvailability,
|
||||
Shift,
|
||||
ShiftAssignment,
|
||||
WorkingZone,
|
||||
)
|
||||
from app.models.types import AuditAction, ShiftAssignmentStatus
|
||||
from app.repositories.availability import (
|
||||
DriverAvailabilityRepository,
|
||||
ShiftAssignmentRepository,
|
||||
ShiftRepository,
|
||||
WorkingZoneRepository,
|
||||
)
|
||||
from app.repositories.drivers import DriverRepository
|
||||
from app.repositories.foundation import (
|
||||
DeliveryHubRepository,
|
||||
DeliveryOrganizationRepository,
|
||||
)
|
||||
from app.schemas.availability import (
|
||||
DriverAvailabilityUpsert,
|
||||
ShiftAssignmentCreate,
|
||||
ShiftAssignmentUpdate,
|
||||
ShiftCreate,
|
||||
ShiftUpdate,
|
||||
WorkingZoneCreate,
|
||||
WorkingZoneUpdate,
|
||||
)
|
||||
from app.services._helpers import actor_id, jsonable
|
||||
from app.services.audit_service import AuditService
|
||||
from app.specifications.availability import ShiftListSpec, WorkingZoneListSpec
|
||||
from app.validators import ensure_optimistic_version, validate_code
|
||||
from app.validators.availability import (
|
||||
validate_availability_window,
|
||||
validate_shift_name,
|
||||
validate_shift_window,
|
||||
validate_zone_name,
|
||||
)
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
class AvailabilityService:
|
||||
def __init__(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
publisher: TransactionalEventPublisher | None = None,
|
||||
) -> None:
|
||||
self.session = session
|
||||
self.repo = DriverAvailabilityRepository(session)
|
||||
self.drivers = DriverRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.publisher = publisher or TransactionalEventPublisher(session)
|
||||
|
||||
async def upsert_for_driver(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
driver_id: UUID,
|
||||
body: DriverAvailabilityUpsert,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> DriverAvailability:
|
||||
if await self.drivers.get(tenant_id, driver_id) is None:
|
||||
raise NotFoundError("راننده یافت نشد")
|
||||
validate_availability_window(
|
||||
effective_from=body.effective_from, effective_until=body.effective_until
|
||||
)
|
||||
entity = await self.repo.get_for_driver(tenant_id, driver_id)
|
||||
if entity is None:
|
||||
entity = DriverAvailability(
|
||||
tenant_id=tenant_id,
|
||||
driver_id=driver_id,
|
||||
status=body.status,
|
||||
effective_from=body.effective_from,
|
||||
effective_until=body.effective_until,
|
||||
notes=body.notes,
|
||||
metadata_json=body.metadata_json,
|
||||
created_by=actor_id(actor),
|
||||
updated_by=actor_id(actor),
|
||||
)
|
||||
await self.repo.add(entity)
|
||||
else:
|
||||
if body.version is not None:
|
||||
ensure_optimistic_version(entity, body.version)
|
||||
entity.status = body.status
|
||||
entity.effective_from = body.effective_from
|
||||
entity.effective_until = body.effective_until
|
||||
entity.notes = body.notes
|
||||
entity.metadata_json = body.metadata_json
|
||||
entity.version += 1
|
||||
entity.updated_by = actor_id(actor)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.DRIVER_AVAILABILITY_UPDATED,
|
||||
aggregate_type="driver_availability",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"driver_id": str(driver_id), "status": entity.status.value},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get_for_driver(
|
||||
self, tenant_id: UUID, driver_id: UUID
|
||||
) -> DriverAvailability:
|
||||
if await self.drivers.get(tenant_id, driver_id) is None:
|
||||
raise NotFoundError("راننده یافت نشد")
|
||||
entity = await self.repo.get_for_driver(tenant_id, driver_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("دسترسی راننده یافت نشد")
|
||||
return entity
|
||||
|
||||
|
||||
class ShiftService:
|
||||
def __init__(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
publisher: TransactionalEventPublisher | None = None,
|
||||
) -> None:
|
||||
self.session = session
|
||||
self.repo = ShiftRepository(session)
|
||||
self.assignments = ShiftAssignmentRepository(session)
|
||||
self.drivers = DriverRepository(session)
|
||||
self.orgs = DeliveryOrganizationRepository(session)
|
||||
self.hubs = DeliveryHubRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.publisher = publisher or TransactionalEventPublisher(session)
|
||||
|
||||
async def create(
|
||||
self, tenant_id: UUID, body: ShiftCreate, *, actor: CurrentUser | None = None
|
||||
) -> Shift:
|
||||
if await self.orgs.get(tenant_id, body.organization_id) is None:
|
||||
raise NotFoundError("سازمان یافت نشد")
|
||||
validate_shift_window(starts_at=body.starts_at, ends_at=body.ends_at)
|
||||
code = validate_code(body.code)
|
||||
if await self.repo.get_by_code(tenant_id, code):
|
||||
raise AppError("کد شیفت تکراری است", error_code="duplicate_code", status_code=409)
|
||||
entity = Shift(
|
||||
tenant_id=tenant_id,
|
||||
organization_id=body.organization_id,
|
||||
hub_id=body.hub_id,
|
||||
code=code,
|
||||
name=validate_shift_name(body.name),
|
||||
status=body.status,
|
||||
starts_at=body.starts_at,
|
||||
ends_at=body.ends_at,
|
||||
metadata_json=body.metadata_json,
|
||||
created_by=actor_id(actor),
|
||||
updated_by=actor_id(actor),
|
||||
)
|
||||
await self.repo.add(entity)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.SHIFT_CREATED,
|
||||
aggregate_type="shift",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, shift_id: UUID) -> Shift:
|
||||
entity = await self.repo.get(tenant_id, shift_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("شیفت یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(
|
||||
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: ShiftListSpec | None = None
|
||||
) -> tuple[list[Shift], int]:
|
||||
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
||||
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
||||
|
||||
async def update(
|
||||
self, tenant_id: UUID, shift_id: UUID, body: ShiftUpdate, *, actor: CurrentUser | None = None
|
||||
) -> Shift:
|
||||
entity = await self.get(tenant_id, shift_id)
|
||||
ensure_optimistic_version(entity, body.version)
|
||||
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
||||
starts = data.get("starts_at", entity.starts_at)
|
||||
ends = data.get("ends_at", entity.ends_at)
|
||||
validate_shift_window(starts_at=starts, ends_at=ends)
|
||||
if "name" in data and data["name"] is not None:
|
||||
data["name"] = validate_shift_name(data["name"])
|
||||
for key, value in data.items():
|
||||
setattr(entity, key, value)
|
||||
entity.version += 1
|
||||
entity.updated_by = actor_id(actor)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.SHIFT_UPDATED,
|
||||
aggregate_type="shift",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def soft_delete(
|
||||
self, tenant_id: UUID, shift_id: UUID, *, actor: CurrentUser | None = None
|
||||
) -> Shift:
|
||||
entity = await self.get(tenant_id, shift_id)
|
||||
await self.repo.soft_delete(entity, deleted_by=actor_id(actor))
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.SHIFT_DELETED,
|
||||
aggregate_type="shift",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def assign_driver(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
shift_id: UUID,
|
||||
body: ShiftAssignmentCreate,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> ShiftAssignment:
|
||||
await self.get(tenant_id, shift_id)
|
||||
if await self.drivers.get(tenant_id, body.driver_id) is None:
|
||||
raise NotFoundError("راننده یافت نشد")
|
||||
entity = ShiftAssignment(
|
||||
tenant_id=tenant_id,
|
||||
shift_id=shift_id,
|
||||
driver_id=body.driver_id,
|
||||
status=ShiftAssignmentStatus.ASSIGNED,
|
||||
notes=body.notes,
|
||||
metadata_json=body.metadata_json,
|
||||
created_by=actor_id(actor),
|
||||
updated_by=actor_id(actor),
|
||||
)
|
||||
try:
|
||||
await self.assignments.add(entity)
|
||||
except IntegrityError as exc:
|
||||
raise AppError(
|
||||
"تخصیص شیفت تکراری است",
|
||||
error_code="duplicate_shift_assignment",
|
||||
status_code=409,
|
||||
) from exc
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.SHIFT_ASSIGNMENT_CREATED,
|
||||
aggregate_type="shift_assignment",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"shift_id": str(shift_id), "driver_id": str(body.driver_id)},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def list_assignments(
|
||||
self, tenant_id: UUID, shift_id: UUID
|
||||
) -> list[ShiftAssignment]:
|
||||
await self.get(tenant_id, shift_id)
|
||||
return list(await self.assignments.list_for_shift(tenant_id, shift_id))
|
||||
|
||||
async def update_assignment(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
shift_id: UUID,
|
||||
assignment_id: UUID,
|
||||
body: ShiftAssignmentUpdate,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> ShiftAssignment:
|
||||
await self.get(tenant_id, shift_id)
|
||||
entity = await self.assignments.get(tenant_id, assignment_id)
|
||||
if entity is None or entity.shift_id != shift_id:
|
||||
raise NotFoundError("تخصیص شیفت یافت نشد")
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
for key, value in data.items():
|
||||
setattr(entity, key, value)
|
||||
entity.updated_by = actor_id(actor)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.SHIFT_ASSIGNMENT_UPDATED,
|
||||
aggregate_type="shift_assignment",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"shift_id": str(shift_id)},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
|
||||
class WorkingZoneService:
|
||||
def __init__(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
publisher: TransactionalEventPublisher | None = None,
|
||||
) -> None:
|
||||
self.session = session
|
||||
self.repo = WorkingZoneRepository(session)
|
||||
self.orgs = DeliveryOrganizationRepository(session)
|
||||
self.publisher = publisher or TransactionalEventPublisher(session)
|
||||
|
||||
async def create(
|
||||
self, tenant_id: UUID, body: WorkingZoneCreate, *, actor: CurrentUser | None = None
|
||||
) -> WorkingZone:
|
||||
if await self.orgs.get(tenant_id, body.organization_id) is None:
|
||||
raise NotFoundError("سازمان یافت نشد")
|
||||
code = validate_code(body.code)
|
||||
if await self.repo.get_by_code(tenant_id, code):
|
||||
raise AppError("کد منطقه کاری تکراری است", error_code="duplicate_code", status_code=409)
|
||||
entity = WorkingZone(
|
||||
tenant_id=tenant_id,
|
||||
organization_id=body.organization_id,
|
||||
code=code,
|
||||
name=validate_zone_name(body.name),
|
||||
status=body.status,
|
||||
geo_boundary_ref=body.geo_boundary_ref,
|
||||
metadata_json=body.metadata_json,
|
||||
created_by=actor_id(actor),
|
||||
updated_by=actor_id(actor),
|
||||
)
|
||||
await self.repo.add(entity)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.WORKING_ZONE_CREATED,
|
||||
aggregate_type="working_zone",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, zone_id: UUID) -> WorkingZone:
|
||||
entity = await self.repo.get(tenant_id, zone_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("منطقه کاری یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(
|
||||
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: WorkingZoneListSpec | None = None
|
||||
) -> tuple[list[WorkingZone], int]:
|
||||
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
||||
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
||||
|
||||
async def update(
|
||||
self, tenant_id: UUID, zone_id: UUID, body: WorkingZoneUpdate, *, actor: CurrentUser | None = None
|
||||
) -> WorkingZone:
|
||||
entity = await self.get(tenant_id, zone_id)
|
||||
ensure_optimistic_version(entity, body.version)
|
||||
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
||||
if "name" in data and data["name"] is not None:
|
||||
data["name"] = validate_zone_name(data["name"])
|
||||
for key, value in data.items():
|
||||
setattr(entity, key, value)
|
||||
entity.version += 1
|
||||
entity.updated_by = actor_id(actor)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.WORKING_ZONE_UPDATED,
|
||||
aggregate_type="working_zone",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def soft_delete(
|
||||
self, tenant_id: UUID, zone_id: UUID, *, actor: CurrentUser | None = None
|
||||
) -> WorkingZone:
|
||||
entity = await self.get(tenant_id, zone_id)
|
||||
await self.repo.soft_delete(entity, deleted_by=actor_id(actor))
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.WORKING_ZONE_DELETED,
|
||||
aggregate_type="working_zone",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
@ -1,281 +0,0 @@
|
||||
"""Dispatch application services — Phase 10.5."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.events.publisher import TransactionalEventPublisher
|
||||
from app.events.types import DeliveryEventType
|
||||
from app.models.dispatch import DispatchJob, JobAssignment
|
||||
from app.models.types import AuditAction, JobAssignmentStatus
|
||||
from app.policies.dispatch import DispatchJobPolicy
|
||||
from app.repositories.dispatch import DispatchJobRepository, JobAssignmentRepository
|
||||
from app.repositories.drivers import DriverRepository
|
||||
from app.repositories.foundation import (
|
||||
DeliveryHubRepository,
|
||||
DeliveryOrganizationRepository,
|
||||
)
|
||||
from app.repositories.fleet import VehicleRepository
|
||||
from app.schemas.dispatch import (
|
||||
DispatchJobCreate,
|
||||
DispatchJobStatusRequest,
|
||||
DispatchJobUpdate,
|
||||
JobAssignmentCreate,
|
||||
JobAssignmentUpdate,
|
||||
)
|
||||
from app.services._helpers import actor_id, jsonable
|
||||
from app.services.audit_service import AuditService
|
||||
from app.specifications.dispatch import DispatchJobListSpec, JobAssignmentListSpec
|
||||
from app.validators import ensure_optimistic_version, validate_code
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
class DispatchJobService:
|
||||
def __init__(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
publisher: TransactionalEventPublisher | None = None,
|
||||
) -> None:
|
||||
self.session = session
|
||||
self.repo = DispatchJobRepository(session)
|
||||
self.orgs = DeliveryOrganizationRepository(session)
|
||||
self.hubs = DeliveryHubRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.publisher = publisher or TransactionalEventPublisher(session)
|
||||
self.policy = DispatchJobPolicy()
|
||||
|
||||
async def create(
|
||||
self, tenant_id: UUID, body: DispatchJobCreate, *, actor: CurrentUser | None = None
|
||||
) -> DispatchJob:
|
||||
if await self.orgs.get(tenant_id, body.organization_id) is None:
|
||||
raise NotFoundError("سازمان یافت نشد")
|
||||
code = validate_code(body.code)
|
||||
if await self.repo.get_by_code(tenant_id, code):
|
||||
raise AppError("کد کار ارسال تکراری است", error_code="duplicate_code", status_code=409)
|
||||
entity = DispatchJob(
|
||||
tenant_id=tenant_id,
|
||||
organization_id=body.organization_id,
|
||||
hub_id=body.hub_id,
|
||||
code=code,
|
||||
external_order_ref=body.external_order_ref,
|
||||
external_source=body.external_source,
|
||||
priority=body.priority,
|
||||
pickup_address_ref=body.pickup_address_ref,
|
||||
dropoff_address_ref=body.dropoff_address_ref,
|
||||
scheduled_at=body.scheduled_at,
|
||||
metadata_json=body.metadata_json,
|
||||
created_by=actor_id(actor),
|
||||
updated_by=actor_id(actor),
|
||||
)
|
||||
await self.repo.add(entity)
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="dispatch_job",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.CREATE,
|
||||
actor_user_id=actor_id(actor),
|
||||
changes=jsonable(body.model_dump()),
|
||||
)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.DISPATCH_JOB_CREATED,
|
||||
aggregate_type="dispatch_job",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, job_id: UUID) -> DispatchJob:
|
||||
entity = await self.repo.get(tenant_id, job_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("کار ارسال یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(
|
||||
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: DispatchJobListSpec | None = None
|
||||
) -> tuple[list[DispatchJob], int]:
|
||||
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
||||
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
||||
|
||||
async def update(
|
||||
self, tenant_id: UUID, job_id: UUID, body: DispatchJobUpdate, *, actor: CurrentUser | None = None
|
||||
) -> DispatchJob:
|
||||
entity = await self.get(tenant_id, job_id)
|
||||
ensure_optimistic_version(entity, body.version)
|
||||
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
||||
for key, value in data.items():
|
||||
setattr(entity, key, value)
|
||||
entity.version += 1
|
||||
entity.updated_by = actor_id(actor)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.DISPATCH_JOB_UPDATED,
|
||||
aggregate_type="dispatch_job",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def apply_status(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
job_id: UUID,
|
||||
body: DispatchJobStatusRequest,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> DispatchJob:
|
||||
entity = await self.get(tenant_id, job_id)
|
||||
ensure_optimistic_version(entity, body.version)
|
||||
from_status = entity.status
|
||||
to_status, reason = self.policy.assert_transition(
|
||||
action=body.action, current=from_status, reason=body.reason
|
||||
)
|
||||
entity.status = to_status
|
||||
entity.status_reason = reason
|
||||
entity.version += 1
|
||||
entity.updated_by = actor_id(actor)
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="dispatch_job",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.STATUS_CHANGE,
|
||||
actor_user_id=actor_id(actor),
|
||||
changes={
|
||||
"from_status": from_status.value,
|
||||
"to_status": to_status.value,
|
||||
"action": body.action.value,
|
||||
},
|
||||
)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.DISPATCH_JOB_STATUS_CHANGED,
|
||||
aggregate_type="dispatch_job",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={
|
||||
"action": body.action.value,
|
||||
"from_status": from_status.value,
|
||||
"to_status": to_status.value,
|
||||
},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def soft_delete(
|
||||
self, tenant_id: UUID, job_id: UUID, *, actor: CurrentUser | None = None
|
||||
) -> DispatchJob:
|
||||
entity = await self.get(tenant_id, job_id)
|
||||
await self.repo.soft_delete(entity, deleted_by=actor_id(actor))
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.DISPATCH_JOB_DELETED,
|
||||
aggregate_type="dispatch_job",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
|
||||
class JobAssignmentService:
|
||||
def __init__(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
publisher: TransactionalEventPublisher | None = None,
|
||||
) -> None:
|
||||
self.session = session
|
||||
self.repo = JobAssignmentRepository(session)
|
||||
self.jobs = DispatchJobRepository(session)
|
||||
self.drivers = DriverRepository(session)
|
||||
self.vehicles = VehicleRepository(session)
|
||||
self.publisher = publisher or TransactionalEventPublisher(session)
|
||||
|
||||
async def create(
|
||||
self, tenant_id: UUID, body: JobAssignmentCreate, *, actor: CurrentUser | None = None
|
||||
) -> JobAssignment:
|
||||
if await self.jobs.get(tenant_id, body.dispatch_job_id) is None:
|
||||
raise NotFoundError("کار ارسال یافت نشد")
|
||||
if await self.drivers.get(tenant_id, body.driver_id) is None:
|
||||
raise NotFoundError("راننده یافت نشد")
|
||||
if body.vehicle_id and await self.vehicles.get(tenant_id, body.vehicle_id) is None:
|
||||
raise NotFoundError("خودرو یافت نشد")
|
||||
entity = JobAssignment(
|
||||
tenant_id=tenant_id,
|
||||
dispatch_job_id=body.dispatch_job_id,
|
||||
driver_id=body.driver_id,
|
||||
vehicle_id=body.vehicle_id,
|
||||
status=JobAssignmentStatus.PENDING,
|
||||
assigned_at=datetime.now(timezone.utc),
|
||||
notes=body.notes,
|
||||
metadata_json=body.metadata_json,
|
||||
created_by=actor_id(actor),
|
||||
updated_by=actor_id(actor),
|
||||
)
|
||||
await self.repo.add(entity)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.JOB_ASSIGNMENT_CREATED,
|
||||
aggregate_type="job_assignment",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"dispatch_job_id": str(body.dispatch_job_id)},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, assignment_id: UUID) -> JobAssignment:
|
||||
entity = await self.repo.get(tenant_id, assignment_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("تخصیص کار یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(
|
||||
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: JobAssignmentListSpec | None = None
|
||||
) -> tuple[list[JobAssignment], int]:
|
||||
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
||||
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
||||
|
||||
async def update(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
assignment_id: UUID,
|
||||
body: JobAssignmentUpdate,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> JobAssignment:
|
||||
entity = await self.get(tenant_id, assignment_id)
|
||||
ensure_optimistic_version(entity, body.version)
|
||||
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
||||
old_status = entity.status
|
||||
for key, value in data.items():
|
||||
setattr(entity, key, value)
|
||||
entity.version += 1
|
||||
entity.updated_by = actor_id(actor)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.JOB_ASSIGNMENT_UPDATED,
|
||||
aggregate_type="job_assignment",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"dispatch_job_id": str(entity.dispatch_job_id)},
|
||||
)
|
||||
if "status" in data and entity.status != old_status:
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.JOB_ASSIGNMENT_STATUS_CHANGED,
|
||||
aggregate_type="job_assignment",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"status": entity.status.value},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
@ -1,452 +0,0 @@
|
||||
"""Fleet & vehicle application services — Phase 10.2."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.events.publisher import TransactionalEventPublisher
|
||||
from app.events.types import DeliveryEventType
|
||||
from app.models.fleet import Fleet, Vehicle, VehicleAssignment, VehicleType
|
||||
from app.models.types import AuditAction, VehicleAssignmentStatus, VehicleStatus
|
||||
from app.repositories.drivers import DriverRepository
|
||||
from app.repositories.fleet import (
|
||||
FleetRepository,
|
||||
VehicleAssignmentRepository,
|
||||
VehicleRepository,
|
||||
VehicleTypeRepository,
|
||||
)
|
||||
from app.repositories.foundation import (
|
||||
DeliveryHubRepository,
|
||||
DeliveryOrganizationRepository,
|
||||
)
|
||||
from app.schemas.fleet import (
|
||||
FleetCreate,
|
||||
FleetUpdate,
|
||||
VehicleAssignmentCreate,
|
||||
VehicleAssignmentUpdate,
|
||||
VehicleCreate,
|
||||
VehicleTypeCreate,
|
||||
VehicleTypeUpdate,
|
||||
VehicleUpdate,
|
||||
)
|
||||
from app.services.audit_service import AuditService
|
||||
from app.services._helpers import actor_id, jsonable
|
||||
from app.specifications.fleet import FleetListSpec, VehicleListSpec, VehicleTypeListSpec
|
||||
from app.validators import ensure_optimistic_version, validate_code
|
||||
from app.validators.fleet import (
|
||||
validate_fleet_name,
|
||||
validate_plate_number,
|
||||
validate_vehicle_type_name,
|
||||
)
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
class FleetService:
|
||||
def __init__(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
publisher: TransactionalEventPublisher | None = None,
|
||||
) -> None:
|
||||
self.session = session
|
||||
self.repo = FleetRepository(session)
|
||||
self.orgs = DeliveryOrganizationRepository(session)
|
||||
self.hubs = DeliveryHubRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.publisher = publisher or TransactionalEventPublisher(session)
|
||||
|
||||
async def create(
|
||||
self, tenant_id: UUID, body: FleetCreate, *, actor: CurrentUser | None = None
|
||||
) -> Fleet:
|
||||
org = await self.orgs.get(tenant_id, body.organization_id)
|
||||
if org is None:
|
||||
raise NotFoundError("سازمان یافت نشد")
|
||||
if body.hub_id is not None:
|
||||
hub = await self.hubs.get(tenant_id, body.hub_id)
|
||||
if hub is None:
|
||||
raise NotFoundError("هاب یافت نشد")
|
||||
code = validate_code(body.code)
|
||||
if await self.repo.get_by_code(tenant_id, code):
|
||||
raise AppError("کد ناوگان تکراری است", error_code="duplicate_code", status_code=409)
|
||||
entity = Fleet(
|
||||
tenant_id=tenant_id,
|
||||
organization_id=body.organization_id,
|
||||
hub_id=body.hub_id,
|
||||
code=code,
|
||||
name=validate_fleet_name(body.name),
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
metadata_json=body.metadata_json,
|
||||
created_by=actor_id(actor),
|
||||
updated_by=actor_id(actor),
|
||||
)
|
||||
try:
|
||||
await self.repo.add(entity)
|
||||
except IntegrityError as exc:
|
||||
raise AppError("کد ناوگان تکراری است", error_code="duplicate_code", status_code=409) from exc
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="fleet",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.CREATE,
|
||||
actor_user_id=actor_id(actor),
|
||||
changes=jsonable(body.model_dump()),
|
||||
)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.FLEET_CREATED,
|
||||
aggregate_type="fleet",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, fleet_id: UUID) -> Fleet:
|
||||
entity = await self.repo.get(tenant_id, fleet_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("ناوگان یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(
|
||||
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: FleetListSpec | None = None
|
||||
) -> tuple[list[Fleet], int]:
|
||||
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
||||
total = await self.repo.count_filtered(tenant_id, spec=spec)
|
||||
return items, total
|
||||
|
||||
async def update(
|
||||
self, tenant_id: UUID, fleet_id: UUID, body: FleetUpdate, *, actor: CurrentUser | None = None
|
||||
) -> Fleet:
|
||||
entity = await self.get(tenant_id, fleet_id)
|
||||
ensure_optimistic_version(entity, body.version)
|
||||
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
||||
if "name" in data and data["name"] is not None:
|
||||
data["name"] = validate_fleet_name(data["name"])
|
||||
for key, value in data.items():
|
||||
setattr(entity, key, value)
|
||||
entity.version += 1
|
||||
entity.updated_by = actor_id(actor)
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="fleet",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.UPDATE,
|
||||
actor_user_id=actor_id(actor),
|
||||
changes=jsonable(data),
|
||||
)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.FLEET_UPDATED,
|
||||
aggregate_type="fleet",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def soft_delete(
|
||||
self, tenant_id: UUID, fleet_id: UUID, *, actor: CurrentUser | None = None
|
||||
) -> Fleet:
|
||||
entity = await self.get(tenant_id, fleet_id)
|
||||
await self.repo.soft_delete(entity, deleted_by=actor_id(actor))
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="fleet",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.DELETE,
|
||||
actor_user_id=actor_id(actor),
|
||||
)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.FLEET_DELETED,
|
||||
aggregate_type="fleet",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
|
||||
class VehicleTypeService:
|
||||
def __init__(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
publisher: TransactionalEventPublisher | None = None,
|
||||
) -> None:
|
||||
self.session = session
|
||||
self.repo = VehicleTypeRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.publisher = publisher or TransactionalEventPublisher(session)
|
||||
|
||||
async def create(
|
||||
self, tenant_id: UUID, body: VehicleTypeCreate, *, actor: CurrentUser | None = None
|
||||
) -> VehicleType:
|
||||
code = validate_code(body.code)
|
||||
if await self.repo.get_by_code(tenant_id, code):
|
||||
raise AppError("کد نوع خودرو تکراری است", error_code="duplicate_code", status_code=409)
|
||||
entity = VehicleType(
|
||||
tenant_id=tenant_id,
|
||||
code=code,
|
||||
name=validate_vehicle_type_name(body.name),
|
||||
description=body.description,
|
||||
capacity_kg=body.capacity_kg,
|
||||
capacity_volume=body.capacity_volume,
|
||||
capability_tags=body.capability_tags,
|
||||
metadata_json=body.metadata_json,
|
||||
created_by=actor_id(actor),
|
||||
updated_by=actor_id(actor),
|
||||
)
|
||||
await self.repo.add(entity)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.VEHICLE_TYPE_CREATED,
|
||||
aggregate_type="vehicle_type",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, type_id: UUID) -> VehicleType:
|
||||
entity = await self.repo.get(tenant_id, type_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("نوع خودرو یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(
|
||||
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: VehicleTypeListSpec | None = None
|
||||
) -> tuple[list[VehicleType], int]:
|
||||
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
||||
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
||||
|
||||
async def update(
|
||||
self, tenant_id: UUID, type_id: UUID, body: VehicleTypeUpdate, *, actor: CurrentUser | None = None
|
||||
) -> VehicleType:
|
||||
entity = await self.get(tenant_id, type_id)
|
||||
ensure_optimistic_version(entity, body.version)
|
||||
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
||||
if "name" in data and data["name"] is not None:
|
||||
data["name"] = validate_vehicle_type_name(data["name"])
|
||||
for key, value in data.items():
|
||||
setattr(entity, key, value)
|
||||
entity.version += 1
|
||||
entity.updated_by = actor_id(actor)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.VEHICLE_TYPE_UPDATED,
|
||||
aggregate_type="vehicle_type",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def soft_delete(
|
||||
self, tenant_id: UUID, type_id: UUID, *, actor: CurrentUser | None = None
|
||||
) -> VehicleType:
|
||||
entity = await self.get(tenant_id, type_id)
|
||||
await self.repo.soft_delete(entity, deleted_by=actor_id(actor))
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.VEHICLE_TYPE_DELETED,
|
||||
aggregate_type="vehicle_type",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
|
||||
class VehicleService:
|
||||
def __init__(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
publisher: TransactionalEventPublisher | None = None,
|
||||
) -> None:
|
||||
self.session = session
|
||||
self.repo = VehicleRepository(session)
|
||||
self.fleets = FleetRepository(session)
|
||||
self.types = VehicleTypeRepository(session)
|
||||
self.assignments = VehicleAssignmentRepository(session)
|
||||
self.drivers = DriverRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.publisher = publisher or TransactionalEventPublisher(session)
|
||||
|
||||
async def create(
|
||||
self, tenant_id: UUID, body: VehicleCreate, *, actor: CurrentUser | None = None
|
||||
) -> Vehicle:
|
||||
if await self.fleets.get(tenant_id, body.fleet_id) is None:
|
||||
raise NotFoundError("ناوگان یافت نشد")
|
||||
if await self.types.get(tenant_id, body.vehicle_type_id) is None:
|
||||
raise NotFoundError("نوع خودرو یافت نشد")
|
||||
code = validate_code(body.code)
|
||||
if await self.repo.get_by_code(tenant_id, code):
|
||||
raise AppError("کد خودرو تکراری است", error_code="duplicate_code", status_code=409)
|
||||
entity = Vehicle(
|
||||
tenant_id=tenant_id,
|
||||
fleet_id=body.fleet_id,
|
||||
vehicle_type_id=body.vehicle_type_id,
|
||||
code=code,
|
||||
plate_number=validate_plate_number(body.plate_number),
|
||||
status=body.status,
|
||||
metadata_json=body.metadata_json,
|
||||
created_by=actor_id(actor),
|
||||
updated_by=actor_id(actor),
|
||||
)
|
||||
await self.repo.add(entity)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.VEHICLE_CREATED,
|
||||
aggregate_type="vehicle",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, vehicle_id: UUID) -> Vehicle:
|
||||
entity = await self.repo.get(tenant_id, vehicle_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("خودرو یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(
|
||||
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: VehicleListSpec | None = None
|
||||
) -> tuple[list[Vehicle], int]:
|
||||
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
||||
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
||||
|
||||
async def update(
|
||||
self, tenant_id: UUID, vehicle_id: UUID, body: VehicleUpdate, *, actor: CurrentUser | None = None
|
||||
) -> Vehicle:
|
||||
entity = await self.get(tenant_id, vehicle_id)
|
||||
ensure_optimistic_version(entity, body.version)
|
||||
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
||||
if "plate_number" in data:
|
||||
data["plate_number"] = validate_plate_number(data["plate_number"])
|
||||
for key, value in data.items():
|
||||
setattr(entity, key, value)
|
||||
entity.version += 1
|
||||
entity.updated_by = actor_id(actor)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.VEHICLE_UPDATED,
|
||||
aggregate_type="vehicle",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def soft_delete(
|
||||
self, tenant_id: UUID, vehicle_id: UUID, *, actor: CurrentUser | None = None
|
||||
) -> Vehicle:
|
||||
entity = await self.get(tenant_id, vehicle_id)
|
||||
await self.repo.soft_delete(entity, deleted_by=actor_id(actor))
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.VEHICLE_DELETED,
|
||||
aggregate_type="vehicle",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def create_assignment(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
vehicle_id: UUID,
|
||||
body: VehicleAssignmentCreate,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> VehicleAssignment:
|
||||
await self.get(tenant_id, vehicle_id)
|
||||
if await self.drivers.get(tenant_id, body.driver_id) is None:
|
||||
raise NotFoundError("راننده یافت نشد")
|
||||
active = await self.assignments.get_active_for_vehicle(tenant_id, vehicle_id)
|
||||
if active is not None:
|
||||
raise AppError(
|
||||
"خودرو دارای تخصیص فعال است",
|
||||
status_code=409,
|
||||
error_code="active_assignment_exists",
|
||||
)
|
||||
entity = VehicleAssignment(
|
||||
tenant_id=tenant_id,
|
||||
vehicle_id=vehicle_id,
|
||||
driver_id=body.driver_id,
|
||||
status=VehicleAssignmentStatus.ACTIVE,
|
||||
notes=body.notes,
|
||||
metadata_json=body.metadata_json,
|
||||
created_by=actor_id(actor),
|
||||
updated_by=actor_id(actor),
|
||||
)
|
||||
await self.assignments.add(entity)
|
||||
vehicle = await self.get(tenant_id, vehicle_id)
|
||||
vehicle.status = VehicleStatus.ASSIGNED
|
||||
vehicle.version += 1
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.VEHICLE_ASSIGNMENT_CREATED,
|
||||
aggregate_type="vehicle_assignment",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"vehicle_id": str(vehicle_id), "driver_id": str(body.driver_id)},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def list_assignments(
|
||||
self, tenant_id: UUID, vehicle_id: UUID
|
||||
) -> list[VehicleAssignment]:
|
||||
await self.get(tenant_id, vehicle_id)
|
||||
return list(await self.assignments.list_for_vehicle(tenant_id, vehicle_id))
|
||||
|
||||
async def update_assignment(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
vehicle_id: UUID,
|
||||
assignment_id: UUID,
|
||||
body: VehicleAssignmentUpdate,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> VehicleAssignment:
|
||||
await self.get(tenant_id, vehicle_id)
|
||||
entity = await self.assignments.get(tenant_id, assignment_id)
|
||||
if entity is None or entity.vehicle_id != vehicle_id:
|
||||
raise NotFoundError("تخصیص یافت نشد")
|
||||
data = body.model_dump(exclude_unset=True)
|
||||
for key, value in data.items():
|
||||
setattr(entity, key, value)
|
||||
entity.updated_by = actor_id(actor)
|
||||
event = DeliveryEventType.VEHICLE_ASSIGNMENT_UPDATED
|
||||
if entity.status == VehicleAssignmentStatus.ENDED:
|
||||
event = DeliveryEventType.VEHICLE_ASSIGNMENT_ENDED
|
||||
vehicle = await self.get(tenant_id, vehicle_id)
|
||||
vehicle.status = VehicleStatus.AVAILABLE
|
||||
vehicle.version += 1
|
||||
await self.publisher.publish(
|
||||
event_type=event,
|
||||
aggregate_type="vehicle_assignment",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"vehicle_id": str(vehicle_id)},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
@ -1,330 +0,0 @@
|
||||
"""Pricing application services — Phase 10.4."""
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.events.publisher import TransactionalEventPublisher
|
||||
from app.events.types import DeliveryEventType
|
||||
from app.models.pricing import CapabilityBundle, CapabilityDefinition, PricingRule
|
||||
from app.repositories.foundation import DeliveryOrganizationRepository
|
||||
from app.repositories.pricing import (
|
||||
CapabilityBundleRepository,
|
||||
CapabilityDefinitionRepository,
|
||||
PricingRuleRepository,
|
||||
)
|
||||
from app.schemas.pricing import (
|
||||
BundleCreate,
|
||||
BundleUpdate,
|
||||
CapabilityCreate,
|
||||
CapabilityUpdate,
|
||||
PricingRuleCreate,
|
||||
PricingRuleUpdate,
|
||||
)
|
||||
from app.services._helpers import actor_id
|
||||
from app.specifications.pricing import (
|
||||
BundleListSpec,
|
||||
CapabilityListSpec,
|
||||
PricingRuleListSpec,
|
||||
)
|
||||
from app.validators import ensure_optimistic_version, validate_code
|
||||
from app.validators.pricing import (
|
||||
validate_base_amount,
|
||||
validate_bundle_name,
|
||||
validate_capability_name,
|
||||
validate_currency,
|
||||
validate_pricing_name,
|
||||
)
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
class PricingRuleService:
|
||||
def __init__(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
publisher: TransactionalEventPublisher | None = None,
|
||||
) -> None:
|
||||
self.session = session
|
||||
self.repo = PricingRuleRepository(session)
|
||||
self.orgs = DeliveryOrganizationRepository(session)
|
||||
self.publisher = publisher or TransactionalEventPublisher(session)
|
||||
|
||||
async def create(
|
||||
self, tenant_id: UUID, body: PricingRuleCreate, *, actor: CurrentUser | None = None
|
||||
) -> PricingRule:
|
||||
if await self.orgs.get(tenant_id, body.organization_id) is None:
|
||||
raise NotFoundError("سازمان یافت نشد")
|
||||
code = validate_code(body.code)
|
||||
if await self.repo.get_by_code(tenant_id, code):
|
||||
raise AppError("کد قیمتگذاری تکراری است", error_code="duplicate_code", status_code=409)
|
||||
entity = PricingRule(
|
||||
tenant_id=tenant_id,
|
||||
organization_id=body.organization_id,
|
||||
code=code,
|
||||
name=validate_pricing_name(body.name),
|
||||
status=body.status,
|
||||
base_amount=validate_base_amount(body.base_amount),
|
||||
currency_code=validate_currency(body.currency_code),
|
||||
rule_config=body.rule_config,
|
||||
metadata_json=body.metadata_json,
|
||||
created_by=actor_id(actor),
|
||||
updated_by=actor_id(actor),
|
||||
)
|
||||
await self.repo.add(entity)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.PRICING_RULE_CREATED,
|
||||
aggregate_type="pricing_rule",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, rule_id: UUID) -> PricingRule:
|
||||
entity = await self.repo.get(tenant_id, rule_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("قانون قیمتگذاری یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(
|
||||
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: PricingRuleListSpec | None = None
|
||||
) -> tuple[list[PricingRule], int]:
|
||||
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
||||
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
||||
|
||||
async def update(
|
||||
self, tenant_id: UUID, rule_id: UUID, body: PricingRuleUpdate, *, actor: CurrentUser | None = None
|
||||
) -> PricingRule:
|
||||
entity = await self.get(tenant_id, rule_id)
|
||||
ensure_optimistic_version(entity, body.version)
|
||||
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
||||
if "name" in data and data["name"] is not None:
|
||||
data["name"] = validate_pricing_name(data["name"])
|
||||
if "base_amount" in data and data["base_amount"] is not None:
|
||||
data["base_amount"] = validate_base_amount(data["base_amount"])
|
||||
if "currency_code" in data and data["currency_code"] is not None:
|
||||
data["currency_code"] = validate_currency(data["currency_code"])
|
||||
for key, value in data.items():
|
||||
setattr(entity, key, value)
|
||||
entity.version += 1
|
||||
entity.updated_by = actor_id(actor)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.PRICING_RULE_UPDATED,
|
||||
aggregate_type="pricing_rule",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def soft_delete(
|
||||
self, tenant_id: UUID, rule_id: UUID, *, actor: CurrentUser | None = None
|
||||
) -> PricingRule:
|
||||
entity = await self.get(tenant_id, rule_id)
|
||||
await self.repo.soft_delete(entity, deleted_by=actor_id(actor))
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.PRICING_RULE_DELETED,
|
||||
aggregate_type="pricing_rule",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
|
||||
class CapabilityService:
|
||||
def __init__(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
publisher: TransactionalEventPublisher | None = None,
|
||||
) -> None:
|
||||
self.session = session
|
||||
self.repo = CapabilityDefinitionRepository(session)
|
||||
self.publisher = publisher or TransactionalEventPublisher(session)
|
||||
|
||||
async def create(
|
||||
self, tenant_id: UUID, body: CapabilityCreate, *, actor: CurrentUser | None = None
|
||||
) -> CapabilityDefinition:
|
||||
code = validate_code(body.code)
|
||||
if await self.repo.get_by_code(tenant_id, code):
|
||||
raise AppError("کد قابلیت تکراری است", error_code="duplicate_code", status_code=409)
|
||||
entity = CapabilityDefinition(
|
||||
tenant_id=tenant_id,
|
||||
code=code,
|
||||
name=validate_capability_name(body.name),
|
||||
description=body.description,
|
||||
status=body.status,
|
||||
metadata_json=body.metadata_json,
|
||||
created_by=actor_id(actor),
|
||||
updated_by=actor_id(actor),
|
||||
)
|
||||
await self.repo.add(entity)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.CAPABILITY_CREATED,
|
||||
aggregate_type="capability",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, cap_id: UUID) -> CapabilityDefinition:
|
||||
entity = await self.repo.get(tenant_id, cap_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("قابلیت یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(
|
||||
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: CapabilityListSpec | None = None
|
||||
) -> tuple[list[CapabilityDefinition], int]:
|
||||
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
||||
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
||||
|
||||
async def update(
|
||||
self, tenant_id: UUID, cap_id: UUID, body: CapabilityUpdate, *, actor: CurrentUser | None = None
|
||||
) -> CapabilityDefinition:
|
||||
entity = await self.get(tenant_id, cap_id)
|
||||
ensure_optimistic_version(entity, body.version)
|
||||
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
||||
if "name" in data and data["name"] is not None:
|
||||
data["name"] = validate_capability_name(data["name"])
|
||||
for key, value in data.items():
|
||||
setattr(entity, key, value)
|
||||
entity.version += 1
|
||||
entity.updated_by = actor_id(actor)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.CAPABILITY_UPDATED,
|
||||
aggregate_type="capability",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def soft_delete(
|
||||
self, tenant_id: UUID, cap_id: UUID, *, actor: CurrentUser | None = None
|
||||
) -> CapabilityDefinition:
|
||||
entity = await self.get(tenant_id, cap_id)
|
||||
await self.repo.soft_delete(entity, deleted_by=actor_id(actor))
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.CAPABILITY_DELETED,
|
||||
aggregate_type="capability",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
|
||||
class BundleService:
|
||||
def __init__(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
publisher: TransactionalEventPublisher | None = None,
|
||||
) -> None:
|
||||
self.session = session
|
||||
self.repo = CapabilityBundleRepository(session)
|
||||
self.rules = PricingRuleRepository(session)
|
||||
self.publisher = publisher or TransactionalEventPublisher(session)
|
||||
|
||||
async def create(
|
||||
self, tenant_id: UUID, body: BundleCreate, *, actor: CurrentUser | None = None
|
||||
) -> CapabilityBundle:
|
||||
code = validate_code(body.code)
|
||||
if await self.repo.get_by_code(tenant_id, code):
|
||||
raise AppError("کد بسته تکراری است", error_code="duplicate_code", status_code=409)
|
||||
if body.pricing_rule_id and await self.rules.get(tenant_id, body.pricing_rule_id) is None:
|
||||
raise NotFoundError("قانون قیمتگذاری یافت نشد")
|
||||
entity = CapabilityBundle(
|
||||
tenant_id=tenant_id,
|
||||
code=code,
|
||||
name=validate_bundle_name(body.name),
|
||||
status=body.status,
|
||||
capability_codes=body.capability_codes,
|
||||
pricing_rule_id=body.pricing_rule_id,
|
||||
metadata_json=body.metadata_json,
|
||||
created_by=actor_id(actor),
|
||||
updated_by=actor_id(actor),
|
||||
)
|
||||
await self.repo.add(entity)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.BUNDLE_CREATED,
|
||||
aggregate_type="bundle",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, bundle_id: UUID) -> CapabilityBundle:
|
||||
entity = await self.repo.get(tenant_id, bundle_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("بسته قابلیت یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(
|
||||
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: BundleListSpec | None = None
|
||||
) -> tuple[list[CapabilityBundle], int]:
|
||||
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
||||
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
||||
|
||||
async def update(
|
||||
self, tenant_id: UUID, bundle_id: UUID, body: BundleUpdate, *, actor: CurrentUser | None = None
|
||||
) -> CapabilityBundle:
|
||||
entity = await self.get(tenant_id, bundle_id)
|
||||
ensure_optimistic_version(entity, body.version)
|
||||
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
||||
if "name" in data and data["name"] is not None:
|
||||
data["name"] = validate_bundle_name(data["name"])
|
||||
if "pricing_rule_id" in data and data["pricing_rule_id"]:
|
||||
if await self.rules.get(tenant_id, data["pricing_rule_id"]) is None:
|
||||
raise NotFoundError("قانون قیمتگذاری یافت نشد")
|
||||
for key, value in data.items():
|
||||
setattr(entity, key, value)
|
||||
entity.version += 1
|
||||
entity.updated_by = actor_id(actor)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.BUNDLE_UPDATED,
|
||||
aggregate_type="bundle",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def soft_delete(
|
||||
self, tenant_id: UUID, bundle_id: UUID, *, actor: CurrentUser | None = None
|
||||
) -> CapabilityBundle:
|
||||
entity = await self.get(tenant_id, bundle_id)
|
||||
await self.repo.soft_delete(entity, deleted_by=actor_id(actor))
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.BUNDLE_DELETED,
|
||||
aggregate_type="bundle",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
@ -1,234 +0,0 @@
|
||||
"""Routing application services — Phase 10.6."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.events.publisher import TransactionalEventPublisher
|
||||
from app.events.types import DeliveryEventType
|
||||
from app.models.routing import OptimizationRun, RoutePlan, RouteStop
|
||||
from app.models.types import OptimizationRunStatus, RoutePlanStatus
|
||||
from app.repositories.foundation import DeliveryOrganizationRepository
|
||||
from app.repositories.routing import (
|
||||
OptimizationRunRepository,
|
||||
RoutePlanRepository,
|
||||
RouteStopRepository,
|
||||
)
|
||||
from app.schemas.routing import (
|
||||
OptimizationRunCreate,
|
||||
RoutePlanCreate,
|
||||
RoutePlanUpdate,
|
||||
RouteStopCreate,
|
||||
)
|
||||
from app.services._helpers import actor_id
|
||||
from app.specifications.routing import OptimizationRunListSpec, RoutePlanListSpec
|
||||
from app.validators import ensure_optimistic_version, validate_code
|
||||
from app.validators.routing import validate_stop_sequence
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
class RoutePlanService:
|
||||
def __init__(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
publisher: TransactionalEventPublisher | None = None,
|
||||
) -> None:
|
||||
self.session = session
|
||||
self.repo = RoutePlanRepository(session)
|
||||
self.stops = RouteStopRepository(session)
|
||||
self.orgs = DeliveryOrganizationRepository(session)
|
||||
self.publisher = publisher or TransactionalEventPublisher(session)
|
||||
|
||||
async def create(
|
||||
self, tenant_id: UUID, body: RoutePlanCreate, *, actor: CurrentUser | None = None
|
||||
) -> RoutePlan:
|
||||
if await self.orgs.get(tenant_id, body.organization_id) is None:
|
||||
raise NotFoundError("سازمان یافت نشد")
|
||||
code = validate_code(body.code)
|
||||
if await self.repo.get_by_code(tenant_id, code):
|
||||
raise AppError("کد مسیر تکراری است", error_code="duplicate_code", status_code=409)
|
||||
entity = RoutePlan(
|
||||
tenant_id=tenant_id,
|
||||
organization_id=body.organization_id,
|
||||
code=code,
|
||||
driver_id=body.driver_id,
|
||||
dispatch_job_ids=body.dispatch_job_ids,
|
||||
routing_engine_ref=body.routing_engine_ref,
|
||||
metadata_json=body.metadata_json,
|
||||
created_by=actor_id(actor),
|
||||
updated_by=actor_id(actor),
|
||||
)
|
||||
await self.repo.add(entity)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.ROUTE_PLAN_CREATED,
|
||||
aggregate_type="route_plan",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, plan_id: UUID) -> RoutePlan:
|
||||
entity = await self.repo.get(tenant_id, plan_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("برنامه مسیر یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(
|
||||
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: RoutePlanListSpec | None = None
|
||||
) -> tuple[list[RoutePlan], int]:
|
||||
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
||||
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
||||
|
||||
async def update(
|
||||
self, tenant_id: UUID, plan_id: UUID, body: RoutePlanUpdate, *, actor: CurrentUser | None = None
|
||||
) -> RoutePlan:
|
||||
entity = await self.get(tenant_id, plan_id)
|
||||
ensure_optimistic_version(entity, body.version)
|
||||
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
||||
for key, value in data.items():
|
||||
setattr(entity, key, value)
|
||||
entity.version += 1
|
||||
entity.updated_by = actor_id(actor)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.ROUTE_PLAN_UPDATED,
|
||||
aggregate_type="route_plan",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def soft_delete(
|
||||
self, tenant_id: UUID, plan_id: UUID, *, actor: CurrentUser | None = None
|
||||
) -> RoutePlan:
|
||||
entity = await self.get(tenant_id, plan_id)
|
||||
await self.repo.soft_delete(entity, deleted_by=actor_id(actor))
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.ROUTE_PLAN_DELETED,
|
||||
aggregate_type="route_plan",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def add_stop(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
plan_id: UUID,
|
||||
body: RouteStopCreate,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> RouteStop:
|
||||
await self.get(tenant_id, plan_id)
|
||||
validate_stop_sequence(body.sequence)
|
||||
entity = RouteStop(
|
||||
tenant_id=tenant_id,
|
||||
route_plan_id=plan_id,
|
||||
sequence=body.sequence,
|
||||
address_ref=body.address_ref,
|
||||
external_order_ref=body.external_order_ref,
|
||||
notes=body.notes,
|
||||
metadata_json=body.metadata_json,
|
||||
created_by=actor_id(actor),
|
||||
updated_by=actor_id(actor),
|
||||
)
|
||||
await self.stops.add(entity)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.ROUTE_STOP_ADDED,
|
||||
aggregate_type="route_stop",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"route_plan_id": str(plan_id), "sequence": body.sequence},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def list_stops(self, tenant_id: UUID, plan_id: UUID) -> list[RouteStop]:
|
||||
await self.get(tenant_id, plan_id)
|
||||
return list(await self.stops.list_for_plan(tenant_id, plan_id))
|
||||
|
||||
|
||||
class OptimizationRunService:
|
||||
def __init__(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
publisher: TransactionalEventPublisher | None = None,
|
||||
) -> None:
|
||||
self.session = session
|
||||
self.repo = OptimizationRunRepository(session)
|
||||
self.plans = RoutePlanRepository(session)
|
||||
self.publisher = publisher or TransactionalEventPublisher(session)
|
||||
|
||||
async def start(
|
||||
self, tenant_id: UUID, body: OptimizationRunCreate, *, actor: CurrentUser | None = None
|
||||
) -> OptimizationRun:
|
||||
if body.route_plan_id and await self.plans.get(tenant_id, body.route_plan_id) is None:
|
||||
raise NotFoundError("برنامه مسیر یافت نشد")
|
||||
entity = OptimizationRun(
|
||||
tenant_id=tenant_id,
|
||||
route_plan_id=body.route_plan_id,
|
||||
status=OptimizationRunStatus.RUNNING,
|
||||
routing_engine_ref=body.routing_engine_ref,
|
||||
request_payload=body.request_payload,
|
||||
started_at=datetime.now(timezone.utc),
|
||||
created_by=actor_id(actor),
|
||||
updated_by=actor_id(actor),
|
||||
)
|
||||
await self.repo.add(entity)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.OPTIMIZATION_RUN_STARTED,
|
||||
aggregate_type="optimization_run",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"route_plan_id": str(body.route_plan_id) if body.route_plan_id else None},
|
||||
)
|
||||
entity.status = OptimizationRunStatus.COMPLETED
|
||||
entity.completed_at = datetime.now(timezone.utc)
|
||||
entity.result_payload = {"status": "stub", "message": "optimization stub"}
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.OPTIMIZATION_RUN_COMPLETED,
|
||||
aggregate_type="optimization_run",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"route_plan_id": str(body.route_plan_id) if body.route_plan_id else None},
|
||||
)
|
||||
if body.route_plan_id:
|
||||
plan = await self.plans.get(tenant_id, body.route_plan_id)
|
||||
if plan is not None:
|
||||
plan.status = RoutePlanStatus.PLANNED
|
||||
plan.planned_at = datetime.now(timezone.utc)
|
||||
plan.version += 1
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, run_id: UUID) -> OptimizationRun:
|
||||
entity = await self.repo.get(tenant_id, run_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("اجرای بهینهسازی یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
spec: OptimizationRunListSpec | None = None,
|
||||
) -> tuple[list[OptimizationRun], int]:
|
||||
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
||||
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
||||
@ -1,328 +0,0 @@
|
||||
"""Settlement application services — Phase 10.8.
|
||||
|
||||
Uses AccountingProvider protocol refs only — NO journal entries in delivery_db.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.events.publisher import TransactionalEventPublisher
|
||||
from app.events.types import DeliveryEventType
|
||||
from app.models.settlement import SettlementIntent, SettlementLine
|
||||
from app.models.types import AuditAction, SettlementIntentStatus
|
||||
from app.policies.settlement import SettlementIntentPolicy
|
||||
from app.providers.contracts import AccountingProvider
|
||||
from app.repositories.foundation import DeliveryOrganizationRepository
|
||||
from app.repositories.settlement import SettlementIntentRepository, SettlementLineRepository
|
||||
from app.schemas.settlement import (
|
||||
SettlementActionRequest,
|
||||
SettlementIntentCreate,
|
||||
SettlementIntentUpdate,
|
||||
SettlementLineCreate,
|
||||
)
|
||||
from app.services._helpers import actor_id, jsonable
|
||||
from app.services.audit_service import AuditService
|
||||
from app.specifications.settlement import SettlementIntentListSpec
|
||||
from app.validators import ensure_optimistic_version, validate_code
|
||||
from app.validators.pricing import validate_currency, validate_line_amount
|
||||
from app.validators.settlement import validate_settlement_amount
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
class StubAccountingProvider:
|
||||
"""Test/dev stub — creates external settlement refs only."""
|
||||
|
||||
async def create_settlement_ref(
|
||||
self, *, tenant_id: UUID, reference: str, payload: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"settlement_ref": f"acct://settlement/{tenant_id}/{reference}",
|
||||
"status": "accepted",
|
||||
"payload": payload,
|
||||
}
|
||||
|
||||
|
||||
class SettlementService:
|
||||
def __init__(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
publisher: TransactionalEventPublisher | None = None,
|
||||
accounting: AccountingProvider | None = None,
|
||||
) -> None:
|
||||
self.session = session
|
||||
self.repo = SettlementIntentRepository(session)
|
||||
self.lines = SettlementLineRepository(session)
|
||||
self.orgs = DeliveryOrganizationRepository(session)
|
||||
self.audit = AuditService(session)
|
||||
self.publisher = publisher or TransactionalEventPublisher(session)
|
||||
self.accounting = accounting or StubAccountingProvider()
|
||||
self.policy = SettlementIntentPolicy()
|
||||
|
||||
async def create(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
body: SettlementIntentCreate,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> SettlementIntent:
|
||||
if await self.orgs.get(tenant_id, body.organization_id) is None:
|
||||
raise NotFoundError("سازمان یافت نشد")
|
||||
code = validate_code(body.code)
|
||||
if await self.repo.get_by_code(tenant_id, code):
|
||||
raise AppError("کد تسویه تکراری است", error_code="duplicate_code", status_code=409)
|
||||
entity = SettlementIntent(
|
||||
tenant_id=tenant_id,
|
||||
organization_id=body.organization_id,
|
||||
code=code,
|
||||
driver_id=body.driver_id,
|
||||
dispatch_job_id=body.dispatch_job_id,
|
||||
total_amount=validate_settlement_amount(body.total_amount),
|
||||
currency_code=validate_currency(body.currency_code),
|
||||
notes=body.notes,
|
||||
metadata_json=body.metadata_json,
|
||||
created_by=actor_id(actor),
|
||||
updated_by=actor_id(actor),
|
||||
)
|
||||
await self.repo.add(entity)
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="settlement_intent",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.CREATE,
|
||||
actor_user_id=actor_id(actor),
|
||||
changes=jsonable(body.model_dump()),
|
||||
)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.SETTLEMENT_INTENT_CREATED,
|
||||
aggregate_type="settlement_intent",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, intent_id: UUID) -> SettlementIntent:
|
||||
entity = await self.repo.get(tenant_id, intent_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("قصد تسویه یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
spec: SettlementIntentListSpec | None = None,
|
||||
) -> tuple[list[SettlementIntent], int]:
|
||||
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
||||
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
||||
|
||||
async def update(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
intent_id: UUID,
|
||||
body: SettlementIntentUpdate,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> SettlementIntent:
|
||||
entity = await self.get(tenant_id, intent_id)
|
||||
if entity.status != SettlementIntentStatus.DRAFT:
|
||||
raise AppError(
|
||||
"فقط تسویه پیشنویس قابل ویرایش است",
|
||||
status_code=409,
|
||||
error_code="settlement_not_editable",
|
||||
)
|
||||
ensure_optimistic_version(entity, body.version)
|
||||
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
||||
if "total_amount" in data and data["total_amount"] is not None:
|
||||
data["total_amount"] = validate_settlement_amount(data["total_amount"])
|
||||
if "currency_code" in data and data["currency_code"] is not None:
|
||||
data["currency_code"] = validate_currency(data["currency_code"])
|
||||
for key, value in data.items():
|
||||
setattr(entity, key, value)
|
||||
entity.version += 1
|
||||
entity.updated_by = actor_id(actor)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.SETTLEMENT_INTENT_UPDATED,
|
||||
aggregate_type="settlement_intent",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def _apply_action(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
intent_id: UUID,
|
||||
action: str,
|
||||
body: SettlementActionRequest,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> SettlementIntent:
|
||||
entity = await self.get(tenant_id, intent_id)
|
||||
ensure_optimistic_version(entity, body.version)
|
||||
from_status = entity.status
|
||||
to_status, reason = self.policy.assert_transition(
|
||||
action=action, current=from_status, reason=body.reason
|
||||
)
|
||||
entity.status = to_status
|
||||
entity.version += 1
|
||||
entity.updated_by = actor_id(actor)
|
||||
if action == "submit":
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.SETTLEMENT_INTENT_SUBMITTED,
|
||||
aggregate_type="settlement_intent",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code},
|
||||
)
|
||||
elif action == "settle":
|
||||
result = await self.accounting.create_settlement_ref(
|
||||
tenant_id=tenant_id,
|
||||
reference=entity.code,
|
||||
payload={
|
||||
"amount": str(entity.total_amount),
|
||||
"currency": entity.currency_code,
|
||||
"driver_id": str(entity.driver_id) if entity.driver_id else None,
|
||||
"dispatch_job_id": str(entity.dispatch_job_id)
|
||||
if entity.dispatch_job_id
|
||||
else None,
|
||||
},
|
||||
)
|
||||
entity.external_settlement_ref = result.get("settlement_ref")
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.SETTLEMENT_INTENT_SETTLED,
|
||||
aggregate_type="settlement_intent",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={
|
||||
"code": entity.code,
|
||||
"external_settlement_ref": entity.external_settlement_ref,
|
||||
},
|
||||
)
|
||||
elif action == "fail":
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.SETTLEMENT_INTENT_FAILED,
|
||||
aggregate_type="settlement_intent",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"code": entity.code, "reason": reason},
|
||||
)
|
||||
await self.audit.record(
|
||||
tenant_id=tenant_id,
|
||||
entity_type="settlement_intent",
|
||||
entity_id=entity.id,
|
||||
action=AuditAction.STATUS_CHANGE,
|
||||
actor_user_id=actor_id(actor),
|
||||
changes={
|
||||
"action": action,
|
||||
"from_status": from_status.value,
|
||||
"to_status": to_status.value,
|
||||
},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def submit(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
intent_id: UUID,
|
||||
body: SettlementActionRequest,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> SettlementIntent:
|
||||
return await self._apply_action(
|
||||
tenant_id, intent_id, "submit", body, actor=actor
|
||||
)
|
||||
|
||||
async def settle(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
intent_id: UUID,
|
||||
body: SettlementActionRequest,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> SettlementIntent:
|
||||
return await self._apply_action(
|
||||
tenant_id, intent_id, "settle", body, actor=actor
|
||||
)
|
||||
|
||||
async def fail(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
intent_id: UUID,
|
||||
body: SettlementActionRequest,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> SettlementIntent:
|
||||
return await self._apply_action(
|
||||
tenant_id, intent_id, "fail", body, actor=actor
|
||||
)
|
||||
|
||||
async def cancel(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
intent_id: UUID,
|
||||
body: SettlementActionRequest,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> SettlementIntent:
|
||||
return await self._apply_action(
|
||||
tenant_id, intent_id, "cancel", body, actor=actor
|
||||
)
|
||||
|
||||
async def add_line(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
intent_id: UUID,
|
||||
body: SettlementLineCreate,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> SettlementLine:
|
||||
entity = await self.get(tenant_id, intent_id)
|
||||
if entity.status != SettlementIntentStatus.DRAFT:
|
||||
raise AppError(
|
||||
"فقط به تسویه پیشنویس میتوان سطر اضافه کرد",
|
||||
status_code=409,
|
||||
error_code="settlement_not_editable",
|
||||
)
|
||||
line = SettlementLine(
|
||||
tenant_id=tenant_id,
|
||||
settlement_intent_id=intent_id,
|
||||
kind=body.kind,
|
||||
description=body.description,
|
||||
amount=validate_line_amount(body.amount),
|
||||
currency_code=validate_currency(body.currency_code),
|
||||
metadata_json=body.metadata_json,
|
||||
created_by=actor_id(actor),
|
||||
updated_by=actor_id(actor),
|
||||
)
|
||||
await self.lines.add(line)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.SETTLEMENT_LINE_ADDED,
|
||||
aggregate_type="settlement_line",
|
||||
aggregate_id=line.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"settlement_intent_id": str(intent_id), "kind": body.kind.value},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(line)
|
||||
return line
|
||||
|
||||
async def list_lines(
|
||||
self, tenant_id: UUID, intent_id: UUID
|
||||
) -> list[SettlementLine]:
|
||||
await self.get(tenant_id, intent_id)
|
||||
return list(await self.lines.list_for_intent(tenant_id, intent_id))
|
||||
@ -1,344 +0,0 @@
|
||||
"""Tracking application services — Phase 10.7."""
|
||||
from __future__ import annotations
|
||||
|
||||
import secrets
|
||||
from datetime import datetime, timezone
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.events.publisher import TransactionalEventPublisher
|
||||
from app.events.types import DeliveryEventType
|
||||
from app.models.tracking import (
|
||||
CustomerTrackingToken,
|
||||
ProofOfDelivery,
|
||||
TrackingPoint,
|
||||
TrackingSession,
|
||||
)
|
||||
from app.models.types import CustomerTrackingTokenStatus, ProofOfDeliveryStatus, TrackingSessionStatus
|
||||
from app.repositories.dispatch import DispatchJobRepository
|
||||
from app.repositories.tracking import (
|
||||
CustomerTrackingTokenRepository,
|
||||
ProofOfDeliveryRepository,
|
||||
TrackingPointRepository,
|
||||
TrackingSessionRepository,
|
||||
)
|
||||
from app.schemas.tracking import (
|
||||
CustomerTrackingTokenCreate,
|
||||
ProofOfDeliveryCreate,
|
||||
ProofOfDeliveryUpdate,
|
||||
TrackingPointCreate,
|
||||
TrackingSessionCreate,
|
||||
TrackingSessionUpdate,
|
||||
)
|
||||
from app.services._helpers import actor_id
|
||||
from app.specifications.tracking import (
|
||||
CustomerTrackingTokenListSpec,
|
||||
ProofOfDeliveryListSpec,
|
||||
TrackingSessionListSpec,
|
||||
)
|
||||
from app.validators import ensure_optimistic_version
|
||||
from app.validators.tracking import validate_coordinates, validate_pod_refs
|
||||
from shared.exceptions import AppError, NotFoundError
|
||||
from shared.security import CurrentUser
|
||||
|
||||
|
||||
class TrackingSessionService:
|
||||
def __init__(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
publisher: TransactionalEventPublisher | None = None,
|
||||
) -> None:
|
||||
self.session = session
|
||||
self.repo = TrackingSessionRepository(session)
|
||||
self.points = TrackingPointRepository(session)
|
||||
self.jobs = DispatchJobRepository(session)
|
||||
self.publisher = publisher or TransactionalEventPublisher(session)
|
||||
|
||||
async def start(
|
||||
self, tenant_id: UUID, body: TrackingSessionCreate, *, actor: CurrentUser | None = None
|
||||
) -> TrackingSession:
|
||||
if body.dispatch_job_id and await self.jobs.get(tenant_id, body.dispatch_job_id) is None:
|
||||
raise NotFoundError("کار ارسال یافت نشد")
|
||||
entity = TrackingSession(
|
||||
tenant_id=tenant_id,
|
||||
dispatch_job_id=body.dispatch_job_id,
|
||||
driver_id=body.driver_id,
|
||||
status=TrackingSessionStatus.ACTIVE,
|
||||
started_at=datetime.now(timezone.utc),
|
||||
metadata_json=body.metadata_json,
|
||||
created_by=actor_id(actor),
|
||||
updated_by=actor_id(actor),
|
||||
)
|
||||
await self.repo.add(entity)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.TRACKING_SESSION_STARTED,
|
||||
aggregate_type="tracking_session",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"dispatch_job_id": str(body.dispatch_job_id) if body.dispatch_job_id else None},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, session_id: UUID) -> TrackingSession:
|
||||
entity = await self.repo.get(tenant_id, session_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("جلسه ردیابی یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
spec: TrackingSessionListSpec | None = None,
|
||||
) -> tuple[list[TrackingSession], int]:
|
||||
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
||||
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
||||
|
||||
async def update(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
session_id: UUID,
|
||||
body: TrackingSessionUpdate,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> TrackingSession:
|
||||
entity = await self.get(tenant_id, session_id)
|
||||
ensure_optimistic_version(entity, body.version)
|
||||
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
||||
for key, value in data.items():
|
||||
setattr(entity, key, value)
|
||||
if entity.status in {TrackingSessionStatus.COMPLETED, TrackingSessionStatus.CANCELLED}:
|
||||
entity.ended_at = datetime.now(timezone.utc)
|
||||
entity.version += 1
|
||||
entity.updated_by = actor_id(actor)
|
||||
event = DeliveryEventType.TRACKING_SESSION_UPDATED
|
||||
if entity.status == TrackingSessionStatus.COMPLETED:
|
||||
event = DeliveryEventType.TRACKING_SESSION_ENDED
|
||||
await self.publisher.publish(
|
||||
event_type=event,
|
||||
aggregate_type="tracking_session",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"status": entity.status.value},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def record_point(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
session_id: UUID,
|
||||
body: TrackingPointCreate,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> TrackingPoint:
|
||||
await self.get(tenant_id, session_id)
|
||||
validate_coordinates(latitude=body.latitude, longitude=body.longitude)
|
||||
entity = TrackingPoint(
|
||||
tenant_id=tenant_id,
|
||||
tracking_session_id=session_id,
|
||||
latitude=body.latitude,
|
||||
longitude=body.longitude,
|
||||
recorded_at=body.recorded_at,
|
||||
metadata_json=body.metadata_json,
|
||||
)
|
||||
await self.points.add(entity)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.TRACKING_POINT_RECORDED,
|
||||
aggregate_type="tracking_point",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"tracking_session_id": str(session_id)},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def list_points(
|
||||
self, tenant_id: UUID, session_id: UUID, *, limit: int = 500
|
||||
) -> list[TrackingPoint]:
|
||||
await self.get(tenant_id, session_id)
|
||||
return list(await self.points.list_for_session(tenant_id, session_id, limit=limit))
|
||||
|
||||
|
||||
class ProofOfDeliveryService:
|
||||
def __init__(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
publisher: TransactionalEventPublisher | None = None,
|
||||
) -> None:
|
||||
self.session = session
|
||||
self.repo = ProofOfDeliveryRepository(session)
|
||||
self.jobs = DispatchJobRepository(session)
|
||||
self.publisher = publisher or TransactionalEventPublisher(session)
|
||||
|
||||
async def capture(
|
||||
self, tenant_id: UUID, body: ProofOfDeliveryCreate, *, actor: CurrentUser | None = None
|
||||
) -> ProofOfDelivery:
|
||||
if await self.jobs.get(tenant_id, body.dispatch_job_id) is None:
|
||||
raise NotFoundError("کار ارسال یافت نشد")
|
||||
validate_pod_refs(
|
||||
signature_file_ref=body.signature_file_ref,
|
||||
photo_file_ref=body.photo_file_ref,
|
||||
)
|
||||
entity = ProofOfDelivery(
|
||||
tenant_id=tenant_id,
|
||||
dispatch_job_id=body.dispatch_job_id,
|
||||
status=ProofOfDeliveryStatus.CAPTURED,
|
||||
signature_file_ref=body.signature_file_ref,
|
||||
photo_file_ref=body.photo_file_ref,
|
||||
delivery_code=body.delivery_code,
|
||||
notes=body.notes,
|
||||
captured_at=datetime.now(timezone.utc),
|
||||
metadata_json=body.metadata_json,
|
||||
created_by=actor_id(actor),
|
||||
updated_by=actor_id(actor),
|
||||
)
|
||||
await self.repo.add(entity)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.PROOF_OF_DELIVERY_CAPTURED,
|
||||
aggregate_type="proof_of_delivery",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"dispatch_job_id": str(body.dispatch_job_id)},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, pod_id: UUID) -> ProofOfDelivery:
|
||||
entity = await self.repo.get(tenant_id, pod_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("اثبات تحویل یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
spec: ProofOfDeliveryListSpec | None = None,
|
||||
) -> tuple[list[ProofOfDelivery], int]:
|
||||
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
||||
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
||||
|
||||
async def update(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
pod_id: UUID,
|
||||
body: ProofOfDeliveryUpdate,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> ProofOfDelivery:
|
||||
entity = await self.get(tenant_id, pod_id)
|
||||
ensure_optimistic_version(entity, body.version)
|
||||
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
||||
for key, value in data.items():
|
||||
setattr(entity, key, value)
|
||||
entity.version += 1
|
||||
entity.updated_by = actor_id(actor)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.PROOF_OF_DELIVERY_UPDATED,
|
||||
aggregate_type="proof_of_delivery",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"dispatch_job_id": str(entity.dispatch_job_id)},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
|
||||
class CustomerTrackingTokenService:
|
||||
def __init__(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
publisher: TransactionalEventPublisher | None = None,
|
||||
) -> None:
|
||||
self.session = session
|
||||
self.repo = CustomerTrackingTokenRepository(session)
|
||||
self.jobs = DispatchJobRepository(session)
|
||||
self.publisher = publisher or TransactionalEventPublisher(session)
|
||||
|
||||
async def create(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
body: CustomerTrackingTokenCreate,
|
||||
*,
|
||||
actor: CurrentUser | None = None,
|
||||
) -> CustomerTrackingToken:
|
||||
if await self.jobs.get(tenant_id, body.dispatch_job_id) is None:
|
||||
raise NotFoundError("کار ارسال یافت نشد")
|
||||
token = secrets.token_urlsafe(32)
|
||||
entity = CustomerTrackingToken(
|
||||
tenant_id=tenant_id,
|
||||
dispatch_job_id=body.dispatch_job_id,
|
||||
token=token,
|
||||
status=CustomerTrackingTokenStatus.ACTIVE,
|
||||
expires_at=body.expires_at,
|
||||
metadata_json=body.metadata_json,
|
||||
created_by=actor_id(actor),
|
||||
updated_by=actor_id(actor),
|
||||
)
|
||||
try:
|
||||
await self.repo.add(entity)
|
||||
except Exception as exc:
|
||||
raise AppError(
|
||||
"ایجاد توکن ردیابی ناموفق بود",
|
||||
status_code=409,
|
||||
error_code="token_create_failed",
|
||||
) from exc
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.CUSTOMER_TRACKING_TOKEN_CREATED,
|
||||
aggregate_type="customer_tracking_token",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"dispatch_job_id": str(body.dispatch_job_id)},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
|
||||
async def get(self, tenant_id: UUID, token_id: UUID) -> CustomerTrackingToken:
|
||||
entity = await self.repo.get(tenant_id, token_id)
|
||||
if entity is None:
|
||||
raise NotFoundError("توکن ردیابی یافت نشد")
|
||||
return entity
|
||||
|
||||
async def list(
|
||||
self,
|
||||
tenant_id: UUID,
|
||||
*,
|
||||
offset: int = 0,
|
||||
limit: int = 20,
|
||||
spec: CustomerTrackingTokenListSpec | None = None,
|
||||
) -> tuple[list[CustomerTrackingToken], int]:
|
||||
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
||||
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
||||
|
||||
async def revoke(
|
||||
self, tenant_id: UUID, token_id: UUID, *, actor: CurrentUser | None = None
|
||||
) -> CustomerTrackingToken:
|
||||
entity = await self.get(tenant_id, token_id)
|
||||
entity.status = CustomerTrackingTokenStatus.REVOKED
|
||||
entity.updated_by = actor_id(actor)
|
||||
await self.publisher.publish(
|
||||
event_type=DeliveryEventType.CUSTOMER_TRACKING_TOKEN_REVOKED,
|
||||
aggregate_type="customer_tracking_token",
|
||||
aggregate_id=entity.id,
|
||||
tenant_id=tenant_id,
|
||||
payload={"dispatch_job_id": str(entity.dispatch_job_id)},
|
||||
)
|
||||
await self.session.commit()
|
||||
await self.session.refresh(entity)
|
||||
return entity
|
||||
@ -1,77 +0,0 @@
|
||||
"""Availability query specifications — Phase 10.3."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import Select, asc, desc, or_
|
||||
from sqlalchemy.sql import ColumnElement
|
||||
|
||||
from app.models.availability import Shift, WorkingZone
|
||||
from app.models.types import ShiftStatus, WorkingZoneStatus
|
||||
|
||||
SHIFT_SORT = frozenset({"created_at", "starts_at", "ends_at", "code", "status"})
|
||||
ZONE_SORT = frozenset({"created_at", "updated_at", "code", "name", "status"})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ShiftListSpec:
|
||||
status: ShiftStatus | None = None
|
||||
organization_id: UUID | None = None
|
||||
hub_id: UUID | None = None
|
||||
q: str | None = None
|
||||
sort_by: str = "starts_at"
|
||||
sort_dir: str = "desc"
|
||||
|
||||
def filter_clauses(self) -> list[ColumnElement]:
|
||||
clauses: list[ColumnElement] = []
|
||||
if self.status is not None:
|
||||
clauses.append(Shift.status == self.status)
|
||||
if self.organization_id is not None:
|
||||
clauses.append(Shift.organization_id == self.organization_id)
|
||||
if self.hub_id is not None:
|
||||
clauses.append(Shift.hub_id == self.hub_id)
|
||||
if self.q:
|
||||
term = f"%{self.q.strip()}%"
|
||||
clauses.append(or_(Shift.code.ilike(term), Shift.name.ilike(term)))
|
||||
return clauses
|
||||
|
||||
def apply(self, stmt: Select) -> Select:
|
||||
clauses = self.filter_clauses()
|
||||
if clauses:
|
||||
stmt = stmt.where(*clauses)
|
||||
sort_key = self.sort_by if self.sort_by in SHIFT_SORT else "starts_at"
|
||||
column = getattr(Shift, sort_key)
|
||||
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
||||
return stmt.order_by(order)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class WorkingZoneListSpec:
|
||||
status: WorkingZoneStatus | None = None
|
||||
organization_id: UUID | None = None
|
||||
q: str | None = None
|
||||
sort_by: str = "created_at"
|
||||
sort_dir: str = "desc"
|
||||
|
||||
def filter_clauses(self) -> list[ColumnElement]:
|
||||
clauses: list[ColumnElement] = []
|
||||
if self.status is not None:
|
||||
clauses.append(WorkingZone.status == self.status)
|
||||
if self.organization_id is not None:
|
||||
clauses.append(WorkingZone.organization_id == self.organization_id)
|
||||
if self.q:
|
||||
term = f"%{self.q.strip()}%"
|
||||
clauses.append(
|
||||
or_(WorkingZone.code.ilike(term), WorkingZone.name.ilike(term))
|
||||
)
|
||||
return clauses
|
||||
|
||||
def apply(self, stmt: Select) -> Select:
|
||||
clauses = self.filter_clauses()
|
||||
if clauses:
|
||||
stmt = stmt.where(*clauses)
|
||||
sort_key = self.sort_by if self.sort_by in ZONE_SORT else "created_at"
|
||||
column = getattr(WorkingZone, sort_key)
|
||||
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
||||
return stmt.order_by(order)
|
||||
@ -1,79 +0,0 @@
|
||||
"""Dispatch query specifications — Phase 10.5."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import Select, asc, desc, or_
|
||||
from sqlalchemy.sql import ColumnElement
|
||||
|
||||
from app.models.dispatch import DispatchJob, JobAssignment
|
||||
from app.models.types import DispatchJobStatus, JobAssignmentStatus
|
||||
|
||||
JOB_SORT = frozenset({"created_at", "updated_at", "code", "status", "priority"})
|
||||
ASSIGN_SORT = frozenset({"created_at", "updated_at", "status"})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DispatchJobListSpec:
|
||||
status: DispatchJobStatus | None = None
|
||||
organization_id: UUID | None = None
|
||||
hub_id: UUID | None = None
|
||||
q: str | None = None
|
||||
sort_by: str = "created_at"
|
||||
sort_dir: str = "desc"
|
||||
|
||||
def filter_clauses(self) -> list[ColumnElement]:
|
||||
clauses: list[ColumnElement] = []
|
||||
if self.status is not None:
|
||||
clauses.append(DispatchJob.status == self.status)
|
||||
if self.organization_id is not None:
|
||||
clauses.append(DispatchJob.organization_id == self.organization_id)
|
||||
if self.hub_id is not None:
|
||||
clauses.append(DispatchJob.hub_id == self.hub_id)
|
||||
if self.q:
|
||||
term = f"%{self.q.strip()}%"
|
||||
clauses.append(
|
||||
or_(
|
||||
DispatchJob.code.ilike(term),
|
||||
DispatchJob.external_order_ref.ilike(term),
|
||||
)
|
||||
)
|
||||
return clauses
|
||||
|
||||
def apply(self, stmt: Select) -> Select:
|
||||
clauses = self.filter_clauses()
|
||||
if clauses:
|
||||
stmt = stmt.where(*clauses)
|
||||
sort_key = self.sort_by if self.sort_by in JOB_SORT else "created_at"
|
||||
column = getattr(DispatchJob, sort_key)
|
||||
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
||||
return stmt.order_by(order)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class JobAssignmentListSpec:
|
||||
dispatch_job_id: UUID | None = None
|
||||
driver_id: UUID | None = None
|
||||
status: JobAssignmentStatus | None = None
|
||||
sort_by: str = "created_at"
|
||||
sort_dir: str = "desc"
|
||||
|
||||
def filter_clauses(self) -> list[ColumnElement]:
|
||||
clauses: list[ColumnElement] = []
|
||||
if self.dispatch_job_id is not None:
|
||||
clauses.append(JobAssignment.dispatch_job_id == self.dispatch_job_id)
|
||||
if self.driver_id is not None:
|
||||
clauses.append(JobAssignment.driver_id == self.driver_id)
|
||||
if self.status is not None:
|
||||
clauses.append(JobAssignment.status == self.status)
|
||||
return clauses
|
||||
|
||||
def apply(self, stmt: Select) -> Select:
|
||||
clauses = self.filter_clauses()
|
||||
if clauses:
|
||||
stmt = stmt.where(*clauses)
|
||||
sort_key = self.sort_by if self.sort_by in ASSIGN_SORT else "created_at"
|
||||
column = getattr(JobAssignment, sort_key)
|
||||
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
||||
return stmt.order_by(order)
|
||||
@ -1,103 +0,0 @@
|
||||
"""Fleet query specifications — Phase 10.2."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import Select, asc, desc, or_
|
||||
from sqlalchemy.sql import ColumnElement
|
||||
|
||||
from app.models.fleet import Fleet, Vehicle, VehicleType
|
||||
from app.models.types import FleetStatus, VehicleStatus
|
||||
|
||||
FLEET_SORT = frozenset({"created_at", "updated_at", "code", "name", "status"})
|
||||
VT_SORT = frozenset({"created_at", "updated_at", "code", "name"})
|
||||
VEHICLE_SORT = frozenset({"created_at", "updated_at", "code", "status"})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FleetListSpec:
|
||||
status: FleetStatus | None = None
|
||||
organization_id: UUID | None = None
|
||||
hub_id: UUID | None = None
|
||||
q: str | None = None
|
||||
sort_by: str = "created_at"
|
||||
sort_dir: str = "desc"
|
||||
|
||||
def filter_clauses(self) -> list[ColumnElement]:
|
||||
clauses: list[ColumnElement] = []
|
||||
if self.status is not None:
|
||||
clauses.append(Fleet.status == self.status)
|
||||
if self.organization_id is not None:
|
||||
clauses.append(Fleet.organization_id == self.organization_id)
|
||||
if self.hub_id is not None:
|
||||
clauses.append(Fleet.hub_id == self.hub_id)
|
||||
if self.q:
|
||||
term = f"%{self.q.strip()}%"
|
||||
clauses.append(or_(Fleet.code.ilike(term), Fleet.name.ilike(term)))
|
||||
return clauses
|
||||
|
||||
def apply(self, stmt: Select) -> Select:
|
||||
clauses = self.filter_clauses()
|
||||
if clauses:
|
||||
stmt = stmt.where(*clauses)
|
||||
sort_key = self.sort_by if self.sort_by in FLEET_SORT else "created_at"
|
||||
column = getattr(Fleet, sort_key)
|
||||
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
||||
return stmt.order_by(order)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VehicleTypeListSpec:
|
||||
q: str | None = None
|
||||
sort_by: str = "created_at"
|
||||
sort_dir: str = "desc"
|
||||
|
||||
def filter_clauses(self) -> list[ColumnElement]:
|
||||
clauses: list[ColumnElement] = []
|
||||
if self.q:
|
||||
term = f"%{self.q.strip()}%"
|
||||
clauses.append(
|
||||
or_(VehicleType.code.ilike(term), VehicleType.name.ilike(term))
|
||||
)
|
||||
return clauses
|
||||
|
||||
def apply(self, stmt: Select) -> Select:
|
||||
clauses = self.filter_clauses()
|
||||
if clauses:
|
||||
stmt = stmt.where(*clauses)
|
||||
sort_key = self.sort_by if self.sort_by in VT_SORT else "created_at"
|
||||
column = getattr(VehicleType, sort_key)
|
||||
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
||||
return stmt.order_by(order)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class VehicleListSpec:
|
||||
fleet_id: UUID | None = None
|
||||
status: VehicleStatus | None = None
|
||||
q: str | None = None
|
||||
sort_by: str = "created_at"
|
||||
sort_dir: str = "desc"
|
||||
|
||||
def filter_clauses(self) -> list[ColumnElement]:
|
||||
clauses: list[ColumnElement] = []
|
||||
if self.fleet_id is not None:
|
||||
clauses.append(Vehicle.fleet_id == self.fleet_id)
|
||||
if self.status is not None:
|
||||
clauses.append(Vehicle.status == self.status)
|
||||
if self.q:
|
||||
term = f"%{self.q.strip()}%"
|
||||
clauses.append(
|
||||
or_(Vehicle.code.ilike(term), Vehicle.plate_number.ilike(term))
|
||||
)
|
||||
return clauses
|
||||
|
||||
def apply(self, stmt: Select) -> Select:
|
||||
clauses = self.filter_clauses()
|
||||
if clauses:
|
||||
stmt = stmt.where(*clauses)
|
||||
sort_key = self.sort_by if self.sort_by in VEHICLE_SORT else "created_at"
|
||||
column = getattr(Vehicle, sort_key)
|
||||
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
||||
return stmt.order_by(order)
|
||||
@ -1,108 +0,0 @@
|
||||
"""Pricing query specifications — Phase 10.4."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import Select, asc, desc, or_
|
||||
from sqlalchemy.sql import ColumnElement
|
||||
|
||||
from app.models.pricing import CapabilityBundle, CapabilityDefinition, PricingRule
|
||||
from app.models.types import CapabilityBundleStatus, CapabilityStatus, PricingRuleStatus
|
||||
|
||||
RULE_SORT = frozenset({"created_at", "updated_at", "code", "name", "status"})
|
||||
CAP_SORT = frozenset({"created_at", "updated_at", "code", "name", "status"})
|
||||
BUNDLE_SORT = frozenset({"created_at", "updated_at", "code", "name", "status"})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class PricingRuleListSpec:
|
||||
status: PricingRuleStatus | None = None
|
||||
organization_id: UUID | None = None
|
||||
q: str | None = None
|
||||
sort_by: str = "created_at"
|
||||
sort_dir: str = "desc"
|
||||
|
||||
def filter_clauses(self) -> list[ColumnElement]:
|
||||
clauses: list[ColumnElement] = []
|
||||
if self.status is not None:
|
||||
clauses.append(PricingRule.status == self.status)
|
||||
if self.organization_id is not None:
|
||||
clauses.append(PricingRule.organization_id == self.organization_id)
|
||||
if self.q:
|
||||
term = f"%{self.q.strip()}%"
|
||||
clauses.append(
|
||||
or_(PricingRule.code.ilike(term), PricingRule.name.ilike(term))
|
||||
)
|
||||
return clauses
|
||||
|
||||
def apply(self, stmt: Select) -> Select:
|
||||
clauses = self.filter_clauses()
|
||||
if clauses:
|
||||
stmt = stmt.where(*clauses)
|
||||
sort_key = self.sort_by if self.sort_by in RULE_SORT else "created_at"
|
||||
column = getattr(PricingRule, sort_key)
|
||||
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
||||
return stmt.order_by(order)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CapabilityListSpec:
|
||||
status: CapabilityStatus | None = None
|
||||
q: str | None = None
|
||||
sort_by: str = "created_at"
|
||||
sort_dir: str = "desc"
|
||||
|
||||
def filter_clauses(self) -> list[ColumnElement]:
|
||||
clauses: list[ColumnElement] = []
|
||||
if self.status is not None:
|
||||
clauses.append(CapabilityDefinition.status == self.status)
|
||||
if self.q:
|
||||
term = f"%{self.q.strip()}%"
|
||||
clauses.append(
|
||||
or_(
|
||||
CapabilityDefinition.code.ilike(term),
|
||||
CapabilityDefinition.name.ilike(term),
|
||||
)
|
||||
)
|
||||
return clauses
|
||||
|
||||
def apply(self, stmt: Select) -> Select:
|
||||
clauses = self.filter_clauses()
|
||||
if clauses:
|
||||
stmt = stmt.where(*clauses)
|
||||
sort_key = self.sort_by if self.sort_by in CAP_SORT else "created_at"
|
||||
column = getattr(CapabilityDefinition, sort_key)
|
||||
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
||||
return stmt.order_by(order)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class BundleListSpec:
|
||||
status: CapabilityBundleStatus | None = None
|
||||
q: str | None = None
|
||||
sort_by: str = "created_at"
|
||||
sort_dir: str = "desc"
|
||||
|
||||
def filter_clauses(self) -> list[ColumnElement]:
|
||||
clauses: list[ColumnElement] = []
|
||||
if self.status is not None:
|
||||
clauses.append(CapabilityBundle.status == self.status)
|
||||
if self.q:
|
||||
term = f"%{self.q.strip()}%"
|
||||
clauses.append(
|
||||
or_(
|
||||
CapabilityBundle.code.ilike(term),
|
||||
CapabilityBundle.name.ilike(term),
|
||||
)
|
||||
)
|
||||
return clauses
|
||||
|
||||
def apply(self, stmt: Select) -> Select:
|
||||
clauses = self.filter_clauses()
|
||||
if clauses:
|
||||
stmt = stmt.where(*clauses)
|
||||
sort_key = self.sort_by if self.sort_by in BUNDLE_SORT else "created_at"
|
||||
column = getattr(CapabilityBundle, sort_key)
|
||||
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
||||
return stmt.order_by(order)
|
||||
@ -1,71 +0,0 @@
|
||||
"""Routing query specifications — Phase 10.6."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import Select, asc, desc, or_
|
||||
from sqlalchemy.sql import ColumnElement
|
||||
|
||||
from app.models.routing import OptimizationRun, RoutePlan
|
||||
from app.models.types import OptimizationRunStatus, RoutePlanStatus
|
||||
|
||||
ROUTE_SORT = frozenset({"created_at", "updated_at", "code", "status"})
|
||||
OPT_SORT = frozenset({"created_at", "updated_at", "status"})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RoutePlanListSpec:
|
||||
status: RoutePlanStatus | None = None
|
||||
organization_id: UUID | None = None
|
||||
driver_id: UUID | None = None
|
||||
q: str | None = None
|
||||
sort_by: str = "created_at"
|
||||
sort_dir: str = "desc"
|
||||
|
||||
def filter_clauses(self) -> list[ColumnElement]:
|
||||
clauses: list[ColumnElement] = []
|
||||
if self.status is not None:
|
||||
clauses.append(RoutePlan.status == self.status)
|
||||
if self.organization_id is not None:
|
||||
clauses.append(RoutePlan.organization_id == self.organization_id)
|
||||
if self.driver_id is not None:
|
||||
clauses.append(RoutePlan.driver_id == self.driver_id)
|
||||
if self.q:
|
||||
term = f"%{self.q.strip()}%"
|
||||
clauses.append(RoutePlan.code.ilike(term))
|
||||
return clauses
|
||||
|
||||
def apply(self, stmt: Select) -> Select:
|
||||
clauses = self.filter_clauses()
|
||||
if clauses:
|
||||
stmt = stmt.where(*clauses)
|
||||
sort_key = self.sort_by if self.sort_by in ROUTE_SORT else "created_at"
|
||||
column = getattr(RoutePlan, sort_key)
|
||||
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
||||
return stmt.order_by(order)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class OptimizationRunListSpec:
|
||||
route_plan_id: UUID | None = None
|
||||
status: OptimizationRunStatus | None = None
|
||||
sort_by: str = "created_at"
|
||||
sort_dir: str = "desc"
|
||||
|
||||
def filter_clauses(self) -> list[ColumnElement]:
|
||||
clauses: list[ColumnElement] = []
|
||||
if self.route_plan_id is not None:
|
||||
clauses.append(OptimizationRun.route_plan_id == self.route_plan_id)
|
||||
if self.status is not None:
|
||||
clauses.append(OptimizationRun.status == self.status)
|
||||
return clauses
|
||||
|
||||
def apply(self, stmt: Select) -> Select:
|
||||
clauses = self.filter_clauses()
|
||||
if clauses:
|
||||
stmt = stmt.where(*clauses)
|
||||
sort_key = self.sort_by if self.sort_by in OPT_SORT else "created_at"
|
||||
column = getattr(OptimizationRun, sort_key)
|
||||
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
||||
return stmt.order_by(order)
|
||||
@ -1,45 +0,0 @@
|
||||
"""Settlement query specifications — Phase 10.8."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import Select, asc, desc, or_
|
||||
from sqlalchemy.sql import ColumnElement
|
||||
|
||||
from app.models.settlement import SettlementIntent
|
||||
from app.models.types import SettlementIntentStatus
|
||||
|
||||
INTENT_SORT = frozenset({"created_at", "updated_at", "code", "status"})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class SettlementIntentListSpec:
|
||||
status: SettlementIntentStatus | None = None
|
||||
organization_id: UUID | None = None
|
||||
driver_id: UUID | None = None
|
||||
q: str | None = None
|
||||
sort_by: str = "created_at"
|
||||
sort_dir: str = "desc"
|
||||
|
||||
def filter_clauses(self) -> list[ColumnElement]:
|
||||
clauses: list[ColumnElement] = []
|
||||
if self.status is not None:
|
||||
clauses.append(SettlementIntent.status == self.status)
|
||||
if self.organization_id is not None:
|
||||
clauses.append(SettlementIntent.organization_id == self.organization_id)
|
||||
if self.driver_id is not None:
|
||||
clauses.append(SettlementIntent.driver_id == self.driver_id)
|
||||
if self.q:
|
||||
term = f"%{self.q.strip()}%"
|
||||
clauses.append(SettlementIntent.code.ilike(term))
|
||||
return clauses
|
||||
|
||||
def apply(self, stmt: Select) -> Select:
|
||||
clauses = self.filter_clauses()
|
||||
if clauses:
|
||||
stmt = stmt.where(*clauses)
|
||||
sort_key = self.sort_by if self.sort_by in INTENT_SORT else "created_at"
|
||||
column = getattr(SettlementIntent, sort_key)
|
||||
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
||||
return stmt.order_by(order)
|
||||
@ -1,99 +0,0 @@
|
||||
"""Tracking query specifications — Phase 10.7."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import Select, asc, desc
|
||||
from sqlalchemy.sql import ColumnElement
|
||||
|
||||
from app.models.tracking import CustomerTrackingToken, ProofOfDelivery, TrackingSession
|
||||
from app.models.types import (
|
||||
CustomerTrackingTokenStatus,
|
||||
ProofOfDeliveryStatus,
|
||||
TrackingSessionStatus,
|
||||
)
|
||||
|
||||
SESSION_SORT = frozenset({"created_at", "updated_at", "status"})
|
||||
POD_SORT = frozenset({"created_at", "updated_at", "status"})
|
||||
TOKEN_SORT = frozenset({"created_at", "updated_at", "status"})
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class TrackingSessionListSpec:
|
||||
status: TrackingSessionStatus | None = None
|
||||
dispatch_job_id: UUID | None = None
|
||||
driver_id: UUID | None = None
|
||||
sort_by: str = "created_at"
|
||||
sort_dir: str = "desc"
|
||||
|
||||
def filter_clauses(self) -> list[ColumnElement]:
|
||||
clauses: list[ColumnElement] = []
|
||||
if self.status is not None:
|
||||
clauses.append(TrackingSession.status == self.status)
|
||||
if self.dispatch_job_id is not None:
|
||||
clauses.append(TrackingSession.dispatch_job_id == self.dispatch_job_id)
|
||||
if self.driver_id is not None:
|
||||
clauses.append(TrackingSession.driver_id == self.driver_id)
|
||||
return clauses
|
||||
|
||||
def apply(self, stmt: Select) -> Select:
|
||||
clauses = self.filter_clauses()
|
||||
if clauses:
|
||||
stmt = stmt.where(*clauses)
|
||||
sort_key = self.sort_by if self.sort_by in SESSION_SORT else "created_at"
|
||||
column = getattr(TrackingSession, sort_key)
|
||||
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
||||
return stmt.order_by(order)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProofOfDeliveryListSpec:
|
||||
status: ProofOfDeliveryStatus | None = None
|
||||
dispatch_job_id: UUID | None = None
|
||||
sort_by: str = "created_at"
|
||||
sort_dir: str = "desc"
|
||||
|
||||
def filter_clauses(self) -> list[ColumnElement]:
|
||||
clauses: list[ColumnElement] = []
|
||||
if self.status is not None:
|
||||
clauses.append(ProofOfDelivery.status == self.status)
|
||||
if self.dispatch_job_id is not None:
|
||||
clauses.append(ProofOfDelivery.dispatch_job_id == self.dispatch_job_id)
|
||||
return clauses
|
||||
|
||||
def apply(self, stmt: Select) -> Select:
|
||||
clauses = self.filter_clauses()
|
||||
if clauses:
|
||||
stmt = stmt.where(*clauses)
|
||||
sort_key = self.sort_by if self.sort_by in POD_SORT else "created_at"
|
||||
column = getattr(ProofOfDelivery, sort_key)
|
||||
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
||||
return stmt.order_by(order)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CustomerTrackingTokenListSpec:
|
||||
status: CustomerTrackingTokenStatus | None = None
|
||||
dispatch_job_id: UUID | None = None
|
||||
sort_by: str = "created_at"
|
||||
sort_dir: str = "desc"
|
||||
|
||||
def filter_clauses(self) -> list[ColumnElement]:
|
||||
clauses: list[ColumnElement] = []
|
||||
if self.status is not None:
|
||||
clauses.append(CustomerTrackingToken.status == self.status)
|
||||
if self.dispatch_job_id is not None:
|
||||
clauses.append(
|
||||
CustomerTrackingToken.dispatch_job_id == self.dispatch_job_id
|
||||
)
|
||||
return clauses
|
||||
|
||||
def apply(self, stmt: Select) -> Select:
|
||||
clauses = self.filter_clauses()
|
||||
if clauses:
|
||||
stmt = stmt.where(*clauses)
|
||||
sort_key = self.sort_by if self.sort_by in TOKEN_SORT else "created_at"
|
||||
column = getattr(CustomerTrackingToken, sort_key)
|
||||
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
||||
return stmt.order_by(order)
|
||||
@ -15,18 +15,16 @@ async def test_health_capabilities_metrics(client):
|
||||
caps = await client.get("/capabilities")
|
||||
assert caps.status_code == 200
|
||||
c = caps.json()
|
||||
assert c["phase"] == "10.8"
|
||||
assert c["phase"] == "10.1"
|
||||
assert c["commercial_product"] == "Torbat Driver"
|
||||
assert c["features"]["foundation"] is True
|
||||
assert c["features"]["drivers"] is True
|
||||
assert c["features"]["dispatch_engine"] is True
|
||||
assert c["features"]["settlement"] is True
|
||||
assert c["features"]["dispatch_engine"] is False
|
||||
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.8"
|
||||
assert metrics.json()["phase"] == "10.1"
|
||||
|
||||
|
||||
async def test_foundation_flow_and_events(client):
|
||||
|
||||
@ -32,36 +32,6 @@ 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
|
||||
@ -82,55 +52,59 @@ def test_foundation_aggregates_are_independent():
|
||||
assert not model.__mapper__.relationships
|
||||
|
||||
|
||||
def test_phase_tables_registered():
|
||||
def test_no_dispatch_engine_tables():
|
||||
from app.core.database import Base
|
||||
import app.models # noqa: F401
|
||||
|
||||
names = set(Base.metadata.tables.keys())
|
||||
assert PHASE_TABLES.issubset(names)
|
||||
assert "journal_entries" not in names
|
||||
assert "drivers" in names
|
||||
assert "driver_credentials" in names
|
||||
assert "driver_documents" in names
|
||||
assert "driver_lifecycle_events" in names
|
||||
assert "outbox_events" in names
|
||||
forbidden = {
|
||||
"dispatch_jobs",
|
||||
"routes",
|
||||
"tracking_sessions",
|
||||
"vehicles",
|
||||
"fleets",
|
||||
"proof_of_delivery",
|
||||
}
|
||||
assert forbidden.isdisjoint(names)
|
||||
|
||||
|
||||
def test_permissions_defined():
|
||||
from app.permissions.definitions import ALL_PERMISSIONS, PERMISSION_PREFIXES
|
||||
|
||||
assert "delivery.view" in ALL_PERMISSIONS
|
||||
assert "delivery.organizations.create" in ALL_PERMISSIONS
|
||||
assert "delivery.external_providers.manage" in ALL_PERMISSIONS
|
||||
assert "delivery.routing_engines.view" in ALL_PERMISSIONS
|
||||
assert "delivery.audit.view" in ALL_PERMISSIONS
|
||||
assert "delivery.drivers.create" in ALL_PERMISSIONS
|
||||
assert "delivery.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
|
||||
assert "delivery.drivers.activate" in ALL_PERMISSIONS
|
||||
assert "delivery.drivers.credentials.manage" in ALL_PERMISSIONS
|
||||
assert "delivery.dispatch.manage" in ALL_PERMISSIONS
|
||||
for prefix in PERMISSION_PREFIXES:
|
||||
assert any(
|
||||
p.startswith(prefix.rstrip(".")) or p.startswith(prefix)
|
||||
for p in ALL_PERMISSIONS
|
||||
)
|
||||
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.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"
|
||||
assert DeliveryEventType.DRIVER_ACTIVATED.value == "delivery.driver.activated"
|
||||
assert DeliveryEventType.DRIVER_STATUS_CHANGED.value == "delivery.driver.status_changed"
|
||||
|
||||
|
||||
def test_platform_provider_contracts_exist():
|
||||
@ -148,15 +122,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 StorageProvider is not None
|
||||
assert IdentityProvider is not None
|
||||
assert CRMProvider is not None
|
||||
assert CommunicationProvider is not None
|
||||
assert NotificationProvider is not None
|
||||
assert LoyaltyProvider is not None
|
||||
assert CRMProvider is not None
|
||||
assert NotificationProvider is not None
|
||||
assert StorageProvider is not None
|
||||
assert AIProvider is not None
|
||||
assert IdentityProvider is not None
|
||||
|
||||
|
||||
def test_no_forbidden_service_imports():
|
||||
@ -185,10 +159,8 @@ 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
|
||||
|
||||
@ -20,51 +20,17 @@ 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"
|
||||
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"
|
||||
)
|
||||
files = list(versions.glob("0001_initial*.py"))
|
||||
assert files, "missing 0001_initial migration"
|
||||
phase101 = list(versions.glob("0002_phase_101_drivers*.py"))
|
||||
assert phase101, "missing 0002_phase_101_drivers migration"
|
||||
|
||||
|
||||
def test_metadata_has_all_tables():
|
||||
def test_metadata_has_foundation_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}
|
||||
|
||||
@ -1,93 +0,0 @@
|
||||
"""Phase 10.2 — Fleet & Vehicle tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.tests.conftest import TENANT_A, tenant_headers
|
||||
|
||||
|
||||
async def _org(client, code="ORG_F102"):
|
||||
resp = await client.post(
|
||||
"/api/v1/organizations",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={"code": code, "name": code, "status": "active"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
async def _vehicle_type(client, code="VT1"):
|
||||
resp = await client.post(
|
||||
"/api/v1/vehicle-types",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={"code": code, "name": f"Type {code}"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.text
|
||||
return resp.json()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fleet_vehicle_crud_and_assignment(client):
|
||||
org = await _org(client)
|
||||
vt = await _vehicle_type(client, "VAN")
|
||||
|
||||
fleet = await client.post(
|
||||
"/api/v1/fleets",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={
|
||||
"organization_id": org["id"],
|
||||
"code": "FL1",
|
||||
"name": "Main Fleet",
|
||||
"status": "active",
|
||||
},
|
||||
)
|
||||
assert fleet.status_code == 201, fleet.text
|
||||
fleet_body = fleet.json()
|
||||
|
||||
vehicle = await client.post(
|
||||
"/api/v1/vehicles",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={
|
||||
"fleet_id": fleet_body["id"],
|
||||
"vehicle_type_id": vt["id"],
|
||||
"code": "VH1",
|
||||
"plate_number": "12A345-11",
|
||||
},
|
||||
)
|
||||
assert vehicle.status_code == 201, vehicle.text
|
||||
vid = vehicle.json()["id"]
|
||||
|
||||
driver = await client.post(
|
||||
"/api/v1/drivers",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={
|
||||
"organization_id": org["id"],
|
||||
"code": "DRV_F102",
|
||||
"display_name": "Fleet Driver",
|
||||
"status": "active",
|
||||
},
|
||||
)
|
||||
assert driver.status_code == 201
|
||||
did = driver.json()["id"]
|
||||
|
||||
assign = await client.post(
|
||||
f"/api/v1/vehicles/{vid}/assignments",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={"driver_id": did},
|
||||
)
|
||||
assert assign.status_code == 201, assign.text
|
||||
assert assign.json()["status"] == "active"
|
||||
|
||||
listed = await client.get(
|
||||
"/api/v1/vehicles",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
params={"fleet_id": fleet_body["id"]},
|
||||
)
|
||||
assert listed.status_code == 200
|
||||
assert listed.json()["total"] == 1
|
||||
|
||||
events = {e.event_type for e in get_event_publisher().published}
|
||||
assert "delivery.fleet.created" in events
|
||||
assert "delivery.vehicle.created" in events
|
||||
assert "delivery.vehicle_assignment.created" in events
|
||||
@ -1,83 +0,0 @@
|
||||
"""Phase 10.3 — Availability, Shifts & Working Zones tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.tests.conftest import TENANT_A, tenant_headers
|
||||
|
||||
|
||||
async def _org(client, code="ORG_A103"):
|
||||
resp = await client.post(
|
||||
"/api/v1/organizations",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={"code": code, "name": code, "status": "active"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
return resp.json()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_availability_shift_zone_flow(client):
|
||||
org = await _org(client)
|
||||
driver = await client.post(
|
||||
"/api/v1/drivers",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={
|
||||
"organization_id": org["id"],
|
||||
"code": "DRV_A103",
|
||||
"display_name": "Shift Driver",
|
||||
"status": "active",
|
||||
},
|
||||
)
|
||||
assert driver.status_code == 201
|
||||
did = driver.json()["id"]
|
||||
|
||||
avail = await client.put(
|
||||
f"/api/v1/drivers/{did}/availability",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={"status": "online"},
|
||||
)
|
||||
assert avail.status_code == 200, avail.text
|
||||
assert avail.json()["status"] == "online"
|
||||
|
||||
start = datetime.now(timezone.utc)
|
||||
end = start + timedelta(hours=8)
|
||||
shift = await client.post(
|
||||
"/api/v1/shifts",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={
|
||||
"organization_id": org["id"],
|
||||
"code": "SH1",
|
||||
"name": "Morning",
|
||||
"starts_at": start.isoformat(),
|
||||
"ends_at": end.isoformat(),
|
||||
},
|
||||
)
|
||||
assert shift.status_code == 201, shift.text
|
||||
sid = shift.json()["id"]
|
||||
|
||||
assign = await client.post(
|
||||
f"/api/v1/shifts/{sid}/assignments",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={"driver_id": did},
|
||||
)
|
||||
assert assign.status_code == 201
|
||||
|
||||
zone = await client.post(
|
||||
"/api/v1/working-zones",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={
|
||||
"organization_id": org["id"],
|
||||
"code": "ZN1",
|
||||
"name": "North Zone",
|
||||
},
|
||||
)
|
||||
assert zone.status_code == 201, zone.text
|
||||
|
||||
events = {e.event_type for e in get_event_publisher().published}
|
||||
assert "delivery.driver_availability.updated" in events
|
||||
assert "delivery.shift.created" in events
|
||||
assert "delivery.working_zone.created" in events
|
||||
@ -1,67 +0,0 @@
|
||||
"""Phase 10.4 — Pricing, Capabilities & Bundles tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.tests.conftest import TENANT_A, tenant_headers
|
||||
|
||||
|
||||
async def _org(client):
|
||||
resp = await client.post(
|
||||
"/api/v1/organizations",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={"code": "ORG_P104", "name": "Pricing Org", "status": "active"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
return resp.json()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_pricing_capability_bundle_flow(client):
|
||||
org = await _org(client)
|
||||
|
||||
rule = await client.post(
|
||||
"/api/v1/pricing-rules",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={
|
||||
"organization_id": org["id"],
|
||||
"code": "PR1",
|
||||
"name": "Base Delivery",
|
||||
"base_amount": "50000",
|
||||
"currency_code": "IRR",
|
||||
},
|
||||
)
|
||||
assert rule.status_code == 201, rule.text
|
||||
rid = rule.json()["id"]
|
||||
|
||||
cap = await client.post(
|
||||
"/api/v1/capabilities",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={"code": "COLD", "name": "Cold Chain"},
|
||||
)
|
||||
assert cap.status_code == 201
|
||||
|
||||
bundle = await client.post(
|
||||
"/api/v1/bundles",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={
|
||||
"code": "BND1",
|
||||
"name": "Cold Bundle",
|
||||
"capability_codes": ["COLD"],
|
||||
"pricing_rule_id": rid,
|
||||
},
|
||||
)
|
||||
assert bundle.status_code == 201, bundle.text
|
||||
|
||||
listed = await client.get(
|
||||
"/api/v1/pricing-rules",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
)
|
||||
assert listed.status_code == 200
|
||||
assert listed.json()["total"] >= 1
|
||||
|
||||
events = {e.event_type for e in get_event_publisher().published}
|
||||
assert "delivery.pricing_rule.created" in events
|
||||
assert "delivery.capability.created" in events
|
||||
assert "delivery.bundle.created" in events
|
||||
@ -1,73 +0,0 @@
|
||||
"""Phase 10.5 — Dispatch Engine tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.models.types import DispatchJobAction
|
||||
from app.tests.conftest import TENANT_A, tenant_headers
|
||||
from app.validators.dispatch import ensure_dispatch_job_transition
|
||||
|
||||
|
||||
def test_dispatch_transition_matrix():
|
||||
from app.models.types import DispatchJobStatus
|
||||
|
||||
ensure_dispatch_job_transition(
|
||||
action=DispatchJobAction.ASSIGN, current=DispatchJobStatus.PENDING
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dispatch_job_lifecycle(client):
|
||||
org = await client.post(
|
||||
"/api/v1/organizations",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={"code": "ORG_D105", "name": "Dispatch Org", "status": "active"},
|
||||
)
|
||||
assert org.status_code == 201
|
||||
org_id = org.json()["id"]
|
||||
|
||||
job = await client.post(
|
||||
"/api/v1/dispatch/jobs",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={
|
||||
"organization_id": org_id,
|
||||
"code": "JOB1",
|
||||
"external_order_ref": "ORD-100",
|
||||
},
|
||||
)
|
||||
assert job.status_code == 201, job.text
|
||||
jid = job.json()["id"]
|
||||
version = job.json()["version"]
|
||||
|
||||
assigned = await client.post(
|
||||
f"/api/v1/dispatch/jobs/{jid}/status",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={"action": "assign", "version": version},
|
||||
)
|
||||
assert assigned.status_code == 200
|
||||
assert assigned.json()["status"] == "assigned"
|
||||
|
||||
driver = await client.post(
|
||||
"/api/v1/drivers",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={
|
||||
"organization_id": org_id,
|
||||
"code": "DRV_D105",
|
||||
"display_name": "Dispatch Driver",
|
||||
"status": "active",
|
||||
},
|
||||
)
|
||||
assert driver.status_code == 201
|
||||
|
||||
assignment = await client.post(
|
||||
"/api/v1/dispatch/assignments",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={"dispatch_job_id": jid, "driver_id": driver.json()["id"]},
|
||||
)
|
||||
assert assignment.status_code == 201
|
||||
|
||||
events = {e.event_type for e in get_event_publisher().published}
|
||||
assert "delivery.dispatch_job.created" in events
|
||||
assert "delivery.dispatch_job.status_changed" in events
|
||||
assert "delivery.job_assignment.created" in events
|
||||
@ -1,46 +0,0 @@
|
||||
"""Phase 10.6 — Routing & Optimization tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.tests.conftest import TENANT_A, tenant_headers
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_route_plan_and_optimization(client):
|
||||
org = await client.post(
|
||||
"/api/v1/organizations",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={"code": "ORG_R106", "name": "Route Org", "status": "active"},
|
||||
)
|
||||
assert org.status_code == 201
|
||||
org_id = org.json()["id"]
|
||||
|
||||
route = await client.post(
|
||||
"/api/v1/routes",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={"organization_id": org_id, "code": "RT1"},
|
||||
)
|
||||
assert route.status_code == 201, route.text
|
||||
pid = route.json()["id"]
|
||||
|
||||
stop = await client.post(
|
||||
f"/api/v1/routes/{pid}/stops",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={"sequence": 0, "address_ref": "addr://1"},
|
||||
)
|
||||
assert stop.status_code == 201
|
||||
|
||||
run = await client.post(
|
||||
"/api/v1/optimization/runs",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={"route_plan_id": pid, "routing_engine_ref": "engine://default"},
|
||||
)
|
||||
assert run.status_code == 201, run.text
|
||||
assert run.json()["status"] == "completed"
|
||||
|
||||
events = {e.event_type for e in get_event_publisher().published}
|
||||
assert "delivery.route_plan.created" in events
|
||||
assert "delivery.route_stop.added" in events
|
||||
assert "delivery.optimization_run.completed" in events
|
||||
@ -1,73 +0,0 @@
|
||||
"""Phase 10.7 — Tracking & Proof of Delivery tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
import pytest
|
||||
|
||||
from app.events.publisher import get_event_publisher
|
||||
from app.tests.conftest import TENANT_A, tenant_headers
|
||||
|
||||
|
||||
async def _dispatch_job(client):
|
||||
org = await client.post(
|
||||
"/api/v1/organizations",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={"code": "ORG_T107", "name": "Track Org", "status": "active"},
|
||||
)
|
||||
assert org.status_code == 201
|
||||
job = await client.post(
|
||||
"/api/v1/dispatch/jobs",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={"organization_id": org.json()["id"], "code": "TJ1"},
|
||||
)
|
||||
assert job.status_code == 201
|
||||
return job.json()["id"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tracking_pod_and_customer_token(client):
|
||||
jid = await _dispatch_job(client)
|
||||
|
||||
session = await client.post(
|
||||
"/api/v1/tracking/sessions",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={"dispatch_job_id": jid},
|
||||
)
|
||||
assert session.status_code == 201, session.text
|
||||
sid = session.json()["id"]
|
||||
|
||||
point = await client.post(
|
||||
f"/api/v1/tracking/sessions/{sid}/points",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={
|
||||
"latitude": 35.6892,
|
||||
"longitude": 51.389,
|
||||
"recorded_at": datetime.now(timezone.utc).isoformat(),
|
||||
},
|
||||
)
|
||||
assert point.status_code == 201
|
||||
|
||||
pod = await client.post(
|
||||
"/api/v1/proof-of-delivery",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={
|
||||
"dispatch_job_id": jid,
|
||||
"signature_file_ref": "storage://pod/sig-1",
|
||||
},
|
||||
)
|
||||
assert pod.status_code == 201, pod.text
|
||||
|
||||
token = await client.post(
|
||||
"/api/v1/customer-tracking/tokens",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={"dispatch_job_id": jid},
|
||||
)
|
||||
assert token.status_code == 201, token.text
|
||||
assert token.json()["token"]
|
||||
|
||||
events = {e.event_type for e in get_event_publisher().published}
|
||||
assert "delivery.tracking_session.started" in events
|
||||
assert "delivery.tracking_point.recorded" in events
|
||||
assert "delivery.proof_of_delivery.captured" in events
|
||||
assert "delivery.customer_tracking_token.created" in events
|
||||
@ -1,80 +0,0 @@
|
||||
"""Phase 10.8 — Settlement tests (AccountingProvider refs only)."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.core.database import Base
|
||||
from app.events.publisher import get_event_publisher
|
||||
import app.models # noqa: F401
|
||||
from app.tests.conftest import TENANT_A, tenant_headers
|
||||
|
||||
|
||||
def test_no_journal_entry_table_in_delivery_db():
|
||||
names = set(Base.metadata.tables.keys())
|
||||
forbidden = {
|
||||
"journal_entries",
|
||||
"journal_entry",
|
||||
"accounting_journal_entries",
|
||||
"accounting_journals",
|
||||
}
|
||||
assert names.isdisjoint(forbidden)
|
||||
for name in names:
|
||||
assert "journal" not in name.lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_settlement_intent_via_accounting_provider_ref(client):
|
||||
org = await client.post(
|
||||
"/api/v1/organizations",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={"code": "ORG_S108", "name": "Settlement Org", "status": "active"},
|
||||
)
|
||||
assert org.status_code == 201
|
||||
org_id = org.json()["id"]
|
||||
|
||||
intent = await client.post(
|
||||
"/api/v1/settlements",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={
|
||||
"organization_id": org_id,
|
||||
"code": "SET1",
|
||||
"total_amount": "120000",
|
||||
"currency_code": "IRR",
|
||||
},
|
||||
)
|
||||
assert intent.status_code == 201, intent.text
|
||||
iid = intent.json()["id"]
|
||||
version = intent.json()["version"]
|
||||
assert intent.json()["external_settlement_ref"] is None
|
||||
|
||||
line = await client.post(
|
||||
f"/api/v1/settlements/{iid}/lines",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={"kind": "delivery_fee", "amount": "100000", "currency_code": "IRR"},
|
||||
)
|
||||
assert line.status_code == 201
|
||||
|
||||
submitted = await client.post(
|
||||
f"/api/v1/settlements/{iid}/submit",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={"version": version},
|
||||
)
|
||||
assert submitted.status_code == 200
|
||||
assert submitted.json()["status"] == "submitted"
|
||||
version = submitted.json()["version"]
|
||||
|
||||
settled = await client.post(
|
||||
f"/api/v1/settlements/{iid}/settle",
|
||||
headers=tenant_headers(TENANT_A),
|
||||
json={"version": version},
|
||||
)
|
||||
assert settled.status_code == 200
|
||||
assert settled.json()["status"] == "settled"
|
||||
assert settled.json()["external_settlement_ref"] is not None
|
||||
assert settled.json()["external_settlement_ref"].startswith("acct://")
|
||||
|
||||
events = {e.event_type for e in get_event_publisher().published}
|
||||
assert "delivery.settlement_intent.created" in events
|
||||
assert "delivery.settlement_intent.submitted" in events
|
||||
assert "delivery.settlement_intent.settled" in events
|
||||
assert "delivery.settlement_line.added" in events
|
||||
@ -1,36 +0,0 @@
|
||||
"""Availability validators — Phase 10.3."""
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from shared.exceptions import AppError
|
||||
|
||||
from app.validators.foundation import validate_non_empty
|
||||
|
||||
|
||||
def validate_shift_window(*, starts_at: datetime, ends_at: datetime) -> None:
|
||||
if ends_at <= starts_at:
|
||||
raise AppError(
|
||||
"زمان پایان شیفت باید بعد از زمان شروع باشد",
|
||||
status_code=422,
|
||||
error_code="invalid_shift_window",
|
||||
)
|
||||
|
||||
|
||||
def validate_availability_window(
|
||||
*, effective_from: datetime | None, effective_until: datetime | None
|
||||
) -> None:
|
||||
if effective_from and effective_until and effective_until <= effective_from:
|
||||
raise AppError(
|
||||
"بازه دسترسی نامعتبر است",
|
||||
status_code=422,
|
||||
error_code="invalid_availability_window",
|
||||
)
|
||||
|
||||
|
||||
def validate_shift_name(name: str) -> str:
|
||||
return validate_non_empty(name, "name")
|
||||
|
||||
|
||||
def validate_zone_name(name: str) -> str:
|
||||
return validate_non_empty(name, "name")
|
||||
@ -1,84 +0,0 @@
|
||||
"""Dispatch validators — Phase 10.5."""
|
||||
from __future__ import annotations
|
||||
|
||||
from shared.exceptions import AppError
|
||||
|
||||
from app.models.types import DispatchJobAction, DispatchJobStatus
|
||||
|
||||
ALLOWED_TRANSITIONS: dict[DispatchJobAction, set[DispatchJobStatus]] = {
|
||||
DispatchJobAction.ASSIGN: {DispatchJobStatus.PENDING},
|
||||
DispatchJobAction.START: {DispatchJobStatus.ASSIGNED},
|
||||
DispatchJobAction.COMPLETE: {
|
||||
DispatchJobStatus.ASSIGNED,
|
||||
DispatchJobStatus.IN_PROGRESS,
|
||||
},
|
||||
DispatchJobAction.CANCEL: {
|
||||
DispatchJobStatus.PENDING,
|
||||
DispatchJobStatus.ASSIGNED,
|
||||
DispatchJobStatus.IN_PROGRESS,
|
||||
},
|
||||
DispatchJobAction.FAIL: {
|
||||
DispatchJobStatus.PENDING,
|
||||
DispatchJobStatus.ASSIGNED,
|
||||
DispatchJobStatus.IN_PROGRESS,
|
||||
},
|
||||
}
|
||||
|
||||
TARGET_STATUS: dict[DispatchJobAction, DispatchJobStatus] = {
|
||||
DispatchJobAction.ASSIGN: DispatchJobStatus.ASSIGNED,
|
||||
DispatchJobAction.START: DispatchJobStatus.IN_PROGRESS,
|
||||
DispatchJobAction.COMPLETE: DispatchJobStatus.COMPLETED,
|
||||
DispatchJobAction.CANCEL: DispatchJobStatus.CANCELLED,
|
||||
DispatchJobAction.FAIL: DispatchJobStatus.FAILED,
|
||||
}
|
||||
|
||||
TERMINAL = frozenset(
|
||||
{DispatchJobStatus.COMPLETED, DispatchJobStatus.CANCELLED, DispatchJobStatus.FAILED}
|
||||
)
|
||||
|
||||
|
||||
def ensure_dispatch_job_transition(
|
||||
*, action: DispatchJobAction, current: DispatchJobStatus
|
||||
) -> None:
|
||||
if current in TERMINAL:
|
||||
raise AppError(
|
||||
"کار ارسال در وضعیت پایانی است",
|
||||
status_code=409,
|
||||
error_code="dispatch_job_terminal",
|
||||
details={"status": current.value, "action": action.value},
|
||||
)
|
||||
allowed = ALLOWED_TRANSITIONS.get(action, set())
|
||||
if current not in allowed:
|
||||
raise AppError(
|
||||
"انتقال وضعیت کار ارسال مجاز نیست",
|
||||
status_code=409,
|
||||
error_code="invalid_dispatch_job_transition",
|
||||
details={
|
||||
"from_status": current.value,
|
||||
"action": action.value,
|
||||
"allowed_from": sorted(s.value for s in allowed),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def target_status_for(action: DispatchJobAction) -> DispatchJobStatus:
|
||||
return TARGET_STATUS[action]
|
||||
|
||||
|
||||
def validate_dispatch_reason(reason: str | None, *, required: bool = False) -> str | None:
|
||||
if reason is None or not str(reason).strip():
|
||||
if required:
|
||||
raise AppError(
|
||||
"دلیل الزامی است",
|
||||
status_code=422,
|
||||
error_code="reason_required",
|
||||
)
|
||||
return None
|
||||
text = str(reason).strip()
|
||||
if len(text) > 500:
|
||||
raise AppError(
|
||||
"دلیل بیش از حد طولانی است",
|
||||
status_code=422,
|
||||
error_code="reason_too_long",
|
||||
)
|
||||
return text
|
||||
@ -1,27 +0,0 @@
|
||||
"""Fleet validators — Phase 10.2."""
|
||||
from __future__ import annotations
|
||||
|
||||
from app.validators.foundation import validate_non_empty
|
||||
|
||||
|
||||
def validate_fleet_name(name: str) -> str:
|
||||
return validate_non_empty(name, "name")
|
||||
|
||||
|
||||
def validate_vehicle_type_name(name: str) -> str:
|
||||
return validate_non_empty(name, "name")
|
||||
|
||||
|
||||
def validate_plate_number(plate: str | None) -> str | None:
|
||||
if plate is None or not str(plate).strip():
|
||||
return None
|
||||
value = str(plate).strip()
|
||||
if len(value) > 32:
|
||||
from shared.exceptions import AppError
|
||||
|
||||
raise AppError(
|
||||
"شماره پلاک بیش از حد طولانی است",
|
||||
status_code=422,
|
||||
error_code="plate_too_long",
|
||||
)
|
||||
return value
|
||||
@ -1,38 +0,0 @@
|
||||
"""Pricing validators — Phase 10.4."""
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from shared.exceptions import AppError
|
||||
|
||||
from app.validators.foundation import validate_currency_code, validate_non_empty
|
||||
|
||||
|
||||
def validate_pricing_name(name: str) -> str:
|
||||
return validate_non_empty(name, "name")
|
||||
|
||||
|
||||
def validate_base_amount(amount: Decimal) -> Decimal:
|
||||
if amount <= 0:
|
||||
raise AppError(
|
||||
"مبلغ پایه باید بزرگتر از صفر باشد",
|
||||
status_code=422,
|
||||
error_code="invalid_base_amount",
|
||||
)
|
||||
return amount
|
||||
|
||||
|
||||
def validate_line_amount(amount: Decimal) -> Decimal:
|
||||
return validate_base_amount(amount)
|
||||
|
||||
|
||||
def validate_capability_name(name: str) -> str:
|
||||
return validate_non_empty(name, "name")
|
||||
|
||||
|
||||
def validate_bundle_name(name: str) -> str:
|
||||
return validate_non_empty(name, "name")
|
||||
|
||||
|
||||
def validate_currency(currency_code: str) -> str:
|
||||
return validate_currency_code(currency_code)
|
||||
@ -1,25 +0,0 @@
|
||||
"""Routing validators — Phase 10.6."""
|
||||
from __future__ import annotations
|
||||
|
||||
from shared.exceptions import AppError
|
||||
|
||||
|
||||
def validate_route_code_present(code: str) -> str:
|
||||
code = str(code).strip()
|
||||
if not code:
|
||||
raise AppError(
|
||||
"کد مسیر الزامی است",
|
||||
status_code=422,
|
||||
error_code="route_code_required",
|
||||
)
|
||||
return code
|
||||
|
||||
|
||||
def validate_stop_sequence(sequence: int) -> int:
|
||||
if sequence < 0:
|
||||
raise AppError(
|
||||
"ترتیب توقف نامعتبر است",
|
||||
status_code=422,
|
||||
error_code="invalid_stop_sequence",
|
||||
)
|
||||
return sequence
|
||||
@ -1,66 +0,0 @@
|
||||
"""Settlement validators — Phase 10.8."""
|
||||
from __future__ import annotations
|
||||
|
||||
from decimal import Decimal
|
||||
|
||||
from shared.exceptions import AppError
|
||||
|
||||
from app.models.types import SettlementIntentStatus
|
||||
|
||||
ALLOWED_TRANSITIONS: dict[str, set[SettlementIntentStatus]] = {
|
||||
"submit": {SettlementIntentStatus.DRAFT},
|
||||
"settle": {SettlementIntentStatus.SUBMITTED},
|
||||
"fail": {SettlementIntentStatus.SUBMITTED},
|
||||
"cancel": {SettlementIntentStatus.DRAFT, SettlementIntentStatus.PENDING},
|
||||
}
|
||||
|
||||
TARGET_STATUS: dict[str, SettlementIntentStatus] = {
|
||||
"submit": SettlementIntentStatus.SUBMITTED,
|
||||
"settle": SettlementIntentStatus.SETTLED,
|
||||
"fail": SettlementIntentStatus.FAILED,
|
||||
"cancel": SettlementIntentStatus.CANCELLED,
|
||||
}
|
||||
|
||||
TERMINAL = frozenset(
|
||||
{
|
||||
SettlementIntentStatus.SETTLED,
|
||||
SettlementIntentStatus.FAILED,
|
||||
SettlementIntentStatus.CANCELLED,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def ensure_settlement_transition(*, action: str, current: SettlementIntentStatus) -> None:
|
||||
if current in TERMINAL:
|
||||
raise AppError(
|
||||
"تسویه در وضعیت پایانی است",
|
||||
status_code=409,
|
||||
error_code="settlement_terminal",
|
||||
details={"status": current.value, "action": action},
|
||||
)
|
||||
allowed = ALLOWED_TRANSITIONS.get(action, set())
|
||||
if current not in allowed:
|
||||
raise AppError(
|
||||
"انتقال وضعیت تسویه مجاز نیست",
|
||||
status_code=409,
|
||||
error_code="invalid_settlement_transition",
|
||||
details={
|
||||
"from_status": current.value,
|
||||
"action": action,
|
||||
"allowed_from": sorted(s.value for s in allowed),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def target_settlement_status(action: str) -> SettlementIntentStatus:
|
||||
return TARGET_STATUS[action]
|
||||
|
||||
|
||||
def validate_settlement_amount(amount: Decimal) -> Decimal:
|
||||
if amount <= 0:
|
||||
raise AppError(
|
||||
"مبلغ تسویه باید بزرگتر از صفر باشد",
|
||||
status_code=422,
|
||||
error_code="invalid_settlement_amount",
|
||||
)
|
||||
return amount
|
||||
@ -1,30 +0,0 @@
|
||||
"""Tracking validators — Phase 10.7."""
|
||||
from __future__ import annotations
|
||||
|
||||
from shared.exceptions import AppError
|
||||
|
||||
|
||||
def validate_coordinates(*, latitude: float, longitude: float) -> None:
|
||||
if not (-90 <= latitude <= 90):
|
||||
raise AppError(
|
||||
"عرض جغرافیایی نامعتبر است",
|
||||
status_code=422,
|
||||
error_code="invalid_latitude",
|
||||
)
|
||||
if not (-180 <= longitude <= 180):
|
||||
raise AppError(
|
||||
"طول جغرافیایی نامعتبر است",
|
||||
status_code=422,
|
||||
error_code="invalid_longitude",
|
||||
)
|
||||
|
||||
|
||||
def validate_pod_refs(
|
||||
*, signature_file_ref: str | None, photo_file_ref: str | None
|
||||
) -> None:
|
||||
if not signature_file_ref and not photo_file_ref:
|
||||
raise AppError(
|
||||
"حداقل یک مرجع فایل برای اثبات تحویل الزامی است",
|
||||
status_code=422,
|
||||
error_code="pod_file_ref_required",
|
||||
)
|
||||
@ -1,38 +0,0 @@
|
||||
"""Build all delivery phase 10.2-10.8 files."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
FILES: dict[str, str] = {}
|
||||
|
||||
|
||||
def register(rel: str, content: str) -> None:
|
||||
FILES[rel] = content.rstrip() + "\n"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
_register_repositories()
|
||||
_register_specifications()
|
||||
_register_validators()
|
||||
_register_policies()
|
||||
_register_services()
|
||||
_register_commands_queries()
|
||||
_register_api()
|
||||
_register_migrations()
|
||||
_register_tests()
|
||||
_register_updates()
|
||||
|
||||
for rel, content in sorted(FILES.items()):
|
||||
path = ROOT / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8")
|
||||
print(f"wrote {rel}")
|
||||
print(f"total: {len(FILES)} files")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user