Compare commits
2 Commits
4451b32a33
...
0eec9f729b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0eec9f729b | ||
|
|
72077908f1 |
@ -0,0 +1,29 @@
|
|||||||
|
"""Phase 10.2 — Fleet & Vehicle Types schema (additive)."""
|
||||||
|
from alembic import op
|
||||||
|
from app.core.database import Base
|
||||||
|
import app.models # noqa: F401
|
||||||
|
|
||||||
|
revision = "0003_phase_102_fleet"
|
||||||
|
down_revision = "0002_phase_101_drivers"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_NEW_TABLES = (
|
||||||
|
"fleets",
|
||||||
|
"vehicle_types",
|
||||||
|
"vehicles",
|
||||||
|
"vehicle_assignments",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
Base.metadata.create_all(bind=bind)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
for name in reversed(_NEW_TABLES):
|
||||||
|
table = Base.metadata.tables.get(name)
|
||||||
|
if table is not None:
|
||||||
|
table.drop(bind=bind, checkfirst=True)
|
||||||
@ -0,0 +1,29 @@
|
|||||||
|
"""Phase 10.3 — Availability, Shifts & Working Zones schema (additive)."""
|
||||||
|
from alembic import op
|
||||||
|
from app.core.database import Base
|
||||||
|
import app.models # noqa: F401
|
||||||
|
|
||||||
|
revision = "0004_phase_103_availability"
|
||||||
|
down_revision = "0003_phase_102_fleet"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_NEW_TABLES = (
|
||||||
|
"driver_availabilities",
|
||||||
|
"shifts",
|
||||||
|
"shift_assignments",
|
||||||
|
"working_zones",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
Base.metadata.create_all(bind=bind)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
for name in reversed(_NEW_TABLES):
|
||||||
|
table = Base.metadata.tables.get(name)
|
||||||
|
if table is not None:
|
||||||
|
table.drop(bind=bind, checkfirst=True)
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
"""Phase 10.4 — Pricing, Capabilities & Bundles schema (additive)."""
|
||||||
|
from alembic import op
|
||||||
|
from app.core.database import Base
|
||||||
|
import app.models # noqa: F401
|
||||||
|
|
||||||
|
revision = "0005_phase_104_pricing"
|
||||||
|
down_revision = "0004_phase_103_availability"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_NEW_TABLES = (
|
||||||
|
"pricing_rules",
|
||||||
|
"capability_definitions",
|
||||||
|
"capability_bundles",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
Base.metadata.create_all(bind=bind)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
for name in reversed(_NEW_TABLES):
|
||||||
|
table = Base.metadata.tables.get(name)
|
||||||
|
if table is not None:
|
||||||
|
table.drop(bind=bind, checkfirst=True)
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
"""Phase 10.5 — Dispatch Engine schema (additive)."""
|
||||||
|
from alembic import op
|
||||||
|
from app.core.database import Base
|
||||||
|
import app.models # noqa: F401
|
||||||
|
|
||||||
|
revision = "0006_phase_105_dispatch"
|
||||||
|
down_revision = "0005_phase_104_pricing"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_NEW_TABLES = (
|
||||||
|
"dispatch_jobs",
|
||||||
|
"job_assignments",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
Base.metadata.create_all(bind=bind)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
for name in reversed(_NEW_TABLES):
|
||||||
|
table = Base.metadata.tables.get(name)
|
||||||
|
if table is not None:
|
||||||
|
table.drop(bind=bind, checkfirst=True)
|
||||||
@ -0,0 +1,28 @@
|
|||||||
|
"""Phase 10.6 — Routing & Optimization schema (additive)."""
|
||||||
|
from alembic import op
|
||||||
|
from app.core.database import Base
|
||||||
|
import app.models # noqa: F401
|
||||||
|
|
||||||
|
revision = "0007_phase_106_routing"
|
||||||
|
down_revision = "0006_phase_105_dispatch"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_NEW_TABLES = (
|
||||||
|
"route_plans",
|
||||||
|
"route_stops",
|
||||||
|
"optimization_runs",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
Base.metadata.create_all(bind=bind)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
for name in reversed(_NEW_TABLES):
|
||||||
|
table = Base.metadata.tables.get(name)
|
||||||
|
if table is not None:
|
||||||
|
table.drop(bind=bind, checkfirst=True)
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
"""Phase 10.7+10.8 — Tracking, POD & Settlement schema (additive)."""
|
||||||
|
from alembic import op
|
||||||
|
from app.core.database import Base
|
||||||
|
import app.models # noqa: F401
|
||||||
|
|
||||||
|
revision = "0008_phase_107_108_tracking_settlement"
|
||||||
|
down_revision = "0007_phase_106_routing"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
_NEW_TABLES = (
|
||||||
|
"tracking_sessions",
|
||||||
|
"tracking_points",
|
||||||
|
"proof_of_delivery",
|
||||||
|
"customer_tracking_tokens",
|
||||||
|
"settlement_intents",
|
||||||
|
"settlement_lines",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
Base.metadata.create_all(bind=bind)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
bind = op.get_bind()
|
||||||
|
for name in reversed(_NEW_TABLES):
|
||||||
|
table = Base.metadata.tables.get(name)
|
||||||
|
if table is not None:
|
||||||
|
table.drop(bind=bind, checkfirst=True)
|
||||||
@ -1 +1 @@
|
|||||||
__version__ = "0.10.1.0"
|
__version__ = "0.10.8.0"
|
||||||
|
|||||||
@ -2,14 +2,31 @@ from fastapi import APIRouter
|
|||||||
|
|
||||||
from app.api.v1 import (
|
from app.api.v1 import (
|
||||||
audit,
|
audit,
|
||||||
|
availability,
|
||||||
|
bundles,
|
||||||
|
capabilities,
|
||||||
configurations,
|
configurations,
|
||||||
|
customer_tracking,
|
||||||
|
dispatch_assignments,
|
||||||
|
dispatch_jobs,
|
||||||
drivers,
|
drivers,
|
||||||
external_providers,
|
external_providers,
|
||||||
|
fleets,
|
||||||
hubs,
|
hubs,
|
||||||
|
optimization,
|
||||||
organizations,
|
organizations,
|
||||||
permissions,
|
permissions,
|
||||||
|
pricing_rules,
|
||||||
|
proof_of_delivery,
|
||||||
|
routes,
|
||||||
routing_engines,
|
routing_engines,
|
||||||
settings,
|
settings,
|
||||||
|
settlements,
|
||||||
|
shifts,
|
||||||
|
tracking,
|
||||||
|
vehicle_types,
|
||||||
|
vehicles,
|
||||||
|
working_zones,
|
||||||
)
|
)
|
||||||
|
|
||||||
api_router = APIRouter()
|
api_router = APIRouter()
|
||||||
@ -31,6 +48,51 @@ api_router.include_router(
|
|||||||
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
api_router.include_router(settings.router, prefix="/settings", tags=["settings"])
|
||||||
api_router.include_router(audit.router, prefix="/audit", tags=["audit"])
|
api_router.include_router(audit.router, prefix="/audit", tags=["audit"])
|
||||||
api_router.include_router(drivers.router, prefix="/drivers", tags=["drivers"])
|
api_router.include_router(drivers.router, prefix="/drivers", tags=["drivers"])
|
||||||
|
api_router.include_router(
|
||||||
|
availability.router, prefix="/drivers", tags=["driver-availability"]
|
||||||
|
)
|
||||||
|
api_router.include_router(fleets.router, prefix="/fleets", tags=["fleets"])
|
||||||
|
api_router.include_router(
|
||||||
|
vehicle_types.router, prefix="/vehicle-types", tags=["vehicle-types"]
|
||||||
|
)
|
||||||
|
api_router.include_router(vehicles.router, prefix="/vehicles", tags=["vehicles"])
|
||||||
|
api_router.include_router(shifts.router, prefix="/shifts", tags=["shifts"])
|
||||||
|
api_router.include_router(
|
||||||
|
working_zones.router, prefix="/working-zones", tags=["working-zones"]
|
||||||
|
)
|
||||||
|
api_router.include_router(
|
||||||
|
pricing_rules.router, prefix="/pricing-rules", tags=["pricing-rules"]
|
||||||
|
)
|
||||||
|
api_router.include_router(
|
||||||
|
capabilities.router, prefix="/capabilities", tags=["capabilities"]
|
||||||
|
)
|
||||||
|
api_router.include_router(bundles.router, prefix="/bundles", tags=["bundles"])
|
||||||
|
api_router.include_router(
|
||||||
|
dispatch_jobs.router, prefix="/dispatch/jobs", tags=["dispatch-jobs"]
|
||||||
|
)
|
||||||
|
api_router.include_router(
|
||||||
|
dispatch_assignments.router,
|
||||||
|
prefix="/dispatch/assignments",
|
||||||
|
tags=["dispatch-assignments"],
|
||||||
|
)
|
||||||
|
api_router.include_router(routes.router, prefix="/routes", tags=["routes"])
|
||||||
|
api_router.include_router(
|
||||||
|
optimization.router, prefix="/optimization", tags=["optimization"]
|
||||||
|
)
|
||||||
|
api_router.include_router(tracking.router, prefix="/tracking", tags=["tracking"])
|
||||||
|
api_router.include_router(
|
||||||
|
proof_of_delivery.router,
|
||||||
|
prefix="/proof-of-delivery",
|
||||||
|
tags=["proof-of-delivery"],
|
||||||
|
)
|
||||||
|
api_router.include_router(
|
||||||
|
customer_tracking.router,
|
||||||
|
prefix="/customer-tracking",
|
||||||
|
tags=["customer-tracking"],
|
||||||
|
)
|
||||||
|
api_router.include_router(
|
||||||
|
settlements.router, prefix="/settlements", tags=["settlements"]
|
||||||
|
)
|
||||||
api_router.include_router(
|
api_router.include_router(
|
||||||
permissions.router, prefix="/permissions", tags=["permissions"]
|
permissions.router, prefix="/permissions", tags=["permissions"]
|
||||||
)
|
)
|
||||||
|
|||||||
40
backend/services/delivery/app/api/v1/availability.py
Normal file
40
backend/services/delivery/app/api/v1/availability.py
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
"""Driver availability APIs — Phase 10.3."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, require_tenant
|
||||||
|
from app.api.permissions import require_permissions
|
||||||
|
from app.commands.availability import AvailabilityCommands
|
||||||
|
from app.permissions.definitions import AVAILABILITY_MANAGE, AVAILABILITY_VIEW
|
||||||
|
from app.queries.availability import AvailabilityQueries
|
||||||
|
from app.schemas.availability import DriverAvailabilityRead, DriverAvailabilityUpsert
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{driver_id}/availability", response_model=DriverAvailabilityRead)
|
||||||
|
async def get_availability(
|
||||||
|
driver_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(AVAILABILITY_VIEW)),
|
||||||
|
):
|
||||||
|
return await AvailabilityQueries(db).get_availability(tenant_id, driver_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/{driver_id}/availability", response_model=DriverAvailabilityRead)
|
||||||
|
async def upsert_availability(
|
||||||
|
driver_id: UUID,
|
||||||
|
body: DriverAvailabilityUpsert,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(AVAILABILITY_MANAGE)),
|
||||||
|
):
|
||||||
|
return await AvailabilityCommands(db).upsert_availability(
|
||||||
|
tenant_id, driver_id, body, actor=user
|
||||||
|
)
|
||||||
100
backend/services/delivery/app/api/v1/bundles.py
Normal file
100
backend/services/delivery/app/api/v1/bundles.py
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
"""Capability bundle APIs — Phase 10.4."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, get_pagination, require_tenant
|
||||||
|
from app.api.permissions import require_permissions
|
||||||
|
from app.commands.pricing import PricingCommands
|
||||||
|
from app.models.types import CapabilityBundleStatus
|
||||||
|
from app.permissions.definitions import (
|
||||||
|
BUNDLES_CREATE,
|
||||||
|
BUNDLES_DELETE,
|
||||||
|
BUNDLES_UPDATE,
|
||||||
|
BUNDLES_VIEW,
|
||||||
|
)
|
||||||
|
from app.queries.pricing import PricingQueries
|
||||||
|
from app.schemas.pricing import (
|
||||||
|
BundleCreate,
|
||||||
|
BundleListResponse,
|
||||||
|
BundleRead,
|
||||||
|
BundleUpdate,
|
||||||
|
)
|
||||||
|
from app.specifications.pricing import BundleListSpec
|
||||||
|
from shared.pagination import PaginationParams
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _list_spec(
|
||||||
|
status_filter: CapabilityBundleStatus | None = Query(default=None, alias="status"),
|
||||||
|
q: str | None = Query(default=None, max_length=100),
|
||||||
|
sort_by: str = Query(default="created_at"),
|
||||||
|
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
||||||
|
) -> BundleListSpec:
|
||||||
|
return BundleListSpec(
|
||||||
|
status=status_filter, q=q, sort_by=sort_by, sort_dir=sort_dir.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=BundleRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_bundle(
|
||||||
|
body: BundleCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(BUNDLES_CREATE)),
|
||||||
|
):
|
||||||
|
return await PricingCommands(db).create_bundle(tenant_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=BundleListResponse)
|
||||||
|
async def list_bundles(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
pagination: PaginationParams = Depends(get_pagination),
|
||||||
|
spec: BundleListSpec = Depends(_list_spec),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(BUNDLES_VIEW)),
|
||||||
|
):
|
||||||
|
items, total = await PricingQueries(db).list_bundles(
|
||||||
|
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
||||||
|
)
|
||||||
|
return BundleListResponse(
|
||||||
|
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{bundle_id}", response_model=BundleRead)
|
||||||
|
async def get_bundle(
|
||||||
|
bundle_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(BUNDLES_VIEW)),
|
||||||
|
):
|
||||||
|
return await PricingQueries(db).get_bundle(tenant_id, bundle_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{bundle_id}", response_model=BundleRead)
|
||||||
|
async def update_bundle(
|
||||||
|
bundle_id: UUID,
|
||||||
|
body: BundleUpdate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(BUNDLES_UPDATE)),
|
||||||
|
):
|
||||||
|
return await PricingCommands(db).update_bundle(
|
||||||
|
tenant_id, bundle_id, body, actor=user
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{bundle_id}/delete", response_model=BundleRead)
|
||||||
|
async def delete_bundle(
|
||||||
|
bundle_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(BUNDLES_DELETE)),
|
||||||
|
):
|
||||||
|
return await PricingCommands(db).delete_bundle(tenant_id, bundle_id, actor=user)
|
||||||
100
backend/services/delivery/app/api/v1/capabilities.py
Normal file
100
backend/services/delivery/app/api/v1/capabilities.py
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
"""Capability APIs — Phase 10.4."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, get_pagination, require_tenant
|
||||||
|
from app.api.permissions import require_permissions
|
||||||
|
from app.commands.pricing import PricingCommands
|
||||||
|
from app.models.types import CapabilityStatus
|
||||||
|
from app.permissions.definitions import (
|
||||||
|
CAPABILITIES_CREATE,
|
||||||
|
CAPABILITIES_DELETE,
|
||||||
|
CAPABILITIES_UPDATE,
|
||||||
|
CAPABILITIES_VIEW,
|
||||||
|
)
|
||||||
|
from app.queries.pricing import PricingQueries
|
||||||
|
from app.schemas.pricing import (
|
||||||
|
CapabilityCreate,
|
||||||
|
CapabilityListResponse,
|
||||||
|
CapabilityRead,
|
||||||
|
CapabilityUpdate,
|
||||||
|
)
|
||||||
|
from app.specifications.pricing import CapabilityListSpec
|
||||||
|
from shared.pagination import PaginationParams
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _list_spec(
|
||||||
|
status_filter: CapabilityStatus | None = Query(default=None, alias="status"),
|
||||||
|
q: str | None = Query(default=None, max_length=100),
|
||||||
|
sort_by: str = Query(default="created_at"),
|
||||||
|
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
||||||
|
) -> CapabilityListSpec:
|
||||||
|
return CapabilityListSpec(
|
||||||
|
status=status_filter, q=q, sort_by=sort_by, sort_dir=sort_dir.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=CapabilityRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_capability(
|
||||||
|
body: CapabilityCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(CAPABILITIES_CREATE)),
|
||||||
|
):
|
||||||
|
return await PricingCommands(db).create_capability(tenant_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=CapabilityListResponse)
|
||||||
|
async def list_capabilities(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
pagination: PaginationParams = Depends(get_pagination),
|
||||||
|
spec: CapabilityListSpec = Depends(_list_spec),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(CAPABILITIES_VIEW)),
|
||||||
|
):
|
||||||
|
items, total = await PricingQueries(db).list_capabilities(
|
||||||
|
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
||||||
|
)
|
||||||
|
return CapabilityListResponse(
|
||||||
|
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{cap_id}", response_model=CapabilityRead)
|
||||||
|
async def get_capability(
|
||||||
|
cap_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(CAPABILITIES_VIEW)),
|
||||||
|
):
|
||||||
|
return await PricingQueries(db).get_capability(tenant_id, cap_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{cap_id}", response_model=CapabilityRead)
|
||||||
|
async def update_capability(
|
||||||
|
cap_id: UUID,
|
||||||
|
body: CapabilityUpdate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(CAPABILITIES_UPDATE)),
|
||||||
|
):
|
||||||
|
return await PricingCommands(db).update_capability(
|
||||||
|
tenant_id, cap_id, body, actor=user
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{cap_id}/delete", response_model=CapabilityRead)
|
||||||
|
async def delete_capability(
|
||||||
|
cap_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(CAPABILITIES_DELETE)),
|
||||||
|
):
|
||||||
|
return await PricingCommands(db).delete_capability(tenant_id, cap_id, actor=user)
|
||||||
88
backend/services/delivery/app/api/v1/customer_tracking.py
Normal file
88
backend/services/delivery/app/api/v1/customer_tracking.py
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
"""Customer tracking token APIs — Phase 10.7."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, get_pagination, require_tenant
|
||||||
|
from app.api.permissions import require_permissions
|
||||||
|
from app.commands.tracking import TrackingCommands
|
||||||
|
from app.models.types import CustomerTrackingTokenStatus
|
||||||
|
from app.permissions.definitions import (
|
||||||
|
CUSTOMER_TRACKING_CREATE,
|
||||||
|
CUSTOMER_TRACKING_REVOKE,
|
||||||
|
CUSTOMER_TRACKING_VIEW,
|
||||||
|
)
|
||||||
|
from app.queries.tracking import TrackingQueries
|
||||||
|
from app.schemas.tracking import (
|
||||||
|
CustomerTrackingTokenCreate,
|
||||||
|
CustomerTrackingTokenListResponse,
|
||||||
|
CustomerTrackingTokenRead,
|
||||||
|
)
|
||||||
|
from app.specifications.tracking import CustomerTrackingTokenListSpec
|
||||||
|
from shared.pagination import PaginationParams
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _list_spec(
|
||||||
|
status_filter: CustomerTrackingTokenStatus | None = Query(default=None, alias="status"),
|
||||||
|
dispatch_job_id: UUID | None = Query(default=None),
|
||||||
|
sort_by: str = Query(default="created_at"),
|
||||||
|
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
||||||
|
) -> CustomerTrackingTokenListSpec:
|
||||||
|
return CustomerTrackingTokenListSpec(
|
||||||
|
status=status_filter,
|
||||||
|
dispatch_job_id=dispatch_job_id,
|
||||||
|
sort_by=sort_by,
|
||||||
|
sort_dir=sort_dir.lower(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tokens", response_model=CustomerTrackingTokenRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_token(
|
||||||
|
body: CustomerTrackingTokenCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(CUSTOMER_TRACKING_CREATE)),
|
||||||
|
):
|
||||||
|
return await TrackingCommands(db).create_token(tenant_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tokens", response_model=CustomerTrackingTokenListResponse)
|
||||||
|
async def list_tokens(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
pagination: PaginationParams = Depends(get_pagination),
|
||||||
|
spec: CustomerTrackingTokenListSpec = Depends(_list_spec),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(CUSTOMER_TRACKING_VIEW)),
|
||||||
|
):
|
||||||
|
items, total = await TrackingQueries(db).list_tokens(
|
||||||
|
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
||||||
|
)
|
||||||
|
return CustomerTrackingTokenListResponse(
|
||||||
|
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/tokens/{token_id}", response_model=CustomerTrackingTokenRead)
|
||||||
|
async def get_token(
|
||||||
|
token_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(CUSTOMER_TRACKING_VIEW)),
|
||||||
|
):
|
||||||
|
return await TrackingQueries(db).get_token(tenant_id, token_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tokens/{token_id}/revoke", response_model=CustomerTrackingTokenRead)
|
||||||
|
async def revoke_token(
|
||||||
|
token_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(CUSTOMER_TRACKING_REVOKE)),
|
||||||
|
):
|
||||||
|
return await TrackingCommands(db).revoke_token(tenant_id, token_id, actor=user)
|
||||||
94
backend/services/delivery/app/api/v1/dispatch_assignments.py
Normal file
94
backend/services/delivery/app/api/v1/dispatch_assignments.py
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
"""Dispatch assignment APIs — Phase 10.5."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, get_pagination, require_tenant
|
||||||
|
from app.api.permissions import require_permissions
|
||||||
|
from app.commands.dispatch import DispatchCommands
|
||||||
|
from app.models.types import JobAssignmentStatus
|
||||||
|
from app.permissions.definitions import (
|
||||||
|
DISPATCH_ASSIGNMENTS_CREATE,
|
||||||
|
DISPATCH_ASSIGNMENTS_UPDATE,
|
||||||
|
DISPATCH_ASSIGNMENTS_VIEW,
|
||||||
|
)
|
||||||
|
from app.queries.dispatch import DispatchQueries
|
||||||
|
from app.schemas.dispatch import (
|
||||||
|
JobAssignmentCreate,
|
||||||
|
JobAssignmentListResponse,
|
||||||
|
JobAssignmentRead,
|
||||||
|
JobAssignmentUpdate,
|
||||||
|
)
|
||||||
|
from app.specifications.dispatch import JobAssignmentListSpec
|
||||||
|
from shared.pagination import PaginationParams
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _list_spec(
|
||||||
|
dispatch_job_id: UUID | None = Query(default=None),
|
||||||
|
driver_id: UUID | None = Query(default=None),
|
||||||
|
status_filter: JobAssignmentStatus | None = Query(default=None, alias="status"),
|
||||||
|
sort_by: str = Query(default="created_at"),
|
||||||
|
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
||||||
|
) -> JobAssignmentListSpec:
|
||||||
|
return JobAssignmentListSpec(
|
||||||
|
dispatch_job_id=dispatch_job_id,
|
||||||
|
driver_id=driver_id,
|
||||||
|
status=status_filter,
|
||||||
|
sort_by=sort_by,
|
||||||
|
sort_dir=sort_dir.lower(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=JobAssignmentRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_assignment(
|
||||||
|
body: JobAssignmentCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(DISPATCH_ASSIGNMENTS_CREATE)),
|
||||||
|
):
|
||||||
|
return await DispatchCommands(db).create_assignment(tenant_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=JobAssignmentListResponse)
|
||||||
|
async def list_assignments(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
pagination: PaginationParams = Depends(get_pagination),
|
||||||
|
spec: JobAssignmentListSpec = Depends(_list_spec),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(DISPATCH_ASSIGNMENTS_VIEW)),
|
||||||
|
):
|
||||||
|
items, total = await DispatchQueries(db).list_assignments(
|
||||||
|
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
||||||
|
)
|
||||||
|
return JobAssignmentListResponse(
|
||||||
|
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{assignment_id}", response_model=JobAssignmentRead)
|
||||||
|
async def get_assignment(
|
||||||
|
assignment_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(DISPATCH_ASSIGNMENTS_VIEW)),
|
||||||
|
):
|
||||||
|
return await DispatchQueries(db).get_assignment(tenant_id, assignment_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{assignment_id}", response_model=JobAssignmentRead)
|
||||||
|
async def update_assignment(
|
||||||
|
assignment_id: UUID,
|
||||||
|
body: JobAssignmentUpdate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(DISPATCH_ASSIGNMENTS_UPDATE)),
|
||||||
|
):
|
||||||
|
return await DispatchCommands(db).update_assignment(
|
||||||
|
tenant_id, assignment_id, body, actor=user
|
||||||
|
)
|
||||||
120
backend/services/delivery/app/api/v1/dispatch_jobs.py
Normal file
120
backend/services/delivery/app/api/v1/dispatch_jobs.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
"""Dispatch job APIs — Phase 10.5."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, get_pagination, require_tenant
|
||||||
|
from app.api.permissions import require_permissions
|
||||||
|
from app.commands.dispatch import DispatchCommands
|
||||||
|
from app.models.types import DispatchJobStatus
|
||||||
|
from app.permissions.definitions import (
|
||||||
|
DISPATCH_JOBS_CREATE,
|
||||||
|
DISPATCH_JOBS_DELETE,
|
||||||
|
DISPATCH_JOBS_STATUS,
|
||||||
|
DISPATCH_JOBS_UPDATE,
|
||||||
|
DISPATCH_JOBS_VIEW,
|
||||||
|
)
|
||||||
|
from app.queries.dispatch import DispatchQueries
|
||||||
|
from app.schemas.dispatch import (
|
||||||
|
DispatchJobCreate,
|
||||||
|
DispatchJobListResponse,
|
||||||
|
DispatchJobRead,
|
||||||
|
DispatchJobStatusRequest,
|
||||||
|
DispatchJobUpdate,
|
||||||
|
)
|
||||||
|
from app.specifications.dispatch import DispatchJobListSpec
|
||||||
|
from shared.pagination import PaginationParams
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _list_spec(
|
||||||
|
status_filter: DispatchJobStatus | None = Query(default=None, alias="status"),
|
||||||
|
organization_id: UUID | None = Query(default=None),
|
||||||
|
hub_id: UUID | None = Query(default=None),
|
||||||
|
q: str | None = Query(default=None, max_length=100),
|
||||||
|
sort_by: str = Query(default="created_at"),
|
||||||
|
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
||||||
|
) -> DispatchJobListSpec:
|
||||||
|
return DispatchJobListSpec(
|
||||||
|
status=status_filter,
|
||||||
|
organization_id=organization_id,
|
||||||
|
hub_id=hub_id,
|
||||||
|
q=q,
|
||||||
|
sort_by=sort_by,
|
||||||
|
sort_dir=sort_dir.lower(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=DispatchJobRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_dispatch_job(
|
||||||
|
body: DispatchJobCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_CREATE)),
|
||||||
|
):
|
||||||
|
return await DispatchCommands(db).create_job(tenant_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=DispatchJobListResponse)
|
||||||
|
async def list_dispatch_jobs(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
pagination: PaginationParams = Depends(get_pagination),
|
||||||
|
spec: DispatchJobListSpec = Depends(_list_spec),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_VIEW)),
|
||||||
|
):
|
||||||
|
items, total = await DispatchQueries(db).list_jobs(
|
||||||
|
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
||||||
|
)
|
||||||
|
return DispatchJobListResponse(
|
||||||
|
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{job_id}", response_model=DispatchJobRead)
|
||||||
|
async def get_dispatch_job(
|
||||||
|
job_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_VIEW)),
|
||||||
|
):
|
||||||
|
return await DispatchQueries(db).get_job(tenant_id, job_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{job_id}", response_model=DispatchJobRead)
|
||||||
|
async def update_dispatch_job(
|
||||||
|
job_id: UUID,
|
||||||
|
body: DispatchJobUpdate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_UPDATE)),
|
||||||
|
):
|
||||||
|
return await DispatchCommands(db).update_job(tenant_id, job_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{job_id}/status", response_model=DispatchJobRead)
|
||||||
|
async def apply_dispatch_status(
|
||||||
|
job_id: UUID,
|
||||||
|
body: DispatchJobStatusRequest,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_STATUS)),
|
||||||
|
):
|
||||||
|
return await DispatchCommands(db).apply_status(
|
||||||
|
tenant_id, job_id, body, actor=user
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{job_id}/delete", response_model=DispatchJobRead)
|
||||||
|
async def delete_dispatch_job(
|
||||||
|
job_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(DISPATCH_JOBS_DELETE)),
|
||||||
|
):
|
||||||
|
return await DispatchCommands(db).delete_job(tenant_id, job_id, actor=user)
|
||||||
102
backend/services/delivery/app/api/v1/fleets.py
Normal file
102
backend/services/delivery/app/api/v1/fleets.py
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
"""Fleet management APIs — Phase 10.2."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, get_pagination, require_tenant
|
||||||
|
from app.api.permissions import require_permissions
|
||||||
|
from app.commands.fleet import FleetCommands
|
||||||
|
from app.models.types import FleetStatus, VehicleStatus
|
||||||
|
from app.permissions.definitions import (
|
||||||
|
FLEETS_CREATE,
|
||||||
|
FLEETS_DELETE,
|
||||||
|
FLEETS_UPDATE,
|
||||||
|
FLEETS_VIEW,
|
||||||
|
)
|
||||||
|
from app.queries.fleet import FleetQueries
|
||||||
|
from app.schemas.fleet import FleetCreate, FleetListResponse, FleetRead, FleetUpdate
|
||||||
|
from app.specifications.fleet import FLEET_SORT, FleetListSpec
|
||||||
|
from shared.pagination import PaginationParams
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _list_spec(
|
||||||
|
status_filter: FleetStatus | None = Query(default=None, alias="status"),
|
||||||
|
organization_id: UUID | None = Query(default=None),
|
||||||
|
hub_id: UUID | None = Query(default=None),
|
||||||
|
q: str | None = Query(default=None, max_length=100),
|
||||||
|
sort_by: str = Query(default="created_at"),
|
||||||
|
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
||||||
|
) -> FleetListSpec:
|
||||||
|
if sort_by not in FLEET_SORT:
|
||||||
|
sort_by = "created_at"
|
||||||
|
return FleetListSpec(
|
||||||
|
status=status_filter,
|
||||||
|
organization_id=organization_id,
|
||||||
|
hub_id=hub_id,
|
||||||
|
q=q,
|
||||||
|
sort_by=sort_by,
|
||||||
|
sort_dir=sort_dir.lower(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=FleetRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_fleet(
|
||||||
|
body: FleetCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(FLEETS_CREATE)),
|
||||||
|
):
|
||||||
|
return await FleetCommands(db).create_fleet(tenant_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=FleetListResponse)
|
||||||
|
async def list_fleets(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
pagination: PaginationParams = Depends(get_pagination),
|
||||||
|
spec: FleetListSpec = Depends(_list_spec),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(FLEETS_VIEW)),
|
||||||
|
):
|
||||||
|
items, total = await FleetQueries(db).list_fleets(
|
||||||
|
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
||||||
|
)
|
||||||
|
return FleetListResponse(
|
||||||
|
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{fleet_id}", response_model=FleetRead)
|
||||||
|
async def get_fleet(
|
||||||
|
fleet_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(FLEETS_VIEW)),
|
||||||
|
):
|
||||||
|
return await FleetQueries(db).get_fleet(tenant_id, fleet_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{fleet_id}", response_model=FleetRead)
|
||||||
|
async def update_fleet(
|
||||||
|
fleet_id: UUID,
|
||||||
|
body: FleetUpdate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(FLEETS_UPDATE)),
|
||||||
|
):
|
||||||
|
return await FleetCommands(db).update_fleet(tenant_id, fleet_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{fleet_id}/delete", response_model=FleetRead)
|
||||||
|
async def delete_fleet(
|
||||||
|
fleet_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(FLEETS_DELETE)),
|
||||||
|
):
|
||||||
|
return await FleetCommands(db).delete_fleet(tenant_id, fleet_id, actor=user)
|
||||||
@ -20,7 +20,7 @@ async def capabilities():
|
|||||||
return {
|
return {
|
||||||
"service": settings.service_name,
|
"service": settings.service_name,
|
||||||
"version": __version__,
|
"version": __version__,
|
||||||
"phase": "10.1",
|
"phase": "10.8",
|
||||||
"commercial_product": "Torbat Driver",
|
"commercial_product": "Torbat Driver",
|
||||||
"features": {
|
"features": {
|
||||||
"foundation": True,
|
"foundation": True,
|
||||||
@ -33,12 +33,23 @@ async def capabilities():
|
|||||||
"driver_lifecycle": True,
|
"driver_lifecycle": True,
|
||||||
"driver_credentials": True,
|
"driver_credentials": True,
|
||||||
"driver_documents": True,
|
"driver_documents": True,
|
||||||
"fleet": False,
|
"fleet": True,
|
||||||
"dispatch_engine": False,
|
"vehicle_types": True,
|
||||||
"routing_engine": False,
|
"vehicles": True,
|
||||||
"tracking": False,
|
"vehicle_assignments": True,
|
||||||
"proof_of_delivery": False,
|
"driver_availability": True,
|
||||||
"settlement": False,
|
"shifts": True,
|
||||||
|
"working_zones": True,
|
||||||
|
"pricing_rules": True,
|
||||||
|
"capabilities_catalog": True,
|
||||||
|
"capability_bundles": True,
|
||||||
|
"dispatch_engine": True,
|
||||||
|
"routing_engine": True,
|
||||||
|
"optimization_runs": True,
|
||||||
|
"tracking": True,
|
||||||
|
"proof_of_delivery": True,
|
||||||
|
"customer_tracking": True,
|
||||||
|
"settlement": True,
|
||||||
"ai": False,
|
"ai": False,
|
||||||
},
|
},
|
||||||
"independence": {
|
"independence": {
|
||||||
@ -56,31 +67,34 @@ async def capabilities():
|
|||||||
"pharmacy",
|
"pharmacy",
|
||||||
],
|
],
|
||||||
"integration_mode": "api_and_events_only",
|
"integration_mode": "api_and_events_only",
|
||||||
|
"accounting_mode": "settlement_refs_only",
|
||||||
|
"no_journal_entries_in_delivery_db": True,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/metrics")
|
@router.get("/metrics")
|
||||||
async def metrics():
|
async def metrics():
|
||||||
"""Phase 10.1 metrics discovery — driver counters reserved for ops wiring."""
|
"""Phase 10.8 metrics discovery — tenant-scoped counters."""
|
||||||
return {
|
return {
|
||||||
"service": settings.service_name,
|
"service": settings.service_name,
|
||||||
"version": __version__,
|
"version": __version__,
|
||||||
"phase": "10.1",
|
"phase": "10.8",
|
||||||
"metrics": {
|
"metrics": {
|
||||||
"organizations": "tenant_scoped",
|
"organizations": "tenant_scoped",
|
||||||
"hubs": "tenant_scoped",
|
"hubs": "tenant_scoped",
|
||||||
"external_providers": "tenant_scoped",
|
|
||||||
"routing_engines": "tenant_scoped",
|
|
||||||
"configurations": "tenant_scoped",
|
|
||||||
"drivers": "tenant_scoped",
|
"drivers": "tenant_scoped",
|
||||||
"driver_credentials": "tenant_scoped",
|
"fleets": "tenant_scoped",
|
||||||
"driver_documents": "tenant_scoped",
|
"vehicles": "tenant_scoped",
|
||||||
"driver_lifecycle_events": "tenant_scoped",
|
"shifts": "tenant_scoped",
|
||||||
|
"pricing_rules": "tenant_scoped",
|
||||||
|
"dispatch_jobs": "tenant_scoped",
|
||||||
|
"route_plans": "tenant_scoped",
|
||||||
|
"optimization_runs": "tenant_scoped",
|
||||||
|
"tracking_sessions": "tenant_scoped",
|
||||||
|
"proof_of_delivery": "tenant_scoped",
|
||||||
|
"settlement_intents": "tenant_scoped",
|
||||||
"outbox_events": "tenant_scoped",
|
"outbox_events": "tenant_scoped",
|
||||||
"dispatch_jobs": 0,
|
|
||||||
"active_routes": 0,
|
|
||||||
"tracking_sessions": 0,
|
|
||||||
},
|
},
|
||||||
"note": "Driver aggregates live; dispatch/routing/tracking counters reserved for later phases",
|
"note": "Full delivery platform through phase 10.8; settlement uses AccountingProvider refs only",
|
||||||
}
|
}
|
||||||
|
|||||||
74
backend/services/delivery/app/api/v1/optimization.py
Normal file
74
backend/services/delivery/app/api/v1/optimization.py
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
"""Optimization run APIs — Phase 10.6."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, get_pagination, require_tenant
|
||||||
|
from app.api.permissions import require_permissions
|
||||||
|
from app.commands.routing import RoutingCommands
|
||||||
|
from app.models.types import OptimizationRunStatus
|
||||||
|
from app.permissions.definitions import OPTIMIZATION_RUNS_CREATE, OPTIMIZATION_RUNS_VIEW
|
||||||
|
from app.queries.routing import RoutingQueries
|
||||||
|
from app.schemas.routing import (
|
||||||
|
OptimizationRunCreate,
|
||||||
|
OptimizationRunListResponse,
|
||||||
|
OptimizationRunRead,
|
||||||
|
)
|
||||||
|
from app.specifications.routing import OptimizationRunListSpec
|
||||||
|
from shared.pagination import PaginationParams
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _list_spec(
|
||||||
|
route_plan_id: UUID | None = Query(default=None),
|
||||||
|
status_filter: OptimizationRunStatus | None = Query(default=None, alias="status"),
|
||||||
|
sort_by: str = Query(default="created_at"),
|
||||||
|
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
||||||
|
) -> OptimizationRunListSpec:
|
||||||
|
return OptimizationRunListSpec(
|
||||||
|
route_plan_id=route_plan_id,
|
||||||
|
status=status_filter,
|
||||||
|
sort_by=sort_by,
|
||||||
|
sort_dir=sort_dir.lower(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/runs", response_model=OptimizationRunRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def start_optimization(
|
||||||
|
body: OptimizationRunCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(OPTIMIZATION_RUNS_CREATE)),
|
||||||
|
):
|
||||||
|
return await RoutingCommands(db).start_optimization(tenant_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/runs", response_model=OptimizationRunListResponse)
|
||||||
|
async def list_optimization_runs(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
pagination: PaginationParams = Depends(get_pagination),
|
||||||
|
spec: OptimizationRunListSpec = Depends(_list_spec),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(OPTIMIZATION_RUNS_VIEW)),
|
||||||
|
):
|
||||||
|
items, total = await RoutingQueries(db).list_optimization_runs(
|
||||||
|
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
||||||
|
)
|
||||||
|
return OptimizationRunListResponse(
|
||||||
|
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/runs/{run_id}", response_model=OptimizationRunRead)
|
||||||
|
async def get_optimization_run(
|
||||||
|
run_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(OPTIMIZATION_RUNS_VIEW)),
|
||||||
|
):
|
||||||
|
return await RoutingQueries(db).get_optimization_run(tenant_id, run_id)
|
||||||
103
backend/services/delivery/app/api/v1/pricing_rules.py
Normal file
103
backend/services/delivery/app/api/v1/pricing_rules.py
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
"""Pricing rule APIs — Phase 10.4."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, get_pagination, require_tenant
|
||||||
|
from app.api.permissions import require_permissions
|
||||||
|
from app.commands.pricing import PricingCommands
|
||||||
|
from app.models.types import PricingRuleStatus
|
||||||
|
from app.permissions.definitions import (
|
||||||
|
PRICING_RULES_CREATE,
|
||||||
|
PRICING_RULES_DELETE,
|
||||||
|
PRICING_RULES_UPDATE,
|
||||||
|
PRICING_RULES_VIEW,
|
||||||
|
)
|
||||||
|
from app.queries.pricing import PricingQueries
|
||||||
|
from app.schemas.pricing import (
|
||||||
|
PricingRuleCreate,
|
||||||
|
PricingRuleListResponse,
|
||||||
|
PricingRuleRead,
|
||||||
|
PricingRuleUpdate,
|
||||||
|
)
|
||||||
|
from app.specifications.pricing import PricingRuleListSpec
|
||||||
|
from shared.pagination import PaginationParams
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _list_spec(
|
||||||
|
status_filter: PricingRuleStatus | None = Query(default=None, alias="status"),
|
||||||
|
organization_id: UUID | None = Query(default=None),
|
||||||
|
q: str | None = Query(default=None, max_length=100),
|
||||||
|
sort_by: str = Query(default="created_at"),
|
||||||
|
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
||||||
|
) -> PricingRuleListSpec:
|
||||||
|
return PricingRuleListSpec(
|
||||||
|
status=status_filter,
|
||||||
|
organization_id=organization_id,
|
||||||
|
q=q,
|
||||||
|
sort_by=sort_by,
|
||||||
|
sort_dir=sort_dir.lower(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=PricingRuleRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_pricing_rule(
|
||||||
|
body: PricingRuleCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(PRICING_RULES_CREATE)),
|
||||||
|
):
|
||||||
|
return await PricingCommands(db).create_rule(tenant_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=PricingRuleListResponse)
|
||||||
|
async def list_pricing_rules(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
pagination: PaginationParams = Depends(get_pagination),
|
||||||
|
spec: PricingRuleListSpec = Depends(_list_spec),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(PRICING_RULES_VIEW)),
|
||||||
|
):
|
||||||
|
items, total = await PricingQueries(db).list_rules(
|
||||||
|
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
||||||
|
)
|
||||||
|
return PricingRuleListResponse(
|
||||||
|
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{rule_id}", response_model=PricingRuleRead)
|
||||||
|
async def get_pricing_rule(
|
||||||
|
rule_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(PRICING_RULES_VIEW)),
|
||||||
|
):
|
||||||
|
return await PricingQueries(db).get_rule(tenant_id, rule_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{rule_id}", response_model=PricingRuleRead)
|
||||||
|
async def update_pricing_rule(
|
||||||
|
rule_id: UUID,
|
||||||
|
body: PricingRuleUpdate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(PRICING_RULES_UPDATE)),
|
||||||
|
):
|
||||||
|
return await PricingCommands(db).update_rule(tenant_id, rule_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{rule_id}/delete", response_model=PricingRuleRead)
|
||||||
|
async def delete_pricing_rule(
|
||||||
|
rule_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(PRICING_RULES_DELETE)),
|
||||||
|
):
|
||||||
|
return await PricingCommands(db).delete_rule(tenant_id, rule_id, actor=user)
|
||||||
90
backend/services/delivery/app/api/v1/proof_of_delivery.py
Normal file
90
backend/services/delivery/app/api/v1/proof_of_delivery.py
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
"""Proof of delivery APIs — Phase 10.7."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, get_pagination, require_tenant
|
||||||
|
from app.api.permissions import require_permissions
|
||||||
|
from app.commands.tracking import TrackingCommands
|
||||||
|
from app.models.types import ProofOfDeliveryStatus
|
||||||
|
from app.permissions.definitions import (
|
||||||
|
PROOF_OF_DELIVERY_CREATE,
|
||||||
|
PROOF_OF_DELIVERY_UPDATE,
|
||||||
|
PROOF_OF_DELIVERY_VIEW,
|
||||||
|
)
|
||||||
|
from app.queries.tracking import TrackingQueries
|
||||||
|
from app.schemas.tracking import (
|
||||||
|
ProofOfDeliveryCreate,
|
||||||
|
ProofOfDeliveryListResponse,
|
||||||
|
ProofOfDeliveryRead,
|
||||||
|
ProofOfDeliveryUpdate,
|
||||||
|
)
|
||||||
|
from app.specifications.tracking import ProofOfDeliveryListSpec
|
||||||
|
from shared.pagination import PaginationParams
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _list_spec(
|
||||||
|
status_filter: ProofOfDeliveryStatus | None = Query(default=None, alias="status"),
|
||||||
|
dispatch_job_id: UUID | None = Query(default=None),
|
||||||
|
sort_by: str = Query(default="created_at"),
|
||||||
|
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
||||||
|
) -> ProofOfDeliveryListSpec:
|
||||||
|
return ProofOfDeliveryListSpec(
|
||||||
|
status=status_filter,
|
||||||
|
dispatch_job_id=dispatch_job_id,
|
||||||
|
sort_by=sort_by,
|
||||||
|
sort_dir=sort_dir.lower(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ProofOfDeliveryRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def capture_pod(
|
||||||
|
body: ProofOfDeliveryCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(PROOF_OF_DELIVERY_CREATE)),
|
||||||
|
):
|
||||||
|
return await TrackingCommands(db).capture_pod(tenant_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ProofOfDeliveryListResponse)
|
||||||
|
async def list_pods(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
pagination: PaginationParams = Depends(get_pagination),
|
||||||
|
spec: ProofOfDeliveryListSpec = Depends(_list_spec),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(PROOF_OF_DELIVERY_VIEW)),
|
||||||
|
):
|
||||||
|
items, total = await TrackingQueries(db).list_pods(
|
||||||
|
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
||||||
|
)
|
||||||
|
return ProofOfDeliveryListResponse(
|
||||||
|
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{pod_id}", response_model=ProofOfDeliveryRead)
|
||||||
|
async def get_pod(
|
||||||
|
pod_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(PROOF_OF_DELIVERY_VIEW)),
|
||||||
|
):
|
||||||
|
return await TrackingQueries(db).get_pod(tenant_id, pod_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{pod_id}", response_model=ProofOfDeliveryRead)
|
||||||
|
async def update_pod(
|
||||||
|
pod_id: UUID,
|
||||||
|
body: ProofOfDeliveryUpdate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(PROOF_OF_DELIVERY_UPDATE)),
|
||||||
|
):
|
||||||
|
return await TrackingCommands(db).update_pod(tenant_id, pod_id, body, actor=user)
|
||||||
134
backend/services/delivery/app/api/v1/routes.py
Normal file
134
backend/services/delivery/app/api/v1/routes.py
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
"""Route plan APIs — Phase 10.6."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, get_pagination, require_tenant
|
||||||
|
from app.api.permissions import require_permissions
|
||||||
|
from app.commands.routing import RoutingCommands
|
||||||
|
from app.models.types import RoutePlanStatus
|
||||||
|
from app.permissions.definitions import (
|
||||||
|
ROUTES_CREATE,
|
||||||
|
ROUTES_DELETE,
|
||||||
|
ROUTES_STOPS_MANAGE,
|
||||||
|
ROUTES_STOPS_VIEW,
|
||||||
|
ROUTES_UPDATE,
|
||||||
|
ROUTES_VIEW,
|
||||||
|
)
|
||||||
|
from app.queries.routing import RoutingQueries
|
||||||
|
from app.schemas.routing import (
|
||||||
|
RoutePlanCreate,
|
||||||
|
RoutePlanListResponse,
|
||||||
|
RoutePlanRead,
|
||||||
|
RoutePlanUpdate,
|
||||||
|
RouteStopCreate,
|
||||||
|
RouteStopRead,
|
||||||
|
)
|
||||||
|
from app.specifications.routing import RoutePlanListSpec
|
||||||
|
from shared.pagination import PaginationParams
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _list_spec(
|
||||||
|
status_filter: RoutePlanStatus | None = Query(default=None, alias="status"),
|
||||||
|
organization_id: UUID | None = Query(default=None),
|
||||||
|
driver_id: UUID | None = Query(default=None),
|
||||||
|
q: str | None = Query(default=None, max_length=100),
|
||||||
|
sort_by: str = Query(default="created_at"),
|
||||||
|
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
||||||
|
) -> RoutePlanListSpec:
|
||||||
|
return RoutePlanListSpec(
|
||||||
|
status=status_filter,
|
||||||
|
organization_id=organization_id,
|
||||||
|
driver_id=driver_id,
|
||||||
|
q=q,
|
||||||
|
sort_by=sort_by,
|
||||||
|
sort_dir=sort_dir.lower(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=RoutePlanRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_route(
|
||||||
|
body: RoutePlanCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(ROUTES_CREATE)),
|
||||||
|
):
|
||||||
|
return await RoutingCommands(db).create_route(tenant_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=RoutePlanListResponse)
|
||||||
|
async def list_routes(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
pagination: PaginationParams = Depends(get_pagination),
|
||||||
|
spec: RoutePlanListSpec = Depends(_list_spec),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(ROUTES_VIEW)),
|
||||||
|
):
|
||||||
|
items, total = await RoutingQueries(db).list_routes(
|
||||||
|
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
||||||
|
)
|
||||||
|
return RoutePlanListResponse(
|
||||||
|
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{plan_id}", response_model=RoutePlanRead)
|
||||||
|
async def get_route(
|
||||||
|
plan_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(ROUTES_VIEW)),
|
||||||
|
):
|
||||||
|
return await RoutingQueries(db).get_route(tenant_id, plan_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{plan_id}", response_model=RoutePlanRead)
|
||||||
|
async def update_route(
|
||||||
|
plan_id: UUID,
|
||||||
|
body: RoutePlanUpdate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(ROUTES_UPDATE)),
|
||||||
|
):
|
||||||
|
return await RoutingCommands(db).update_route(tenant_id, plan_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{plan_id}/delete", response_model=RoutePlanRead)
|
||||||
|
async def delete_route(
|
||||||
|
plan_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(ROUTES_DELETE)),
|
||||||
|
):
|
||||||
|
return await RoutingCommands(db).delete_route(tenant_id, plan_id, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{plan_id}/stops",
|
||||||
|
response_model=RouteStopRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def add_stop(
|
||||||
|
plan_id: UUID,
|
||||||
|
body: RouteStopCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(ROUTES_STOPS_MANAGE)),
|
||||||
|
):
|
||||||
|
return await RoutingCommands(db).add_stop(tenant_id, plan_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{plan_id}/stops", response_model=list[RouteStopRead])
|
||||||
|
async def list_stops(
|
||||||
|
plan_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(ROUTES_STOPS_VIEW)),
|
||||||
|
):
|
||||||
|
return await RoutingQueries(db).list_stops(tenant_id, plan_id)
|
||||||
169
backend/services/delivery/app/api/v1/settlements.py
Normal file
169
backend/services/delivery/app/api/v1/settlements.py
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
"""Settlement APIs — Phase 10.8."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, get_pagination, require_tenant
|
||||||
|
from app.api.permissions import require_permissions
|
||||||
|
from app.commands.settlement import SettlementCommands
|
||||||
|
from app.models.types import SettlementIntentStatus
|
||||||
|
from app.permissions.definitions import (
|
||||||
|
SETTLEMENTS_CREATE,
|
||||||
|
SETTLEMENTS_LINES_MANAGE,
|
||||||
|
SETTLEMENTS_LINES_VIEW,
|
||||||
|
SETTLEMENTS_STATUS,
|
||||||
|
SETTLEMENTS_UPDATE,
|
||||||
|
SETTLEMENTS_VIEW,
|
||||||
|
)
|
||||||
|
from app.queries.settlement import SettlementQueries
|
||||||
|
from app.schemas.settlement import (
|
||||||
|
SettlementActionRequest,
|
||||||
|
SettlementIntentCreate,
|
||||||
|
SettlementIntentListResponse,
|
||||||
|
SettlementIntentRead,
|
||||||
|
SettlementIntentUpdate,
|
||||||
|
SettlementLineCreate,
|
||||||
|
SettlementLineRead,
|
||||||
|
)
|
||||||
|
from app.specifications.settlement import SettlementIntentListSpec
|
||||||
|
from shared.pagination import PaginationParams
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _list_spec(
|
||||||
|
status_filter: SettlementIntentStatus | None = Query(default=None, alias="status"),
|
||||||
|
organization_id: UUID | None = Query(default=None),
|
||||||
|
driver_id: UUID | None = Query(default=None),
|
||||||
|
q: str | None = Query(default=None, max_length=100),
|
||||||
|
sort_by: str = Query(default="created_at"),
|
||||||
|
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
||||||
|
) -> SettlementIntentListSpec:
|
||||||
|
return SettlementIntentListSpec(
|
||||||
|
status=status_filter,
|
||||||
|
organization_id=organization_id,
|
||||||
|
driver_id=driver_id,
|
||||||
|
q=q,
|
||||||
|
sort_by=sort_by,
|
||||||
|
sort_dir=sort_dir.lower(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=SettlementIntentRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_settlement(
|
||||||
|
body: SettlementIntentCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_CREATE)),
|
||||||
|
):
|
||||||
|
return await SettlementCommands(db).create(tenant_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=SettlementIntentListResponse)
|
||||||
|
async def list_settlements(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
pagination: PaginationParams = Depends(get_pagination),
|
||||||
|
spec: SettlementIntentListSpec = Depends(_list_spec),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(SETTLEMENTS_VIEW)),
|
||||||
|
):
|
||||||
|
items, total = await SettlementQueries(db).list(
|
||||||
|
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
||||||
|
)
|
||||||
|
return SettlementIntentListResponse(
|
||||||
|
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{intent_id}", response_model=SettlementIntentRead)
|
||||||
|
async def get_settlement(
|
||||||
|
intent_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(SETTLEMENTS_VIEW)),
|
||||||
|
):
|
||||||
|
return await SettlementQueries(db).get(tenant_id, intent_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{intent_id}", response_model=SettlementIntentRead)
|
||||||
|
async def update_settlement(
|
||||||
|
intent_id: UUID,
|
||||||
|
body: SettlementIntentUpdate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_UPDATE)),
|
||||||
|
):
|
||||||
|
return await SettlementCommands(db).update(tenant_id, intent_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{intent_id}/submit", response_model=SettlementIntentRead)
|
||||||
|
async def submit_settlement(
|
||||||
|
intent_id: UUID,
|
||||||
|
body: SettlementActionRequest,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_STATUS)),
|
||||||
|
):
|
||||||
|
return await SettlementCommands(db).submit(tenant_id, intent_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{intent_id}/settle", response_model=SettlementIntentRead)
|
||||||
|
async def settle_settlement(
|
||||||
|
intent_id: UUID,
|
||||||
|
body: SettlementActionRequest,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_STATUS)),
|
||||||
|
):
|
||||||
|
return await SettlementCommands(db).settle(tenant_id, intent_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{intent_id}/fail", response_model=SettlementIntentRead)
|
||||||
|
async def fail_settlement(
|
||||||
|
intent_id: UUID,
|
||||||
|
body: SettlementActionRequest,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_STATUS)),
|
||||||
|
):
|
||||||
|
return await SettlementCommands(db).fail(tenant_id, intent_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{intent_id}/cancel", response_model=SettlementIntentRead)
|
||||||
|
async def cancel_settlement(
|
||||||
|
intent_id: UUID,
|
||||||
|
body: SettlementActionRequest,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_STATUS)),
|
||||||
|
):
|
||||||
|
return await SettlementCommands(db).cancel(tenant_id, intent_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{intent_id}/lines",
|
||||||
|
response_model=SettlementLineRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def add_settlement_line(
|
||||||
|
intent_id: UUID,
|
||||||
|
body: SettlementLineCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(SETTLEMENTS_LINES_MANAGE)),
|
||||||
|
):
|
||||||
|
return await SettlementCommands(db).add_line(tenant_id, intent_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{intent_id}/lines", response_model=list[SettlementLineRead])
|
||||||
|
async def list_settlement_lines(
|
||||||
|
intent_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(SETTLEMENTS_LINES_VIEW)),
|
||||||
|
):
|
||||||
|
return await SettlementQueries(db).lines(tenant_id, intent_id)
|
||||||
156
backend/services/delivery/app/api/v1/shifts.py
Normal file
156
backend/services/delivery/app/api/v1/shifts.py
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
"""Shift APIs — Phase 10.3."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, get_pagination, require_tenant
|
||||||
|
from app.api.permissions import require_permissions
|
||||||
|
from app.commands.availability import AvailabilityCommands
|
||||||
|
from app.models.types import ShiftStatus
|
||||||
|
from app.permissions.definitions import (
|
||||||
|
SHIFTS_ASSIGNMENTS_MANAGE,
|
||||||
|
SHIFTS_ASSIGNMENTS_VIEW,
|
||||||
|
SHIFTS_CREATE,
|
||||||
|
SHIFTS_DELETE,
|
||||||
|
SHIFTS_UPDATE,
|
||||||
|
SHIFTS_VIEW,
|
||||||
|
)
|
||||||
|
from app.queries.availability import AvailabilityQueries
|
||||||
|
from app.schemas.availability import (
|
||||||
|
ShiftAssignmentCreate,
|
||||||
|
ShiftAssignmentRead,
|
||||||
|
ShiftAssignmentUpdate,
|
||||||
|
ShiftCreate,
|
||||||
|
ShiftListResponse,
|
||||||
|
ShiftRead,
|
||||||
|
ShiftUpdate,
|
||||||
|
)
|
||||||
|
from app.specifications.availability import ShiftListSpec
|
||||||
|
from shared.pagination import PaginationParams
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _list_spec(
|
||||||
|
status_filter: ShiftStatus | None = Query(default=None, alias="status"),
|
||||||
|
organization_id: UUID | None = Query(default=None),
|
||||||
|
hub_id: UUID | None = Query(default=None),
|
||||||
|
q: str | None = Query(default=None, max_length=100),
|
||||||
|
sort_by: str = Query(default="starts_at"),
|
||||||
|
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
||||||
|
) -> ShiftListSpec:
|
||||||
|
return ShiftListSpec(
|
||||||
|
status=status_filter,
|
||||||
|
organization_id=organization_id,
|
||||||
|
hub_id=hub_id,
|
||||||
|
q=q,
|
||||||
|
sort_by=sort_by,
|
||||||
|
sort_dir=sort_dir.lower(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=ShiftRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_shift(
|
||||||
|
body: ShiftCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(SHIFTS_CREATE)),
|
||||||
|
):
|
||||||
|
return await AvailabilityCommands(db).create_shift(tenant_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=ShiftListResponse)
|
||||||
|
async def list_shifts(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
pagination: PaginationParams = Depends(get_pagination),
|
||||||
|
spec: ShiftListSpec = Depends(_list_spec),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(SHIFTS_VIEW)),
|
||||||
|
):
|
||||||
|
items, total = await AvailabilityQueries(db).list_shifts(
|
||||||
|
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
||||||
|
)
|
||||||
|
return ShiftListResponse(
|
||||||
|
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{shift_id}", response_model=ShiftRead)
|
||||||
|
async def get_shift(
|
||||||
|
shift_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(SHIFTS_VIEW)),
|
||||||
|
):
|
||||||
|
return await AvailabilityQueries(db).get_shift(tenant_id, shift_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{shift_id}", response_model=ShiftRead)
|
||||||
|
async def update_shift(
|
||||||
|
shift_id: UUID,
|
||||||
|
body: ShiftUpdate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(SHIFTS_UPDATE)),
|
||||||
|
):
|
||||||
|
return await AvailabilityCommands(db).update_shift(
|
||||||
|
tenant_id, shift_id, body, actor=user
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{shift_id}/delete", response_model=ShiftRead)
|
||||||
|
async def delete_shift(
|
||||||
|
shift_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(SHIFTS_DELETE)),
|
||||||
|
):
|
||||||
|
return await AvailabilityCommands(db).delete_shift(tenant_id, shift_id, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{shift_id}/assignments",
|
||||||
|
response_model=ShiftAssignmentRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def assign_shift(
|
||||||
|
shift_id: UUID,
|
||||||
|
body: ShiftAssignmentCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(SHIFTS_ASSIGNMENTS_MANAGE)),
|
||||||
|
):
|
||||||
|
return await AvailabilityCommands(db).assign_shift(
|
||||||
|
tenant_id, shift_id, body, actor=user
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{shift_id}/assignments", response_model=list[ShiftAssignmentRead])
|
||||||
|
async def list_shift_assignments(
|
||||||
|
shift_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(SHIFTS_ASSIGNMENTS_VIEW)),
|
||||||
|
):
|
||||||
|
return await AvailabilityQueries(db).list_shift_assignments(tenant_id, shift_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/{shift_id}/assignments/{assignment_id}",
|
||||||
|
response_model=ShiftAssignmentRead,
|
||||||
|
)
|
||||||
|
async def update_shift_assignment(
|
||||||
|
shift_id: UUID,
|
||||||
|
assignment_id: UUID,
|
||||||
|
body: ShiftAssignmentUpdate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(SHIFTS_ASSIGNMENTS_MANAGE)),
|
||||||
|
):
|
||||||
|
return await AvailabilityCommands(db).update_shift_assignment(
|
||||||
|
tenant_id, shift_id, assignment_id, body, actor=user
|
||||||
|
)
|
||||||
126
backend/services/delivery/app/api/v1/tracking.py
Normal file
126
backend/services/delivery/app/api/v1/tracking.py
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
"""Tracking session APIs — Phase 10.7."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, get_pagination, require_tenant
|
||||||
|
from app.api.permissions import require_permissions
|
||||||
|
from app.commands.tracking import TrackingCommands
|
||||||
|
from app.models.types import TrackingSessionStatus
|
||||||
|
from app.permissions.definitions import (
|
||||||
|
TRACKING_POINTS_MANAGE,
|
||||||
|
TRACKING_POINTS_VIEW,
|
||||||
|
TRACKING_SESSIONS_CREATE,
|
||||||
|
TRACKING_SESSIONS_UPDATE,
|
||||||
|
TRACKING_SESSIONS_VIEW,
|
||||||
|
)
|
||||||
|
from app.queries.tracking import TrackingQueries
|
||||||
|
from app.schemas.tracking import (
|
||||||
|
TrackingPointCreate,
|
||||||
|
TrackingPointRead,
|
||||||
|
TrackingSessionCreate,
|
||||||
|
TrackingSessionListResponse,
|
||||||
|
TrackingSessionRead,
|
||||||
|
TrackingSessionUpdate,
|
||||||
|
)
|
||||||
|
from app.specifications.tracking import TrackingSessionListSpec
|
||||||
|
from shared.pagination import PaginationParams
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _list_spec(
|
||||||
|
status_filter: TrackingSessionStatus | None = Query(default=None, alias="status"),
|
||||||
|
dispatch_job_id: UUID | None = Query(default=None),
|
||||||
|
driver_id: UUID | None = Query(default=None),
|
||||||
|
sort_by: str = Query(default="created_at"),
|
||||||
|
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
||||||
|
) -> TrackingSessionListSpec:
|
||||||
|
return TrackingSessionListSpec(
|
||||||
|
status=status_filter,
|
||||||
|
dispatch_job_id=dispatch_job_id,
|
||||||
|
driver_id=driver_id,
|
||||||
|
sort_by=sort_by,
|
||||||
|
sort_dir=sort_dir.lower(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/sessions", response_model=TrackingSessionRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def start_session(
|
||||||
|
body: TrackingSessionCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(TRACKING_SESSIONS_CREATE)),
|
||||||
|
):
|
||||||
|
return await TrackingCommands(db).start_session(tenant_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions", response_model=TrackingSessionListResponse)
|
||||||
|
async def list_sessions(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
pagination: PaginationParams = Depends(get_pagination),
|
||||||
|
spec: TrackingSessionListSpec = Depends(_list_spec),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(TRACKING_SESSIONS_VIEW)),
|
||||||
|
):
|
||||||
|
items, total = await TrackingQueries(db).list_sessions(
|
||||||
|
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
||||||
|
)
|
||||||
|
return TrackingSessionListResponse(
|
||||||
|
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions/{session_id}", response_model=TrackingSessionRead)
|
||||||
|
async def get_session(
|
||||||
|
session_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(TRACKING_SESSIONS_VIEW)),
|
||||||
|
):
|
||||||
|
return await TrackingQueries(db).get_session(tenant_id, session_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/sessions/{session_id}", response_model=TrackingSessionRead)
|
||||||
|
async def update_session(
|
||||||
|
session_id: UUID,
|
||||||
|
body: TrackingSessionUpdate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(TRACKING_SESSIONS_UPDATE)),
|
||||||
|
):
|
||||||
|
return await TrackingCommands(db).update_session(
|
||||||
|
tenant_id, session_id, body, actor=user
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/sessions/{session_id}/points",
|
||||||
|
response_model=TrackingPointRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def record_point(
|
||||||
|
session_id: UUID,
|
||||||
|
body: TrackingPointCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(TRACKING_POINTS_MANAGE)),
|
||||||
|
):
|
||||||
|
return await TrackingCommands(db).record_point(
|
||||||
|
tenant_id, session_id, body, actor=user
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/sessions/{session_id}/points", response_model=list[TrackingPointRead])
|
||||||
|
async def list_points(
|
||||||
|
session_id: UUID,
|
||||||
|
limit: int = Query(default=500, ge=1, le=2000),
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(TRACKING_POINTS_VIEW)),
|
||||||
|
):
|
||||||
|
return await TrackingQueries(db).list_points(tenant_id, session_id, limit=limit)
|
||||||
96
backend/services/delivery/app/api/v1/vehicle_types.py
Normal file
96
backend/services/delivery/app/api/v1/vehicle_types.py
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
"""Vehicle type APIs — Phase 10.2."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, get_pagination, require_tenant
|
||||||
|
from app.api.permissions import require_permissions
|
||||||
|
from app.commands.fleet import FleetCommands
|
||||||
|
from app.permissions.definitions import (
|
||||||
|
VEHICLE_TYPES_CREATE,
|
||||||
|
VEHICLE_TYPES_DELETE,
|
||||||
|
VEHICLE_TYPES_UPDATE,
|
||||||
|
VEHICLE_TYPES_VIEW,
|
||||||
|
)
|
||||||
|
from app.queries.fleet import FleetQueries
|
||||||
|
from app.schemas.fleet import (
|
||||||
|
VehicleTypeCreate,
|
||||||
|
VehicleTypeListResponse,
|
||||||
|
VehicleTypeRead,
|
||||||
|
VehicleTypeUpdate,
|
||||||
|
)
|
||||||
|
from app.specifications.fleet import VehicleTypeListSpec
|
||||||
|
from shared.pagination import PaginationParams
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _list_spec(
|
||||||
|
q: str | None = Query(default=None, max_length=100),
|
||||||
|
sort_by: str = Query(default="created_at"),
|
||||||
|
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
||||||
|
) -> VehicleTypeListSpec:
|
||||||
|
return VehicleTypeListSpec(q=q, sort_by=sort_by, sort_dir=sort_dir.lower())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=VehicleTypeRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_vehicle_type(
|
||||||
|
body: VehicleTypeCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(VEHICLE_TYPES_CREATE)),
|
||||||
|
):
|
||||||
|
return await FleetCommands(db).create_vehicle_type(tenant_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=VehicleTypeListResponse)
|
||||||
|
async def list_vehicle_types(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
pagination: PaginationParams = Depends(get_pagination),
|
||||||
|
spec: VehicleTypeListSpec = Depends(_list_spec),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(VEHICLE_TYPES_VIEW)),
|
||||||
|
):
|
||||||
|
items, total = await FleetQueries(db).list_vehicle_types(
|
||||||
|
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
||||||
|
)
|
||||||
|
return VehicleTypeListResponse(
|
||||||
|
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{type_id}", response_model=VehicleTypeRead)
|
||||||
|
async def get_vehicle_type(
|
||||||
|
type_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(VEHICLE_TYPES_VIEW)),
|
||||||
|
):
|
||||||
|
return await FleetQueries(db).get_vehicle_type(tenant_id, type_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{type_id}", response_model=VehicleTypeRead)
|
||||||
|
async def update_vehicle_type(
|
||||||
|
type_id: UUID,
|
||||||
|
body: VehicleTypeUpdate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(VEHICLE_TYPES_UPDATE)),
|
||||||
|
):
|
||||||
|
return await FleetCommands(db).update_vehicle_type(
|
||||||
|
tenant_id, type_id, body, actor=user
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{type_id}/delete", response_model=VehicleTypeRead)
|
||||||
|
async def delete_vehicle_type(
|
||||||
|
type_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(VEHICLE_TYPES_DELETE)),
|
||||||
|
):
|
||||||
|
return await FleetCommands(db).delete_vehicle_type(tenant_id, type_id, actor=user)
|
||||||
154
backend/services/delivery/app/api/v1/vehicles.py
Normal file
154
backend/services/delivery/app/api/v1/vehicles.py
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
"""Vehicle APIs — Phase 10.2."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, get_pagination, require_tenant
|
||||||
|
from app.api.permissions import require_permissions
|
||||||
|
from app.commands.fleet import FleetCommands
|
||||||
|
from app.models.types import VehicleStatus
|
||||||
|
from app.permissions.definitions import (
|
||||||
|
VEHICLES_ASSIGNMENTS_MANAGE,
|
||||||
|
VEHICLES_ASSIGNMENTS_VIEW,
|
||||||
|
VEHICLES_CREATE,
|
||||||
|
VEHICLES_DELETE,
|
||||||
|
VEHICLES_UPDATE,
|
||||||
|
VEHICLES_VIEW,
|
||||||
|
)
|
||||||
|
from app.queries.fleet import FleetQueries
|
||||||
|
from app.schemas.fleet import (
|
||||||
|
VehicleAssignmentCreate,
|
||||||
|
VehicleAssignmentRead,
|
||||||
|
VehicleAssignmentUpdate,
|
||||||
|
VehicleCreate,
|
||||||
|
VehicleListResponse,
|
||||||
|
VehicleRead,
|
||||||
|
VehicleUpdate,
|
||||||
|
)
|
||||||
|
from app.specifications.fleet import VehicleListSpec
|
||||||
|
from shared.pagination import PaginationParams
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _list_spec(
|
||||||
|
fleet_id: UUID | None = Query(default=None),
|
||||||
|
status_filter: VehicleStatus | None = Query(default=None, alias="status"),
|
||||||
|
q: str | None = Query(default=None, max_length=100),
|
||||||
|
sort_by: str = Query(default="created_at"),
|
||||||
|
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
||||||
|
) -> VehicleListSpec:
|
||||||
|
return VehicleListSpec(
|
||||||
|
fleet_id=fleet_id,
|
||||||
|
status=status_filter,
|
||||||
|
q=q,
|
||||||
|
sort_by=sort_by,
|
||||||
|
sort_dir=sort_dir.lower(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=VehicleRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_vehicle(
|
||||||
|
body: VehicleCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(VEHICLES_CREATE)),
|
||||||
|
):
|
||||||
|
return await FleetCommands(db).create_vehicle(tenant_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=VehicleListResponse)
|
||||||
|
async def list_vehicles(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
pagination: PaginationParams = Depends(get_pagination),
|
||||||
|
spec: VehicleListSpec = Depends(_list_spec),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(VEHICLES_VIEW)),
|
||||||
|
):
|
||||||
|
items, total = await FleetQueries(db).list_vehicles(
|
||||||
|
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
||||||
|
)
|
||||||
|
return VehicleListResponse(
|
||||||
|
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{vehicle_id}", response_model=VehicleRead)
|
||||||
|
async def get_vehicle(
|
||||||
|
vehicle_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(VEHICLES_VIEW)),
|
||||||
|
):
|
||||||
|
return await FleetQueries(db).get_vehicle(tenant_id, vehicle_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{vehicle_id}", response_model=VehicleRead)
|
||||||
|
async def update_vehicle(
|
||||||
|
vehicle_id: UUID,
|
||||||
|
body: VehicleUpdate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(VEHICLES_UPDATE)),
|
||||||
|
):
|
||||||
|
return await FleetCommands(db).update_vehicle(
|
||||||
|
tenant_id, vehicle_id, body, actor=user
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{vehicle_id}/delete", response_model=VehicleRead)
|
||||||
|
async def delete_vehicle(
|
||||||
|
vehicle_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(VEHICLES_DELETE)),
|
||||||
|
):
|
||||||
|
return await FleetCommands(db).delete_vehicle(tenant_id, vehicle_id, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{vehicle_id}/assignments",
|
||||||
|
response_model=VehicleAssignmentRead,
|
||||||
|
status_code=status.HTTP_201_CREATED,
|
||||||
|
)
|
||||||
|
async def create_assignment(
|
||||||
|
vehicle_id: UUID,
|
||||||
|
body: VehicleAssignmentCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(VEHICLES_ASSIGNMENTS_MANAGE)),
|
||||||
|
):
|
||||||
|
return await FleetCommands(db).create_assignment(
|
||||||
|
tenant_id, vehicle_id, body, actor=user
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{vehicle_id}/assignments", response_model=list[VehicleAssignmentRead])
|
||||||
|
async def list_assignments(
|
||||||
|
vehicle_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(VEHICLES_ASSIGNMENTS_VIEW)),
|
||||||
|
):
|
||||||
|
return await FleetQueries(db).list_assignments(tenant_id, vehicle_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch(
|
||||||
|
"/{vehicle_id}/assignments/{assignment_id}",
|
||||||
|
response_model=VehicleAssignmentRead,
|
||||||
|
)
|
||||||
|
async def update_assignment(
|
||||||
|
vehicle_id: UUID,
|
||||||
|
assignment_id: UUID,
|
||||||
|
body: VehicleAssignmentUpdate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(VEHICLES_ASSIGNMENTS_MANAGE)),
|
||||||
|
):
|
||||||
|
return await FleetCommands(db).update_assignment(
|
||||||
|
tenant_id, vehicle_id, assignment_id, body, actor=user
|
||||||
|
)
|
||||||
105
backend/services/delivery/app/api/v1/working_zones.py
Normal file
105
backend/services/delivery/app/api/v1/working_zones.py
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
"""Working zone APIs — Phase 10.3."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, status
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.api.deps import get_db, get_pagination, require_tenant
|
||||||
|
from app.api.permissions import require_permissions
|
||||||
|
from app.commands.availability import AvailabilityCommands
|
||||||
|
from app.models.types import WorkingZoneStatus
|
||||||
|
from app.permissions.definitions import (
|
||||||
|
WORKING_ZONES_CREATE,
|
||||||
|
WORKING_ZONES_DELETE,
|
||||||
|
WORKING_ZONES_UPDATE,
|
||||||
|
WORKING_ZONES_VIEW,
|
||||||
|
)
|
||||||
|
from app.queries.availability import AvailabilityQueries
|
||||||
|
from app.schemas.availability import (
|
||||||
|
WorkingZoneCreate,
|
||||||
|
WorkingZoneListResponse,
|
||||||
|
WorkingZoneRead,
|
||||||
|
WorkingZoneUpdate,
|
||||||
|
)
|
||||||
|
from app.specifications.availability import WorkingZoneListSpec
|
||||||
|
from shared.pagination import PaginationParams
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _list_spec(
|
||||||
|
status_filter: WorkingZoneStatus | None = Query(default=None, alias="status"),
|
||||||
|
organization_id: UUID | None = Query(default=None),
|
||||||
|
q: str | None = Query(default=None, max_length=100),
|
||||||
|
sort_by: str = Query(default="created_at"),
|
||||||
|
sort_dir: str = Query(default="desc", pattern="^(?i)(asc|desc)$"),
|
||||||
|
) -> WorkingZoneListSpec:
|
||||||
|
return WorkingZoneListSpec(
|
||||||
|
status=status_filter,
|
||||||
|
organization_id=organization_id,
|
||||||
|
q=q,
|
||||||
|
sort_by=sort_by,
|
||||||
|
sort_dir=sort_dir.lower(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("", response_model=WorkingZoneRead, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_zone(
|
||||||
|
body: WorkingZoneCreate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(WORKING_ZONES_CREATE)),
|
||||||
|
):
|
||||||
|
return await AvailabilityCommands(db).create_zone(tenant_id, body, actor=user)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=WorkingZoneListResponse)
|
||||||
|
async def list_zones(
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
pagination: PaginationParams = Depends(get_pagination),
|
||||||
|
spec: WorkingZoneListSpec = Depends(_list_spec),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(WORKING_ZONES_VIEW)),
|
||||||
|
):
|
||||||
|
items, total = await AvailabilityQueries(db).list_zones(
|
||||||
|
tenant_id, offset=pagination.offset, limit=pagination.page_size, spec=spec
|
||||||
|
)
|
||||||
|
return WorkingZoneListResponse(
|
||||||
|
items=items, total=total, page=pagination.page, page_size=pagination.page_size
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{zone_id}", response_model=WorkingZoneRead)
|
||||||
|
async def get_zone(
|
||||||
|
zone_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
_user: CurrentUser = Depends(require_permissions(WORKING_ZONES_VIEW)),
|
||||||
|
):
|
||||||
|
return await AvailabilityQueries(db).get_zone(tenant_id, zone_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/{zone_id}", response_model=WorkingZoneRead)
|
||||||
|
async def update_zone(
|
||||||
|
zone_id: UUID,
|
||||||
|
body: WorkingZoneUpdate,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(WORKING_ZONES_UPDATE)),
|
||||||
|
):
|
||||||
|
return await AvailabilityCommands(db).update_zone(
|
||||||
|
tenant_id, zone_id, body, actor=user
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{zone_id}/delete", response_model=WorkingZoneRead)
|
||||||
|
async def delete_zone(
|
||||||
|
zone_id: UUID,
|
||||||
|
tenant_id: UUID = Depends(require_tenant),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
user: CurrentUser = Depends(require_permissions(WORKING_ZONES_DELETE)),
|
||||||
|
):
|
||||||
|
return await AvailabilityCommands(db).delete_zone(tenant_id, zone_id, actor=user)
|
||||||
90
backend/services/delivery/app/commands/availability.py
Normal file
90
backend/services/delivery/app/commands/availability.py
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
"""Availability command handlers — Phase 10.3."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.schemas.availability import (
|
||||||
|
DriverAvailabilityUpsert,
|
||||||
|
ShiftAssignmentCreate,
|
||||||
|
ShiftAssignmentUpdate,
|
||||||
|
ShiftCreate,
|
||||||
|
ShiftUpdate,
|
||||||
|
WorkingZoneCreate,
|
||||||
|
WorkingZoneUpdate,
|
||||||
|
)
|
||||||
|
from app.services.availability import AvailabilityService, ShiftService, WorkingZoneService
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
|
||||||
|
class AvailabilityCommands:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.availability = AvailabilityService(session)
|
||||||
|
self.shifts = ShiftService(session)
|
||||||
|
self.zones = WorkingZoneService(session)
|
||||||
|
|
||||||
|
async def upsert_availability(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
driver_id: UUID,
|
||||||
|
body: DriverAvailabilityUpsert,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None,
|
||||||
|
):
|
||||||
|
return await self.availability.upsert_for_driver(
|
||||||
|
tenant_id, driver_id, body, actor=actor
|
||||||
|
)
|
||||||
|
|
||||||
|
async def create_shift(
|
||||||
|
self, tenant_id: UUID, body: ShiftCreate, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.shifts.create(tenant_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def update_shift(
|
||||||
|
self, tenant_id: UUID, shift_id: UUID, body: ShiftUpdate, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.shifts.update(tenant_id, shift_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def delete_shift(
|
||||||
|
self, tenant_id: UUID, shift_id: UUID, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.shifts.soft_delete(tenant_id, shift_id, actor=actor)
|
||||||
|
|
||||||
|
async def assign_shift(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
shift_id: UUID,
|
||||||
|
body: ShiftAssignmentCreate,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None,
|
||||||
|
):
|
||||||
|
return await self.shifts.assign_driver(tenant_id, shift_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def update_shift_assignment(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
shift_id: UUID,
|
||||||
|
assignment_id: UUID,
|
||||||
|
body: ShiftAssignmentUpdate,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None,
|
||||||
|
):
|
||||||
|
return await self.shifts.update_assignment(
|
||||||
|
tenant_id, shift_id, assignment_id, body, actor=actor
|
||||||
|
)
|
||||||
|
|
||||||
|
async def create_zone(
|
||||||
|
self, tenant_id: UUID, body: WorkingZoneCreate, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.zones.create(tenant_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def update_zone(
|
||||||
|
self, tenant_id: UUID, zone_id: UUID, body: WorkingZoneUpdate, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.zones.update(tenant_id, zone_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def delete_zone(
|
||||||
|
self, tenant_id: UUID, zone_id: UUID, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.zones.soft_delete(tenant_id, zone_id, actor=actor)
|
||||||
62
backend/services/delivery/app/commands/dispatch.py
Normal file
62
backend/services/delivery/app/commands/dispatch.py
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
"""Dispatch command handlers — Phase 10.5."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.schemas.dispatch import (
|
||||||
|
DispatchJobCreate,
|
||||||
|
DispatchJobStatusRequest,
|
||||||
|
DispatchJobUpdate,
|
||||||
|
JobAssignmentCreate,
|
||||||
|
JobAssignmentUpdate,
|
||||||
|
)
|
||||||
|
from app.services.dispatch import DispatchJobService, JobAssignmentService
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
|
||||||
|
class DispatchCommands:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.jobs = DispatchJobService(session)
|
||||||
|
self.assignments = JobAssignmentService(session)
|
||||||
|
|
||||||
|
async def create_job(
|
||||||
|
self, tenant_id: UUID, body: DispatchJobCreate, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.jobs.create(tenant_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def update_job(
|
||||||
|
self, tenant_id: UUID, job_id: UUID, body: DispatchJobUpdate, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.jobs.update(tenant_id, job_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def apply_status(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
job_id: UUID,
|
||||||
|
body: DispatchJobStatusRequest,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None,
|
||||||
|
):
|
||||||
|
return await self.jobs.apply_status(tenant_id, job_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def delete_job(
|
||||||
|
self, tenant_id: UUID, job_id: UUID, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.jobs.soft_delete(tenant_id, job_id, actor=actor)
|
||||||
|
|
||||||
|
async def create_assignment(
|
||||||
|
self, tenant_id: UUID, body: JobAssignmentCreate, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.assignments.create(tenant_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def update_assignment(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
assignment_id: UUID,
|
||||||
|
body: JobAssignmentUpdate,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None,
|
||||||
|
):
|
||||||
|
return await self.assignments.update(tenant_id, assignment_id, body, actor=actor)
|
||||||
92
backend/services/delivery/app/commands/fleet.py
Normal file
92
backend/services/delivery/app/commands/fleet.py
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
"""Fleet command handlers — Phase 10.2."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.schemas.fleet import (
|
||||||
|
FleetCreate,
|
||||||
|
FleetUpdate,
|
||||||
|
VehicleAssignmentCreate,
|
||||||
|
VehicleAssignmentUpdate,
|
||||||
|
VehicleCreate,
|
||||||
|
VehicleTypeCreate,
|
||||||
|
VehicleTypeUpdate,
|
||||||
|
VehicleUpdate,
|
||||||
|
)
|
||||||
|
from app.services.fleet import FleetService, VehicleService, VehicleTypeService
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
|
||||||
|
class FleetCommands:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.fleets = FleetService(session)
|
||||||
|
self.types = VehicleTypeService(session)
|
||||||
|
self.vehicles = VehicleService(session)
|
||||||
|
|
||||||
|
async def create_fleet(self, tenant_id: UUID, body: FleetCreate, *, actor: CurrentUser | None):
|
||||||
|
return await self.fleets.create(tenant_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def update_fleet(
|
||||||
|
self, tenant_id: UUID, fleet_id: UUID, body: FleetUpdate, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.fleets.update(tenant_id, fleet_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def delete_fleet(self, tenant_id: UUID, fleet_id: UUID, *, actor: CurrentUser | None):
|
||||||
|
return await self.fleets.soft_delete(tenant_id, fleet_id, actor=actor)
|
||||||
|
|
||||||
|
async def create_vehicle_type(
|
||||||
|
self, tenant_id: UUID, body: VehicleTypeCreate, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.types.create(tenant_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def update_vehicle_type(
|
||||||
|
self, tenant_id: UUID, type_id: UUID, body: VehicleTypeUpdate, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.types.update(tenant_id, type_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def delete_vehicle_type(
|
||||||
|
self, tenant_id: UUID, type_id: UUID, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.types.soft_delete(tenant_id, type_id, actor=actor)
|
||||||
|
|
||||||
|
async def create_vehicle(
|
||||||
|
self, tenant_id: UUID, body: VehicleCreate, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.vehicles.create(tenant_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def update_vehicle(
|
||||||
|
self, tenant_id: UUID, vehicle_id: UUID, body: VehicleUpdate, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.vehicles.update(tenant_id, vehicle_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def delete_vehicle(
|
||||||
|
self, tenant_id: UUID, vehicle_id: UUID, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.vehicles.soft_delete(tenant_id, vehicle_id, actor=actor)
|
||||||
|
|
||||||
|
async def create_assignment(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
vehicle_id: UUID,
|
||||||
|
body: VehicleAssignmentCreate,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None,
|
||||||
|
):
|
||||||
|
return await self.vehicles.create_assignment(
|
||||||
|
tenant_id, vehicle_id, body, actor=actor
|
||||||
|
)
|
||||||
|
|
||||||
|
async def update_assignment(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
vehicle_id: UUID,
|
||||||
|
assignment_id: UUID,
|
||||||
|
body: VehicleAssignmentUpdate,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None,
|
||||||
|
):
|
||||||
|
return await self.vehicles.update_assignment(
|
||||||
|
tenant_id, vehicle_id, assignment_id, body, actor=actor
|
||||||
|
)
|
||||||
69
backend/services/delivery/app/commands/pricing.py
Normal file
69
backend/services/delivery/app/commands/pricing.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
"""Pricing command handlers — Phase 10.4."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.schemas.pricing import (
|
||||||
|
BundleCreate,
|
||||||
|
BundleUpdate,
|
||||||
|
CapabilityCreate,
|
||||||
|
CapabilityUpdate,
|
||||||
|
PricingRuleCreate,
|
||||||
|
PricingRuleUpdate,
|
||||||
|
)
|
||||||
|
from app.services.pricing import BundleService, CapabilityService, PricingRuleService
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
|
||||||
|
class PricingCommands:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.rules = PricingRuleService(session)
|
||||||
|
self.capabilities = CapabilityService(session)
|
||||||
|
self.bundles = BundleService(session)
|
||||||
|
|
||||||
|
async def create_rule(
|
||||||
|
self, tenant_id: UUID, body: PricingRuleCreate, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.rules.create(tenant_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def update_rule(
|
||||||
|
self, tenant_id: UUID, rule_id: UUID, body: PricingRuleUpdate, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.rules.update(tenant_id, rule_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def delete_rule(
|
||||||
|
self, tenant_id: UUID, rule_id: UUID, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.rules.soft_delete(tenant_id, rule_id, actor=actor)
|
||||||
|
|
||||||
|
async def create_capability(
|
||||||
|
self, tenant_id: UUID, body: CapabilityCreate, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.capabilities.create(tenant_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def update_capability(
|
||||||
|
self, tenant_id: UUID, cap_id: UUID, body: CapabilityUpdate, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.capabilities.update(tenant_id, cap_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def delete_capability(
|
||||||
|
self, tenant_id: UUID, cap_id: UUID, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.capabilities.soft_delete(tenant_id, cap_id, actor=actor)
|
||||||
|
|
||||||
|
async def create_bundle(
|
||||||
|
self, tenant_id: UUID, body: BundleCreate, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.bundles.create(tenant_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def update_bundle(
|
||||||
|
self, tenant_id: UUID, bundle_id: UUID, body: BundleUpdate, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.bundles.update(tenant_id, bundle_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def delete_bundle(
|
||||||
|
self, tenant_id: UUID, bundle_id: UUID, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.bundles.soft_delete(tenant_id, bundle_id, actor=actor)
|
||||||
51
backend/services/delivery/app/commands/routing.py
Normal file
51
backend/services/delivery/app/commands/routing.py
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
"""Routing command handlers — Phase 10.6."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.schemas.routing import (
|
||||||
|
OptimizationRunCreate,
|
||||||
|
RoutePlanCreate,
|
||||||
|
RoutePlanUpdate,
|
||||||
|
RouteStopCreate,
|
||||||
|
)
|
||||||
|
from app.services.routing import OptimizationRunService, RoutePlanService
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
|
||||||
|
class RoutingCommands:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.routes = RoutePlanService(session)
|
||||||
|
self.optimization = OptimizationRunService(session)
|
||||||
|
|
||||||
|
async def create_route(
|
||||||
|
self, tenant_id: UUID, body: RoutePlanCreate, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.routes.create(tenant_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def update_route(
|
||||||
|
self, tenant_id: UUID, plan_id: UUID, body: RoutePlanUpdate, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.routes.update(tenant_id, plan_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def delete_route(
|
||||||
|
self, tenant_id: UUID, plan_id: UUID, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.routes.soft_delete(tenant_id, plan_id, actor=actor)
|
||||||
|
|
||||||
|
async def add_stop(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
plan_id: UUID,
|
||||||
|
body: RouteStopCreate,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None,
|
||||||
|
):
|
||||||
|
return await self.routes.add_stop(tenant_id, plan_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def start_optimization(
|
||||||
|
self, tenant_id: UUID, body: OptimizationRunCreate, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.optimization.start(tenant_id, body, actor=actor)
|
||||||
85
backend/services/delivery/app/commands/settlement.py
Normal file
85
backend/services/delivery/app/commands/settlement.py
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
"""Settlement command handlers — Phase 10.8."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.schemas.settlement import (
|
||||||
|
SettlementActionRequest,
|
||||||
|
SettlementIntentCreate,
|
||||||
|
SettlementIntentUpdate,
|
||||||
|
SettlementLineCreate,
|
||||||
|
)
|
||||||
|
from app.services.settlement import SettlementService
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementCommands:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.service = SettlementService(session)
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self, tenant_id: UUID, body: SettlementIntentCreate, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.service.create(tenant_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def update(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
intent_id: UUID,
|
||||||
|
body: SettlementIntentUpdate,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None,
|
||||||
|
):
|
||||||
|
return await self.service.update(tenant_id, intent_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def submit(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
intent_id: UUID,
|
||||||
|
body: SettlementActionRequest,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None,
|
||||||
|
):
|
||||||
|
return await self.service.submit(tenant_id, intent_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def settle(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
intent_id: UUID,
|
||||||
|
body: SettlementActionRequest,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None,
|
||||||
|
):
|
||||||
|
return await self.service.settle(tenant_id, intent_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def fail(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
intent_id: UUID,
|
||||||
|
body: SettlementActionRequest,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None,
|
||||||
|
):
|
||||||
|
return await self.service.fail(tenant_id, intent_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def cancel(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
intent_id: UUID,
|
||||||
|
body: SettlementActionRequest,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None,
|
||||||
|
):
|
||||||
|
return await self.service.cancel(tenant_id, intent_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def add_line(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
intent_id: UUID,
|
||||||
|
body: SettlementLineCreate,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None,
|
||||||
|
):
|
||||||
|
return await self.service.add_line(tenant_id, intent_id, body, actor=actor)
|
||||||
84
backend/services/delivery/app/commands/tracking.py
Normal file
84
backend/services/delivery/app/commands/tracking.py
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
"""Tracking command handlers — Phase 10.7."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.schemas.tracking import (
|
||||||
|
CustomerTrackingTokenCreate,
|
||||||
|
ProofOfDeliveryCreate,
|
||||||
|
ProofOfDeliveryUpdate,
|
||||||
|
TrackingPointCreate,
|
||||||
|
TrackingSessionCreate,
|
||||||
|
TrackingSessionUpdate,
|
||||||
|
)
|
||||||
|
from app.services.tracking import (
|
||||||
|
CustomerTrackingTokenService,
|
||||||
|
ProofOfDeliveryService,
|
||||||
|
TrackingSessionService,
|
||||||
|
)
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
|
||||||
|
class TrackingCommands:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.sessions = TrackingSessionService(session)
|
||||||
|
self.pod = ProofOfDeliveryService(session)
|
||||||
|
self.tokens = CustomerTrackingTokenService(session)
|
||||||
|
|
||||||
|
async def start_session(
|
||||||
|
self, tenant_id: UUID, body: TrackingSessionCreate, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.sessions.start(tenant_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def update_session(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
session_id: UUID,
|
||||||
|
body: TrackingSessionUpdate,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None,
|
||||||
|
):
|
||||||
|
return await self.sessions.update(tenant_id, session_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def record_point(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
session_id: UUID,
|
||||||
|
body: TrackingPointCreate,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None,
|
||||||
|
):
|
||||||
|
return await self.sessions.record_point(
|
||||||
|
tenant_id, session_id, body, actor=actor
|
||||||
|
)
|
||||||
|
|
||||||
|
async def capture_pod(
|
||||||
|
self, tenant_id: UUID, body: ProofOfDeliveryCreate, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.pod.capture(tenant_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def update_pod(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
pod_id: UUID,
|
||||||
|
body: ProofOfDeliveryUpdate,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None,
|
||||||
|
):
|
||||||
|
return await self.pod.update(tenant_id, pod_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def create_token(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
body: CustomerTrackingTokenCreate,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None,
|
||||||
|
):
|
||||||
|
return await self.tokens.create(tenant_id, body, actor=actor)
|
||||||
|
|
||||||
|
async def revoke_token(
|
||||||
|
self, tenant_id: UUID, token_id: UUID, *, actor: CurrentUser | None
|
||||||
|
):
|
||||||
|
return await self.tokens.revoke(tenant_id, token_id, actor=actor)
|
||||||
@ -31,3 +31,75 @@ class DeliveryEventType(str, enum.Enum):
|
|||||||
DRIVER_CREDENTIAL_ADDED = "delivery.driver.credential_added"
|
DRIVER_CREDENTIAL_ADDED = "delivery.driver.credential_added"
|
||||||
DRIVER_DOCUMENT_ATTACHED = "delivery.driver.document_attached"
|
DRIVER_DOCUMENT_ATTACHED = "delivery.driver.document_attached"
|
||||||
DRIVER_STATUS_CHANGED = "delivery.driver.status_changed"
|
DRIVER_STATUS_CHANGED = "delivery.driver.status_changed"
|
||||||
|
|
||||||
|
# Phase 10.2 — Fleet & Vehicle Types
|
||||||
|
FLEET_CREATED = "delivery.fleet.created"
|
||||||
|
FLEET_UPDATED = "delivery.fleet.updated"
|
||||||
|
FLEET_DELETED = "delivery.fleet.deleted"
|
||||||
|
VEHICLE_TYPE_CREATED = "delivery.vehicle_type.created"
|
||||||
|
VEHICLE_TYPE_UPDATED = "delivery.vehicle_type.updated"
|
||||||
|
VEHICLE_TYPE_DELETED = "delivery.vehicle_type.deleted"
|
||||||
|
VEHICLE_CREATED = "delivery.vehicle.created"
|
||||||
|
VEHICLE_UPDATED = "delivery.vehicle.updated"
|
||||||
|
VEHICLE_DELETED = "delivery.vehicle.deleted"
|
||||||
|
VEHICLE_ASSIGNMENT_CREATED = "delivery.vehicle_assignment.created"
|
||||||
|
VEHICLE_ASSIGNMENT_UPDATED = "delivery.vehicle_assignment.updated"
|
||||||
|
VEHICLE_ASSIGNMENT_ENDED = "delivery.vehicle_assignment.ended"
|
||||||
|
|
||||||
|
# Phase 10.3 — Availability, Shifts & Working Zones
|
||||||
|
DRIVER_AVAILABILITY_UPDATED = "delivery.driver_availability.updated"
|
||||||
|
SHIFT_CREATED = "delivery.shift.created"
|
||||||
|
SHIFT_UPDATED = "delivery.shift.updated"
|
||||||
|
SHIFT_DELETED = "delivery.shift.deleted"
|
||||||
|
SHIFT_ASSIGNMENT_CREATED = "delivery.shift_assignment.created"
|
||||||
|
SHIFT_ASSIGNMENT_UPDATED = "delivery.shift_assignment.updated"
|
||||||
|
WORKING_ZONE_CREATED = "delivery.working_zone.created"
|
||||||
|
WORKING_ZONE_UPDATED = "delivery.working_zone.updated"
|
||||||
|
WORKING_ZONE_DELETED = "delivery.working_zone.deleted"
|
||||||
|
|
||||||
|
# Phase 10.4 — Pricing, Capabilities & Bundles
|
||||||
|
PRICING_RULE_CREATED = "delivery.pricing_rule.created"
|
||||||
|
PRICING_RULE_UPDATED = "delivery.pricing_rule.updated"
|
||||||
|
PRICING_RULE_DELETED = "delivery.pricing_rule.deleted"
|
||||||
|
CAPABILITY_CREATED = "delivery.capability.created"
|
||||||
|
CAPABILITY_UPDATED = "delivery.capability.updated"
|
||||||
|
CAPABILITY_DELETED = "delivery.capability.deleted"
|
||||||
|
BUNDLE_CREATED = "delivery.bundle.created"
|
||||||
|
BUNDLE_UPDATED = "delivery.bundle.updated"
|
||||||
|
BUNDLE_DELETED = "delivery.bundle.deleted"
|
||||||
|
|
||||||
|
# Phase 10.5 — Dispatch Engine
|
||||||
|
DISPATCH_JOB_CREATED = "delivery.dispatch_job.created"
|
||||||
|
DISPATCH_JOB_UPDATED = "delivery.dispatch_job.updated"
|
||||||
|
DISPATCH_JOB_STATUS_CHANGED = "delivery.dispatch_job.status_changed"
|
||||||
|
DISPATCH_JOB_DELETED = "delivery.dispatch_job.deleted"
|
||||||
|
JOB_ASSIGNMENT_CREATED = "delivery.job_assignment.created"
|
||||||
|
JOB_ASSIGNMENT_UPDATED = "delivery.job_assignment.updated"
|
||||||
|
JOB_ASSIGNMENT_STATUS_CHANGED = "delivery.job_assignment.status_changed"
|
||||||
|
|
||||||
|
# Phase 10.6 — Routing & Optimization
|
||||||
|
ROUTE_PLAN_CREATED = "delivery.route_plan.created"
|
||||||
|
ROUTE_PLAN_UPDATED = "delivery.route_plan.updated"
|
||||||
|
ROUTE_PLAN_DELETED = "delivery.route_plan.deleted"
|
||||||
|
ROUTE_STOP_ADDED = "delivery.route_stop.added"
|
||||||
|
OPTIMIZATION_RUN_STARTED = "delivery.optimization_run.started"
|
||||||
|
OPTIMIZATION_RUN_COMPLETED = "delivery.optimization_run.completed"
|
||||||
|
OPTIMIZATION_RUN_FAILED = "delivery.optimization_run.failed"
|
||||||
|
|
||||||
|
# Phase 10.7 — Tracking & Proof of Delivery
|
||||||
|
TRACKING_SESSION_STARTED = "delivery.tracking_session.started"
|
||||||
|
TRACKING_SESSION_UPDATED = "delivery.tracking_session.updated"
|
||||||
|
TRACKING_SESSION_ENDED = "delivery.tracking_session.ended"
|
||||||
|
TRACKING_POINT_RECORDED = "delivery.tracking_point.recorded"
|
||||||
|
PROOF_OF_DELIVERY_CAPTURED = "delivery.proof_of_delivery.captured"
|
||||||
|
PROOF_OF_DELIVERY_UPDATED = "delivery.proof_of_delivery.updated"
|
||||||
|
CUSTOMER_TRACKING_TOKEN_CREATED = "delivery.customer_tracking_token.created"
|
||||||
|
CUSTOMER_TRACKING_TOKEN_REVOKED = "delivery.customer_tracking_token.revoked"
|
||||||
|
|
||||||
|
# Phase 10.8 — Settlement
|
||||||
|
SETTLEMENT_INTENT_CREATED = "delivery.settlement_intent.created"
|
||||||
|
SETTLEMENT_INTENT_UPDATED = "delivery.settlement_intent.updated"
|
||||||
|
SETTLEMENT_INTENT_SUBMITTED = "delivery.settlement_intent.submitted"
|
||||||
|
SETTLEMENT_INTENT_SETTLED = "delivery.settlement_intent.settled"
|
||||||
|
SETTLEMENT_INTENT_FAILED = "delivery.settlement_intent.failed"
|
||||||
|
SETTLEMENT_LINE_ADDED = "delivery.settlement_line.added"
|
||||||
|
|||||||
@ -1,10 +1,18 @@
|
|||||||
"""Import all models for Alembic metadata discovery."""
|
"""Import all models for Alembic metadata discovery."""
|
||||||
|
from app.models.availability import ( # noqa: F401
|
||||||
|
DriverAvailability,
|
||||||
|
Shift,
|
||||||
|
ShiftAssignment,
|
||||||
|
WorkingZone,
|
||||||
|
)
|
||||||
|
from app.models.dispatch import DispatchJob, JobAssignment # noqa: F401
|
||||||
from app.models.drivers import ( # noqa: F401
|
from app.models.drivers import ( # noqa: F401
|
||||||
Driver,
|
Driver,
|
||||||
DriverCredential,
|
DriverCredential,
|
||||||
DriverDocument,
|
DriverDocument,
|
||||||
DriverLifecycleEvent,
|
DriverLifecycleEvent,
|
||||||
)
|
)
|
||||||
|
from app.models.fleet import Fleet, Vehicle, VehicleAssignment, VehicleType # noqa: F401
|
||||||
from app.models.foundation import ( # noqa: F401
|
from app.models.foundation import ( # noqa: F401
|
||||||
DeliveryAuditLog,
|
DeliveryAuditLog,
|
||||||
DeliveryConfiguration,
|
DeliveryConfiguration,
|
||||||
@ -17,3 +25,16 @@ from app.models.foundation import ( # noqa: F401
|
|||||||
RoutingEngineRegistration,
|
RoutingEngineRegistration,
|
||||||
)
|
)
|
||||||
from app.models.outbox import OutboxEvent # noqa: F401
|
from app.models.outbox import OutboxEvent # noqa: F401
|
||||||
|
from app.models.pricing import ( # noqa: F401
|
||||||
|
CapabilityBundle,
|
||||||
|
CapabilityDefinition,
|
||||||
|
PricingRule,
|
||||||
|
)
|
||||||
|
from app.models.routing import OptimizationRun, RoutePlan, RouteStop # noqa: F401
|
||||||
|
from app.models.settlement import SettlementIntent, SettlementLine # noqa: F401
|
||||||
|
from app.models.tracking import ( # noqa: F401
|
||||||
|
CustomerTrackingToken,
|
||||||
|
ProofOfDelivery,
|
||||||
|
TrackingPoint,
|
||||||
|
TrackingSession,
|
||||||
|
)
|
||||||
|
|||||||
142
backend/services/delivery/app/models/availability.py
Normal file
142
backend/services/delivery/app/models/availability.py
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
"""Availability, shifts & working zones — Phase 10.3."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Index, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from sqlalchemy.types import JSON
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from app.models.base import (
|
||||||
|
ActorAuditMixin,
|
||||||
|
OptimisticLockMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
)
|
||||||
|
from app.models.types import (
|
||||||
|
DriverAvailabilityStatus,
|
||||||
|
GUID,
|
||||||
|
ShiftAssignmentStatus,
|
||||||
|
ShiftStatus,
|
||||||
|
WorkingZoneStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DriverAvailability(
|
||||||
|
Base,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
ActorAuditMixin,
|
||||||
|
OptimisticLockMixin,
|
||||||
|
):
|
||||||
|
__tablename__ = "driver_availabilities"
|
||||||
|
|
||||||
|
driver_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
status: Mapped[DriverAvailabilityStatus] = mapped_column(
|
||||||
|
default=DriverAvailabilityStatus.OFFLINE, nullable=False
|
||||||
|
)
|
||||||
|
effective_from: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
effective_until: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_driver_availabilities_driver", "tenant_id", "driver_id"),
|
||||||
|
Index("ix_driver_availabilities_tenant_deleted", "tenant_id", "is_deleted"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Shift(
|
||||||
|
Base,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
ActorAuditMixin,
|
||||||
|
OptimisticLockMixin,
|
||||||
|
):
|
||||||
|
__tablename__ = "shifts"
|
||||||
|
|
||||||
|
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
hub_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
status: Mapped[ShiftStatus] = mapped_column(
|
||||||
|
default=ShiftStatus.DRAFT, nullable=False
|
||||||
|
)
|
||||||
|
starts_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
ends_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||||
|
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "code", name="uq_shifts_tenant_code"),
|
||||||
|
Index("ix_shifts_tenant_status", "tenant_id", "status"),
|
||||||
|
Index("ix_shifts_tenant_deleted", "tenant_id", "is_deleted"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ShiftAssignment(
|
||||||
|
Base,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
ActorAuditMixin,
|
||||||
|
):
|
||||||
|
__tablename__ = "shift_assignments"
|
||||||
|
|
||||||
|
shift_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
driver_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
status: Mapped[ShiftAssignmentStatus] = mapped_column(
|
||||||
|
default=ShiftAssignmentStatus.ASSIGNED, nullable=False
|
||||||
|
)
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id",
|
||||||
|
"shift_id",
|
||||||
|
"driver_id",
|
||||||
|
name="uq_shift_assignments_tenant_shift_driver",
|
||||||
|
),
|
||||||
|
Index("ix_shift_assignments_shift", "tenant_id", "shift_id"),
|
||||||
|
Index("ix_shift_assignments_driver", "tenant_id", "driver_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkingZone(
|
||||||
|
Base,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
ActorAuditMixin,
|
||||||
|
OptimisticLockMixin,
|
||||||
|
):
|
||||||
|
__tablename__ = "working_zones"
|
||||||
|
|
||||||
|
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
status: Mapped[WorkingZoneStatus] = mapped_column(
|
||||||
|
default=WorkingZoneStatus.ACTIVE, nullable=False
|
||||||
|
)
|
||||||
|
geo_boundary_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "code", name="uq_working_zones_tenant_code"),
|
||||||
|
Index("ix_working_zones_tenant_status", "tenant_id", "status"),
|
||||||
|
Index("ix_working_zones_tenant_deleted", "tenant_id", "is_deleted"),
|
||||||
|
)
|
||||||
89
backend/services/delivery/app/models/dispatch.py
Normal file
89
backend/services/delivery/app/models/dispatch.py
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
"""Dispatch engine aggregates — Phase 10.5."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Index, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from sqlalchemy.types import JSON
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from app.models.base import (
|
||||||
|
ActorAuditMixin,
|
||||||
|
OptimisticLockMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
)
|
||||||
|
from app.models.types import DispatchJobStatus, GUID, JobAssignmentStatus
|
||||||
|
|
||||||
|
|
||||||
|
class DispatchJob(
|
||||||
|
Base,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
ActorAuditMixin,
|
||||||
|
OptimisticLockMixin,
|
||||||
|
):
|
||||||
|
"""Vertical job refs only — no deep order ownership."""
|
||||||
|
|
||||||
|
__tablename__ = "dispatch_jobs"
|
||||||
|
|
||||||
|
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
hub_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
status: Mapped[DispatchJobStatus] = mapped_column(
|
||||||
|
default=DispatchJobStatus.PENDING, nullable=False
|
||||||
|
)
|
||||||
|
external_order_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
external_source: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||||
|
priority: Mapped[int] = mapped_column(default=0, nullable=False)
|
||||||
|
pickup_address_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
dropoff_address_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
scheduled_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
status_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||||
|
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "code", name="uq_dispatch_jobs_tenant_code"),
|
||||||
|
Index("ix_dispatch_jobs_tenant_status", "tenant_id", "status"),
|
||||||
|
Index("ix_dispatch_jobs_external_ref", "tenant_id", "external_order_ref"),
|
||||||
|
Index("ix_dispatch_jobs_tenant_deleted", "tenant_id", "is_deleted"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class JobAssignment(
|
||||||
|
Base,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
ActorAuditMixin,
|
||||||
|
OptimisticLockMixin,
|
||||||
|
):
|
||||||
|
__tablename__ = "job_assignments"
|
||||||
|
|
||||||
|
dispatch_job_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
driver_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
vehicle_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
status: Mapped[JobAssignmentStatus] = mapped_column(
|
||||||
|
default=JobAssignmentStatus.PENDING, nullable=False
|
||||||
|
)
|
||||||
|
assigned_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_job_assignments_job", "tenant_id", "dispatch_job_id"),
|
||||||
|
Index("ix_job_assignments_driver", "tenant_id", "driver_id"),
|
||||||
|
Index("ix_job_assignments_tenant_status", "tenant_id", "status"),
|
||||||
|
Index("ix_job_assignments_tenant_deleted", "tenant_id", "is_deleted"),
|
||||||
|
)
|
||||||
126
backend/services/delivery/app/models/fleet.py
Normal file
126
backend/services/delivery/app/models/fleet.py
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
"""Fleet & Vehicle aggregates — Phase 10.2."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from sqlalchemy import Index, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from sqlalchemy.types import JSON
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from app.models.base import (
|
||||||
|
ActorAuditMixin,
|
||||||
|
OptimisticLockMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
)
|
||||||
|
from app.models.types import FleetStatus, GUID, VehicleAssignmentStatus, VehicleStatus
|
||||||
|
|
||||||
|
|
||||||
|
class Fleet(
|
||||||
|
Base,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
ActorAuditMixin,
|
||||||
|
OptimisticLockMixin,
|
||||||
|
):
|
||||||
|
__tablename__ = "fleets"
|
||||||
|
|
||||||
|
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
hub_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
status: Mapped[FleetStatus] = mapped_column(default=FleetStatus.DRAFT, nullable=False)
|
||||||
|
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "code", name="uq_fleets_tenant_code"),
|
||||||
|
Index("ix_fleets_tenant_status", "tenant_id", "status"),
|
||||||
|
Index("ix_fleets_org", "tenant_id", "organization_id"),
|
||||||
|
Index("ix_fleets_tenant_deleted", "tenant_id", "is_deleted"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleType(
|
||||||
|
Base,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
ActorAuditMixin,
|
||||||
|
OptimisticLockMixin,
|
||||||
|
):
|
||||||
|
__tablename__ = "vehicle_types"
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
capacity_kg: Mapped[float | None] = mapped_column(nullable=True)
|
||||||
|
capacity_volume: Mapped[float | None] = mapped_column(nullable=True)
|
||||||
|
capability_tags: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||||
|
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "code", name="uq_vehicle_types_tenant_code"),
|
||||||
|
Index("ix_vehicle_types_tenant_deleted", "tenant_id", "is_deleted"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Vehicle(
|
||||||
|
Base,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
ActorAuditMixin,
|
||||||
|
OptimisticLockMixin,
|
||||||
|
):
|
||||||
|
__tablename__ = "vehicles"
|
||||||
|
|
||||||
|
fleet_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
vehicle_type_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
plate_number: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
|
status: Mapped[VehicleStatus] = mapped_column(
|
||||||
|
default=VehicleStatus.AVAILABLE, nullable=False
|
||||||
|
)
|
||||||
|
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "code", name="uq_vehicles_tenant_code"),
|
||||||
|
Index("ix_vehicles_fleet", "tenant_id", "fleet_id"),
|
||||||
|
Index("ix_vehicles_tenant_status", "tenant_id", "status"),
|
||||||
|
Index("ix_vehicles_tenant_deleted", "tenant_id", "is_deleted"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleAssignment(
|
||||||
|
Base,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
ActorAuditMixin,
|
||||||
|
):
|
||||||
|
"""Shell assignment linking vehicles to drivers — no dispatch engine."""
|
||||||
|
|
||||||
|
__tablename__ = "vehicle_assignments"
|
||||||
|
|
||||||
|
vehicle_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
driver_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
status: Mapped[VehicleAssignmentStatus] = mapped_column(
|
||||||
|
default=VehicleAssignmentStatus.ACTIVE, nullable=False
|
||||||
|
)
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_vehicle_assignments_vehicle", "tenant_id", "vehicle_id"),
|
||||||
|
Index("ix_vehicle_assignments_driver", "tenant_id", "driver_id"),
|
||||||
|
Index("ix_vehicle_assignments_tenant_deleted", "tenant_id", "is_deleted"),
|
||||||
|
)
|
||||||
108
backend/services/delivery/app/models/pricing.py
Normal file
108
backend/services/delivery/app/models/pricing.py
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
"""Pricing, capabilities & bundles — Phase 10.4."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import Index, Numeric, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from sqlalchemy.types import JSON
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from app.models.base import (
|
||||||
|
ActorAuditMixin,
|
||||||
|
OptimisticLockMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
)
|
||||||
|
from app.models.types import (
|
||||||
|
CapabilityBundleStatus,
|
||||||
|
CapabilityStatus,
|
||||||
|
GUID,
|
||||||
|
PricingRuleStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PricingRule(
|
||||||
|
Base,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
ActorAuditMixin,
|
||||||
|
OptimisticLockMixin,
|
||||||
|
):
|
||||||
|
__tablename__ = "pricing_rules"
|
||||||
|
|
||||||
|
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
status: Mapped[PricingRuleStatus] = mapped_column(
|
||||||
|
default=PricingRuleStatus.DRAFT, nullable=False
|
||||||
|
)
|
||||||
|
base_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
currency_code: Mapped[str] = mapped_column(String(3), nullable=False, default="IRR")
|
||||||
|
rule_config: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "code", name="uq_pricing_rules_tenant_code"),
|
||||||
|
Index("ix_pricing_rules_tenant_status", "tenant_id", "status"),
|
||||||
|
Index("ix_pricing_rules_tenant_deleted", "tenant_id", "is_deleted"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityDefinition(
|
||||||
|
Base,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
ActorAuditMixin,
|
||||||
|
OptimisticLockMixin,
|
||||||
|
):
|
||||||
|
__tablename__ = "capability_definitions"
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
status: Mapped[CapabilityStatus] = mapped_column(
|
||||||
|
default=CapabilityStatus.ACTIVE, nullable=False
|
||||||
|
)
|
||||||
|
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint(
|
||||||
|
"tenant_id", "code", name="uq_capability_definitions_tenant_code"
|
||||||
|
),
|
||||||
|
Index("ix_capability_definitions_tenant_deleted", "tenant_id", "is_deleted"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityBundle(
|
||||||
|
Base,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
ActorAuditMixin,
|
||||||
|
OptimisticLockMixin,
|
||||||
|
):
|
||||||
|
__tablename__ = "capability_bundles"
|
||||||
|
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
status: Mapped[CapabilityBundleStatus] = mapped_column(
|
||||||
|
default=CapabilityBundleStatus.DRAFT, nullable=False
|
||||||
|
)
|
||||||
|
capability_codes: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||||
|
pricing_rule_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "code", name="uq_capability_bundles_tenant_code"),
|
||||||
|
Index("ix_capability_bundles_tenant_status", "tenant_id", "status"),
|
||||||
|
Index("ix_capability_bundles_tenant_deleted", "tenant_id", "is_deleted"),
|
||||||
|
)
|
||||||
110
backend/services/delivery/app/models/routing.py
Normal file
110
backend/services/delivery/app/models/routing.py
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
"""Routing & optimization aggregates — Phase 10.6."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Index, Integer, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from sqlalchemy.types import JSON
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from app.models.base import (
|
||||||
|
ActorAuditMixin,
|
||||||
|
OptimisticLockMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
)
|
||||||
|
from app.models.types import GUID, OptimizationRunStatus, RoutePlanStatus, RouteStopStatus
|
||||||
|
|
||||||
|
|
||||||
|
class RoutePlan(
|
||||||
|
Base,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
ActorAuditMixin,
|
||||||
|
OptimisticLockMixin,
|
||||||
|
):
|
||||||
|
__tablename__ = "route_plans"
|
||||||
|
|
||||||
|
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
status: Mapped[RoutePlanStatus] = mapped_column(
|
||||||
|
default=RoutePlanStatus.DRAFT, nullable=False
|
||||||
|
)
|
||||||
|
driver_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
dispatch_job_ids: Mapped[list | None] = mapped_column(JSON, nullable=True)
|
||||||
|
routing_engine_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
planned_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "code", name="uq_route_plans_tenant_code"),
|
||||||
|
Index("ix_route_plans_tenant_status", "tenant_id", "status"),
|
||||||
|
Index("ix_route_plans_tenant_deleted", "tenant_id", "is_deleted"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class RouteStop(
|
||||||
|
Base,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
ActorAuditMixin,
|
||||||
|
):
|
||||||
|
__tablename__ = "route_stops"
|
||||||
|
|
||||||
|
route_plan_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
sequence: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||||
|
status: Mapped[RouteStopStatus] = mapped_column(
|
||||||
|
default=RouteStopStatus.PENDING, nullable=False
|
||||||
|
)
|
||||||
|
address_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
external_order_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_route_stops_plan", "tenant_id", "route_plan_id"),
|
||||||
|
Index("ix_route_stops_tenant_deleted", "tenant_id", "is_deleted"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class OptimizationRun(
|
||||||
|
Base,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
ActorAuditMixin,
|
||||||
|
):
|
||||||
|
"""External routing via RoutingEngineProvider protocol refs only."""
|
||||||
|
|
||||||
|
__tablename__ = "optimization_runs"
|
||||||
|
|
||||||
|
route_plan_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
status: Mapped[OptimizationRunStatus] = mapped_column(
|
||||||
|
default=OptimizationRunStatus.PENDING, nullable=False
|
||||||
|
)
|
||||||
|
routing_engine_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
request_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
result_payload: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
started_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
completed_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_optimization_runs_tenant_status", "tenant_id", "status"),
|
||||||
|
Index("ix_optimization_runs_plan", "tenant_id", "route_plan_id"),
|
||||||
|
)
|
||||||
78
backend/services/delivery/app/models/settlement.py
Normal file
78
backend/services/delivery/app/models/settlement.py
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
"""Settlement aggregates — Phase 10.8.
|
||||||
|
|
||||||
|
Uses AccountingProvider protocol refs only — NO journal entries in delivery_db.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from sqlalchemy import Index, Numeric, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from sqlalchemy.types import JSON
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from app.models.base import (
|
||||||
|
ActorAuditMixin,
|
||||||
|
OptimisticLockMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
)
|
||||||
|
from app.models.types import GUID, SettlementIntentStatus, SettlementLineKind
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementIntent(
|
||||||
|
Base,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
ActorAuditMixin,
|
||||||
|
OptimisticLockMixin,
|
||||||
|
):
|
||||||
|
__tablename__ = "settlement_intents"
|
||||||
|
|
||||||
|
organization_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
code: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
status: Mapped[SettlementIntentStatus] = mapped_column(
|
||||||
|
default=SettlementIntentStatus.DRAFT, nullable=False
|
||||||
|
)
|
||||||
|
driver_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
dispatch_job_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
external_settlement_ref: Mapped[str | None] = mapped_column(
|
||||||
|
String(255), nullable=True
|
||||||
|
)
|
||||||
|
total_amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
currency_code: Mapped[str] = mapped_column(String(3), nullable=False, default="IRR")
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "code", name="uq_settlement_intents_tenant_code"),
|
||||||
|
Index("ix_settlement_intents_tenant_status", "tenant_id", "status"),
|
||||||
|
Index("ix_settlement_intents_tenant_deleted", "tenant_id", "is_deleted"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementLine(
|
||||||
|
Base,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
ActorAuditMixin,
|
||||||
|
):
|
||||||
|
__tablename__ = "settlement_lines"
|
||||||
|
|
||||||
|
settlement_intent_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
kind: Mapped[SettlementLineKind] = mapped_column(nullable=False)
|
||||||
|
description: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
amount: Mapped[Decimal] = mapped_column(Numeric(18, 4), nullable=False)
|
||||||
|
currency_code: Mapped[str] = mapped_column(String(3), nullable=False, default="IRR")
|
||||||
|
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_settlement_lines_intent", "tenant_id", "settlement_intent_id"),
|
||||||
|
)
|
||||||
138
backend/services/delivery/app/models/tracking.py
Normal file
138
backend/services/delivery/app/models/tracking.py
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
"""Tracking & proof of delivery — Phase 10.7."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, Float, Index, String, Text, UniqueConstraint
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from sqlalchemy.types import JSON
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from app.models.base import (
|
||||||
|
ActorAuditMixin,
|
||||||
|
OptimisticLockMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
)
|
||||||
|
from app.models.types import (
|
||||||
|
CustomerTrackingTokenStatus,
|
||||||
|
GUID,
|
||||||
|
ProofOfDeliveryStatus,
|
||||||
|
TrackingSessionStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TrackingSession(
|
||||||
|
Base,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
ActorAuditMixin,
|
||||||
|
OptimisticLockMixin,
|
||||||
|
):
|
||||||
|
__tablename__ = "tracking_sessions"
|
||||||
|
|
||||||
|
dispatch_job_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
driver_id: Mapped[uuid.UUID | None] = mapped_column(GUID(), nullable=True)
|
||||||
|
status: Mapped[TrackingSessionStatus] = mapped_column(
|
||||||
|
default=TrackingSessionStatus.ACTIVE, nullable=False
|
||||||
|
)
|
||||||
|
started_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
ended_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_tracking_sessions_tenant_status", "tenant_id", "status"),
|
||||||
|
Index("ix_tracking_sessions_job", "tenant_id", "dispatch_job_id"),
|
||||||
|
Index("ix_tracking_sessions_tenant_deleted", "tenant_id", "is_deleted"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TrackingPoint(
|
||||||
|
Base,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
):
|
||||||
|
__tablename__ = "tracking_points"
|
||||||
|
|
||||||
|
tracking_session_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
latitude: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
longitude: Mapped[float] = mapped_column(Float, nullable=False)
|
||||||
|
recorded_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=False
|
||||||
|
)
|
||||||
|
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_tracking_points_session", "tenant_id", "tracking_session_id"),
|
||||||
|
Index("ix_tracking_points_recorded", "tenant_id", "recorded_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProofOfDelivery(
|
||||||
|
Base,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
ActorAuditMixin,
|
||||||
|
OptimisticLockMixin,
|
||||||
|
):
|
||||||
|
"""Storage file refs for POD only — no blob ownership."""
|
||||||
|
|
||||||
|
__tablename__ = "proof_of_delivery"
|
||||||
|
|
||||||
|
dispatch_job_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
status: Mapped[ProofOfDeliveryStatus] = mapped_column(
|
||||||
|
default=ProofOfDeliveryStatus.PENDING, nullable=False
|
||||||
|
)
|
||||||
|
signature_file_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
photo_file_ref: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
delivery_code: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||||
|
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
captured_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_proof_of_delivery_job", "tenant_id", "dispatch_job_id"),
|
||||||
|
Index("ix_proof_of_delivery_tenant_status", "tenant_id", "status"),
|
||||||
|
Index("ix_proof_of_delivery_tenant_deleted", "tenant_id", "is_deleted"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerTrackingToken(
|
||||||
|
Base,
|
||||||
|
UUIDPrimaryKeyMixin,
|
||||||
|
TenantMixin,
|
||||||
|
TimestampMixin,
|
||||||
|
SoftDeleteMixin,
|
||||||
|
ActorAuditMixin,
|
||||||
|
):
|
||||||
|
__tablename__ = "customer_tracking_tokens"
|
||||||
|
|
||||||
|
dispatch_job_id: Mapped[uuid.UUID] = mapped_column(GUID(), nullable=False)
|
||||||
|
token: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||||
|
status: Mapped[CustomerTrackingTokenStatus] = mapped_column(
|
||||||
|
default=CustomerTrackingTokenStatus.ACTIVE, nullable=False
|
||||||
|
)
|
||||||
|
expires_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
UniqueConstraint("tenant_id", "token", name="uq_customer_tracking_tokens_token"),
|
||||||
|
Index("ix_customer_tracking_tokens_job", "tenant_id", "dispatch_job_id"),
|
||||||
|
Index("ix_customer_tracking_tokens_tenant_status", "tenant_id", "status"),
|
||||||
|
)
|
||||||
@ -116,3 +116,162 @@ class DriverDocumentKind(str, enum.Enum):
|
|||||||
CONTRACT = "contract"
|
CONTRACT = "contract"
|
||||||
PHOTO = "photo"
|
PHOTO = "photo"
|
||||||
OTHER = "other"
|
OTHER = "other"
|
||||||
|
|
||||||
|
|
||||||
|
# Phase 10.2 — Fleet & Vehicle Types
|
||||||
|
class FleetStatus(str, enum.Enum):
|
||||||
|
DRAFT = "draft"
|
||||||
|
ACTIVE = "active"
|
||||||
|
INACTIVE = "inactive"
|
||||||
|
ARCHIVED = "archived"
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleStatus(str, enum.Enum):
|
||||||
|
AVAILABLE = "available"
|
||||||
|
ASSIGNED = "assigned"
|
||||||
|
MAINTENANCE = "maintenance"
|
||||||
|
INACTIVE = "inactive"
|
||||||
|
ARCHIVED = "archived"
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleAssignmentStatus(str, enum.Enum):
|
||||||
|
ACTIVE = "active"
|
||||||
|
ENDED = "ended"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
# Phase 10.3 — Availability, Shifts & Working Zones
|
||||||
|
class DriverAvailabilityStatus(str, enum.Enum):
|
||||||
|
ONLINE = "online"
|
||||||
|
OFFLINE = "offline"
|
||||||
|
BUSY = "busy"
|
||||||
|
BREAK = "break"
|
||||||
|
|
||||||
|
|
||||||
|
class ShiftStatus(str, enum.Enum):
|
||||||
|
DRAFT = "draft"
|
||||||
|
SCHEDULED = "scheduled"
|
||||||
|
ACTIVE = "active"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class ShiftAssignmentStatus(str, enum.Enum):
|
||||||
|
ASSIGNED = "assigned"
|
||||||
|
CHECKED_IN = "checked_in"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class WorkingZoneStatus(str, enum.Enum):
|
||||||
|
ACTIVE = "active"
|
||||||
|
INACTIVE = "inactive"
|
||||||
|
|
||||||
|
|
||||||
|
# Phase 10.4 — Pricing, Capabilities & Bundles
|
||||||
|
class PricingRuleStatus(str, enum.Enum):
|
||||||
|
DRAFT = "draft"
|
||||||
|
ACTIVE = "active"
|
||||||
|
INACTIVE = "inactive"
|
||||||
|
ARCHIVED = "archived"
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityStatus(str, enum.Enum):
|
||||||
|
ACTIVE = "active"
|
||||||
|
INACTIVE = "inactive"
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityBundleStatus(str, enum.Enum):
|
||||||
|
DRAFT = "draft"
|
||||||
|
ACTIVE = "active"
|
||||||
|
INACTIVE = "inactive"
|
||||||
|
ARCHIVED = "archived"
|
||||||
|
|
||||||
|
|
||||||
|
# Phase 10.5 — Dispatch Engine
|
||||||
|
class DispatchJobStatus(str, enum.Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
ASSIGNED = "assigned"
|
||||||
|
IN_PROGRESS = "in_progress"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
class DispatchJobAction(str, enum.Enum):
|
||||||
|
ASSIGN = "assign"
|
||||||
|
START = "start"
|
||||||
|
COMPLETE = "complete"
|
||||||
|
CANCEL = "cancel"
|
||||||
|
FAIL = "fail"
|
||||||
|
|
||||||
|
|
||||||
|
class JobAssignmentStatus(str, enum.Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
ACCEPTED = "accepted"
|
||||||
|
REJECTED = "rejected"
|
||||||
|
ACTIVE = "active"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
# Phase 10.6 — Routing & Optimization
|
||||||
|
class RoutePlanStatus(str, enum.Enum):
|
||||||
|
DRAFT = "draft"
|
||||||
|
PLANNED = "planned"
|
||||||
|
ACTIVE = "active"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class RouteStopStatus(str, enum.Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
ARRIVED = "arrived"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
SKIPPED = "skipped"
|
||||||
|
|
||||||
|
|
||||||
|
class OptimizationRunStatus(str, enum.Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
RUNNING = "running"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
FAILED = "failed"
|
||||||
|
|
||||||
|
|
||||||
|
# Phase 10.7 — Tracking & Proof of Delivery
|
||||||
|
class TrackingSessionStatus(str, enum.Enum):
|
||||||
|
ACTIVE = "active"
|
||||||
|
PAUSED = "paused"
|
||||||
|
COMPLETED = "completed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class ProofOfDeliveryStatus(str, enum.Enum):
|
||||||
|
PENDING = "pending"
|
||||||
|
CAPTURED = "captured"
|
||||||
|
VERIFIED = "verified"
|
||||||
|
REJECTED = "rejected"
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerTrackingTokenStatus(str, enum.Enum):
|
||||||
|
ACTIVE = "active"
|
||||||
|
EXPIRED = "expired"
|
||||||
|
REVOKED = "revoked"
|
||||||
|
|
||||||
|
|
||||||
|
# Phase 10.8 — Settlement
|
||||||
|
class SettlementIntentStatus(str, enum.Enum):
|
||||||
|
DRAFT = "draft"
|
||||||
|
PENDING = "pending"
|
||||||
|
SUBMITTED = "submitted"
|
||||||
|
SETTLED = "settled"
|
||||||
|
FAILED = "failed"
|
||||||
|
CANCELLED = "cancelled"
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementLineKind(str, enum.Enum):
|
||||||
|
DELIVERY_FEE = "delivery_fee"
|
||||||
|
TIP = "tip"
|
||||||
|
SURCHARGE = "surcharge"
|
||||||
|
ADJUSTMENT = "adjustment"
|
||||||
|
OTHER = "other"
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
"""Delivery permission definitions — Phase 10.0 foundation + 10.1 drivers."""
|
"""Delivery permission definitions — Phase 10.0–10.8."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
DELIVERY_VIEW = "delivery.view"
|
DELIVERY_VIEW = "delivery.view"
|
||||||
@ -58,18 +58,134 @@ DRIVERS_DOCUMENTS_VIEW = "delivery.drivers.documents.view"
|
|||||||
DRIVERS_DOCUMENTS_MANAGE = "delivery.drivers.documents.manage"
|
DRIVERS_DOCUMENTS_MANAGE = "delivery.drivers.documents.manage"
|
||||||
DRIVERS_MANAGE = "delivery.drivers.manage"
|
DRIVERS_MANAGE = "delivery.drivers.manage"
|
||||||
|
|
||||||
# Planned trees (registered for discovery; enforced in later phases)
|
# Phase 10.2 — Fleet & Vehicles
|
||||||
FLEET_VIEW = "delivery.fleet.view"
|
FLEETS_VIEW = "delivery.fleets.view"
|
||||||
FLEET_MANAGE = "delivery.fleet.manage"
|
FLEETS_CREATE = "delivery.fleets.create"
|
||||||
|
FLEETS_UPDATE = "delivery.fleets.update"
|
||||||
|
FLEETS_DELETE = "delivery.fleets.delete"
|
||||||
|
FLEETS_MANAGE = "delivery.fleets.manage"
|
||||||
|
|
||||||
|
VEHICLE_TYPES_VIEW = "delivery.vehicle_types.view"
|
||||||
|
VEHICLE_TYPES_CREATE = "delivery.vehicle_types.create"
|
||||||
|
VEHICLE_TYPES_UPDATE = "delivery.vehicle_types.update"
|
||||||
|
VEHICLE_TYPES_DELETE = "delivery.vehicle_types.delete"
|
||||||
|
VEHICLE_TYPES_MANAGE = "delivery.vehicle_types.manage"
|
||||||
|
|
||||||
|
VEHICLES_VIEW = "delivery.vehicles.view"
|
||||||
|
VEHICLES_CREATE = "delivery.vehicles.create"
|
||||||
|
VEHICLES_UPDATE = "delivery.vehicles.update"
|
||||||
|
VEHICLES_DELETE = "delivery.vehicles.delete"
|
||||||
|
VEHICLES_ASSIGNMENTS_VIEW = "delivery.vehicles.assignments.view"
|
||||||
|
VEHICLES_ASSIGNMENTS_MANAGE = "delivery.vehicles.assignments.manage"
|
||||||
|
VEHICLES_MANAGE = "delivery.vehicles.manage"
|
||||||
|
|
||||||
|
# Phase 10.3 — Availability, Shifts & Working Zones
|
||||||
|
AVAILABILITY_VIEW = "delivery.availability.view"
|
||||||
|
AVAILABILITY_MANAGE = "delivery.availability.manage"
|
||||||
|
|
||||||
|
SHIFTS_VIEW = "delivery.shifts.view"
|
||||||
|
SHIFTS_CREATE = "delivery.shifts.create"
|
||||||
|
SHIFTS_UPDATE = "delivery.shifts.update"
|
||||||
|
SHIFTS_DELETE = "delivery.shifts.delete"
|
||||||
|
SHIFTS_ASSIGNMENTS_VIEW = "delivery.shifts.assignments.view"
|
||||||
|
SHIFTS_ASSIGNMENTS_MANAGE = "delivery.shifts.assignments.manage"
|
||||||
|
SHIFTS_MANAGE = "delivery.shifts.manage"
|
||||||
|
|
||||||
|
WORKING_ZONES_VIEW = "delivery.working_zones.view"
|
||||||
|
WORKING_ZONES_CREATE = "delivery.working_zones.create"
|
||||||
|
WORKING_ZONES_UPDATE = "delivery.working_zones.update"
|
||||||
|
WORKING_ZONES_DELETE = "delivery.working_zones.delete"
|
||||||
|
WORKING_ZONES_MANAGE = "delivery.working_zones.manage"
|
||||||
|
|
||||||
|
# Phase 10.4 — Pricing, Capabilities & Bundles
|
||||||
|
PRICING_RULES_VIEW = "delivery.pricing_rules.view"
|
||||||
|
PRICING_RULES_CREATE = "delivery.pricing_rules.create"
|
||||||
|
PRICING_RULES_UPDATE = "delivery.pricing_rules.update"
|
||||||
|
PRICING_RULES_DELETE = "delivery.pricing_rules.delete"
|
||||||
|
PRICING_RULES_MANAGE = "delivery.pricing_rules.manage"
|
||||||
|
|
||||||
|
CAPABILITIES_VIEW = "delivery.capabilities.view"
|
||||||
|
CAPABILITIES_CREATE = "delivery.capabilities.create"
|
||||||
|
CAPABILITIES_UPDATE = "delivery.capabilities.update"
|
||||||
|
CAPABILITIES_DELETE = "delivery.capabilities.delete"
|
||||||
|
CAPABILITIES_MANAGE = "delivery.capabilities.manage"
|
||||||
|
|
||||||
|
BUNDLES_VIEW = "delivery.bundles.view"
|
||||||
|
BUNDLES_CREATE = "delivery.bundles.create"
|
||||||
|
BUNDLES_UPDATE = "delivery.bundles.update"
|
||||||
|
BUNDLES_DELETE = "delivery.bundles.delete"
|
||||||
|
BUNDLES_MANAGE = "delivery.bundles.manage"
|
||||||
|
|
||||||
|
# Phase 10.5 — Dispatch Engine
|
||||||
|
DISPATCH_JOBS_VIEW = "delivery.dispatch.jobs.view"
|
||||||
|
DISPATCH_JOBS_CREATE = "delivery.dispatch.jobs.create"
|
||||||
|
DISPATCH_JOBS_UPDATE = "delivery.dispatch.jobs.update"
|
||||||
|
DISPATCH_JOBS_DELETE = "delivery.dispatch.jobs.delete"
|
||||||
|
DISPATCH_JOBS_STATUS = "delivery.dispatch.jobs.status"
|
||||||
|
DISPATCH_JOBS_MANAGE = "delivery.dispatch.jobs.manage"
|
||||||
|
|
||||||
|
DISPATCH_ASSIGNMENTS_VIEW = "delivery.dispatch.assignments.view"
|
||||||
|
DISPATCH_ASSIGNMENTS_CREATE = "delivery.dispatch.assignments.create"
|
||||||
|
DISPATCH_ASSIGNMENTS_UPDATE = "delivery.dispatch.assignments.update"
|
||||||
|
DISPATCH_ASSIGNMENTS_MANAGE = "delivery.dispatch.assignments.manage"
|
||||||
|
|
||||||
DISPATCH_VIEW = "delivery.dispatch.view"
|
DISPATCH_VIEW = "delivery.dispatch.view"
|
||||||
DISPATCH_MANAGE = "delivery.dispatch.manage"
|
DISPATCH_MANAGE = "delivery.dispatch.manage"
|
||||||
|
|
||||||
|
# Phase 10.6 — Routing & Optimization
|
||||||
|
ROUTES_VIEW = "delivery.routes.view"
|
||||||
|
ROUTES_CREATE = "delivery.routes.create"
|
||||||
|
ROUTES_UPDATE = "delivery.routes.update"
|
||||||
|
ROUTES_DELETE = "delivery.routes.delete"
|
||||||
|
ROUTES_STOPS_VIEW = "delivery.routes.stops.view"
|
||||||
|
ROUTES_STOPS_MANAGE = "delivery.routes.stops.manage"
|
||||||
|
ROUTES_MANAGE = "delivery.routes.manage"
|
||||||
|
|
||||||
|
OPTIMIZATION_RUNS_VIEW = "delivery.optimization.runs.view"
|
||||||
|
OPTIMIZATION_RUNS_CREATE = "delivery.optimization.runs.create"
|
||||||
|
OPTIMIZATION_RUNS_MANAGE = "delivery.optimization.runs.manage"
|
||||||
|
|
||||||
ROUTING_VIEW = "delivery.routing.view"
|
ROUTING_VIEW = "delivery.routing.view"
|
||||||
ROUTING_MANAGE = "delivery.routing.manage"
|
ROUTING_MANAGE = "delivery.routing.manage"
|
||||||
|
|
||||||
|
# Phase 10.7 — Tracking & Proof of Delivery
|
||||||
|
TRACKING_SESSIONS_VIEW = "delivery.tracking.sessions.view"
|
||||||
|
TRACKING_SESSIONS_CREATE = "delivery.tracking.sessions.create"
|
||||||
|
TRACKING_SESSIONS_UPDATE = "delivery.tracking.sessions.update"
|
||||||
|
TRACKING_SESSIONS_MANAGE = "delivery.tracking.sessions.manage"
|
||||||
|
|
||||||
|
TRACKING_POINTS_VIEW = "delivery.tracking.points.view"
|
||||||
|
TRACKING_POINTS_MANAGE = "delivery.tracking.points.manage"
|
||||||
|
|
||||||
|
PROOF_OF_DELIVERY_VIEW = "delivery.proof_of_delivery.view"
|
||||||
|
PROOF_OF_DELIVERY_CREATE = "delivery.proof_of_delivery.create"
|
||||||
|
PROOF_OF_DELIVERY_UPDATE = "delivery.proof_of_delivery.update"
|
||||||
|
PROOF_OF_DELIVERY_MANAGE = "delivery.proof_of_delivery.manage"
|
||||||
|
|
||||||
|
CUSTOMER_TRACKING_VIEW = "delivery.customer_tracking.view"
|
||||||
|
CUSTOMER_TRACKING_CREATE = "delivery.customer_tracking.create"
|
||||||
|
CUSTOMER_TRACKING_REVOKE = "delivery.customer_tracking.revoke"
|
||||||
|
CUSTOMER_TRACKING_MANAGE = "delivery.customer_tracking.manage"
|
||||||
|
|
||||||
TRACKING_VIEW = "delivery.tracking.view"
|
TRACKING_VIEW = "delivery.tracking.view"
|
||||||
TRACKING_MANAGE = "delivery.tracking.manage"
|
TRACKING_MANAGE = "delivery.tracking.manage"
|
||||||
|
|
||||||
|
# Phase 10.8 — Settlement
|
||||||
|
SETTLEMENTS_VIEW = "delivery.settlements.view"
|
||||||
|
SETTLEMENTS_CREATE = "delivery.settlements.create"
|
||||||
|
SETTLEMENTS_UPDATE = "delivery.settlements.update"
|
||||||
|
SETTLEMENTS_STATUS = "delivery.settlements.status"
|
||||||
|
SETTLEMENTS_LINES_VIEW = "delivery.settlements.lines.view"
|
||||||
|
SETTLEMENTS_LINES_MANAGE = "delivery.settlements.lines.manage"
|
||||||
|
SETTLEMENTS_MANAGE = "delivery.settlements.manage"
|
||||||
|
|
||||||
SETTLEMENT_VIEW = "delivery.settlement.view"
|
SETTLEMENT_VIEW = "delivery.settlement.view"
|
||||||
SETTLEMENT_MANAGE = "delivery.settlement.manage"
|
SETTLEMENT_MANAGE = "delivery.settlement.manage"
|
||||||
|
|
||||||
|
# Legacy aliases
|
||||||
|
FLEET_VIEW = FLEETS_VIEW
|
||||||
|
FLEET_MANAGE = FLEETS_MANAGE
|
||||||
|
|
||||||
ALL_PERMISSIONS: list[str] = [
|
ALL_PERMISSIONS: list[str] = [
|
||||||
DELIVERY_VIEW,
|
DELIVERY_VIEW,
|
||||||
DELIVERY_MANAGE,
|
DELIVERY_MANAGE,
|
||||||
@ -118,16 +234,103 @@ ALL_PERMISSIONS: list[str] = [
|
|||||||
DRIVERS_DOCUMENTS_VIEW,
|
DRIVERS_DOCUMENTS_VIEW,
|
||||||
DRIVERS_DOCUMENTS_MANAGE,
|
DRIVERS_DOCUMENTS_MANAGE,
|
||||||
DRIVERS_MANAGE,
|
DRIVERS_MANAGE,
|
||||||
FLEET_VIEW,
|
FLEETS_VIEW,
|
||||||
FLEET_MANAGE,
|
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_VIEW,
|
||||||
DISPATCH_MANAGE,
|
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_VIEW,
|
||||||
ROUTING_MANAGE,
|
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_VIEW,
|
||||||
TRACKING_MANAGE,
|
TRACKING_MANAGE,
|
||||||
|
SETTLEMENTS_VIEW,
|
||||||
|
SETTLEMENTS_CREATE,
|
||||||
|
SETTLEMENTS_UPDATE,
|
||||||
|
SETTLEMENTS_STATUS,
|
||||||
|
SETTLEMENTS_LINES_VIEW,
|
||||||
|
SETTLEMENTS_LINES_MANAGE,
|
||||||
|
SETTLEMENTS_MANAGE,
|
||||||
SETTLEMENT_VIEW,
|
SETTLEMENT_VIEW,
|
||||||
SETTLEMENT_MANAGE,
|
SETTLEMENT_MANAGE,
|
||||||
|
FLEET_VIEW,
|
||||||
|
FLEET_MANAGE,
|
||||||
]
|
]
|
||||||
|
|
||||||
PERMISSION_PREFIXES: tuple[str, ...] = (
|
PERMISSION_PREFIXES: tuple[str, ...] = (
|
||||||
@ -140,9 +343,23 @@ PERMISSION_PREFIXES: tuple[str, ...] = (
|
|||||||
"delivery.settings.",
|
"delivery.settings.",
|
||||||
"delivery.audit.",
|
"delivery.audit.",
|
||||||
"delivery.drivers.",
|
"delivery.drivers.",
|
||||||
"delivery.fleet.",
|
"delivery.fleets.",
|
||||||
|
"delivery.vehicle_types.",
|
||||||
|
"delivery.vehicles.",
|
||||||
|
"delivery.availability.",
|
||||||
|
"delivery.shifts.",
|
||||||
|
"delivery.working_zones.",
|
||||||
|
"delivery.pricing_rules.",
|
||||||
|
"delivery.capabilities.",
|
||||||
|
"delivery.bundles.",
|
||||||
"delivery.dispatch.",
|
"delivery.dispatch.",
|
||||||
"delivery.routing.",
|
"delivery.routes.",
|
||||||
|
"delivery.optimization.",
|
||||||
"delivery.tracking.",
|
"delivery.tracking.",
|
||||||
|
"delivery.proof_of_delivery.",
|
||||||
|
"delivery.customer_tracking.",
|
||||||
|
"delivery.settlements.",
|
||||||
|
"delivery.fleet.",
|
||||||
|
"delivery.routing.",
|
||||||
"delivery.settlement.",
|
"delivery.settlement.",
|
||||||
)
|
)
|
||||||
|
|||||||
28
backend/services/delivery/app/policies/dispatch.py
Normal file
28
backend/services/delivery/app/policies/dispatch.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
"""Dispatch domain policies — Phase 10.5."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.models.types import DispatchJobAction, DispatchJobStatus
|
||||||
|
from app.validators.dispatch import (
|
||||||
|
ensure_dispatch_job_transition,
|
||||||
|
target_status_for,
|
||||||
|
validate_dispatch_reason,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DispatchJobPolicy:
|
||||||
|
REQUIRES_REASON = frozenset(
|
||||||
|
{DispatchJobAction.CANCEL, DispatchJobAction.FAIL}
|
||||||
|
)
|
||||||
|
|
||||||
|
def assert_transition(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
action: DispatchJobAction,
|
||||||
|
current: DispatchJobStatus,
|
||||||
|
reason: str | None = None,
|
||||||
|
) -> tuple[DispatchJobStatus, str | None]:
|
||||||
|
ensure_dispatch_job_transition(action=action, current=current)
|
||||||
|
cleaned = validate_dispatch_reason(
|
||||||
|
reason, required=action in self.REQUIRES_REASON
|
||||||
|
)
|
||||||
|
return target_status_for(action), cleaned
|
||||||
26
backend/services/delivery/app/policies/settlement.py
Normal file
26
backend/services/delivery/app/policies/settlement.py
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
"""Settlement domain policies — Phase 10.8."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.models.types import SettlementIntentStatus
|
||||||
|
from app.validators.dispatch import validate_dispatch_reason
|
||||||
|
from app.validators.settlement import (
|
||||||
|
ensure_settlement_transition,
|
||||||
|
target_settlement_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementIntentPolicy:
|
||||||
|
REQUIRES_REASON = frozenset({"fail", "cancel"})
|
||||||
|
|
||||||
|
def assert_transition(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
action: str,
|
||||||
|
current: SettlementIntentStatus,
|
||||||
|
reason: str | None = None,
|
||||||
|
) -> tuple[SettlementIntentStatus, str | None]:
|
||||||
|
ensure_settlement_transition(action=action, current=current)
|
||||||
|
cleaned = validate_dispatch_reason(
|
||||||
|
reason, required=action in self.REQUIRES_REASON
|
||||||
|
)
|
||||||
|
return target_settlement_status(action), cleaned
|
||||||
38
backend/services/delivery/app/queries/availability.py
Normal file
38
backend/services/delivery/app/queries/availability.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
"""Availability query handlers — Phase 10.3."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.services.availability import AvailabilityService, ShiftService, WorkingZoneService
|
||||||
|
from app.specifications.availability import ShiftListSpec, WorkingZoneListSpec
|
||||||
|
|
||||||
|
|
||||||
|
class AvailabilityQueries:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.availability = AvailabilityService(session)
|
||||||
|
self.shifts = ShiftService(session)
|
||||||
|
self.zones = WorkingZoneService(session)
|
||||||
|
|
||||||
|
async def get_availability(self, tenant_id: UUID, driver_id: UUID):
|
||||||
|
return await self.availability.get_for_driver(tenant_id, driver_id)
|
||||||
|
|
||||||
|
async def get_shift(self, tenant_id: UUID, shift_id: UUID):
|
||||||
|
return await self.shifts.get(tenant_id, shift_id)
|
||||||
|
|
||||||
|
async def list_shifts(
|
||||||
|
self, tenant_id: UUID, *, offset: int, limit: int, spec: ShiftListSpec | None
|
||||||
|
):
|
||||||
|
return await self.shifts.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
||||||
|
|
||||||
|
async def list_shift_assignments(self, tenant_id: UUID, shift_id: UUID):
|
||||||
|
return await self.shifts.list_assignments(tenant_id, shift_id)
|
||||||
|
|
||||||
|
async def get_zone(self, tenant_id: UUID, zone_id: UUID):
|
||||||
|
return await self.zones.get(tenant_id, zone_id)
|
||||||
|
|
||||||
|
async def list_zones(
|
||||||
|
self, tenant_id: UUID, *, offset: int, limit: int, spec: WorkingZoneListSpec | None
|
||||||
|
):
|
||||||
|
return await self.zones.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
||||||
38
backend/services/delivery/app/queries/dispatch.py
Normal file
38
backend/services/delivery/app/queries/dispatch.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
"""Dispatch query handlers — Phase 10.5."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.services.dispatch import DispatchJobService, JobAssignmentService
|
||||||
|
from app.specifications.dispatch import DispatchJobListSpec, JobAssignmentListSpec
|
||||||
|
|
||||||
|
|
||||||
|
class DispatchQueries:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.jobs = DispatchJobService(session)
|
||||||
|
self.assignments = JobAssignmentService(session)
|
||||||
|
|
||||||
|
async def get_job(self, tenant_id: UUID, job_id: UUID):
|
||||||
|
return await self.jobs.get(tenant_id, job_id)
|
||||||
|
|
||||||
|
async def list_jobs(
|
||||||
|
self, tenant_id: UUID, *, offset: int, limit: int, spec: DispatchJobListSpec | None
|
||||||
|
):
|
||||||
|
return await self.jobs.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
||||||
|
|
||||||
|
async def get_assignment(self, tenant_id: UUID, assignment_id: UUID):
|
||||||
|
return await self.assignments.get(tenant_id, assignment_id)
|
||||||
|
|
||||||
|
async def list_assignments(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
offset: int,
|
||||||
|
limit: int,
|
||||||
|
spec: JobAssignmentListSpec | None,
|
||||||
|
):
|
||||||
|
return await self.assignments.list(
|
||||||
|
tenant_id, offset=offset, limit=limit, spec=spec
|
||||||
|
)
|
||||||
43
backend/services/delivery/app/queries/fleet.py
Normal file
43
backend/services/delivery/app/queries/fleet.py
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
"""Fleet query handlers — Phase 10.2."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.services.fleet import FleetService, VehicleService, VehicleTypeService
|
||||||
|
from app.specifications.fleet import FleetListSpec, VehicleListSpec, VehicleTypeListSpec
|
||||||
|
|
||||||
|
|
||||||
|
class FleetQueries:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.fleets = FleetService(session)
|
||||||
|
self.types = VehicleTypeService(session)
|
||||||
|
self.vehicles = VehicleService(session)
|
||||||
|
|
||||||
|
async def get_fleet(self, tenant_id: UUID, fleet_id: UUID):
|
||||||
|
return await self.fleets.get(tenant_id, fleet_id)
|
||||||
|
|
||||||
|
async def list_fleets(
|
||||||
|
self, tenant_id: UUID, *, offset: int, limit: int, spec: FleetListSpec | None
|
||||||
|
):
|
||||||
|
return await self.fleets.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
||||||
|
|
||||||
|
async def get_vehicle_type(self, tenant_id: UUID, type_id: UUID):
|
||||||
|
return await self.types.get(tenant_id, type_id)
|
||||||
|
|
||||||
|
async def list_vehicle_types(
|
||||||
|
self, tenant_id: UUID, *, offset: int, limit: int, spec: VehicleTypeListSpec | None
|
||||||
|
):
|
||||||
|
return await self.types.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
||||||
|
|
||||||
|
async def get_vehicle(self, tenant_id: UUID, vehicle_id: UUID):
|
||||||
|
return await self.vehicles.get(tenant_id, vehicle_id)
|
||||||
|
|
||||||
|
async def list_vehicles(
|
||||||
|
self, tenant_id: UUID, *, offset: int, limit: int, spec: VehicleListSpec | None
|
||||||
|
):
|
||||||
|
return await self.vehicles.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
||||||
|
|
||||||
|
async def list_assignments(self, tenant_id: UUID, vehicle_id: UUID):
|
||||||
|
return await self.vehicles.list_assignments(tenant_id, vehicle_id)
|
||||||
44
backend/services/delivery/app/queries/pricing.py
Normal file
44
backend/services/delivery/app/queries/pricing.py
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
"""Pricing query handlers — Phase 10.4."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.services.pricing import BundleService, CapabilityService, PricingRuleService
|
||||||
|
from app.specifications.pricing import (
|
||||||
|
BundleListSpec,
|
||||||
|
CapabilityListSpec,
|
||||||
|
PricingRuleListSpec,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PricingQueries:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.rules = PricingRuleService(session)
|
||||||
|
self.capabilities = CapabilityService(session)
|
||||||
|
self.bundles = BundleService(session)
|
||||||
|
|
||||||
|
async def get_rule(self, tenant_id: UUID, rule_id: UUID):
|
||||||
|
return await self.rules.get(tenant_id, rule_id)
|
||||||
|
|
||||||
|
async def list_rules(
|
||||||
|
self, tenant_id: UUID, *, offset: int, limit: int, spec: PricingRuleListSpec | None
|
||||||
|
):
|
||||||
|
return await self.rules.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
||||||
|
|
||||||
|
async def get_capability(self, tenant_id: UUID, cap_id: UUID):
|
||||||
|
return await self.capabilities.get(tenant_id, cap_id)
|
||||||
|
|
||||||
|
async def list_capabilities(
|
||||||
|
self, tenant_id: UUID, *, offset: int, limit: int, spec: CapabilityListSpec | None
|
||||||
|
):
|
||||||
|
return await self.capabilities.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
||||||
|
|
||||||
|
async def get_bundle(self, tenant_id: UUID, bundle_id: UUID):
|
||||||
|
return await self.bundles.get(tenant_id, bundle_id)
|
||||||
|
|
||||||
|
async def list_bundles(
|
||||||
|
self, tenant_id: UUID, *, offset: int, limit: int, spec: BundleListSpec | None
|
||||||
|
):
|
||||||
|
return await self.bundles.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
||||||
41
backend/services/delivery/app/queries/routing.py
Normal file
41
backend/services/delivery/app/queries/routing.py
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
"""Routing query handlers — Phase 10.6."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.services.routing import OptimizationRunService, RoutePlanService
|
||||||
|
from app.specifications.routing import OptimizationRunListSpec, RoutePlanListSpec
|
||||||
|
|
||||||
|
|
||||||
|
class RoutingQueries:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.routes = RoutePlanService(session)
|
||||||
|
self.optimization = OptimizationRunService(session)
|
||||||
|
|
||||||
|
async def get_route(self, tenant_id: UUID, plan_id: UUID):
|
||||||
|
return await self.routes.get(tenant_id, plan_id)
|
||||||
|
|
||||||
|
async def list_routes(
|
||||||
|
self, tenant_id: UUID, *, offset: int, limit: int, spec: RoutePlanListSpec | None
|
||||||
|
):
|
||||||
|
return await self.routes.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
||||||
|
|
||||||
|
async def list_stops(self, tenant_id: UUID, plan_id: UUID):
|
||||||
|
return await self.routes.list_stops(tenant_id, plan_id)
|
||||||
|
|
||||||
|
async def get_optimization_run(self, tenant_id: UUID, run_id: UUID):
|
||||||
|
return await self.optimization.get(tenant_id, run_id)
|
||||||
|
|
||||||
|
async def list_optimization_runs(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
offset: int,
|
||||||
|
limit: int,
|
||||||
|
spec: OptimizationRunListSpec | None,
|
||||||
|
):
|
||||||
|
return await self.optimization.list(
|
||||||
|
tenant_id, offset=offset, limit=limit, spec=spec
|
||||||
|
)
|
||||||
32
backend/services/delivery/app/queries/settlement.py
Normal file
32
backend/services/delivery/app/queries/settlement.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
"""Settlement query handlers — Phase 10.8."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.services.settlement import SettlementService
|
||||||
|
from app.specifications.settlement import SettlementIntentListSpec
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementQueries:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.service = SettlementService(session)
|
||||||
|
|
||||||
|
async def get(self, tenant_id: UUID, intent_id: UUID):
|
||||||
|
return await self.service.get(tenant_id, intent_id)
|
||||||
|
|
||||||
|
async def list(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
offset: int,
|
||||||
|
limit: int,
|
||||||
|
spec: SettlementIntentListSpec | None,
|
||||||
|
):
|
||||||
|
return await self.service.list(
|
||||||
|
tenant_id, offset=offset, limit=limit, spec=spec
|
||||||
|
)
|
||||||
|
|
||||||
|
async def lines(self, tenant_id: UUID, intent_id: UUID):
|
||||||
|
return await self.service.list_lines(tenant_id, intent_id)
|
||||||
68
backend/services/delivery/app/queries/tracking.py
Normal file
68
backend/services/delivery/app/queries/tracking.py
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
"""Tracking query handlers — Phase 10.7."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.services.tracking import (
|
||||||
|
CustomerTrackingTokenService,
|
||||||
|
ProofOfDeliveryService,
|
||||||
|
TrackingSessionService,
|
||||||
|
)
|
||||||
|
from app.specifications.tracking import (
|
||||||
|
CustomerTrackingTokenListSpec,
|
||||||
|
ProofOfDeliveryListSpec,
|
||||||
|
TrackingSessionListSpec,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TrackingQueries:
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.sessions = TrackingSessionService(session)
|
||||||
|
self.pod = ProofOfDeliveryService(session)
|
||||||
|
self.tokens = CustomerTrackingTokenService(session)
|
||||||
|
|
||||||
|
async def get_session(self, tenant_id: UUID, session_id: UUID):
|
||||||
|
return await self.sessions.get(tenant_id, session_id)
|
||||||
|
|
||||||
|
async def list_sessions(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
offset: int,
|
||||||
|
limit: int,
|
||||||
|
spec: TrackingSessionListSpec | None,
|
||||||
|
):
|
||||||
|
return await self.sessions.list(
|
||||||
|
tenant_id, offset=offset, limit=limit, spec=spec
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_points(self, tenant_id: UUID, session_id: UUID, *, limit: int = 500):
|
||||||
|
return await self.sessions.list_points(tenant_id, session_id, limit=limit)
|
||||||
|
|
||||||
|
async def get_pod(self, tenant_id: UUID, pod_id: UUID):
|
||||||
|
return await self.pod.get(tenant_id, pod_id)
|
||||||
|
|
||||||
|
async def list_pods(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
offset: int,
|
||||||
|
limit: int,
|
||||||
|
spec: ProofOfDeliveryListSpec | None,
|
||||||
|
):
|
||||||
|
return await self.pod.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
||||||
|
|
||||||
|
async def get_token(self, tenant_id: UUID, token_id: UUID):
|
||||||
|
return await self.tokens.get(tenant_id, token_id)
|
||||||
|
|
||||||
|
async def list_tokens(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
offset: int,
|
||||||
|
limit: int,
|
||||||
|
spec: CustomerTrackingTokenListSpec | None,
|
||||||
|
):
|
||||||
|
return await self.tokens.list(tenant_id, offset=offset, limit=limit, spec=spec)
|
||||||
145
backend/services/delivery/app/repositories/availability.py
Normal file
145
backend/services/delivery/app/repositories/availability.py
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
"""Availability repositories — Phase 10.3."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Sequence
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from app.models.availability import (
|
||||||
|
DriverAvailability,
|
||||||
|
Shift,
|
||||||
|
ShiftAssignment,
|
||||||
|
WorkingZone,
|
||||||
|
)
|
||||||
|
from app.repositories.base import TenantBaseRepository
|
||||||
|
from app.specifications.availability import ShiftListSpec, WorkingZoneListSpec
|
||||||
|
|
||||||
|
|
||||||
|
class DriverAvailabilityRepository(TenantBaseRepository[DriverAvailability]):
|
||||||
|
model = DriverAvailability
|
||||||
|
|
||||||
|
async def get_for_driver(
|
||||||
|
self, tenant_id: UUID, driver_id: UUID
|
||||||
|
) -> DriverAvailability | None:
|
||||||
|
stmt = (
|
||||||
|
select(self.model)
|
||||||
|
.where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.driver_id == driver_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
.order_by(self.model.updated_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
|
class ShiftRepository(TenantBaseRepository[Shift]):
|
||||||
|
model = Shift
|
||||||
|
|
||||||
|
async def get_by_code(self, tenant_id: UUID, code: str) -> Shift | None:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.code == code,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def list_filtered(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
spec: ShiftListSpec | None = None,
|
||||||
|
) -> Sequence[Shift]:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
if spec is not None:
|
||||||
|
stmt = spec.apply(stmt)
|
||||||
|
else:
|
||||||
|
stmt = stmt.order_by(self.model.starts_at.desc())
|
||||||
|
stmt = stmt.offset(offset).limit(limit)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def count_filtered(
|
||||||
|
self, tenant_id: UUID, *, spec: ShiftListSpec | None = None
|
||||||
|
) -> int:
|
||||||
|
stmt = select(func.count()).select_from(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
if spec is not None:
|
||||||
|
clauses = spec.filter_clauses()
|
||||||
|
if clauses:
|
||||||
|
stmt = stmt.where(*clauses)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return int(result.scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
class ShiftAssignmentRepository(TenantBaseRepository[ShiftAssignment]):
|
||||||
|
model = ShiftAssignment
|
||||||
|
|
||||||
|
async def list_for_shift(
|
||||||
|
self, tenant_id: UUID, shift_id: UUID
|
||||||
|
) -> Sequence[ShiftAssignment]:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.shift_id == shift_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
class WorkingZoneRepository(TenantBaseRepository[WorkingZone]):
|
||||||
|
model = WorkingZone
|
||||||
|
|
||||||
|
async def get_by_code(self, tenant_id: UUID, code: str) -> WorkingZone | None:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.code == code,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def list_filtered(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
spec: WorkingZoneListSpec | None = None,
|
||||||
|
) -> Sequence[WorkingZone]:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
if spec is not None:
|
||||||
|
stmt = spec.apply(stmt)
|
||||||
|
else:
|
||||||
|
stmt = stmt.order_by(self.model.created_at.desc())
|
||||||
|
stmt = stmt.offset(offset).limit(limit)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def count_filtered(
|
||||||
|
self, tenant_id: UUID, *, spec: WorkingZoneListSpec | None = None
|
||||||
|
) -> int:
|
||||||
|
stmt = select(func.count()).select_from(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
if spec is not None:
|
||||||
|
clauses = spec.filter_clauses()
|
||||||
|
if clauses:
|
||||||
|
stmt = stmt.where(*clauses)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return int(result.scalar_one())
|
||||||
107
backend/services/delivery/app/repositories/dispatch.py
Normal file
107
backend/services/delivery/app/repositories/dispatch.py
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
"""Dispatch repositories — Phase 10.5."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Sequence
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from app.models.dispatch import DispatchJob, JobAssignment
|
||||||
|
from app.repositories.base import TenantBaseRepository
|
||||||
|
from app.specifications.dispatch import DispatchJobListSpec, JobAssignmentListSpec
|
||||||
|
|
||||||
|
|
||||||
|
class DispatchJobRepository(TenantBaseRepository[DispatchJob]):
|
||||||
|
model = DispatchJob
|
||||||
|
|
||||||
|
async def get_by_code(self, tenant_id: UUID, code: str) -> DispatchJob | None:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.code == code,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def list_filtered(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
spec: DispatchJobListSpec | None = None,
|
||||||
|
) -> Sequence[DispatchJob]:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
if spec is not None:
|
||||||
|
stmt = spec.apply(stmt)
|
||||||
|
else:
|
||||||
|
stmt = stmt.order_by(self.model.created_at.desc())
|
||||||
|
stmt = stmt.offset(offset).limit(limit)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def count_filtered(
|
||||||
|
self, tenant_id: UUID, *, spec: DispatchJobListSpec | None = None
|
||||||
|
) -> int:
|
||||||
|
stmt = select(func.count()).select_from(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
if spec is not None:
|
||||||
|
clauses = spec.filter_clauses()
|
||||||
|
if clauses:
|
||||||
|
stmt = stmt.where(*clauses)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return int(result.scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
class JobAssignmentRepository(TenantBaseRepository[JobAssignment]):
|
||||||
|
model = JobAssignment
|
||||||
|
|
||||||
|
async def list_filtered(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
spec: JobAssignmentListSpec | None = None,
|
||||||
|
) -> Sequence[JobAssignment]:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
if spec is not None:
|
||||||
|
stmt = spec.apply(stmt)
|
||||||
|
else:
|
||||||
|
stmt = stmt.order_by(self.model.created_at.desc())
|
||||||
|
stmt = stmt.offset(offset).limit(limit)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def count_filtered(
|
||||||
|
self, tenant_id: UUID, *, spec: JobAssignmentListSpec | None = None
|
||||||
|
) -> int:
|
||||||
|
stmt = select(func.count()).select_from(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
if spec is not None:
|
||||||
|
clauses = spec.filter_clauses()
|
||||||
|
if clauses:
|
||||||
|
stmt = stmt.where(*clauses)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return int(result.scalar_one())
|
||||||
|
|
||||||
|
async def list_for_job(
|
||||||
|
self, tenant_id: UUID, dispatch_job_id: UUID
|
||||||
|
) -> Sequence[JobAssignment]:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.dispatch_job_id == dispatch_job_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
184
backend/services/delivery/app/repositories/fleet.py
Normal file
184
backend/services/delivery/app/repositories/fleet.py
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
"""Fleet repositories — Phase 10.2."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Sequence
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from app.models.fleet import Fleet, Vehicle, VehicleAssignment, VehicleType
|
||||||
|
from app.models.types import VehicleAssignmentStatus
|
||||||
|
from app.repositories.base import TenantBaseRepository
|
||||||
|
from app.specifications.fleet import FleetListSpec, VehicleListSpec, VehicleTypeListSpec
|
||||||
|
|
||||||
|
|
||||||
|
class FleetRepository(TenantBaseRepository[Fleet]):
|
||||||
|
model = Fleet
|
||||||
|
|
||||||
|
async def get_by_code(self, tenant_id: UUID, code: str) -> Fleet | None:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.code == code,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def list_filtered(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
spec: FleetListSpec | None = None,
|
||||||
|
) -> Sequence[Fleet]:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
if spec is not None:
|
||||||
|
stmt = spec.apply(stmt)
|
||||||
|
else:
|
||||||
|
stmt = stmt.order_by(self.model.created_at.desc())
|
||||||
|
stmt = stmt.offset(offset).limit(limit)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def count_filtered(
|
||||||
|
self, tenant_id: UUID, *, spec: FleetListSpec | None = None
|
||||||
|
) -> int:
|
||||||
|
stmt = select(func.count()).select_from(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
if spec is not None:
|
||||||
|
clauses = spec.filter_clauses()
|
||||||
|
if clauses:
|
||||||
|
stmt = stmt.where(*clauses)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return int(result.scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleTypeRepository(TenantBaseRepository[VehicleType]):
|
||||||
|
model = VehicleType
|
||||||
|
|
||||||
|
async def get_by_code(self, tenant_id: UUID, code: str) -> VehicleType | None:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.code == code,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def list_filtered(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
spec: VehicleTypeListSpec | None = None,
|
||||||
|
) -> Sequence[VehicleType]:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
if spec is not None:
|
||||||
|
stmt = spec.apply(stmt)
|
||||||
|
else:
|
||||||
|
stmt = stmt.order_by(self.model.created_at.desc())
|
||||||
|
stmt = stmt.offset(offset).limit(limit)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def count_filtered(
|
||||||
|
self, tenant_id: UUID, *, spec: VehicleTypeListSpec | None = None
|
||||||
|
) -> int:
|
||||||
|
stmt = select(func.count()).select_from(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
if spec is not None:
|
||||||
|
clauses = spec.filter_clauses()
|
||||||
|
if clauses:
|
||||||
|
stmt = stmt.where(*clauses)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return int(result.scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleRepository(TenantBaseRepository[Vehicle]):
|
||||||
|
model = Vehicle
|
||||||
|
|
||||||
|
async def get_by_code(self, tenant_id: UUID, code: str) -> Vehicle | None:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.code == code,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def list_filtered(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
spec: VehicleListSpec | None = None,
|
||||||
|
) -> Sequence[Vehicle]:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
if spec is not None:
|
||||||
|
stmt = spec.apply(stmt)
|
||||||
|
else:
|
||||||
|
stmt = stmt.order_by(self.model.created_at.desc())
|
||||||
|
stmt = stmt.offset(offset).limit(limit)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def count_filtered(
|
||||||
|
self, tenant_id: UUID, *, spec: VehicleListSpec | None = None
|
||||||
|
) -> int:
|
||||||
|
stmt = select(func.count()).select_from(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
if spec is not None:
|
||||||
|
clauses = spec.filter_clauses()
|
||||||
|
if clauses:
|
||||||
|
stmt = stmt.where(*clauses)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return int(result.scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleAssignmentRepository(TenantBaseRepository[VehicleAssignment]):
|
||||||
|
model = VehicleAssignment
|
||||||
|
|
||||||
|
async def list_for_vehicle(
|
||||||
|
self, tenant_id: UUID, vehicle_id: UUID
|
||||||
|
) -> Sequence[VehicleAssignment]:
|
||||||
|
stmt = (
|
||||||
|
select(self.model)
|
||||||
|
.where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.vehicle_id == vehicle_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
.order_by(self.model.created_at.desc())
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def get_active_for_vehicle(
|
||||||
|
self, tenant_id: UUID, vehicle_id: UUID
|
||||||
|
) -> VehicleAssignment | None:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.vehicle_id == vehicle_id,
|
||||||
|
self.model.status == VehicleAssignmentStatus.ACTIVE,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
158
backend/services/delivery/app/repositories/pricing.py
Normal file
158
backend/services/delivery/app/repositories/pricing.py
Normal file
@ -0,0 +1,158 @@
|
|||||||
|
"""Pricing repositories — Phase 10.4."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Sequence
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from app.models.pricing import CapabilityBundle, CapabilityDefinition, PricingRule
|
||||||
|
from app.repositories.base import TenantBaseRepository
|
||||||
|
from app.specifications.pricing import (
|
||||||
|
BundleListSpec,
|
||||||
|
CapabilityListSpec,
|
||||||
|
PricingRuleListSpec,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PricingRuleRepository(TenantBaseRepository[PricingRule]):
|
||||||
|
model = PricingRule
|
||||||
|
|
||||||
|
async def get_by_code(self, tenant_id: UUID, code: str) -> PricingRule | None:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.code == code,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def list_filtered(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
spec: PricingRuleListSpec | None = None,
|
||||||
|
) -> Sequence[PricingRule]:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
if spec is not None:
|
||||||
|
stmt = spec.apply(stmt)
|
||||||
|
else:
|
||||||
|
stmt = stmt.order_by(self.model.created_at.desc())
|
||||||
|
stmt = stmt.offset(offset).limit(limit)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def count_filtered(
|
||||||
|
self, tenant_id: UUID, *, spec: PricingRuleListSpec | None = None
|
||||||
|
) -> int:
|
||||||
|
stmt = select(func.count()).select_from(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
if spec is not None:
|
||||||
|
clauses = spec.filter_clauses()
|
||||||
|
if clauses:
|
||||||
|
stmt = stmt.where(*clauses)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return int(result.scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityDefinitionRepository(TenantBaseRepository[CapabilityDefinition]):
|
||||||
|
model = CapabilityDefinition
|
||||||
|
|
||||||
|
async def get_by_code(
|
||||||
|
self, tenant_id: UUID, code: str
|
||||||
|
) -> CapabilityDefinition | None:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.code == code,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def list_filtered(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
spec: CapabilityListSpec | None = None,
|
||||||
|
) -> Sequence[CapabilityDefinition]:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
if spec is not None:
|
||||||
|
stmt = spec.apply(stmt)
|
||||||
|
else:
|
||||||
|
stmt = stmt.order_by(self.model.created_at.desc())
|
||||||
|
stmt = stmt.offset(offset).limit(limit)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def count_filtered(
|
||||||
|
self, tenant_id: UUID, *, spec: CapabilityListSpec | None = None
|
||||||
|
) -> int:
|
||||||
|
stmt = select(func.count()).select_from(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
if spec is not None:
|
||||||
|
clauses = spec.filter_clauses()
|
||||||
|
if clauses:
|
||||||
|
stmt = stmt.where(*clauses)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return int(result.scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityBundleRepository(TenantBaseRepository[CapabilityBundle]):
|
||||||
|
model = CapabilityBundle
|
||||||
|
|
||||||
|
async def get_by_code(self, tenant_id: UUID, code: str) -> CapabilityBundle | None:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.code == code,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def list_filtered(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
spec: BundleListSpec | None = None,
|
||||||
|
) -> Sequence[CapabilityBundle]:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
if spec is not None:
|
||||||
|
stmt = spec.apply(stmt)
|
||||||
|
else:
|
||||||
|
stmt = stmt.order_by(self.model.created_at.desc())
|
||||||
|
stmt = stmt.offset(offset).limit(limit)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def count_filtered(
|
||||||
|
self, tenant_id: UUID, *, spec: BundleListSpec | None = None
|
||||||
|
) -> int:
|
||||||
|
stmt = select(func.count()).select_from(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
if spec is not None:
|
||||||
|
clauses = spec.filter_clauses()
|
||||||
|
if clauses:
|
||||||
|
stmt = stmt.where(*clauses)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return int(result.scalar_one())
|
||||||
115
backend/services/delivery/app/repositories/routing.py
Normal file
115
backend/services/delivery/app/repositories/routing.py
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
"""Routing repositories — Phase 10.6."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Sequence
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from app.models.routing import OptimizationRun, RoutePlan, RouteStop
|
||||||
|
from app.repositories.base import TenantBaseRepository
|
||||||
|
from app.specifications.routing import OptimizationRunListSpec, RoutePlanListSpec
|
||||||
|
|
||||||
|
|
||||||
|
class RoutePlanRepository(TenantBaseRepository[RoutePlan]):
|
||||||
|
model = RoutePlan
|
||||||
|
|
||||||
|
async def get_by_code(self, tenant_id: UUID, code: str) -> RoutePlan | None:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.code == code,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def list_filtered(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
spec: RoutePlanListSpec | None = None,
|
||||||
|
) -> Sequence[RoutePlan]:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
if spec is not None:
|
||||||
|
stmt = spec.apply(stmt)
|
||||||
|
else:
|
||||||
|
stmt = stmt.order_by(self.model.created_at.desc())
|
||||||
|
stmt = stmt.offset(offset).limit(limit)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def count_filtered(
|
||||||
|
self, tenant_id: UUID, *, spec: RoutePlanListSpec | None = None
|
||||||
|
) -> int:
|
||||||
|
stmt = select(func.count()).select_from(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
if spec is not None:
|
||||||
|
clauses = spec.filter_clauses()
|
||||||
|
if clauses:
|
||||||
|
stmt = stmt.where(*clauses)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return int(result.scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
class RouteStopRepository(TenantBaseRepository[RouteStop]):
|
||||||
|
model = RouteStop
|
||||||
|
|
||||||
|
async def list_for_plan(
|
||||||
|
self, tenant_id: UUID, route_plan_id: UUID
|
||||||
|
) -> Sequence[RouteStop]:
|
||||||
|
stmt = (
|
||||||
|
select(self.model)
|
||||||
|
.where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.route_plan_id == route_plan_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
.order_by(self.model.sequence.asc())
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
class OptimizationRunRepository(TenantBaseRepository[OptimizationRun]):
|
||||||
|
model = OptimizationRun
|
||||||
|
|
||||||
|
async def list_filtered(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
spec: OptimizationRunListSpec | None = None,
|
||||||
|
) -> Sequence[OptimizationRun]:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
if spec is not None:
|
||||||
|
stmt = spec.apply(stmt)
|
||||||
|
else:
|
||||||
|
stmt = stmt.order_by(self.model.created_at.desc())
|
||||||
|
stmt = stmt.offset(offset).limit(limit)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def count_filtered(
|
||||||
|
self, tenant_id: UUID, *, spec: OptimizationRunListSpec | None = None
|
||||||
|
) -> int:
|
||||||
|
stmt = select(func.count()).select_from(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
if spec is not None:
|
||||||
|
clauses = spec.filter_clauses()
|
||||||
|
if clauses:
|
||||||
|
stmt = stmt.where(*clauses)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return int(result.scalar_one())
|
||||||
75
backend/services/delivery/app/repositories/settlement.py
Normal file
75
backend/services/delivery/app/repositories/settlement.py
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
"""Settlement repositories — Phase 10.8."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Sequence
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from app.models.settlement import SettlementIntent, SettlementLine
|
||||||
|
from app.repositories.base import TenantBaseRepository
|
||||||
|
from app.specifications.settlement import SettlementIntentListSpec
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementIntentRepository(TenantBaseRepository[SettlementIntent]):
|
||||||
|
model = SettlementIntent
|
||||||
|
|
||||||
|
async def get_by_code(
|
||||||
|
self, tenant_id: UUID, code: str
|
||||||
|
) -> SettlementIntent | None:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.code == code,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def list_filtered(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
spec: SettlementIntentListSpec | None = None,
|
||||||
|
) -> Sequence[SettlementIntent]:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
if spec is not None:
|
||||||
|
stmt = spec.apply(stmt)
|
||||||
|
else:
|
||||||
|
stmt = stmt.order_by(self.model.created_at.desc())
|
||||||
|
stmt = stmt.offset(offset).limit(limit)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def count_filtered(
|
||||||
|
self, tenant_id: UUID, *, spec: SettlementIntentListSpec | None = None
|
||||||
|
) -> int:
|
||||||
|
stmt = select(func.count()).select_from(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
if spec is not None:
|
||||||
|
clauses = spec.filter_clauses()
|
||||||
|
if clauses:
|
||||||
|
stmt = stmt.where(*clauses)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return int(result.scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementLineRepository(TenantBaseRepository[SettlementLine]):
|
||||||
|
model = SettlementLine
|
||||||
|
|
||||||
|
async def list_for_intent(
|
||||||
|
self, tenant_id: UUID, settlement_intent_id: UUID
|
||||||
|
) -> Sequence[SettlementLine]:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.settlement_intent_id == settlement_intent_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
164
backend/services/delivery/app/repositories/tracking.py
Normal file
164
backend/services/delivery/app/repositories/tracking.py
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
"""Tracking repositories — Phase 10.7."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Sequence
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from app.models.tracking import (
|
||||||
|
CustomerTrackingToken,
|
||||||
|
ProofOfDelivery,
|
||||||
|
TrackingPoint,
|
||||||
|
TrackingSession,
|
||||||
|
)
|
||||||
|
from app.repositories.base import TenantBaseRepository
|
||||||
|
from app.specifications.tracking import (
|
||||||
|
CustomerTrackingTokenListSpec,
|
||||||
|
ProofOfDeliveryListSpec,
|
||||||
|
TrackingSessionListSpec,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TrackingSessionRepository(TenantBaseRepository[TrackingSession]):
|
||||||
|
model = TrackingSession
|
||||||
|
|
||||||
|
async def list_filtered(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
spec: TrackingSessionListSpec | None = None,
|
||||||
|
) -> Sequence[TrackingSession]:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
if spec is not None:
|
||||||
|
stmt = spec.apply(stmt)
|
||||||
|
else:
|
||||||
|
stmt = stmt.order_by(self.model.created_at.desc())
|
||||||
|
stmt = stmt.offset(offset).limit(limit)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def count_filtered(
|
||||||
|
self, tenant_id: UUID, *, spec: TrackingSessionListSpec | None = None
|
||||||
|
) -> int:
|
||||||
|
stmt = select(func.count()).select_from(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
if spec is not None:
|
||||||
|
clauses = spec.filter_clauses()
|
||||||
|
if clauses:
|
||||||
|
stmt = stmt.where(*clauses)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return int(result.scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
class TrackingPointRepository(TenantBaseRepository[TrackingPoint]):
|
||||||
|
model = TrackingPoint
|
||||||
|
|
||||||
|
async def list_for_session(
|
||||||
|
self, tenant_id: UUID, tracking_session_id: UUID, *, limit: int = 500
|
||||||
|
) -> Sequence[TrackingPoint]:
|
||||||
|
stmt = (
|
||||||
|
select(self.model)
|
||||||
|
.where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.tracking_session_id == tracking_session_id,
|
||||||
|
)
|
||||||
|
.order_by(self.model.recorded_at.asc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
|
||||||
|
class ProofOfDeliveryRepository(TenantBaseRepository[ProofOfDelivery]):
|
||||||
|
model = ProofOfDelivery
|
||||||
|
|
||||||
|
async def list_filtered(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
spec: ProofOfDeliveryListSpec | None = None,
|
||||||
|
) -> Sequence[ProofOfDelivery]:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
if spec is not None:
|
||||||
|
stmt = spec.apply(stmt)
|
||||||
|
else:
|
||||||
|
stmt = stmt.order_by(self.model.created_at.desc())
|
||||||
|
stmt = stmt.offset(offset).limit(limit)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def count_filtered(
|
||||||
|
self, tenant_id: UUID, *, spec: ProofOfDeliveryListSpec | None = None
|
||||||
|
) -> int:
|
||||||
|
stmt = select(func.count()).select_from(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
if spec is not None:
|
||||||
|
clauses = spec.filter_clauses()
|
||||||
|
if clauses:
|
||||||
|
stmt = stmt.where(*clauses)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return int(result.scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerTrackingTokenRepository(TenantBaseRepository[CustomerTrackingToken]):
|
||||||
|
model = CustomerTrackingToken
|
||||||
|
|
||||||
|
async def get_by_token(
|
||||||
|
self, tenant_id: UUID, token: str
|
||||||
|
) -> CustomerTrackingToken | None:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.token == token,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
async def list_filtered(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
spec: CustomerTrackingTokenListSpec | None = None,
|
||||||
|
) -> Sequence[CustomerTrackingToken]:
|
||||||
|
stmt = select(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
if spec is not None:
|
||||||
|
stmt = spec.apply(stmt)
|
||||||
|
else:
|
||||||
|
stmt = stmt.order_by(self.model.created_at.desc())
|
||||||
|
stmt = stmt.offset(offset).limit(limit)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return result.scalars().all()
|
||||||
|
|
||||||
|
async def count_filtered(
|
||||||
|
self, tenant_id: UUID, *, spec: CustomerTrackingTokenListSpec | None = None
|
||||||
|
) -> int:
|
||||||
|
stmt = select(func.count()).select_from(self.model).where(
|
||||||
|
self.model.tenant_id == tenant_id,
|
||||||
|
self.model.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
if spec is not None:
|
||||||
|
clauses = spec.filter_clauses()
|
||||||
|
if clauses:
|
||||||
|
stmt = stmt.where(*clauses)
|
||||||
|
result = await self.session.execute(stmt)
|
||||||
|
return int(result.scalar_one())
|
||||||
172
backend/services/delivery/app/schemas/availability.py
Normal file
172
backend/services/delivery/app/schemas/availability.py
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
"""Availability, shifts & working zones DTOs — Phase 10.3."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from app.models.types import (
|
||||||
|
DriverAvailabilityStatus,
|
||||||
|
ShiftAssignmentStatus,
|
||||||
|
ShiftStatus,
|
||||||
|
WorkingZoneStatus,
|
||||||
|
)
|
||||||
|
from app.schemas.common import ORMBase
|
||||||
|
|
||||||
|
_FORBID = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
|
class DriverAvailabilityUpsert(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
status: DriverAvailabilityStatus
|
||||||
|
effective_from: datetime | None = None
|
||||||
|
effective_until: datetime | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
version: int | None = Field(default=None, ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class DriverAvailabilityRead(ORMBase):
|
||||||
|
id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
driver_id: UUID
|
||||||
|
status: DriverAvailabilityStatus
|
||||||
|
effective_from: datetime | None
|
||||||
|
effective_until: datetime | None
|
||||||
|
notes: str | None
|
||||||
|
metadata_json: dict | None
|
||||||
|
version: int
|
||||||
|
is_deleted: bool
|
||||||
|
created_by: str | None
|
||||||
|
updated_by: str | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ShiftCreate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
organization_id: UUID
|
||||||
|
hub_id: UUID | None = None
|
||||||
|
code: str = Field(max_length=50)
|
||||||
|
name: str = Field(max_length=255)
|
||||||
|
status: ShiftStatus = ShiftStatus.DRAFT
|
||||||
|
starts_at: datetime
|
||||||
|
ends_at: datetime
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ShiftUpdate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
hub_id: UUID | None = None
|
||||||
|
name: str | None = Field(default=None, max_length=255)
|
||||||
|
status: ShiftStatus | None = None
|
||||||
|
starts_at: datetime | None = None
|
||||||
|
ends_at: datetime | None = None
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
version: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class ShiftRead(ORMBase):
|
||||||
|
id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
organization_id: UUID
|
||||||
|
hub_id: UUID | None
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
status: ShiftStatus
|
||||||
|
starts_at: datetime
|
||||||
|
ends_at: datetime
|
||||||
|
metadata_json: dict | None
|
||||||
|
version: int
|
||||||
|
is_deleted: bool
|
||||||
|
created_by: str | None
|
||||||
|
updated_by: str | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ShiftListResponse(BaseModel):
|
||||||
|
items: list[ShiftRead]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class ShiftAssignmentCreate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
driver_id: UUID
|
||||||
|
notes: str | None = None
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ShiftAssignmentUpdate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
status: ShiftAssignmentStatus | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ShiftAssignmentRead(ORMBase):
|
||||||
|
id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
shift_id: UUID
|
||||||
|
driver_id: UUID
|
||||||
|
status: ShiftAssignmentStatus
|
||||||
|
notes: str | None
|
||||||
|
metadata_json: dict | None
|
||||||
|
is_deleted: bool
|
||||||
|
created_by: str | None
|
||||||
|
updated_by: str | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class WorkingZoneCreate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
organization_id: UUID
|
||||||
|
code: str = Field(max_length=50)
|
||||||
|
name: str = Field(max_length=255)
|
||||||
|
status: WorkingZoneStatus = WorkingZoneStatus.ACTIVE
|
||||||
|
geo_boundary_ref: str | None = Field(default=None, max_length=255)
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WorkingZoneUpdate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
name: str | None = Field(default=None, max_length=255)
|
||||||
|
status: WorkingZoneStatus | None = None
|
||||||
|
geo_boundary_ref: str | None = Field(default=None, max_length=255)
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
version: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class WorkingZoneRead(ORMBase):
|
||||||
|
id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
organization_id: UUID
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
status: WorkingZoneStatus
|
||||||
|
geo_boundary_ref: str | None
|
||||||
|
metadata_json: dict | None
|
||||||
|
version: int
|
||||||
|
is_deleted: bool
|
||||||
|
created_by: str | None
|
||||||
|
updated_by: str | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class WorkingZoneListResponse(BaseModel):
|
||||||
|
items: list[WorkingZoneRead]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
122
backend/services/delivery/app/schemas/dispatch.py
Normal file
122
backend/services/delivery/app/schemas/dispatch.py
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
"""Dispatch engine DTOs — Phase 10.5."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from app.models.types import DispatchJobAction, DispatchJobStatus, JobAssignmentStatus
|
||||||
|
from app.schemas.common import ORMBase
|
||||||
|
|
||||||
|
_FORBID = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
|
class DispatchJobCreate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
organization_id: UUID
|
||||||
|
hub_id: UUID | None = None
|
||||||
|
code: str = Field(max_length=50)
|
||||||
|
external_order_ref: str | None = Field(default=None, max_length=255)
|
||||||
|
external_source: str | None = Field(default=None, max_length=100)
|
||||||
|
priority: int = 0
|
||||||
|
pickup_address_ref: str | None = Field(default=None, max_length=255)
|
||||||
|
dropoff_address_ref: str | None = Field(default=None, max_length=255)
|
||||||
|
scheduled_at: datetime | None = None
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class DispatchJobUpdate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
hub_id: UUID | None = None
|
||||||
|
priority: int | None = None
|
||||||
|
pickup_address_ref: str | None = Field(default=None, max_length=255)
|
||||||
|
dropoff_address_ref: str | None = Field(default=None, max_length=255)
|
||||||
|
scheduled_at: datetime | None = None
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
version: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class DispatchJobStatusRequest(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
action: DispatchJobAction
|
||||||
|
reason: str | None = Field(default=None, max_length=500)
|
||||||
|
version: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class DispatchJobRead(ORMBase):
|
||||||
|
id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
organization_id: UUID
|
||||||
|
hub_id: UUID | None
|
||||||
|
code: str
|
||||||
|
status: DispatchJobStatus
|
||||||
|
external_order_ref: str | None
|
||||||
|
external_source: str | None
|
||||||
|
priority: int
|
||||||
|
pickup_address_ref: str | None
|
||||||
|
dropoff_address_ref: str | None
|
||||||
|
scheduled_at: datetime | None
|
||||||
|
status_reason: str | None
|
||||||
|
metadata_json: dict | None
|
||||||
|
version: int
|
||||||
|
is_deleted: bool
|
||||||
|
created_by: str | None
|
||||||
|
updated_by: str | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class DispatchJobListResponse(BaseModel):
|
||||||
|
items: list[DispatchJobRead]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class JobAssignmentCreate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
dispatch_job_id: UUID
|
||||||
|
driver_id: UUID
|
||||||
|
vehicle_id: UUID | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class JobAssignmentUpdate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
status: JobAssignmentStatus | None = None
|
||||||
|
vehicle_id: UUID | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
version: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class JobAssignmentRead(ORMBase):
|
||||||
|
id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
dispatch_job_id: UUID
|
||||||
|
driver_id: UUID
|
||||||
|
vehicle_id: UUID | None
|
||||||
|
status: JobAssignmentStatus
|
||||||
|
assigned_at: datetime | None
|
||||||
|
notes: str | None
|
||||||
|
metadata_json: dict | None
|
||||||
|
version: int
|
||||||
|
is_deleted: bool
|
||||||
|
created_by: str | None
|
||||||
|
updated_by: str | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class JobAssignmentListResponse(BaseModel):
|
||||||
|
items: list[JobAssignmentRead]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
184
backend/services/delivery/app/schemas/fleet.py
Normal file
184
backend/services/delivery/app/schemas/fleet.py
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
"""Fleet & vehicle DTOs — Phase 10.2."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from app.models.types import FleetStatus, VehicleAssignmentStatus, VehicleStatus
|
||||||
|
from app.schemas.common import ORMBase
|
||||||
|
|
||||||
|
_FORBID = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
|
class FleetCreate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
organization_id: UUID
|
||||||
|
hub_id: UUID | None = None
|
||||||
|
code: str = Field(max_length=50)
|
||||||
|
name: str = Field(max_length=255)
|
||||||
|
description: str | None = None
|
||||||
|
status: FleetStatus = FleetStatus.DRAFT
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class FleetUpdate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
hub_id: UUID | None = None
|
||||||
|
name: str | None = Field(default=None, max_length=255)
|
||||||
|
description: str | None = None
|
||||||
|
status: FleetStatus | None = None
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
version: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class FleetRead(ORMBase):
|
||||||
|
id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
organization_id: UUID
|
||||||
|
hub_id: UUID | None
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
status: FleetStatus
|
||||||
|
metadata_json: dict | None
|
||||||
|
version: int
|
||||||
|
is_deleted: bool
|
||||||
|
created_by: str | None
|
||||||
|
updated_by: str | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class FleetListResponse(BaseModel):
|
||||||
|
items: list[FleetRead]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleTypeCreate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
code: str = Field(max_length=50)
|
||||||
|
name: str = Field(max_length=255)
|
||||||
|
description: str | None = None
|
||||||
|
capacity_kg: float | None = None
|
||||||
|
capacity_volume: float | None = None
|
||||||
|
capability_tags: list[str] | None = None
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleTypeUpdate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
name: str | None = Field(default=None, max_length=255)
|
||||||
|
description: str | None = None
|
||||||
|
capacity_kg: float | None = None
|
||||||
|
capacity_volume: float | None = None
|
||||||
|
capability_tags: list[str] | None = None
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
version: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleTypeRead(ORMBase):
|
||||||
|
id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
capacity_kg: float | None
|
||||||
|
capacity_volume: float | None
|
||||||
|
capability_tags: list | None
|
||||||
|
metadata_json: dict | None
|
||||||
|
version: int
|
||||||
|
is_deleted: bool
|
||||||
|
created_by: str | None
|
||||||
|
updated_by: str | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleTypeListResponse(BaseModel):
|
||||||
|
items: list[VehicleTypeRead]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleCreate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
fleet_id: UUID
|
||||||
|
vehicle_type_id: UUID
|
||||||
|
code: str = Field(max_length=50)
|
||||||
|
plate_number: str | None = Field(default=None, max_length=32)
|
||||||
|
status: VehicleStatus = VehicleStatus.AVAILABLE
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleUpdate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
plate_number: str | None = Field(default=None, max_length=32)
|
||||||
|
status: VehicleStatus | None = None
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
version: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleRead(ORMBase):
|
||||||
|
id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
fleet_id: UUID
|
||||||
|
vehicle_type_id: UUID
|
||||||
|
code: str
|
||||||
|
plate_number: str | None
|
||||||
|
status: VehicleStatus
|
||||||
|
metadata_json: dict | None
|
||||||
|
version: int
|
||||||
|
is_deleted: bool
|
||||||
|
created_by: str | None
|
||||||
|
updated_by: str | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleListResponse(BaseModel):
|
||||||
|
items: list[VehicleRead]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleAssignmentCreate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
driver_id: UUID
|
||||||
|
notes: str | None = None
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleAssignmentUpdate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
status: VehicleAssignmentStatus | None = None
|
||||||
|
notes: str | None = None
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleAssignmentRead(ORMBase):
|
||||||
|
id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
vehicle_id: UUID
|
||||||
|
driver_id: UUID
|
||||||
|
status: VehicleAssignmentStatus
|
||||||
|
notes: str | None
|
||||||
|
metadata_json: dict | None
|
||||||
|
is_deleted: bool
|
||||||
|
created_by: str | None
|
||||||
|
updated_by: str | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
153
backend/services/delivery/app/schemas/pricing.py
Normal file
153
backend/services/delivery/app/schemas/pricing.py
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
"""Pricing, capabilities & bundles DTOs — Phase 10.4."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from app.models.types import CapabilityBundleStatus, CapabilityStatus, PricingRuleStatus
|
||||||
|
from app.schemas.common import ORMBase
|
||||||
|
|
||||||
|
_FORBID = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
|
class PricingRuleCreate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
organization_id: UUID
|
||||||
|
code: str = Field(max_length=50)
|
||||||
|
name: str = Field(max_length=255)
|
||||||
|
status: PricingRuleStatus = PricingRuleStatus.DRAFT
|
||||||
|
base_amount: Decimal = Field(gt=0)
|
||||||
|
currency_code: str = Field(default="IRR", max_length=3)
|
||||||
|
rule_config: dict | None = None
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PricingRuleUpdate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
name: str | None = Field(default=None, max_length=255)
|
||||||
|
status: PricingRuleStatus | None = None
|
||||||
|
base_amount: Decimal | None = Field(default=None, gt=0)
|
||||||
|
currency_code: str | None = Field(default=None, max_length=3)
|
||||||
|
rule_config: dict | None = None
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
version: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class PricingRuleRead(ORMBase):
|
||||||
|
id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
organization_id: UUID
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
status: PricingRuleStatus
|
||||||
|
base_amount: Decimal
|
||||||
|
currency_code: str
|
||||||
|
rule_config: dict | None
|
||||||
|
metadata_json: dict | None
|
||||||
|
version: int
|
||||||
|
is_deleted: bool
|
||||||
|
created_by: str | None
|
||||||
|
updated_by: str | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class PricingRuleListResponse(BaseModel):
|
||||||
|
items: list[PricingRuleRead]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityCreate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
code: str = Field(max_length=50)
|
||||||
|
name: str = Field(max_length=255)
|
||||||
|
description: str | None = None
|
||||||
|
status: CapabilityStatus = CapabilityStatus.ACTIVE
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityUpdate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
name: str | None = Field(default=None, max_length=255)
|
||||||
|
description: str | None = None
|
||||||
|
status: CapabilityStatus | None = None
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
version: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityRead(ORMBase):
|
||||||
|
id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
description: str | None
|
||||||
|
status: CapabilityStatus
|
||||||
|
metadata_json: dict | None
|
||||||
|
version: int
|
||||||
|
is_deleted: bool
|
||||||
|
created_by: str | None
|
||||||
|
updated_by: str | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityListResponse(BaseModel):
|
||||||
|
items: list[CapabilityRead]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class BundleCreate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
code: str = Field(max_length=50)
|
||||||
|
name: str = Field(max_length=255)
|
||||||
|
status: CapabilityBundleStatus = CapabilityBundleStatus.DRAFT
|
||||||
|
capability_codes: list[str] | None = None
|
||||||
|
pricing_rule_id: UUID | None = None
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class BundleUpdate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
name: str | None = Field(default=None, max_length=255)
|
||||||
|
status: CapabilityBundleStatus | None = None
|
||||||
|
capability_codes: list[str] | None = None
|
||||||
|
pricing_rule_id: UUID | None = None
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
version: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class BundleRead(ORMBase):
|
||||||
|
id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
code: str
|
||||||
|
name: str
|
||||||
|
status: CapabilityBundleStatus
|
||||||
|
capability_codes: list | None
|
||||||
|
pricing_rule_id: UUID | None
|
||||||
|
metadata_json: dict | None
|
||||||
|
version: int
|
||||||
|
is_deleted: bool
|
||||||
|
created_by: str | None
|
||||||
|
updated_by: str | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class BundleListResponse(BaseModel):
|
||||||
|
items: list[BundleRead]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
120
backend/services/delivery/app/schemas/routing.py
Normal file
120
backend/services/delivery/app/schemas/routing.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
"""Routing & optimization DTOs — Phase 10.6."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from app.models.types import OptimizationRunStatus, RoutePlanStatus, RouteStopStatus
|
||||||
|
from app.schemas.common import ORMBase
|
||||||
|
|
||||||
|
_FORBID = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
|
class RoutePlanCreate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
organization_id: UUID
|
||||||
|
code: str = Field(max_length=50)
|
||||||
|
driver_id: UUID | None = None
|
||||||
|
dispatch_job_ids: list[str] | None = None
|
||||||
|
routing_engine_ref: str | None = Field(default=None, max_length=255)
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RoutePlanUpdate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
driver_id: UUID | None = None
|
||||||
|
status: RoutePlanStatus | None = None
|
||||||
|
dispatch_job_ids: list[str] | None = None
|
||||||
|
routing_engine_ref: str | None = Field(default=None, max_length=255)
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
version: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class RoutePlanRead(ORMBase):
|
||||||
|
id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
organization_id: UUID
|
||||||
|
code: str
|
||||||
|
status: RoutePlanStatus
|
||||||
|
driver_id: UUID | None
|
||||||
|
dispatch_job_ids: list | None
|
||||||
|
routing_engine_ref: str | None
|
||||||
|
planned_at: datetime | None
|
||||||
|
metadata_json: dict | None
|
||||||
|
version: int
|
||||||
|
is_deleted: bool
|
||||||
|
created_by: str | None
|
||||||
|
updated_by: str | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class RoutePlanListResponse(BaseModel):
|
||||||
|
items: list[RoutePlanRead]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class RouteStopCreate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
sequence: int = Field(ge=0)
|
||||||
|
address_ref: str | None = Field(default=None, max_length=255)
|
||||||
|
external_order_ref: str | None = Field(default=None, max_length=255)
|
||||||
|
notes: str | None = None
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RouteStopRead(ORMBase):
|
||||||
|
id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
route_plan_id: UUID
|
||||||
|
sequence: int
|
||||||
|
status: RouteStopStatus
|
||||||
|
address_ref: str | None
|
||||||
|
external_order_ref: str | None
|
||||||
|
notes: str | None
|
||||||
|
metadata_json: dict | None
|
||||||
|
is_deleted: bool
|
||||||
|
created_by: str | None
|
||||||
|
updated_by: str | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class OptimizationRunCreate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
route_plan_id: UUID | None = None
|
||||||
|
routing_engine_ref: str | None = Field(default=None, max_length=255)
|
||||||
|
request_payload: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OptimizationRunRead(ORMBase):
|
||||||
|
id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
route_plan_id: UUID | None
|
||||||
|
status: OptimizationRunStatus
|
||||||
|
routing_engine_ref: str | None
|
||||||
|
request_payload: dict | None
|
||||||
|
result_payload: dict | None
|
||||||
|
started_at: datetime | None
|
||||||
|
completed_at: datetime | None
|
||||||
|
error_message: str | None
|
||||||
|
is_deleted: bool
|
||||||
|
created_by: str | None
|
||||||
|
updated_by: str | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class OptimizationRunListResponse(BaseModel):
|
||||||
|
items: list[OptimizationRunRead]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
99
backend/services/delivery/app/schemas/settlement.py
Normal file
99
backend/services/delivery/app/schemas/settlement.py
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
"""Settlement DTOs — Phase 10.8."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from app.models.types import SettlementIntentStatus, SettlementLineKind
|
||||||
|
from app.schemas.common import ORMBase
|
||||||
|
|
||||||
|
_FORBID = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementIntentCreate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
organization_id: UUID
|
||||||
|
code: str = Field(max_length=50)
|
||||||
|
driver_id: UUID | None = None
|
||||||
|
dispatch_job_id: UUID | None = None
|
||||||
|
total_amount: Decimal = Field(gt=0)
|
||||||
|
currency_code: str = Field(default="IRR", max_length=3)
|
||||||
|
notes: str | None = None
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementIntentUpdate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
driver_id: UUID | None = None
|
||||||
|
dispatch_job_id: UUID | None = None
|
||||||
|
total_amount: Decimal | None = Field(default=None, gt=0)
|
||||||
|
currency_code: str | None = Field(default=None, max_length=3)
|
||||||
|
notes: str | None = None
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
version: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementActionRequest(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
reason: str | None = Field(default=None, max_length=500)
|
||||||
|
version: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementIntentRead(ORMBase):
|
||||||
|
id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
organization_id: UUID
|
||||||
|
code: str
|
||||||
|
status: SettlementIntentStatus
|
||||||
|
driver_id: UUID | None
|
||||||
|
dispatch_job_id: UUID | None
|
||||||
|
external_settlement_ref: str | None
|
||||||
|
total_amount: Decimal
|
||||||
|
currency_code: str
|
||||||
|
notes: str | None
|
||||||
|
metadata_json: dict | None
|
||||||
|
version: int
|
||||||
|
is_deleted: bool
|
||||||
|
created_by: str | None
|
||||||
|
updated_by: str | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementIntentListResponse(BaseModel):
|
||||||
|
items: list[SettlementIntentRead]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementLineCreate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
kind: SettlementLineKind
|
||||||
|
description: str | None = Field(default=None, max_length=255)
|
||||||
|
amount: Decimal = Field(gt=0)
|
||||||
|
currency_code: str = Field(default="IRR", max_length=3)
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementLineRead(ORMBase):
|
||||||
|
id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
settlement_intent_id: UUID
|
||||||
|
kind: SettlementLineKind
|
||||||
|
description: str | None
|
||||||
|
amount: Decimal
|
||||||
|
currency_code: str
|
||||||
|
metadata_json: dict | None
|
||||||
|
is_deleted: bool
|
||||||
|
created_by: str | None
|
||||||
|
updated_by: str | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
156
backend/services/delivery/app/schemas/tracking.py
Normal file
156
backend/services/delivery/app/schemas/tracking.py
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
"""Tracking & proof of delivery DTOs — Phase 10.7."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
|
|
||||||
|
from app.models.types import (
|
||||||
|
CustomerTrackingTokenStatus,
|
||||||
|
ProofOfDeliveryStatus,
|
||||||
|
TrackingSessionStatus,
|
||||||
|
)
|
||||||
|
from app.schemas.common import ORMBase
|
||||||
|
|
||||||
|
_FORBID = ConfigDict(extra="forbid")
|
||||||
|
|
||||||
|
|
||||||
|
class TrackingSessionCreate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
dispatch_job_id: UUID | None = None
|
||||||
|
driver_id: UUID | None = None
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TrackingSessionUpdate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
status: TrackingSessionStatus | None = None
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
version: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class TrackingSessionRead(ORMBase):
|
||||||
|
id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
dispatch_job_id: UUID | None
|
||||||
|
driver_id: UUID | None
|
||||||
|
status: TrackingSessionStatus
|
||||||
|
started_at: datetime | None
|
||||||
|
ended_at: datetime | None
|
||||||
|
metadata_json: dict | None
|
||||||
|
version: int
|
||||||
|
is_deleted: bool
|
||||||
|
created_by: str | None
|
||||||
|
updated_by: str | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class TrackingSessionListResponse(BaseModel):
|
||||||
|
items: list[TrackingSessionRead]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class TrackingPointCreate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
latitude: float = Field(ge=-90, le=90)
|
||||||
|
longitude: float = Field(ge=-180, le=180)
|
||||||
|
recorded_at: datetime
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class TrackingPointRead(ORMBase):
|
||||||
|
id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
tracking_session_id: UUID
|
||||||
|
latitude: float
|
||||||
|
longitude: float
|
||||||
|
recorded_at: datetime
|
||||||
|
metadata_json: dict | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ProofOfDeliveryCreate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
dispatch_job_id: UUID
|
||||||
|
signature_file_ref: str | None = Field(default=None, max_length=255)
|
||||||
|
photo_file_ref: str | None = Field(default=None, max_length=255)
|
||||||
|
delivery_code: str | None = Field(default=None, max_length=50)
|
||||||
|
notes: str | None = None
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class ProofOfDeliveryUpdate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
status: ProofOfDeliveryStatus | None = None
|
||||||
|
signature_file_ref: str | None = Field(default=None, max_length=255)
|
||||||
|
photo_file_ref: str | None = Field(default=None, max_length=255)
|
||||||
|
delivery_code: str | None = Field(default=None, max_length=50)
|
||||||
|
notes: str | None = None
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
version: int = Field(ge=1)
|
||||||
|
|
||||||
|
|
||||||
|
class ProofOfDeliveryRead(ORMBase):
|
||||||
|
id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
dispatch_job_id: UUID
|
||||||
|
status: ProofOfDeliveryStatus
|
||||||
|
signature_file_ref: str | None
|
||||||
|
photo_file_ref: str | None
|
||||||
|
delivery_code: str | None
|
||||||
|
notes: str | None
|
||||||
|
captured_at: datetime | None
|
||||||
|
metadata_json: dict | None
|
||||||
|
version: int
|
||||||
|
is_deleted: bool
|
||||||
|
created_by: str | None
|
||||||
|
updated_by: str | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class ProofOfDeliveryListResponse(BaseModel):
|
||||||
|
items: list[ProofOfDeliveryRead]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerTrackingTokenCreate(BaseModel):
|
||||||
|
model_config = _FORBID
|
||||||
|
|
||||||
|
dispatch_job_id: UUID
|
||||||
|
expires_at: datetime | None = None
|
||||||
|
metadata_json: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerTrackingTokenRead(ORMBase):
|
||||||
|
id: UUID
|
||||||
|
tenant_id: UUID
|
||||||
|
dispatch_job_id: UUID
|
||||||
|
token: str
|
||||||
|
status: CustomerTrackingTokenStatus
|
||||||
|
expires_at: datetime | None
|
||||||
|
metadata_json: dict | None
|
||||||
|
is_deleted: bool
|
||||||
|
created_by: str | None
|
||||||
|
updated_by: str | None
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerTrackingTokenListResponse(BaseModel):
|
||||||
|
items: list[CustomerTrackingTokenRead]
|
||||||
|
total: int
|
||||||
|
page: int
|
||||||
|
page_size: int
|
||||||
29
backend/services/delivery/app/services/_helpers.py
Normal file
29
backend/services/delivery/app/services/_helpers.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
"""Shared service helpers for delivery phases 10.2+."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
|
||||||
|
def actor_id(actor: CurrentUser | None) -> str | None:
|
||||||
|
return actor.user_id if actor else None
|
||||||
|
|
||||||
|
|
||||||
|
def jsonable(data: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
out: dict[str, Any] = {}
|
||||||
|
for key, value in data.items():
|
||||||
|
if isinstance(value, UUID):
|
||||||
|
out[key] = str(value)
|
||||||
|
elif isinstance(value, Decimal):
|
||||||
|
out[key] = str(value)
|
||||||
|
elif isinstance(value, (datetime, date)):
|
||||||
|
out[key] = value.isoformat()
|
||||||
|
elif hasattr(value, "value"):
|
||||||
|
out[key] = value.value
|
||||||
|
else:
|
||||||
|
out[key] = value
|
||||||
|
return out
|
||||||
395
backend/services/delivery/app/services/availability.py
Normal file
395
backend/services/delivery/app/services/availability.py
Normal file
@ -0,0 +1,395 @@
|
|||||||
|
"""Availability application services — Phase 10.3."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.events.publisher import TransactionalEventPublisher
|
||||||
|
from app.events.types import DeliveryEventType
|
||||||
|
from app.models.availability import (
|
||||||
|
DriverAvailability,
|
||||||
|
Shift,
|
||||||
|
ShiftAssignment,
|
||||||
|
WorkingZone,
|
||||||
|
)
|
||||||
|
from app.models.types import AuditAction, ShiftAssignmentStatus
|
||||||
|
from app.repositories.availability import (
|
||||||
|
DriverAvailabilityRepository,
|
||||||
|
ShiftAssignmentRepository,
|
||||||
|
ShiftRepository,
|
||||||
|
WorkingZoneRepository,
|
||||||
|
)
|
||||||
|
from app.repositories.drivers import DriverRepository
|
||||||
|
from app.repositories.foundation import (
|
||||||
|
DeliveryHubRepository,
|
||||||
|
DeliveryOrganizationRepository,
|
||||||
|
)
|
||||||
|
from app.schemas.availability import (
|
||||||
|
DriverAvailabilityUpsert,
|
||||||
|
ShiftAssignmentCreate,
|
||||||
|
ShiftAssignmentUpdate,
|
||||||
|
ShiftCreate,
|
||||||
|
ShiftUpdate,
|
||||||
|
WorkingZoneCreate,
|
||||||
|
WorkingZoneUpdate,
|
||||||
|
)
|
||||||
|
from app.services._helpers import actor_id, jsonable
|
||||||
|
from app.services.audit_service import AuditService
|
||||||
|
from app.specifications.availability import ShiftListSpec, WorkingZoneListSpec
|
||||||
|
from app.validators import ensure_optimistic_version, validate_code
|
||||||
|
from app.validators.availability import (
|
||||||
|
validate_availability_window,
|
||||||
|
validate_shift_name,
|
||||||
|
validate_shift_window,
|
||||||
|
validate_zone_name,
|
||||||
|
)
|
||||||
|
from shared.exceptions import AppError, NotFoundError
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
|
||||||
|
class AvailabilityService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
publisher: TransactionalEventPublisher | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.session = session
|
||||||
|
self.repo = DriverAvailabilityRepository(session)
|
||||||
|
self.drivers = DriverRepository(session)
|
||||||
|
self.audit = AuditService(session)
|
||||||
|
self.publisher = publisher or TransactionalEventPublisher(session)
|
||||||
|
|
||||||
|
async def upsert_for_driver(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
driver_id: UUID,
|
||||||
|
body: DriverAvailabilityUpsert,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None = None,
|
||||||
|
) -> DriverAvailability:
|
||||||
|
if await self.drivers.get(tenant_id, driver_id) is None:
|
||||||
|
raise NotFoundError("راننده یافت نشد")
|
||||||
|
validate_availability_window(
|
||||||
|
effective_from=body.effective_from, effective_until=body.effective_until
|
||||||
|
)
|
||||||
|
entity = await self.repo.get_for_driver(tenant_id, driver_id)
|
||||||
|
if entity is None:
|
||||||
|
entity = DriverAvailability(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
driver_id=driver_id,
|
||||||
|
status=body.status,
|
||||||
|
effective_from=body.effective_from,
|
||||||
|
effective_until=body.effective_until,
|
||||||
|
notes=body.notes,
|
||||||
|
metadata_json=body.metadata_json,
|
||||||
|
created_by=actor_id(actor),
|
||||||
|
updated_by=actor_id(actor),
|
||||||
|
)
|
||||||
|
await self.repo.add(entity)
|
||||||
|
else:
|
||||||
|
if body.version is not None:
|
||||||
|
ensure_optimistic_version(entity, body.version)
|
||||||
|
entity.status = body.status
|
||||||
|
entity.effective_from = body.effective_from
|
||||||
|
entity.effective_until = body.effective_until
|
||||||
|
entity.notes = body.notes
|
||||||
|
entity.metadata_json = body.metadata_json
|
||||||
|
entity.version += 1
|
||||||
|
entity.updated_by = actor_id(actor)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.DRIVER_AVAILABILITY_UPDATED,
|
||||||
|
aggregate_type="driver_availability",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"driver_id": str(driver_id), "status": entity.status.value},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def get_for_driver(
|
||||||
|
self, tenant_id: UUID, driver_id: UUID
|
||||||
|
) -> DriverAvailability:
|
||||||
|
if await self.drivers.get(tenant_id, driver_id) is None:
|
||||||
|
raise NotFoundError("راننده یافت نشد")
|
||||||
|
entity = await self.repo.get_for_driver(tenant_id, driver_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("دسترسی راننده یافت نشد")
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
class ShiftService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
publisher: TransactionalEventPublisher | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.session = session
|
||||||
|
self.repo = ShiftRepository(session)
|
||||||
|
self.assignments = ShiftAssignmentRepository(session)
|
||||||
|
self.drivers = DriverRepository(session)
|
||||||
|
self.orgs = DeliveryOrganizationRepository(session)
|
||||||
|
self.hubs = DeliveryHubRepository(session)
|
||||||
|
self.audit = AuditService(session)
|
||||||
|
self.publisher = publisher or TransactionalEventPublisher(session)
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self, tenant_id: UUID, body: ShiftCreate, *, actor: CurrentUser | None = None
|
||||||
|
) -> Shift:
|
||||||
|
if await self.orgs.get(tenant_id, body.organization_id) is None:
|
||||||
|
raise NotFoundError("سازمان یافت نشد")
|
||||||
|
validate_shift_window(starts_at=body.starts_at, ends_at=body.ends_at)
|
||||||
|
code = validate_code(body.code)
|
||||||
|
if await self.repo.get_by_code(tenant_id, code):
|
||||||
|
raise AppError("کد شیفت تکراری است", error_code="duplicate_code", status_code=409)
|
||||||
|
entity = Shift(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
organization_id=body.organization_id,
|
||||||
|
hub_id=body.hub_id,
|
||||||
|
code=code,
|
||||||
|
name=validate_shift_name(body.name),
|
||||||
|
status=body.status,
|
||||||
|
starts_at=body.starts_at,
|
||||||
|
ends_at=body.ends_at,
|
||||||
|
metadata_json=body.metadata_json,
|
||||||
|
created_by=actor_id(actor),
|
||||||
|
updated_by=actor_id(actor),
|
||||||
|
)
|
||||||
|
await self.repo.add(entity)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.SHIFT_CREATED,
|
||||||
|
aggregate_type="shift",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"code": entity.code},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def get(self, tenant_id: UUID, shift_id: UUID) -> Shift:
|
||||||
|
entity = await self.repo.get(tenant_id, shift_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("شیفت یافت نشد")
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def list(
|
||||||
|
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: ShiftListSpec | None = None
|
||||||
|
) -> tuple[list[Shift], int]:
|
||||||
|
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
||||||
|
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
||||||
|
|
||||||
|
async def update(
|
||||||
|
self, tenant_id: UUID, shift_id: UUID, body: ShiftUpdate, *, actor: CurrentUser | None = None
|
||||||
|
) -> Shift:
|
||||||
|
entity = await self.get(tenant_id, shift_id)
|
||||||
|
ensure_optimistic_version(entity, body.version)
|
||||||
|
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
||||||
|
starts = data.get("starts_at", entity.starts_at)
|
||||||
|
ends = data.get("ends_at", entity.ends_at)
|
||||||
|
validate_shift_window(starts_at=starts, ends_at=ends)
|
||||||
|
if "name" in data and data["name"] is not None:
|
||||||
|
data["name"] = validate_shift_name(data["name"])
|
||||||
|
for key, value in data.items():
|
||||||
|
setattr(entity, key, value)
|
||||||
|
entity.version += 1
|
||||||
|
entity.updated_by = actor_id(actor)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.SHIFT_UPDATED,
|
||||||
|
aggregate_type="shift",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"code": entity.code},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def soft_delete(
|
||||||
|
self, tenant_id: UUID, shift_id: UUID, *, actor: CurrentUser | None = None
|
||||||
|
) -> Shift:
|
||||||
|
entity = await self.get(tenant_id, shift_id)
|
||||||
|
await self.repo.soft_delete(entity, deleted_by=actor_id(actor))
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.SHIFT_DELETED,
|
||||||
|
aggregate_type="shift",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"code": entity.code},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def assign_driver(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
shift_id: UUID,
|
||||||
|
body: ShiftAssignmentCreate,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None = None,
|
||||||
|
) -> ShiftAssignment:
|
||||||
|
await self.get(tenant_id, shift_id)
|
||||||
|
if await self.drivers.get(tenant_id, body.driver_id) is None:
|
||||||
|
raise NotFoundError("راننده یافت نشد")
|
||||||
|
entity = ShiftAssignment(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
shift_id=shift_id,
|
||||||
|
driver_id=body.driver_id,
|
||||||
|
status=ShiftAssignmentStatus.ASSIGNED,
|
||||||
|
notes=body.notes,
|
||||||
|
metadata_json=body.metadata_json,
|
||||||
|
created_by=actor_id(actor),
|
||||||
|
updated_by=actor_id(actor),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self.assignments.add(entity)
|
||||||
|
except IntegrityError as exc:
|
||||||
|
raise AppError(
|
||||||
|
"تخصیص شیفت تکراری است",
|
||||||
|
error_code="duplicate_shift_assignment",
|
||||||
|
status_code=409,
|
||||||
|
) from exc
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.SHIFT_ASSIGNMENT_CREATED,
|
||||||
|
aggregate_type="shift_assignment",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"shift_id": str(shift_id), "driver_id": str(body.driver_id)},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def list_assignments(
|
||||||
|
self, tenant_id: UUID, shift_id: UUID
|
||||||
|
) -> list[ShiftAssignment]:
|
||||||
|
await self.get(tenant_id, shift_id)
|
||||||
|
return list(await self.assignments.list_for_shift(tenant_id, shift_id))
|
||||||
|
|
||||||
|
async def update_assignment(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
shift_id: UUID,
|
||||||
|
assignment_id: UUID,
|
||||||
|
body: ShiftAssignmentUpdate,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None = None,
|
||||||
|
) -> ShiftAssignment:
|
||||||
|
await self.get(tenant_id, shift_id)
|
||||||
|
entity = await self.assignments.get(tenant_id, assignment_id)
|
||||||
|
if entity is None or entity.shift_id != shift_id:
|
||||||
|
raise NotFoundError("تخصیص شیفت یافت نشد")
|
||||||
|
data = body.model_dump(exclude_unset=True)
|
||||||
|
for key, value in data.items():
|
||||||
|
setattr(entity, key, value)
|
||||||
|
entity.updated_by = actor_id(actor)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.SHIFT_ASSIGNMENT_UPDATED,
|
||||||
|
aggregate_type="shift_assignment",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"shift_id": str(shift_id)},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
class WorkingZoneService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
publisher: TransactionalEventPublisher | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.session = session
|
||||||
|
self.repo = WorkingZoneRepository(session)
|
||||||
|
self.orgs = DeliveryOrganizationRepository(session)
|
||||||
|
self.publisher = publisher or TransactionalEventPublisher(session)
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self, tenant_id: UUID, body: WorkingZoneCreate, *, actor: CurrentUser | None = None
|
||||||
|
) -> WorkingZone:
|
||||||
|
if await self.orgs.get(tenant_id, body.organization_id) is None:
|
||||||
|
raise NotFoundError("سازمان یافت نشد")
|
||||||
|
code = validate_code(body.code)
|
||||||
|
if await self.repo.get_by_code(tenant_id, code):
|
||||||
|
raise AppError("کد منطقه کاری تکراری است", error_code="duplicate_code", status_code=409)
|
||||||
|
entity = WorkingZone(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
organization_id=body.organization_id,
|
||||||
|
code=code,
|
||||||
|
name=validate_zone_name(body.name),
|
||||||
|
status=body.status,
|
||||||
|
geo_boundary_ref=body.geo_boundary_ref,
|
||||||
|
metadata_json=body.metadata_json,
|
||||||
|
created_by=actor_id(actor),
|
||||||
|
updated_by=actor_id(actor),
|
||||||
|
)
|
||||||
|
await self.repo.add(entity)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.WORKING_ZONE_CREATED,
|
||||||
|
aggregate_type="working_zone",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"code": entity.code},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def get(self, tenant_id: UUID, zone_id: UUID) -> WorkingZone:
|
||||||
|
entity = await self.repo.get(tenant_id, zone_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("منطقه کاری یافت نشد")
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def list(
|
||||||
|
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: WorkingZoneListSpec | None = None
|
||||||
|
) -> tuple[list[WorkingZone], int]:
|
||||||
|
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
||||||
|
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
||||||
|
|
||||||
|
async def update(
|
||||||
|
self, tenant_id: UUID, zone_id: UUID, body: WorkingZoneUpdate, *, actor: CurrentUser | None = None
|
||||||
|
) -> WorkingZone:
|
||||||
|
entity = await self.get(tenant_id, zone_id)
|
||||||
|
ensure_optimistic_version(entity, body.version)
|
||||||
|
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
||||||
|
if "name" in data and data["name"] is not None:
|
||||||
|
data["name"] = validate_zone_name(data["name"])
|
||||||
|
for key, value in data.items():
|
||||||
|
setattr(entity, key, value)
|
||||||
|
entity.version += 1
|
||||||
|
entity.updated_by = actor_id(actor)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.WORKING_ZONE_UPDATED,
|
||||||
|
aggregate_type="working_zone",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"code": entity.code},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def soft_delete(
|
||||||
|
self, tenant_id: UUID, zone_id: UUID, *, actor: CurrentUser | None = None
|
||||||
|
) -> WorkingZone:
|
||||||
|
entity = await self.get(tenant_id, zone_id)
|
||||||
|
await self.repo.soft_delete(entity, deleted_by=actor_id(actor))
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.WORKING_ZONE_DELETED,
|
||||||
|
aggregate_type="working_zone",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"code": entity.code},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
281
backend/services/delivery/app/services/dispatch.py
Normal file
281
backend/services/delivery/app/services/dispatch.py
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
"""Dispatch application services — Phase 10.5."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.events.publisher import TransactionalEventPublisher
|
||||||
|
from app.events.types import DeliveryEventType
|
||||||
|
from app.models.dispatch import DispatchJob, JobAssignment
|
||||||
|
from app.models.types import AuditAction, JobAssignmentStatus
|
||||||
|
from app.policies.dispatch import DispatchJobPolicy
|
||||||
|
from app.repositories.dispatch import DispatchJobRepository, JobAssignmentRepository
|
||||||
|
from app.repositories.drivers import DriverRepository
|
||||||
|
from app.repositories.foundation import (
|
||||||
|
DeliveryHubRepository,
|
||||||
|
DeliveryOrganizationRepository,
|
||||||
|
)
|
||||||
|
from app.repositories.fleet import VehicleRepository
|
||||||
|
from app.schemas.dispatch import (
|
||||||
|
DispatchJobCreate,
|
||||||
|
DispatchJobStatusRequest,
|
||||||
|
DispatchJobUpdate,
|
||||||
|
JobAssignmentCreate,
|
||||||
|
JobAssignmentUpdate,
|
||||||
|
)
|
||||||
|
from app.services._helpers import actor_id, jsonable
|
||||||
|
from app.services.audit_service import AuditService
|
||||||
|
from app.specifications.dispatch import DispatchJobListSpec, JobAssignmentListSpec
|
||||||
|
from app.validators import ensure_optimistic_version, validate_code
|
||||||
|
from shared.exceptions import AppError, NotFoundError
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
|
||||||
|
class DispatchJobService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
publisher: TransactionalEventPublisher | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.session = session
|
||||||
|
self.repo = DispatchJobRepository(session)
|
||||||
|
self.orgs = DeliveryOrganizationRepository(session)
|
||||||
|
self.hubs = DeliveryHubRepository(session)
|
||||||
|
self.audit = AuditService(session)
|
||||||
|
self.publisher = publisher or TransactionalEventPublisher(session)
|
||||||
|
self.policy = DispatchJobPolicy()
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self, tenant_id: UUID, body: DispatchJobCreate, *, actor: CurrentUser | None = None
|
||||||
|
) -> DispatchJob:
|
||||||
|
if await self.orgs.get(tenant_id, body.organization_id) is None:
|
||||||
|
raise NotFoundError("سازمان یافت نشد")
|
||||||
|
code = validate_code(body.code)
|
||||||
|
if await self.repo.get_by_code(tenant_id, code):
|
||||||
|
raise AppError("کد کار ارسال تکراری است", error_code="duplicate_code", status_code=409)
|
||||||
|
entity = DispatchJob(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
organization_id=body.organization_id,
|
||||||
|
hub_id=body.hub_id,
|
||||||
|
code=code,
|
||||||
|
external_order_ref=body.external_order_ref,
|
||||||
|
external_source=body.external_source,
|
||||||
|
priority=body.priority,
|
||||||
|
pickup_address_ref=body.pickup_address_ref,
|
||||||
|
dropoff_address_ref=body.dropoff_address_ref,
|
||||||
|
scheduled_at=body.scheduled_at,
|
||||||
|
metadata_json=body.metadata_json,
|
||||||
|
created_by=actor_id(actor),
|
||||||
|
updated_by=actor_id(actor),
|
||||||
|
)
|
||||||
|
await self.repo.add(entity)
|
||||||
|
await self.audit.record(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
entity_type="dispatch_job",
|
||||||
|
entity_id=entity.id,
|
||||||
|
action=AuditAction.CREATE,
|
||||||
|
actor_user_id=actor_id(actor),
|
||||||
|
changes=jsonable(body.model_dump()),
|
||||||
|
)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.DISPATCH_JOB_CREATED,
|
||||||
|
aggregate_type="dispatch_job",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"code": entity.code},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def get(self, tenant_id: UUID, job_id: UUID) -> DispatchJob:
|
||||||
|
entity = await self.repo.get(tenant_id, job_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("کار ارسال یافت نشد")
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def list(
|
||||||
|
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: DispatchJobListSpec | None = None
|
||||||
|
) -> tuple[list[DispatchJob], int]:
|
||||||
|
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
||||||
|
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
||||||
|
|
||||||
|
async def update(
|
||||||
|
self, tenant_id: UUID, job_id: UUID, body: DispatchJobUpdate, *, actor: CurrentUser | None = None
|
||||||
|
) -> DispatchJob:
|
||||||
|
entity = await self.get(tenant_id, job_id)
|
||||||
|
ensure_optimistic_version(entity, body.version)
|
||||||
|
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
||||||
|
for key, value in data.items():
|
||||||
|
setattr(entity, key, value)
|
||||||
|
entity.version += 1
|
||||||
|
entity.updated_by = actor_id(actor)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.DISPATCH_JOB_UPDATED,
|
||||||
|
aggregate_type="dispatch_job",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"code": entity.code},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def apply_status(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
job_id: UUID,
|
||||||
|
body: DispatchJobStatusRequest,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None = None,
|
||||||
|
) -> DispatchJob:
|
||||||
|
entity = await self.get(tenant_id, job_id)
|
||||||
|
ensure_optimistic_version(entity, body.version)
|
||||||
|
from_status = entity.status
|
||||||
|
to_status, reason = self.policy.assert_transition(
|
||||||
|
action=body.action, current=from_status, reason=body.reason
|
||||||
|
)
|
||||||
|
entity.status = to_status
|
||||||
|
entity.status_reason = reason
|
||||||
|
entity.version += 1
|
||||||
|
entity.updated_by = actor_id(actor)
|
||||||
|
await self.audit.record(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
entity_type="dispatch_job",
|
||||||
|
entity_id=entity.id,
|
||||||
|
action=AuditAction.STATUS_CHANGE,
|
||||||
|
actor_user_id=actor_id(actor),
|
||||||
|
changes={
|
||||||
|
"from_status": from_status.value,
|
||||||
|
"to_status": to_status.value,
|
||||||
|
"action": body.action.value,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.DISPATCH_JOB_STATUS_CHANGED,
|
||||||
|
aggregate_type="dispatch_job",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={
|
||||||
|
"action": body.action.value,
|
||||||
|
"from_status": from_status.value,
|
||||||
|
"to_status": to_status.value,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def soft_delete(
|
||||||
|
self, tenant_id: UUID, job_id: UUID, *, actor: CurrentUser | None = None
|
||||||
|
) -> DispatchJob:
|
||||||
|
entity = await self.get(tenant_id, job_id)
|
||||||
|
await self.repo.soft_delete(entity, deleted_by=actor_id(actor))
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.DISPATCH_JOB_DELETED,
|
||||||
|
aggregate_type="dispatch_job",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"code": entity.code},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
class JobAssignmentService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
publisher: TransactionalEventPublisher | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.session = session
|
||||||
|
self.repo = JobAssignmentRepository(session)
|
||||||
|
self.jobs = DispatchJobRepository(session)
|
||||||
|
self.drivers = DriverRepository(session)
|
||||||
|
self.vehicles = VehicleRepository(session)
|
||||||
|
self.publisher = publisher or TransactionalEventPublisher(session)
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self, tenant_id: UUID, body: JobAssignmentCreate, *, actor: CurrentUser | None = None
|
||||||
|
) -> JobAssignment:
|
||||||
|
if await self.jobs.get(tenant_id, body.dispatch_job_id) is None:
|
||||||
|
raise NotFoundError("کار ارسال یافت نشد")
|
||||||
|
if await self.drivers.get(tenant_id, body.driver_id) is None:
|
||||||
|
raise NotFoundError("راننده یافت نشد")
|
||||||
|
if body.vehicle_id and await self.vehicles.get(tenant_id, body.vehicle_id) is None:
|
||||||
|
raise NotFoundError("خودرو یافت نشد")
|
||||||
|
entity = JobAssignment(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
dispatch_job_id=body.dispatch_job_id,
|
||||||
|
driver_id=body.driver_id,
|
||||||
|
vehicle_id=body.vehicle_id,
|
||||||
|
status=JobAssignmentStatus.PENDING,
|
||||||
|
assigned_at=datetime.now(timezone.utc),
|
||||||
|
notes=body.notes,
|
||||||
|
metadata_json=body.metadata_json,
|
||||||
|
created_by=actor_id(actor),
|
||||||
|
updated_by=actor_id(actor),
|
||||||
|
)
|
||||||
|
await self.repo.add(entity)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.JOB_ASSIGNMENT_CREATED,
|
||||||
|
aggregate_type="job_assignment",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"dispatch_job_id": str(body.dispatch_job_id)},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def get(self, tenant_id: UUID, assignment_id: UUID) -> JobAssignment:
|
||||||
|
entity = await self.repo.get(tenant_id, assignment_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("تخصیص کار یافت نشد")
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def list(
|
||||||
|
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: JobAssignmentListSpec | None = None
|
||||||
|
) -> tuple[list[JobAssignment], int]:
|
||||||
|
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
||||||
|
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
||||||
|
|
||||||
|
async def update(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
assignment_id: UUID,
|
||||||
|
body: JobAssignmentUpdate,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None = None,
|
||||||
|
) -> JobAssignment:
|
||||||
|
entity = await self.get(tenant_id, assignment_id)
|
||||||
|
ensure_optimistic_version(entity, body.version)
|
||||||
|
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
||||||
|
old_status = entity.status
|
||||||
|
for key, value in data.items():
|
||||||
|
setattr(entity, key, value)
|
||||||
|
entity.version += 1
|
||||||
|
entity.updated_by = actor_id(actor)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.JOB_ASSIGNMENT_UPDATED,
|
||||||
|
aggregate_type="job_assignment",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"dispatch_job_id": str(entity.dispatch_job_id)},
|
||||||
|
)
|
||||||
|
if "status" in data and entity.status != old_status:
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.JOB_ASSIGNMENT_STATUS_CHANGED,
|
||||||
|
aggregate_type="job_assignment",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"status": entity.status.value},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
452
backend/services/delivery/app/services/fleet.py
Normal file
452
backend/services/delivery/app/services/fleet.py
Normal file
@ -0,0 +1,452 @@
|
|||||||
|
"""Fleet & vehicle application services — Phase 10.2."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.events.publisher import TransactionalEventPublisher
|
||||||
|
from app.events.types import DeliveryEventType
|
||||||
|
from app.models.fleet import Fleet, Vehicle, VehicleAssignment, VehicleType
|
||||||
|
from app.models.types import AuditAction, VehicleAssignmentStatus, VehicleStatus
|
||||||
|
from app.repositories.drivers import DriverRepository
|
||||||
|
from app.repositories.fleet import (
|
||||||
|
FleetRepository,
|
||||||
|
VehicleAssignmentRepository,
|
||||||
|
VehicleRepository,
|
||||||
|
VehicleTypeRepository,
|
||||||
|
)
|
||||||
|
from app.repositories.foundation import (
|
||||||
|
DeliveryHubRepository,
|
||||||
|
DeliveryOrganizationRepository,
|
||||||
|
)
|
||||||
|
from app.schemas.fleet import (
|
||||||
|
FleetCreate,
|
||||||
|
FleetUpdate,
|
||||||
|
VehicleAssignmentCreate,
|
||||||
|
VehicleAssignmentUpdate,
|
||||||
|
VehicleCreate,
|
||||||
|
VehicleTypeCreate,
|
||||||
|
VehicleTypeUpdate,
|
||||||
|
VehicleUpdate,
|
||||||
|
)
|
||||||
|
from app.services.audit_service import AuditService
|
||||||
|
from app.services._helpers import actor_id, jsonable
|
||||||
|
from app.specifications.fleet import FleetListSpec, VehicleListSpec, VehicleTypeListSpec
|
||||||
|
from app.validators import ensure_optimistic_version, validate_code
|
||||||
|
from app.validators.fleet import (
|
||||||
|
validate_fleet_name,
|
||||||
|
validate_plate_number,
|
||||||
|
validate_vehicle_type_name,
|
||||||
|
)
|
||||||
|
from shared.exceptions import AppError, NotFoundError
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
|
||||||
|
class FleetService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
publisher: TransactionalEventPublisher | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.session = session
|
||||||
|
self.repo = FleetRepository(session)
|
||||||
|
self.orgs = DeliveryOrganizationRepository(session)
|
||||||
|
self.hubs = DeliveryHubRepository(session)
|
||||||
|
self.audit = AuditService(session)
|
||||||
|
self.publisher = publisher or TransactionalEventPublisher(session)
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self, tenant_id: UUID, body: FleetCreate, *, actor: CurrentUser | None = None
|
||||||
|
) -> Fleet:
|
||||||
|
org = await self.orgs.get(tenant_id, body.organization_id)
|
||||||
|
if org is None:
|
||||||
|
raise NotFoundError("سازمان یافت نشد")
|
||||||
|
if body.hub_id is not None:
|
||||||
|
hub = await self.hubs.get(tenant_id, body.hub_id)
|
||||||
|
if hub is None:
|
||||||
|
raise NotFoundError("هاب یافت نشد")
|
||||||
|
code = validate_code(body.code)
|
||||||
|
if await self.repo.get_by_code(tenant_id, code):
|
||||||
|
raise AppError("کد ناوگان تکراری است", error_code="duplicate_code", status_code=409)
|
||||||
|
entity = Fleet(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
organization_id=body.organization_id,
|
||||||
|
hub_id=body.hub_id,
|
||||||
|
code=code,
|
||||||
|
name=validate_fleet_name(body.name),
|
||||||
|
description=body.description,
|
||||||
|
status=body.status,
|
||||||
|
metadata_json=body.metadata_json,
|
||||||
|
created_by=actor_id(actor),
|
||||||
|
updated_by=actor_id(actor),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self.repo.add(entity)
|
||||||
|
except IntegrityError as exc:
|
||||||
|
raise AppError("کد ناوگان تکراری است", error_code="duplicate_code", status_code=409) from exc
|
||||||
|
await self.audit.record(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
entity_type="fleet",
|
||||||
|
entity_id=entity.id,
|
||||||
|
action=AuditAction.CREATE,
|
||||||
|
actor_user_id=actor_id(actor),
|
||||||
|
changes=jsonable(body.model_dump()),
|
||||||
|
)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.FLEET_CREATED,
|
||||||
|
aggregate_type="fleet",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"code": entity.code},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def get(self, tenant_id: UUID, fleet_id: UUID) -> Fleet:
|
||||||
|
entity = await self.repo.get(tenant_id, fleet_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("ناوگان یافت نشد")
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def list(
|
||||||
|
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: FleetListSpec | None = None
|
||||||
|
) -> tuple[list[Fleet], int]:
|
||||||
|
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
||||||
|
total = await self.repo.count_filtered(tenant_id, spec=spec)
|
||||||
|
return items, total
|
||||||
|
|
||||||
|
async def update(
|
||||||
|
self, tenant_id: UUID, fleet_id: UUID, body: FleetUpdate, *, actor: CurrentUser | None = None
|
||||||
|
) -> Fleet:
|
||||||
|
entity = await self.get(tenant_id, fleet_id)
|
||||||
|
ensure_optimistic_version(entity, body.version)
|
||||||
|
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
||||||
|
if "name" in data and data["name"] is not None:
|
||||||
|
data["name"] = validate_fleet_name(data["name"])
|
||||||
|
for key, value in data.items():
|
||||||
|
setattr(entity, key, value)
|
||||||
|
entity.version += 1
|
||||||
|
entity.updated_by = actor_id(actor)
|
||||||
|
await self.audit.record(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
entity_type="fleet",
|
||||||
|
entity_id=entity.id,
|
||||||
|
action=AuditAction.UPDATE,
|
||||||
|
actor_user_id=actor_id(actor),
|
||||||
|
changes=jsonable(data),
|
||||||
|
)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.FLEET_UPDATED,
|
||||||
|
aggregate_type="fleet",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"code": entity.code},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def soft_delete(
|
||||||
|
self, tenant_id: UUID, fleet_id: UUID, *, actor: CurrentUser | None = None
|
||||||
|
) -> Fleet:
|
||||||
|
entity = await self.get(tenant_id, fleet_id)
|
||||||
|
await self.repo.soft_delete(entity, deleted_by=actor_id(actor))
|
||||||
|
await self.audit.record(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
entity_type="fleet",
|
||||||
|
entity_id=entity.id,
|
||||||
|
action=AuditAction.DELETE,
|
||||||
|
actor_user_id=actor_id(actor),
|
||||||
|
)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.FLEET_DELETED,
|
||||||
|
aggregate_type="fleet",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"code": entity.code},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleTypeService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
publisher: TransactionalEventPublisher | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.session = session
|
||||||
|
self.repo = VehicleTypeRepository(session)
|
||||||
|
self.audit = AuditService(session)
|
||||||
|
self.publisher = publisher or TransactionalEventPublisher(session)
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self, tenant_id: UUID, body: VehicleTypeCreate, *, actor: CurrentUser | None = None
|
||||||
|
) -> VehicleType:
|
||||||
|
code = validate_code(body.code)
|
||||||
|
if await self.repo.get_by_code(tenant_id, code):
|
||||||
|
raise AppError("کد نوع خودرو تکراری است", error_code="duplicate_code", status_code=409)
|
||||||
|
entity = VehicleType(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
code=code,
|
||||||
|
name=validate_vehicle_type_name(body.name),
|
||||||
|
description=body.description,
|
||||||
|
capacity_kg=body.capacity_kg,
|
||||||
|
capacity_volume=body.capacity_volume,
|
||||||
|
capability_tags=body.capability_tags,
|
||||||
|
metadata_json=body.metadata_json,
|
||||||
|
created_by=actor_id(actor),
|
||||||
|
updated_by=actor_id(actor),
|
||||||
|
)
|
||||||
|
await self.repo.add(entity)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.VEHICLE_TYPE_CREATED,
|
||||||
|
aggregate_type="vehicle_type",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"code": entity.code},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def get(self, tenant_id: UUID, type_id: UUID) -> VehicleType:
|
||||||
|
entity = await self.repo.get(tenant_id, type_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("نوع خودرو یافت نشد")
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def list(
|
||||||
|
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: VehicleTypeListSpec | None = None
|
||||||
|
) -> tuple[list[VehicleType], int]:
|
||||||
|
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
||||||
|
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
||||||
|
|
||||||
|
async def update(
|
||||||
|
self, tenant_id: UUID, type_id: UUID, body: VehicleTypeUpdate, *, actor: CurrentUser | None = None
|
||||||
|
) -> VehicleType:
|
||||||
|
entity = await self.get(tenant_id, type_id)
|
||||||
|
ensure_optimistic_version(entity, body.version)
|
||||||
|
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
||||||
|
if "name" in data and data["name"] is not None:
|
||||||
|
data["name"] = validate_vehicle_type_name(data["name"])
|
||||||
|
for key, value in data.items():
|
||||||
|
setattr(entity, key, value)
|
||||||
|
entity.version += 1
|
||||||
|
entity.updated_by = actor_id(actor)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.VEHICLE_TYPE_UPDATED,
|
||||||
|
aggregate_type="vehicle_type",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"code": entity.code},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def soft_delete(
|
||||||
|
self, tenant_id: UUID, type_id: UUID, *, actor: CurrentUser | None = None
|
||||||
|
) -> VehicleType:
|
||||||
|
entity = await self.get(tenant_id, type_id)
|
||||||
|
await self.repo.soft_delete(entity, deleted_by=actor_id(actor))
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.VEHICLE_TYPE_DELETED,
|
||||||
|
aggregate_type="vehicle_type",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"code": entity.code},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
class VehicleService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
publisher: TransactionalEventPublisher | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.session = session
|
||||||
|
self.repo = VehicleRepository(session)
|
||||||
|
self.fleets = FleetRepository(session)
|
||||||
|
self.types = VehicleTypeRepository(session)
|
||||||
|
self.assignments = VehicleAssignmentRepository(session)
|
||||||
|
self.drivers = DriverRepository(session)
|
||||||
|
self.audit = AuditService(session)
|
||||||
|
self.publisher = publisher or TransactionalEventPublisher(session)
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self, tenant_id: UUID, body: VehicleCreate, *, actor: CurrentUser | None = None
|
||||||
|
) -> Vehicle:
|
||||||
|
if await self.fleets.get(tenant_id, body.fleet_id) is None:
|
||||||
|
raise NotFoundError("ناوگان یافت نشد")
|
||||||
|
if await self.types.get(tenant_id, body.vehicle_type_id) is None:
|
||||||
|
raise NotFoundError("نوع خودرو یافت نشد")
|
||||||
|
code = validate_code(body.code)
|
||||||
|
if await self.repo.get_by_code(tenant_id, code):
|
||||||
|
raise AppError("کد خودرو تکراری است", error_code="duplicate_code", status_code=409)
|
||||||
|
entity = Vehicle(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
fleet_id=body.fleet_id,
|
||||||
|
vehicle_type_id=body.vehicle_type_id,
|
||||||
|
code=code,
|
||||||
|
plate_number=validate_plate_number(body.plate_number),
|
||||||
|
status=body.status,
|
||||||
|
metadata_json=body.metadata_json,
|
||||||
|
created_by=actor_id(actor),
|
||||||
|
updated_by=actor_id(actor),
|
||||||
|
)
|
||||||
|
await self.repo.add(entity)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.VEHICLE_CREATED,
|
||||||
|
aggregate_type="vehicle",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"code": entity.code},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def get(self, tenant_id: UUID, vehicle_id: UUID) -> Vehicle:
|
||||||
|
entity = await self.repo.get(tenant_id, vehicle_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("خودرو یافت نشد")
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def list(
|
||||||
|
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: VehicleListSpec | None = None
|
||||||
|
) -> tuple[list[Vehicle], int]:
|
||||||
|
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
||||||
|
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
||||||
|
|
||||||
|
async def update(
|
||||||
|
self, tenant_id: UUID, vehicle_id: UUID, body: VehicleUpdate, *, actor: CurrentUser | None = None
|
||||||
|
) -> Vehicle:
|
||||||
|
entity = await self.get(tenant_id, vehicle_id)
|
||||||
|
ensure_optimistic_version(entity, body.version)
|
||||||
|
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
||||||
|
if "plate_number" in data:
|
||||||
|
data["plate_number"] = validate_plate_number(data["plate_number"])
|
||||||
|
for key, value in data.items():
|
||||||
|
setattr(entity, key, value)
|
||||||
|
entity.version += 1
|
||||||
|
entity.updated_by = actor_id(actor)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.VEHICLE_UPDATED,
|
||||||
|
aggregate_type="vehicle",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"code": entity.code},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def soft_delete(
|
||||||
|
self, tenant_id: UUID, vehicle_id: UUID, *, actor: CurrentUser | None = None
|
||||||
|
) -> Vehicle:
|
||||||
|
entity = await self.get(tenant_id, vehicle_id)
|
||||||
|
await self.repo.soft_delete(entity, deleted_by=actor_id(actor))
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.VEHICLE_DELETED,
|
||||||
|
aggregate_type="vehicle",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"code": entity.code},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def create_assignment(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
vehicle_id: UUID,
|
||||||
|
body: VehicleAssignmentCreate,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None = None,
|
||||||
|
) -> VehicleAssignment:
|
||||||
|
await self.get(tenant_id, vehicle_id)
|
||||||
|
if await self.drivers.get(tenant_id, body.driver_id) is None:
|
||||||
|
raise NotFoundError("راننده یافت نشد")
|
||||||
|
active = await self.assignments.get_active_for_vehicle(tenant_id, vehicle_id)
|
||||||
|
if active is not None:
|
||||||
|
raise AppError(
|
||||||
|
"خودرو دارای تخصیص فعال است",
|
||||||
|
status_code=409,
|
||||||
|
error_code="active_assignment_exists",
|
||||||
|
)
|
||||||
|
entity = VehicleAssignment(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
vehicle_id=vehicle_id,
|
||||||
|
driver_id=body.driver_id,
|
||||||
|
status=VehicleAssignmentStatus.ACTIVE,
|
||||||
|
notes=body.notes,
|
||||||
|
metadata_json=body.metadata_json,
|
||||||
|
created_by=actor_id(actor),
|
||||||
|
updated_by=actor_id(actor),
|
||||||
|
)
|
||||||
|
await self.assignments.add(entity)
|
||||||
|
vehicle = await self.get(tenant_id, vehicle_id)
|
||||||
|
vehicle.status = VehicleStatus.ASSIGNED
|
||||||
|
vehicle.version += 1
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.VEHICLE_ASSIGNMENT_CREATED,
|
||||||
|
aggregate_type="vehicle_assignment",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"vehicle_id": str(vehicle_id), "driver_id": str(body.driver_id)},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def list_assignments(
|
||||||
|
self, tenant_id: UUID, vehicle_id: UUID
|
||||||
|
) -> list[VehicleAssignment]:
|
||||||
|
await self.get(tenant_id, vehicle_id)
|
||||||
|
return list(await self.assignments.list_for_vehicle(tenant_id, vehicle_id))
|
||||||
|
|
||||||
|
async def update_assignment(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
vehicle_id: UUID,
|
||||||
|
assignment_id: UUID,
|
||||||
|
body: VehicleAssignmentUpdate,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None = None,
|
||||||
|
) -> VehicleAssignment:
|
||||||
|
await self.get(tenant_id, vehicle_id)
|
||||||
|
entity = await self.assignments.get(tenant_id, assignment_id)
|
||||||
|
if entity is None or entity.vehicle_id != vehicle_id:
|
||||||
|
raise NotFoundError("تخصیص یافت نشد")
|
||||||
|
data = body.model_dump(exclude_unset=True)
|
||||||
|
for key, value in data.items():
|
||||||
|
setattr(entity, key, value)
|
||||||
|
entity.updated_by = actor_id(actor)
|
||||||
|
event = DeliveryEventType.VEHICLE_ASSIGNMENT_UPDATED
|
||||||
|
if entity.status == VehicleAssignmentStatus.ENDED:
|
||||||
|
event = DeliveryEventType.VEHICLE_ASSIGNMENT_ENDED
|
||||||
|
vehicle = await self.get(tenant_id, vehicle_id)
|
||||||
|
vehicle.status = VehicleStatus.AVAILABLE
|
||||||
|
vehicle.version += 1
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=event,
|
||||||
|
aggregate_type="vehicle_assignment",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"vehicle_id": str(vehicle_id)},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
330
backend/services/delivery/app/services/pricing.py
Normal file
330
backend/services/delivery/app/services/pricing.py
Normal file
@ -0,0 +1,330 @@
|
|||||||
|
"""Pricing application services — Phase 10.4."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.events.publisher import TransactionalEventPublisher
|
||||||
|
from app.events.types import DeliveryEventType
|
||||||
|
from app.models.pricing import CapabilityBundle, CapabilityDefinition, PricingRule
|
||||||
|
from app.repositories.foundation import DeliveryOrganizationRepository
|
||||||
|
from app.repositories.pricing import (
|
||||||
|
CapabilityBundleRepository,
|
||||||
|
CapabilityDefinitionRepository,
|
||||||
|
PricingRuleRepository,
|
||||||
|
)
|
||||||
|
from app.schemas.pricing import (
|
||||||
|
BundleCreate,
|
||||||
|
BundleUpdate,
|
||||||
|
CapabilityCreate,
|
||||||
|
CapabilityUpdate,
|
||||||
|
PricingRuleCreate,
|
||||||
|
PricingRuleUpdate,
|
||||||
|
)
|
||||||
|
from app.services._helpers import actor_id
|
||||||
|
from app.specifications.pricing import (
|
||||||
|
BundleListSpec,
|
||||||
|
CapabilityListSpec,
|
||||||
|
PricingRuleListSpec,
|
||||||
|
)
|
||||||
|
from app.validators import ensure_optimistic_version, validate_code
|
||||||
|
from app.validators.pricing import (
|
||||||
|
validate_base_amount,
|
||||||
|
validate_bundle_name,
|
||||||
|
validate_capability_name,
|
||||||
|
validate_currency,
|
||||||
|
validate_pricing_name,
|
||||||
|
)
|
||||||
|
from shared.exceptions import AppError, NotFoundError
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
|
||||||
|
class PricingRuleService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
publisher: TransactionalEventPublisher | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.session = session
|
||||||
|
self.repo = PricingRuleRepository(session)
|
||||||
|
self.orgs = DeliveryOrganizationRepository(session)
|
||||||
|
self.publisher = publisher or TransactionalEventPublisher(session)
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self, tenant_id: UUID, body: PricingRuleCreate, *, actor: CurrentUser | None = None
|
||||||
|
) -> PricingRule:
|
||||||
|
if await self.orgs.get(tenant_id, body.organization_id) is None:
|
||||||
|
raise NotFoundError("سازمان یافت نشد")
|
||||||
|
code = validate_code(body.code)
|
||||||
|
if await self.repo.get_by_code(tenant_id, code):
|
||||||
|
raise AppError("کد قیمتگذاری تکراری است", error_code="duplicate_code", status_code=409)
|
||||||
|
entity = PricingRule(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
organization_id=body.organization_id,
|
||||||
|
code=code,
|
||||||
|
name=validate_pricing_name(body.name),
|
||||||
|
status=body.status,
|
||||||
|
base_amount=validate_base_amount(body.base_amount),
|
||||||
|
currency_code=validate_currency(body.currency_code),
|
||||||
|
rule_config=body.rule_config,
|
||||||
|
metadata_json=body.metadata_json,
|
||||||
|
created_by=actor_id(actor),
|
||||||
|
updated_by=actor_id(actor),
|
||||||
|
)
|
||||||
|
await self.repo.add(entity)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.PRICING_RULE_CREATED,
|
||||||
|
aggregate_type="pricing_rule",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"code": entity.code},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def get(self, tenant_id: UUID, rule_id: UUID) -> PricingRule:
|
||||||
|
entity = await self.repo.get(tenant_id, rule_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("قانون قیمتگذاری یافت نشد")
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def list(
|
||||||
|
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: PricingRuleListSpec | None = None
|
||||||
|
) -> tuple[list[PricingRule], int]:
|
||||||
|
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
||||||
|
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
||||||
|
|
||||||
|
async def update(
|
||||||
|
self, tenant_id: UUID, rule_id: UUID, body: PricingRuleUpdate, *, actor: CurrentUser | None = None
|
||||||
|
) -> PricingRule:
|
||||||
|
entity = await self.get(tenant_id, rule_id)
|
||||||
|
ensure_optimistic_version(entity, body.version)
|
||||||
|
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
||||||
|
if "name" in data and data["name"] is not None:
|
||||||
|
data["name"] = validate_pricing_name(data["name"])
|
||||||
|
if "base_amount" in data and data["base_amount"] is not None:
|
||||||
|
data["base_amount"] = validate_base_amount(data["base_amount"])
|
||||||
|
if "currency_code" in data and data["currency_code"] is not None:
|
||||||
|
data["currency_code"] = validate_currency(data["currency_code"])
|
||||||
|
for key, value in data.items():
|
||||||
|
setattr(entity, key, value)
|
||||||
|
entity.version += 1
|
||||||
|
entity.updated_by = actor_id(actor)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.PRICING_RULE_UPDATED,
|
||||||
|
aggregate_type="pricing_rule",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"code": entity.code},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def soft_delete(
|
||||||
|
self, tenant_id: UUID, rule_id: UUID, *, actor: CurrentUser | None = None
|
||||||
|
) -> PricingRule:
|
||||||
|
entity = await self.get(tenant_id, rule_id)
|
||||||
|
await self.repo.soft_delete(entity, deleted_by=actor_id(actor))
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.PRICING_RULE_DELETED,
|
||||||
|
aggregate_type="pricing_rule",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"code": entity.code},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
class CapabilityService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
publisher: TransactionalEventPublisher | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.session = session
|
||||||
|
self.repo = CapabilityDefinitionRepository(session)
|
||||||
|
self.publisher = publisher or TransactionalEventPublisher(session)
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self, tenant_id: UUID, body: CapabilityCreate, *, actor: CurrentUser | None = None
|
||||||
|
) -> CapabilityDefinition:
|
||||||
|
code = validate_code(body.code)
|
||||||
|
if await self.repo.get_by_code(tenant_id, code):
|
||||||
|
raise AppError("کد قابلیت تکراری است", error_code="duplicate_code", status_code=409)
|
||||||
|
entity = CapabilityDefinition(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
code=code,
|
||||||
|
name=validate_capability_name(body.name),
|
||||||
|
description=body.description,
|
||||||
|
status=body.status,
|
||||||
|
metadata_json=body.metadata_json,
|
||||||
|
created_by=actor_id(actor),
|
||||||
|
updated_by=actor_id(actor),
|
||||||
|
)
|
||||||
|
await self.repo.add(entity)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.CAPABILITY_CREATED,
|
||||||
|
aggregate_type="capability",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"code": entity.code},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def get(self, tenant_id: UUID, cap_id: UUID) -> CapabilityDefinition:
|
||||||
|
entity = await self.repo.get(tenant_id, cap_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("قابلیت یافت نشد")
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def list(
|
||||||
|
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: CapabilityListSpec | None = None
|
||||||
|
) -> tuple[list[CapabilityDefinition], int]:
|
||||||
|
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
||||||
|
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
||||||
|
|
||||||
|
async def update(
|
||||||
|
self, tenant_id: UUID, cap_id: UUID, body: CapabilityUpdate, *, actor: CurrentUser | None = None
|
||||||
|
) -> CapabilityDefinition:
|
||||||
|
entity = await self.get(tenant_id, cap_id)
|
||||||
|
ensure_optimistic_version(entity, body.version)
|
||||||
|
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
||||||
|
if "name" in data and data["name"] is not None:
|
||||||
|
data["name"] = validate_capability_name(data["name"])
|
||||||
|
for key, value in data.items():
|
||||||
|
setattr(entity, key, value)
|
||||||
|
entity.version += 1
|
||||||
|
entity.updated_by = actor_id(actor)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.CAPABILITY_UPDATED,
|
||||||
|
aggregate_type="capability",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"code": entity.code},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def soft_delete(
|
||||||
|
self, tenant_id: UUID, cap_id: UUID, *, actor: CurrentUser | None = None
|
||||||
|
) -> CapabilityDefinition:
|
||||||
|
entity = await self.get(tenant_id, cap_id)
|
||||||
|
await self.repo.soft_delete(entity, deleted_by=actor_id(actor))
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.CAPABILITY_DELETED,
|
||||||
|
aggregate_type="capability",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"code": entity.code},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
class BundleService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
publisher: TransactionalEventPublisher | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.session = session
|
||||||
|
self.repo = CapabilityBundleRepository(session)
|
||||||
|
self.rules = PricingRuleRepository(session)
|
||||||
|
self.publisher = publisher or TransactionalEventPublisher(session)
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self, tenant_id: UUID, body: BundleCreate, *, actor: CurrentUser | None = None
|
||||||
|
) -> CapabilityBundle:
|
||||||
|
code = validate_code(body.code)
|
||||||
|
if await self.repo.get_by_code(tenant_id, code):
|
||||||
|
raise AppError("کد بسته تکراری است", error_code="duplicate_code", status_code=409)
|
||||||
|
if body.pricing_rule_id and await self.rules.get(tenant_id, body.pricing_rule_id) is None:
|
||||||
|
raise NotFoundError("قانون قیمتگذاری یافت نشد")
|
||||||
|
entity = CapabilityBundle(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
code=code,
|
||||||
|
name=validate_bundle_name(body.name),
|
||||||
|
status=body.status,
|
||||||
|
capability_codes=body.capability_codes,
|
||||||
|
pricing_rule_id=body.pricing_rule_id,
|
||||||
|
metadata_json=body.metadata_json,
|
||||||
|
created_by=actor_id(actor),
|
||||||
|
updated_by=actor_id(actor),
|
||||||
|
)
|
||||||
|
await self.repo.add(entity)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.BUNDLE_CREATED,
|
||||||
|
aggregate_type="bundle",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"code": entity.code},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def get(self, tenant_id: UUID, bundle_id: UUID) -> CapabilityBundle:
|
||||||
|
entity = await self.repo.get(tenant_id, bundle_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("بسته قابلیت یافت نشد")
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def list(
|
||||||
|
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: BundleListSpec | None = None
|
||||||
|
) -> tuple[list[CapabilityBundle], int]:
|
||||||
|
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
||||||
|
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
||||||
|
|
||||||
|
async def update(
|
||||||
|
self, tenant_id: UUID, bundle_id: UUID, body: BundleUpdate, *, actor: CurrentUser | None = None
|
||||||
|
) -> CapabilityBundle:
|
||||||
|
entity = await self.get(tenant_id, bundle_id)
|
||||||
|
ensure_optimistic_version(entity, body.version)
|
||||||
|
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
||||||
|
if "name" in data and data["name"] is not None:
|
||||||
|
data["name"] = validate_bundle_name(data["name"])
|
||||||
|
if "pricing_rule_id" in data and data["pricing_rule_id"]:
|
||||||
|
if await self.rules.get(tenant_id, data["pricing_rule_id"]) is None:
|
||||||
|
raise NotFoundError("قانون قیمتگذاری یافت نشد")
|
||||||
|
for key, value in data.items():
|
||||||
|
setattr(entity, key, value)
|
||||||
|
entity.version += 1
|
||||||
|
entity.updated_by = actor_id(actor)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.BUNDLE_UPDATED,
|
||||||
|
aggregate_type="bundle",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"code": entity.code},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def soft_delete(
|
||||||
|
self, tenant_id: UUID, bundle_id: UUID, *, actor: CurrentUser | None = None
|
||||||
|
) -> CapabilityBundle:
|
||||||
|
entity = await self.get(tenant_id, bundle_id)
|
||||||
|
await self.repo.soft_delete(entity, deleted_by=actor_id(actor))
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.BUNDLE_DELETED,
|
||||||
|
aggregate_type="bundle",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"code": entity.code},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
234
backend/services/delivery/app/services/routing.py
Normal file
234
backend/services/delivery/app/services/routing.py
Normal file
@ -0,0 +1,234 @@
|
|||||||
|
"""Routing application services — Phase 10.6."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.events.publisher import TransactionalEventPublisher
|
||||||
|
from app.events.types import DeliveryEventType
|
||||||
|
from app.models.routing import OptimizationRun, RoutePlan, RouteStop
|
||||||
|
from app.models.types import OptimizationRunStatus, RoutePlanStatus
|
||||||
|
from app.repositories.foundation import DeliveryOrganizationRepository
|
||||||
|
from app.repositories.routing import (
|
||||||
|
OptimizationRunRepository,
|
||||||
|
RoutePlanRepository,
|
||||||
|
RouteStopRepository,
|
||||||
|
)
|
||||||
|
from app.schemas.routing import (
|
||||||
|
OptimizationRunCreate,
|
||||||
|
RoutePlanCreate,
|
||||||
|
RoutePlanUpdate,
|
||||||
|
RouteStopCreate,
|
||||||
|
)
|
||||||
|
from app.services._helpers import actor_id
|
||||||
|
from app.specifications.routing import OptimizationRunListSpec, RoutePlanListSpec
|
||||||
|
from app.validators import ensure_optimistic_version, validate_code
|
||||||
|
from app.validators.routing import validate_stop_sequence
|
||||||
|
from shared.exceptions import AppError, NotFoundError
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
|
||||||
|
class RoutePlanService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
publisher: TransactionalEventPublisher | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.session = session
|
||||||
|
self.repo = RoutePlanRepository(session)
|
||||||
|
self.stops = RouteStopRepository(session)
|
||||||
|
self.orgs = DeliveryOrganizationRepository(session)
|
||||||
|
self.publisher = publisher or TransactionalEventPublisher(session)
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self, tenant_id: UUID, body: RoutePlanCreate, *, actor: CurrentUser | None = None
|
||||||
|
) -> RoutePlan:
|
||||||
|
if await self.orgs.get(tenant_id, body.organization_id) is None:
|
||||||
|
raise NotFoundError("سازمان یافت نشد")
|
||||||
|
code = validate_code(body.code)
|
||||||
|
if await self.repo.get_by_code(tenant_id, code):
|
||||||
|
raise AppError("کد مسیر تکراری است", error_code="duplicate_code", status_code=409)
|
||||||
|
entity = RoutePlan(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
organization_id=body.organization_id,
|
||||||
|
code=code,
|
||||||
|
driver_id=body.driver_id,
|
||||||
|
dispatch_job_ids=body.dispatch_job_ids,
|
||||||
|
routing_engine_ref=body.routing_engine_ref,
|
||||||
|
metadata_json=body.metadata_json,
|
||||||
|
created_by=actor_id(actor),
|
||||||
|
updated_by=actor_id(actor),
|
||||||
|
)
|
||||||
|
await self.repo.add(entity)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.ROUTE_PLAN_CREATED,
|
||||||
|
aggregate_type="route_plan",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"code": entity.code},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def get(self, tenant_id: UUID, plan_id: UUID) -> RoutePlan:
|
||||||
|
entity = await self.repo.get(tenant_id, plan_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("برنامه مسیر یافت نشد")
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def list(
|
||||||
|
self, tenant_id: UUID, *, offset: int = 0, limit: int = 20, spec: RoutePlanListSpec | None = None
|
||||||
|
) -> tuple[list[RoutePlan], int]:
|
||||||
|
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
||||||
|
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
||||||
|
|
||||||
|
async def update(
|
||||||
|
self, tenant_id: UUID, plan_id: UUID, body: RoutePlanUpdate, *, actor: CurrentUser | None = None
|
||||||
|
) -> RoutePlan:
|
||||||
|
entity = await self.get(tenant_id, plan_id)
|
||||||
|
ensure_optimistic_version(entity, body.version)
|
||||||
|
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
||||||
|
for key, value in data.items():
|
||||||
|
setattr(entity, key, value)
|
||||||
|
entity.version += 1
|
||||||
|
entity.updated_by = actor_id(actor)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.ROUTE_PLAN_UPDATED,
|
||||||
|
aggregate_type="route_plan",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"code": entity.code},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def soft_delete(
|
||||||
|
self, tenant_id: UUID, plan_id: UUID, *, actor: CurrentUser | None = None
|
||||||
|
) -> RoutePlan:
|
||||||
|
entity = await self.get(tenant_id, plan_id)
|
||||||
|
await self.repo.soft_delete(entity, deleted_by=actor_id(actor))
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.ROUTE_PLAN_DELETED,
|
||||||
|
aggregate_type="route_plan",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"code": entity.code},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def add_stop(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
plan_id: UUID,
|
||||||
|
body: RouteStopCreate,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None = None,
|
||||||
|
) -> RouteStop:
|
||||||
|
await self.get(tenant_id, plan_id)
|
||||||
|
validate_stop_sequence(body.sequence)
|
||||||
|
entity = RouteStop(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
route_plan_id=plan_id,
|
||||||
|
sequence=body.sequence,
|
||||||
|
address_ref=body.address_ref,
|
||||||
|
external_order_ref=body.external_order_ref,
|
||||||
|
notes=body.notes,
|
||||||
|
metadata_json=body.metadata_json,
|
||||||
|
created_by=actor_id(actor),
|
||||||
|
updated_by=actor_id(actor),
|
||||||
|
)
|
||||||
|
await self.stops.add(entity)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.ROUTE_STOP_ADDED,
|
||||||
|
aggregate_type="route_stop",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"route_plan_id": str(plan_id), "sequence": body.sequence},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def list_stops(self, tenant_id: UUID, plan_id: UUID) -> list[RouteStop]:
|
||||||
|
await self.get(tenant_id, plan_id)
|
||||||
|
return list(await self.stops.list_for_plan(tenant_id, plan_id))
|
||||||
|
|
||||||
|
|
||||||
|
class OptimizationRunService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
publisher: TransactionalEventPublisher | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.session = session
|
||||||
|
self.repo = OptimizationRunRepository(session)
|
||||||
|
self.plans = RoutePlanRepository(session)
|
||||||
|
self.publisher = publisher or TransactionalEventPublisher(session)
|
||||||
|
|
||||||
|
async def start(
|
||||||
|
self, tenant_id: UUID, body: OptimizationRunCreate, *, actor: CurrentUser | None = None
|
||||||
|
) -> OptimizationRun:
|
||||||
|
if body.route_plan_id and await self.plans.get(tenant_id, body.route_plan_id) is None:
|
||||||
|
raise NotFoundError("برنامه مسیر یافت نشد")
|
||||||
|
entity = OptimizationRun(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
route_plan_id=body.route_plan_id,
|
||||||
|
status=OptimizationRunStatus.RUNNING,
|
||||||
|
routing_engine_ref=body.routing_engine_ref,
|
||||||
|
request_payload=body.request_payload,
|
||||||
|
started_at=datetime.now(timezone.utc),
|
||||||
|
created_by=actor_id(actor),
|
||||||
|
updated_by=actor_id(actor),
|
||||||
|
)
|
||||||
|
await self.repo.add(entity)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.OPTIMIZATION_RUN_STARTED,
|
||||||
|
aggregate_type="optimization_run",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"route_plan_id": str(body.route_plan_id) if body.route_plan_id else None},
|
||||||
|
)
|
||||||
|
entity.status = OptimizationRunStatus.COMPLETED
|
||||||
|
entity.completed_at = datetime.now(timezone.utc)
|
||||||
|
entity.result_payload = {"status": "stub", "message": "optimization stub"}
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.OPTIMIZATION_RUN_COMPLETED,
|
||||||
|
aggregate_type="optimization_run",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"route_plan_id": str(body.route_plan_id) if body.route_plan_id else None},
|
||||||
|
)
|
||||||
|
if body.route_plan_id:
|
||||||
|
plan = await self.plans.get(tenant_id, body.route_plan_id)
|
||||||
|
if plan is not None:
|
||||||
|
plan.status = RoutePlanStatus.PLANNED
|
||||||
|
plan.planned_at = datetime.now(timezone.utc)
|
||||||
|
plan.version += 1
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def get(self, tenant_id: UUID, run_id: UUID) -> OptimizationRun:
|
||||||
|
entity = await self.repo.get(tenant_id, run_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("اجرای بهینهسازی یافت نشد")
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def list(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
spec: OptimizationRunListSpec | None = None,
|
||||||
|
) -> tuple[list[OptimizationRun], int]:
|
||||||
|
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
||||||
|
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
||||||
328
backend/services/delivery/app/services/settlement.py
Normal file
328
backend/services/delivery/app/services/settlement.py
Normal file
@ -0,0 +1,328 @@
|
|||||||
|
"""Settlement application services — Phase 10.8.
|
||||||
|
|
||||||
|
Uses AccountingProvider protocol refs only — NO journal entries in delivery_db.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.events.publisher import TransactionalEventPublisher
|
||||||
|
from app.events.types import DeliveryEventType
|
||||||
|
from app.models.settlement import SettlementIntent, SettlementLine
|
||||||
|
from app.models.types import AuditAction, SettlementIntentStatus
|
||||||
|
from app.policies.settlement import SettlementIntentPolicy
|
||||||
|
from app.providers.contracts import AccountingProvider
|
||||||
|
from app.repositories.foundation import DeliveryOrganizationRepository
|
||||||
|
from app.repositories.settlement import SettlementIntentRepository, SettlementLineRepository
|
||||||
|
from app.schemas.settlement import (
|
||||||
|
SettlementActionRequest,
|
||||||
|
SettlementIntentCreate,
|
||||||
|
SettlementIntentUpdate,
|
||||||
|
SettlementLineCreate,
|
||||||
|
)
|
||||||
|
from app.services._helpers import actor_id, jsonable
|
||||||
|
from app.services.audit_service import AuditService
|
||||||
|
from app.specifications.settlement import SettlementIntentListSpec
|
||||||
|
from app.validators import ensure_optimistic_version, validate_code
|
||||||
|
from app.validators.pricing import validate_currency, validate_line_amount
|
||||||
|
from app.validators.settlement import validate_settlement_amount
|
||||||
|
from shared.exceptions import AppError, NotFoundError
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
|
||||||
|
class StubAccountingProvider:
|
||||||
|
"""Test/dev stub — creates external settlement refs only."""
|
||||||
|
|
||||||
|
async def create_settlement_ref(
|
||||||
|
self, *, tenant_id: UUID, reference: str, payload: dict[str, Any]
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"settlement_ref": f"acct://settlement/{tenant_id}/{reference}",
|
||||||
|
"status": "accepted",
|
||||||
|
"payload": payload,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SettlementService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
publisher: TransactionalEventPublisher | None = None,
|
||||||
|
accounting: AccountingProvider | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.session = session
|
||||||
|
self.repo = SettlementIntentRepository(session)
|
||||||
|
self.lines = SettlementLineRepository(session)
|
||||||
|
self.orgs = DeliveryOrganizationRepository(session)
|
||||||
|
self.audit = AuditService(session)
|
||||||
|
self.publisher = publisher or TransactionalEventPublisher(session)
|
||||||
|
self.accounting = accounting or StubAccountingProvider()
|
||||||
|
self.policy = SettlementIntentPolicy()
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
body: SettlementIntentCreate,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None = None,
|
||||||
|
) -> SettlementIntent:
|
||||||
|
if await self.orgs.get(tenant_id, body.organization_id) is None:
|
||||||
|
raise NotFoundError("سازمان یافت نشد")
|
||||||
|
code = validate_code(body.code)
|
||||||
|
if await self.repo.get_by_code(tenant_id, code):
|
||||||
|
raise AppError("کد تسویه تکراری است", error_code="duplicate_code", status_code=409)
|
||||||
|
entity = SettlementIntent(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
organization_id=body.organization_id,
|
||||||
|
code=code,
|
||||||
|
driver_id=body.driver_id,
|
||||||
|
dispatch_job_id=body.dispatch_job_id,
|
||||||
|
total_amount=validate_settlement_amount(body.total_amount),
|
||||||
|
currency_code=validate_currency(body.currency_code),
|
||||||
|
notes=body.notes,
|
||||||
|
metadata_json=body.metadata_json,
|
||||||
|
created_by=actor_id(actor),
|
||||||
|
updated_by=actor_id(actor),
|
||||||
|
)
|
||||||
|
await self.repo.add(entity)
|
||||||
|
await self.audit.record(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
entity_type="settlement_intent",
|
||||||
|
entity_id=entity.id,
|
||||||
|
action=AuditAction.CREATE,
|
||||||
|
actor_user_id=actor_id(actor),
|
||||||
|
changes=jsonable(body.model_dump()),
|
||||||
|
)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.SETTLEMENT_INTENT_CREATED,
|
||||||
|
aggregate_type="settlement_intent",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"code": entity.code},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def get(self, tenant_id: UUID, intent_id: UUID) -> SettlementIntent:
|
||||||
|
entity = await self.repo.get(tenant_id, intent_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("قصد تسویه یافت نشد")
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def list(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
spec: SettlementIntentListSpec | None = None,
|
||||||
|
) -> tuple[list[SettlementIntent], int]:
|
||||||
|
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
||||||
|
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
||||||
|
|
||||||
|
async def update(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
intent_id: UUID,
|
||||||
|
body: SettlementIntentUpdate,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None = None,
|
||||||
|
) -> SettlementIntent:
|
||||||
|
entity = await self.get(tenant_id, intent_id)
|
||||||
|
if entity.status != SettlementIntentStatus.DRAFT:
|
||||||
|
raise AppError(
|
||||||
|
"فقط تسویه پیشنویس قابل ویرایش است",
|
||||||
|
status_code=409,
|
||||||
|
error_code="settlement_not_editable",
|
||||||
|
)
|
||||||
|
ensure_optimistic_version(entity, body.version)
|
||||||
|
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
||||||
|
if "total_amount" in data and data["total_amount"] is not None:
|
||||||
|
data["total_amount"] = validate_settlement_amount(data["total_amount"])
|
||||||
|
if "currency_code" in data and data["currency_code"] is not None:
|
||||||
|
data["currency_code"] = validate_currency(data["currency_code"])
|
||||||
|
for key, value in data.items():
|
||||||
|
setattr(entity, key, value)
|
||||||
|
entity.version += 1
|
||||||
|
entity.updated_by = actor_id(actor)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.SETTLEMENT_INTENT_UPDATED,
|
||||||
|
aggregate_type="settlement_intent",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"code": entity.code},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def _apply_action(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
intent_id: UUID,
|
||||||
|
action: str,
|
||||||
|
body: SettlementActionRequest,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None = None,
|
||||||
|
) -> SettlementIntent:
|
||||||
|
entity = await self.get(tenant_id, intent_id)
|
||||||
|
ensure_optimistic_version(entity, body.version)
|
||||||
|
from_status = entity.status
|
||||||
|
to_status, reason = self.policy.assert_transition(
|
||||||
|
action=action, current=from_status, reason=body.reason
|
||||||
|
)
|
||||||
|
entity.status = to_status
|
||||||
|
entity.version += 1
|
||||||
|
entity.updated_by = actor_id(actor)
|
||||||
|
if action == "submit":
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.SETTLEMENT_INTENT_SUBMITTED,
|
||||||
|
aggregate_type="settlement_intent",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"code": entity.code},
|
||||||
|
)
|
||||||
|
elif action == "settle":
|
||||||
|
result = await self.accounting.create_settlement_ref(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
reference=entity.code,
|
||||||
|
payload={
|
||||||
|
"amount": str(entity.total_amount),
|
||||||
|
"currency": entity.currency_code,
|
||||||
|
"driver_id": str(entity.driver_id) if entity.driver_id else None,
|
||||||
|
"dispatch_job_id": str(entity.dispatch_job_id)
|
||||||
|
if entity.dispatch_job_id
|
||||||
|
else None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
entity.external_settlement_ref = result.get("settlement_ref")
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.SETTLEMENT_INTENT_SETTLED,
|
||||||
|
aggregate_type="settlement_intent",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={
|
||||||
|
"code": entity.code,
|
||||||
|
"external_settlement_ref": entity.external_settlement_ref,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
elif action == "fail":
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.SETTLEMENT_INTENT_FAILED,
|
||||||
|
aggregate_type="settlement_intent",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"code": entity.code, "reason": reason},
|
||||||
|
)
|
||||||
|
await self.audit.record(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
entity_type="settlement_intent",
|
||||||
|
entity_id=entity.id,
|
||||||
|
action=AuditAction.STATUS_CHANGE,
|
||||||
|
actor_user_id=actor_id(actor),
|
||||||
|
changes={
|
||||||
|
"action": action,
|
||||||
|
"from_status": from_status.value,
|
||||||
|
"to_status": to_status.value,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def submit(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
intent_id: UUID,
|
||||||
|
body: SettlementActionRequest,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None = None,
|
||||||
|
) -> SettlementIntent:
|
||||||
|
return await self._apply_action(
|
||||||
|
tenant_id, intent_id, "submit", body, actor=actor
|
||||||
|
)
|
||||||
|
|
||||||
|
async def settle(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
intent_id: UUID,
|
||||||
|
body: SettlementActionRequest,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None = None,
|
||||||
|
) -> SettlementIntent:
|
||||||
|
return await self._apply_action(
|
||||||
|
tenant_id, intent_id, "settle", body, actor=actor
|
||||||
|
)
|
||||||
|
|
||||||
|
async def fail(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
intent_id: UUID,
|
||||||
|
body: SettlementActionRequest,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None = None,
|
||||||
|
) -> SettlementIntent:
|
||||||
|
return await self._apply_action(
|
||||||
|
tenant_id, intent_id, "fail", body, actor=actor
|
||||||
|
)
|
||||||
|
|
||||||
|
async def cancel(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
intent_id: UUID,
|
||||||
|
body: SettlementActionRequest,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None = None,
|
||||||
|
) -> SettlementIntent:
|
||||||
|
return await self._apply_action(
|
||||||
|
tenant_id, intent_id, "cancel", body, actor=actor
|
||||||
|
)
|
||||||
|
|
||||||
|
async def add_line(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
intent_id: UUID,
|
||||||
|
body: SettlementLineCreate,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None = None,
|
||||||
|
) -> SettlementLine:
|
||||||
|
entity = await self.get(tenant_id, intent_id)
|
||||||
|
if entity.status != SettlementIntentStatus.DRAFT:
|
||||||
|
raise AppError(
|
||||||
|
"فقط به تسویه پیشنویس میتوان سطر اضافه کرد",
|
||||||
|
status_code=409,
|
||||||
|
error_code="settlement_not_editable",
|
||||||
|
)
|
||||||
|
line = SettlementLine(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
settlement_intent_id=intent_id,
|
||||||
|
kind=body.kind,
|
||||||
|
description=body.description,
|
||||||
|
amount=validate_line_amount(body.amount),
|
||||||
|
currency_code=validate_currency(body.currency_code),
|
||||||
|
metadata_json=body.metadata_json,
|
||||||
|
created_by=actor_id(actor),
|
||||||
|
updated_by=actor_id(actor),
|
||||||
|
)
|
||||||
|
await self.lines.add(line)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.SETTLEMENT_LINE_ADDED,
|
||||||
|
aggregate_type="settlement_line",
|
||||||
|
aggregate_id=line.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"settlement_intent_id": str(intent_id), "kind": body.kind.value},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(line)
|
||||||
|
return line
|
||||||
|
|
||||||
|
async def list_lines(
|
||||||
|
self, tenant_id: UUID, intent_id: UUID
|
||||||
|
) -> list[SettlementLine]:
|
||||||
|
await self.get(tenant_id, intent_id)
|
||||||
|
return list(await self.lines.list_for_intent(tenant_id, intent_id))
|
||||||
344
backend/services/delivery/app/services/tracking.py
Normal file
344
backend/services/delivery/app/services/tracking.py
Normal file
@ -0,0 +1,344 @@
|
|||||||
|
"""Tracking application services — Phase 10.7."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.events.publisher import TransactionalEventPublisher
|
||||||
|
from app.events.types import DeliveryEventType
|
||||||
|
from app.models.tracking import (
|
||||||
|
CustomerTrackingToken,
|
||||||
|
ProofOfDelivery,
|
||||||
|
TrackingPoint,
|
||||||
|
TrackingSession,
|
||||||
|
)
|
||||||
|
from app.models.types import CustomerTrackingTokenStatus, ProofOfDeliveryStatus, TrackingSessionStatus
|
||||||
|
from app.repositories.dispatch import DispatchJobRepository
|
||||||
|
from app.repositories.tracking import (
|
||||||
|
CustomerTrackingTokenRepository,
|
||||||
|
ProofOfDeliveryRepository,
|
||||||
|
TrackingPointRepository,
|
||||||
|
TrackingSessionRepository,
|
||||||
|
)
|
||||||
|
from app.schemas.tracking import (
|
||||||
|
CustomerTrackingTokenCreate,
|
||||||
|
ProofOfDeliveryCreate,
|
||||||
|
ProofOfDeliveryUpdate,
|
||||||
|
TrackingPointCreate,
|
||||||
|
TrackingSessionCreate,
|
||||||
|
TrackingSessionUpdate,
|
||||||
|
)
|
||||||
|
from app.services._helpers import actor_id
|
||||||
|
from app.specifications.tracking import (
|
||||||
|
CustomerTrackingTokenListSpec,
|
||||||
|
ProofOfDeliveryListSpec,
|
||||||
|
TrackingSessionListSpec,
|
||||||
|
)
|
||||||
|
from app.validators import ensure_optimistic_version
|
||||||
|
from app.validators.tracking import validate_coordinates, validate_pod_refs
|
||||||
|
from shared.exceptions import AppError, NotFoundError
|
||||||
|
from shared.security import CurrentUser
|
||||||
|
|
||||||
|
|
||||||
|
class TrackingSessionService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
publisher: TransactionalEventPublisher | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.session = session
|
||||||
|
self.repo = TrackingSessionRepository(session)
|
||||||
|
self.points = TrackingPointRepository(session)
|
||||||
|
self.jobs = DispatchJobRepository(session)
|
||||||
|
self.publisher = publisher or TransactionalEventPublisher(session)
|
||||||
|
|
||||||
|
async def start(
|
||||||
|
self, tenant_id: UUID, body: TrackingSessionCreate, *, actor: CurrentUser | None = None
|
||||||
|
) -> TrackingSession:
|
||||||
|
if body.dispatch_job_id and await self.jobs.get(tenant_id, body.dispatch_job_id) is None:
|
||||||
|
raise NotFoundError("کار ارسال یافت نشد")
|
||||||
|
entity = TrackingSession(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
dispatch_job_id=body.dispatch_job_id,
|
||||||
|
driver_id=body.driver_id,
|
||||||
|
status=TrackingSessionStatus.ACTIVE,
|
||||||
|
started_at=datetime.now(timezone.utc),
|
||||||
|
metadata_json=body.metadata_json,
|
||||||
|
created_by=actor_id(actor),
|
||||||
|
updated_by=actor_id(actor),
|
||||||
|
)
|
||||||
|
await self.repo.add(entity)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.TRACKING_SESSION_STARTED,
|
||||||
|
aggregate_type="tracking_session",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"dispatch_job_id": str(body.dispatch_job_id) if body.dispatch_job_id else None},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def get(self, tenant_id: UUID, session_id: UUID) -> TrackingSession:
|
||||||
|
entity = await self.repo.get(tenant_id, session_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("جلسه ردیابی یافت نشد")
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def list(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
spec: TrackingSessionListSpec | None = None,
|
||||||
|
) -> tuple[list[TrackingSession], int]:
|
||||||
|
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
||||||
|
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
||||||
|
|
||||||
|
async def update(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
session_id: UUID,
|
||||||
|
body: TrackingSessionUpdate,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None = None,
|
||||||
|
) -> TrackingSession:
|
||||||
|
entity = await self.get(tenant_id, session_id)
|
||||||
|
ensure_optimistic_version(entity, body.version)
|
||||||
|
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
||||||
|
for key, value in data.items():
|
||||||
|
setattr(entity, key, value)
|
||||||
|
if entity.status in {TrackingSessionStatus.COMPLETED, TrackingSessionStatus.CANCELLED}:
|
||||||
|
entity.ended_at = datetime.now(timezone.utc)
|
||||||
|
entity.version += 1
|
||||||
|
entity.updated_by = actor_id(actor)
|
||||||
|
event = DeliveryEventType.TRACKING_SESSION_UPDATED
|
||||||
|
if entity.status == TrackingSessionStatus.COMPLETED:
|
||||||
|
event = DeliveryEventType.TRACKING_SESSION_ENDED
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=event,
|
||||||
|
aggregate_type="tracking_session",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"status": entity.status.value},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def record_point(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
session_id: UUID,
|
||||||
|
body: TrackingPointCreate,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None = None,
|
||||||
|
) -> TrackingPoint:
|
||||||
|
await self.get(tenant_id, session_id)
|
||||||
|
validate_coordinates(latitude=body.latitude, longitude=body.longitude)
|
||||||
|
entity = TrackingPoint(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
tracking_session_id=session_id,
|
||||||
|
latitude=body.latitude,
|
||||||
|
longitude=body.longitude,
|
||||||
|
recorded_at=body.recorded_at,
|
||||||
|
metadata_json=body.metadata_json,
|
||||||
|
)
|
||||||
|
await self.points.add(entity)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.TRACKING_POINT_RECORDED,
|
||||||
|
aggregate_type="tracking_point",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"tracking_session_id": str(session_id)},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def list_points(
|
||||||
|
self, tenant_id: UUID, session_id: UUID, *, limit: int = 500
|
||||||
|
) -> list[TrackingPoint]:
|
||||||
|
await self.get(tenant_id, session_id)
|
||||||
|
return list(await self.points.list_for_session(tenant_id, session_id, limit=limit))
|
||||||
|
|
||||||
|
|
||||||
|
class ProofOfDeliveryService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
publisher: TransactionalEventPublisher | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.session = session
|
||||||
|
self.repo = ProofOfDeliveryRepository(session)
|
||||||
|
self.jobs = DispatchJobRepository(session)
|
||||||
|
self.publisher = publisher or TransactionalEventPublisher(session)
|
||||||
|
|
||||||
|
async def capture(
|
||||||
|
self, tenant_id: UUID, body: ProofOfDeliveryCreate, *, actor: CurrentUser | None = None
|
||||||
|
) -> ProofOfDelivery:
|
||||||
|
if await self.jobs.get(tenant_id, body.dispatch_job_id) is None:
|
||||||
|
raise NotFoundError("کار ارسال یافت نشد")
|
||||||
|
validate_pod_refs(
|
||||||
|
signature_file_ref=body.signature_file_ref,
|
||||||
|
photo_file_ref=body.photo_file_ref,
|
||||||
|
)
|
||||||
|
entity = ProofOfDelivery(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
dispatch_job_id=body.dispatch_job_id,
|
||||||
|
status=ProofOfDeliveryStatus.CAPTURED,
|
||||||
|
signature_file_ref=body.signature_file_ref,
|
||||||
|
photo_file_ref=body.photo_file_ref,
|
||||||
|
delivery_code=body.delivery_code,
|
||||||
|
notes=body.notes,
|
||||||
|
captured_at=datetime.now(timezone.utc),
|
||||||
|
metadata_json=body.metadata_json,
|
||||||
|
created_by=actor_id(actor),
|
||||||
|
updated_by=actor_id(actor),
|
||||||
|
)
|
||||||
|
await self.repo.add(entity)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.PROOF_OF_DELIVERY_CAPTURED,
|
||||||
|
aggregate_type="proof_of_delivery",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"dispatch_job_id": str(body.dispatch_job_id)},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def get(self, tenant_id: UUID, pod_id: UUID) -> ProofOfDelivery:
|
||||||
|
entity = await self.repo.get(tenant_id, pod_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("اثبات تحویل یافت نشد")
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def list(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
spec: ProofOfDeliveryListSpec | None = None,
|
||||||
|
) -> tuple[list[ProofOfDelivery], int]:
|
||||||
|
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
||||||
|
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
||||||
|
|
||||||
|
async def update(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
pod_id: UUID,
|
||||||
|
body: ProofOfDeliveryUpdate,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None = None,
|
||||||
|
) -> ProofOfDelivery:
|
||||||
|
entity = await self.get(tenant_id, pod_id)
|
||||||
|
ensure_optimistic_version(entity, body.version)
|
||||||
|
data = body.model_dump(exclude_unset=True, exclude={"version"})
|
||||||
|
for key, value in data.items():
|
||||||
|
setattr(entity, key, value)
|
||||||
|
entity.version += 1
|
||||||
|
entity.updated_by = actor_id(actor)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.PROOF_OF_DELIVERY_UPDATED,
|
||||||
|
aggregate_type="proof_of_delivery",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"dispatch_job_id": str(entity.dispatch_job_id)},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
|
||||||
|
class CustomerTrackingTokenService:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
session: AsyncSession,
|
||||||
|
*,
|
||||||
|
publisher: TransactionalEventPublisher | None = None,
|
||||||
|
) -> None:
|
||||||
|
self.session = session
|
||||||
|
self.repo = CustomerTrackingTokenRepository(session)
|
||||||
|
self.jobs = DispatchJobRepository(session)
|
||||||
|
self.publisher = publisher or TransactionalEventPublisher(session)
|
||||||
|
|
||||||
|
async def create(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
body: CustomerTrackingTokenCreate,
|
||||||
|
*,
|
||||||
|
actor: CurrentUser | None = None,
|
||||||
|
) -> CustomerTrackingToken:
|
||||||
|
if await self.jobs.get(tenant_id, body.dispatch_job_id) is None:
|
||||||
|
raise NotFoundError("کار ارسال یافت نشد")
|
||||||
|
token = secrets.token_urlsafe(32)
|
||||||
|
entity = CustomerTrackingToken(
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
dispatch_job_id=body.dispatch_job_id,
|
||||||
|
token=token,
|
||||||
|
status=CustomerTrackingTokenStatus.ACTIVE,
|
||||||
|
expires_at=body.expires_at,
|
||||||
|
metadata_json=body.metadata_json,
|
||||||
|
created_by=actor_id(actor),
|
||||||
|
updated_by=actor_id(actor),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self.repo.add(entity)
|
||||||
|
except Exception as exc:
|
||||||
|
raise AppError(
|
||||||
|
"ایجاد توکن ردیابی ناموفق بود",
|
||||||
|
status_code=409,
|
||||||
|
error_code="token_create_failed",
|
||||||
|
) from exc
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.CUSTOMER_TRACKING_TOKEN_CREATED,
|
||||||
|
aggregate_type="customer_tracking_token",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"dispatch_job_id": str(body.dispatch_job_id)},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def get(self, tenant_id: UUID, token_id: UUID) -> CustomerTrackingToken:
|
||||||
|
entity = await self.repo.get(tenant_id, token_id)
|
||||||
|
if entity is None:
|
||||||
|
raise NotFoundError("توکن ردیابی یافت نشد")
|
||||||
|
return entity
|
||||||
|
|
||||||
|
async def list(
|
||||||
|
self,
|
||||||
|
tenant_id: UUID,
|
||||||
|
*,
|
||||||
|
offset: int = 0,
|
||||||
|
limit: int = 20,
|
||||||
|
spec: CustomerTrackingTokenListSpec | None = None,
|
||||||
|
) -> tuple[list[CustomerTrackingToken], int]:
|
||||||
|
items = list(await self.repo.list_filtered(tenant_id, offset=offset, limit=limit, spec=spec))
|
||||||
|
return items, await self.repo.count_filtered(tenant_id, spec=spec)
|
||||||
|
|
||||||
|
async def revoke(
|
||||||
|
self, tenant_id: UUID, token_id: UUID, *, actor: CurrentUser | None = None
|
||||||
|
) -> CustomerTrackingToken:
|
||||||
|
entity = await self.get(tenant_id, token_id)
|
||||||
|
entity.status = CustomerTrackingTokenStatus.REVOKED
|
||||||
|
entity.updated_by = actor_id(actor)
|
||||||
|
await self.publisher.publish(
|
||||||
|
event_type=DeliveryEventType.CUSTOMER_TRACKING_TOKEN_REVOKED,
|
||||||
|
aggregate_type="customer_tracking_token",
|
||||||
|
aggregate_id=entity.id,
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
payload={"dispatch_job_id": str(entity.dispatch_job_id)},
|
||||||
|
)
|
||||||
|
await self.session.commit()
|
||||||
|
await self.session.refresh(entity)
|
||||||
|
return entity
|
||||||
77
backend/services/delivery/app/specifications/availability.py
Normal file
77
backend/services/delivery/app/specifications/availability.py
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
"""Availability query specifications — Phase 10.3."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import Select, asc, desc, or_
|
||||||
|
from sqlalchemy.sql import ColumnElement
|
||||||
|
|
||||||
|
from app.models.availability import Shift, WorkingZone
|
||||||
|
from app.models.types import ShiftStatus, WorkingZoneStatus
|
||||||
|
|
||||||
|
SHIFT_SORT = frozenset({"created_at", "starts_at", "ends_at", "code", "status"})
|
||||||
|
ZONE_SORT = frozenset({"created_at", "updated_at", "code", "name", "status"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ShiftListSpec:
|
||||||
|
status: ShiftStatus | None = None
|
||||||
|
organization_id: UUID | None = None
|
||||||
|
hub_id: UUID | None = None
|
||||||
|
q: str | None = None
|
||||||
|
sort_by: str = "starts_at"
|
||||||
|
sort_dir: str = "desc"
|
||||||
|
|
||||||
|
def filter_clauses(self) -> list[ColumnElement]:
|
||||||
|
clauses: list[ColumnElement] = []
|
||||||
|
if self.status is not None:
|
||||||
|
clauses.append(Shift.status == self.status)
|
||||||
|
if self.organization_id is not None:
|
||||||
|
clauses.append(Shift.organization_id == self.organization_id)
|
||||||
|
if self.hub_id is not None:
|
||||||
|
clauses.append(Shift.hub_id == self.hub_id)
|
||||||
|
if self.q:
|
||||||
|
term = f"%{self.q.strip()}%"
|
||||||
|
clauses.append(or_(Shift.code.ilike(term), Shift.name.ilike(term)))
|
||||||
|
return clauses
|
||||||
|
|
||||||
|
def apply(self, stmt: Select) -> Select:
|
||||||
|
clauses = self.filter_clauses()
|
||||||
|
if clauses:
|
||||||
|
stmt = stmt.where(*clauses)
|
||||||
|
sort_key = self.sort_by if self.sort_by in SHIFT_SORT else "starts_at"
|
||||||
|
column = getattr(Shift, sort_key)
|
||||||
|
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
||||||
|
return stmt.order_by(order)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class WorkingZoneListSpec:
|
||||||
|
status: WorkingZoneStatus | None = None
|
||||||
|
organization_id: UUID | None = None
|
||||||
|
q: str | None = None
|
||||||
|
sort_by: str = "created_at"
|
||||||
|
sort_dir: str = "desc"
|
||||||
|
|
||||||
|
def filter_clauses(self) -> list[ColumnElement]:
|
||||||
|
clauses: list[ColumnElement] = []
|
||||||
|
if self.status is not None:
|
||||||
|
clauses.append(WorkingZone.status == self.status)
|
||||||
|
if self.organization_id is not None:
|
||||||
|
clauses.append(WorkingZone.organization_id == self.organization_id)
|
||||||
|
if self.q:
|
||||||
|
term = f"%{self.q.strip()}%"
|
||||||
|
clauses.append(
|
||||||
|
or_(WorkingZone.code.ilike(term), WorkingZone.name.ilike(term))
|
||||||
|
)
|
||||||
|
return clauses
|
||||||
|
|
||||||
|
def apply(self, stmt: Select) -> Select:
|
||||||
|
clauses = self.filter_clauses()
|
||||||
|
if clauses:
|
||||||
|
stmt = stmt.where(*clauses)
|
||||||
|
sort_key = self.sort_by if self.sort_by in ZONE_SORT else "created_at"
|
||||||
|
column = getattr(WorkingZone, sort_key)
|
||||||
|
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
||||||
|
return stmt.order_by(order)
|
||||||
79
backend/services/delivery/app/specifications/dispatch.py
Normal file
79
backend/services/delivery/app/specifications/dispatch.py
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
"""Dispatch query specifications — Phase 10.5."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import Select, asc, desc, or_
|
||||||
|
from sqlalchemy.sql import ColumnElement
|
||||||
|
|
||||||
|
from app.models.dispatch import DispatchJob, JobAssignment
|
||||||
|
from app.models.types import DispatchJobStatus, JobAssignmentStatus
|
||||||
|
|
||||||
|
JOB_SORT = frozenset({"created_at", "updated_at", "code", "status", "priority"})
|
||||||
|
ASSIGN_SORT = frozenset({"created_at", "updated_at", "status"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class DispatchJobListSpec:
|
||||||
|
status: DispatchJobStatus | None = None
|
||||||
|
organization_id: UUID | None = None
|
||||||
|
hub_id: UUID | None = None
|
||||||
|
q: str | None = None
|
||||||
|
sort_by: str = "created_at"
|
||||||
|
sort_dir: str = "desc"
|
||||||
|
|
||||||
|
def filter_clauses(self) -> list[ColumnElement]:
|
||||||
|
clauses: list[ColumnElement] = []
|
||||||
|
if self.status is not None:
|
||||||
|
clauses.append(DispatchJob.status == self.status)
|
||||||
|
if self.organization_id is not None:
|
||||||
|
clauses.append(DispatchJob.organization_id == self.organization_id)
|
||||||
|
if self.hub_id is not None:
|
||||||
|
clauses.append(DispatchJob.hub_id == self.hub_id)
|
||||||
|
if self.q:
|
||||||
|
term = f"%{self.q.strip()}%"
|
||||||
|
clauses.append(
|
||||||
|
or_(
|
||||||
|
DispatchJob.code.ilike(term),
|
||||||
|
DispatchJob.external_order_ref.ilike(term),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return clauses
|
||||||
|
|
||||||
|
def apply(self, stmt: Select) -> Select:
|
||||||
|
clauses = self.filter_clauses()
|
||||||
|
if clauses:
|
||||||
|
stmt = stmt.where(*clauses)
|
||||||
|
sort_key = self.sort_by if self.sort_by in JOB_SORT else "created_at"
|
||||||
|
column = getattr(DispatchJob, sort_key)
|
||||||
|
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
||||||
|
return stmt.order_by(order)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class JobAssignmentListSpec:
|
||||||
|
dispatch_job_id: UUID | None = None
|
||||||
|
driver_id: UUID | None = None
|
||||||
|
status: JobAssignmentStatus | None = None
|
||||||
|
sort_by: str = "created_at"
|
||||||
|
sort_dir: str = "desc"
|
||||||
|
|
||||||
|
def filter_clauses(self) -> list[ColumnElement]:
|
||||||
|
clauses: list[ColumnElement] = []
|
||||||
|
if self.dispatch_job_id is not None:
|
||||||
|
clauses.append(JobAssignment.dispatch_job_id == self.dispatch_job_id)
|
||||||
|
if self.driver_id is not None:
|
||||||
|
clauses.append(JobAssignment.driver_id == self.driver_id)
|
||||||
|
if self.status is not None:
|
||||||
|
clauses.append(JobAssignment.status == self.status)
|
||||||
|
return clauses
|
||||||
|
|
||||||
|
def apply(self, stmt: Select) -> Select:
|
||||||
|
clauses = self.filter_clauses()
|
||||||
|
if clauses:
|
||||||
|
stmt = stmt.where(*clauses)
|
||||||
|
sort_key = self.sort_by if self.sort_by in ASSIGN_SORT else "created_at"
|
||||||
|
column = getattr(JobAssignment, sort_key)
|
||||||
|
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
||||||
|
return stmt.order_by(order)
|
||||||
103
backend/services/delivery/app/specifications/fleet.py
Normal file
103
backend/services/delivery/app/specifications/fleet.py
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
"""Fleet query specifications — Phase 10.2."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import Select, asc, desc, or_
|
||||||
|
from sqlalchemy.sql import ColumnElement
|
||||||
|
|
||||||
|
from app.models.fleet import Fleet, Vehicle, VehicleType
|
||||||
|
from app.models.types import FleetStatus, VehicleStatus
|
||||||
|
|
||||||
|
FLEET_SORT = frozenset({"created_at", "updated_at", "code", "name", "status"})
|
||||||
|
VT_SORT = frozenset({"created_at", "updated_at", "code", "name"})
|
||||||
|
VEHICLE_SORT = frozenset({"created_at", "updated_at", "code", "status"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class FleetListSpec:
|
||||||
|
status: FleetStatus | None = None
|
||||||
|
organization_id: UUID | None = None
|
||||||
|
hub_id: UUID | None = None
|
||||||
|
q: str | None = None
|
||||||
|
sort_by: str = "created_at"
|
||||||
|
sort_dir: str = "desc"
|
||||||
|
|
||||||
|
def filter_clauses(self) -> list[ColumnElement]:
|
||||||
|
clauses: list[ColumnElement] = []
|
||||||
|
if self.status is not None:
|
||||||
|
clauses.append(Fleet.status == self.status)
|
||||||
|
if self.organization_id is not None:
|
||||||
|
clauses.append(Fleet.organization_id == self.organization_id)
|
||||||
|
if self.hub_id is not None:
|
||||||
|
clauses.append(Fleet.hub_id == self.hub_id)
|
||||||
|
if self.q:
|
||||||
|
term = f"%{self.q.strip()}%"
|
||||||
|
clauses.append(or_(Fleet.code.ilike(term), Fleet.name.ilike(term)))
|
||||||
|
return clauses
|
||||||
|
|
||||||
|
def apply(self, stmt: Select) -> Select:
|
||||||
|
clauses = self.filter_clauses()
|
||||||
|
if clauses:
|
||||||
|
stmt = stmt.where(*clauses)
|
||||||
|
sort_key = self.sort_by if self.sort_by in FLEET_SORT else "created_at"
|
||||||
|
column = getattr(Fleet, sort_key)
|
||||||
|
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
||||||
|
return stmt.order_by(order)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class VehicleTypeListSpec:
|
||||||
|
q: str | None = None
|
||||||
|
sort_by: str = "created_at"
|
||||||
|
sort_dir: str = "desc"
|
||||||
|
|
||||||
|
def filter_clauses(self) -> list[ColumnElement]:
|
||||||
|
clauses: list[ColumnElement] = []
|
||||||
|
if self.q:
|
||||||
|
term = f"%{self.q.strip()}%"
|
||||||
|
clauses.append(
|
||||||
|
or_(VehicleType.code.ilike(term), VehicleType.name.ilike(term))
|
||||||
|
)
|
||||||
|
return clauses
|
||||||
|
|
||||||
|
def apply(self, stmt: Select) -> Select:
|
||||||
|
clauses = self.filter_clauses()
|
||||||
|
if clauses:
|
||||||
|
stmt = stmt.where(*clauses)
|
||||||
|
sort_key = self.sort_by if self.sort_by in VT_SORT else "created_at"
|
||||||
|
column = getattr(VehicleType, sort_key)
|
||||||
|
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
||||||
|
return stmt.order_by(order)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class VehicleListSpec:
|
||||||
|
fleet_id: UUID | None = None
|
||||||
|
status: VehicleStatus | None = None
|
||||||
|
q: str | None = None
|
||||||
|
sort_by: str = "created_at"
|
||||||
|
sort_dir: str = "desc"
|
||||||
|
|
||||||
|
def filter_clauses(self) -> list[ColumnElement]:
|
||||||
|
clauses: list[ColumnElement] = []
|
||||||
|
if self.fleet_id is not None:
|
||||||
|
clauses.append(Vehicle.fleet_id == self.fleet_id)
|
||||||
|
if self.status is not None:
|
||||||
|
clauses.append(Vehicle.status == self.status)
|
||||||
|
if self.q:
|
||||||
|
term = f"%{self.q.strip()}%"
|
||||||
|
clauses.append(
|
||||||
|
or_(Vehicle.code.ilike(term), Vehicle.plate_number.ilike(term))
|
||||||
|
)
|
||||||
|
return clauses
|
||||||
|
|
||||||
|
def apply(self, stmt: Select) -> Select:
|
||||||
|
clauses = self.filter_clauses()
|
||||||
|
if clauses:
|
||||||
|
stmt = stmt.where(*clauses)
|
||||||
|
sort_key = self.sort_by if self.sort_by in VEHICLE_SORT else "created_at"
|
||||||
|
column = getattr(Vehicle, sort_key)
|
||||||
|
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
||||||
|
return stmt.order_by(order)
|
||||||
108
backend/services/delivery/app/specifications/pricing.py
Normal file
108
backend/services/delivery/app/specifications/pricing.py
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
"""Pricing query specifications — Phase 10.4."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import Select, asc, desc, or_
|
||||||
|
from sqlalchemy.sql import ColumnElement
|
||||||
|
|
||||||
|
from app.models.pricing import CapabilityBundle, CapabilityDefinition, PricingRule
|
||||||
|
from app.models.types import CapabilityBundleStatus, CapabilityStatus, PricingRuleStatus
|
||||||
|
|
||||||
|
RULE_SORT = frozenset({"created_at", "updated_at", "code", "name", "status"})
|
||||||
|
CAP_SORT = frozenset({"created_at", "updated_at", "code", "name", "status"})
|
||||||
|
BUNDLE_SORT = frozenset({"created_at", "updated_at", "code", "name", "status"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PricingRuleListSpec:
|
||||||
|
status: PricingRuleStatus | None = None
|
||||||
|
organization_id: UUID | None = None
|
||||||
|
q: str | None = None
|
||||||
|
sort_by: str = "created_at"
|
||||||
|
sort_dir: str = "desc"
|
||||||
|
|
||||||
|
def filter_clauses(self) -> list[ColumnElement]:
|
||||||
|
clauses: list[ColumnElement] = []
|
||||||
|
if self.status is not None:
|
||||||
|
clauses.append(PricingRule.status == self.status)
|
||||||
|
if self.organization_id is not None:
|
||||||
|
clauses.append(PricingRule.organization_id == self.organization_id)
|
||||||
|
if self.q:
|
||||||
|
term = f"%{self.q.strip()}%"
|
||||||
|
clauses.append(
|
||||||
|
or_(PricingRule.code.ilike(term), PricingRule.name.ilike(term))
|
||||||
|
)
|
||||||
|
return clauses
|
||||||
|
|
||||||
|
def apply(self, stmt: Select) -> Select:
|
||||||
|
clauses = self.filter_clauses()
|
||||||
|
if clauses:
|
||||||
|
stmt = stmt.where(*clauses)
|
||||||
|
sort_key = self.sort_by if self.sort_by in RULE_SORT else "created_at"
|
||||||
|
column = getattr(PricingRule, sort_key)
|
||||||
|
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
||||||
|
return stmt.order_by(order)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CapabilityListSpec:
|
||||||
|
status: CapabilityStatus | None = None
|
||||||
|
q: str | None = None
|
||||||
|
sort_by: str = "created_at"
|
||||||
|
sort_dir: str = "desc"
|
||||||
|
|
||||||
|
def filter_clauses(self) -> list[ColumnElement]:
|
||||||
|
clauses: list[ColumnElement] = []
|
||||||
|
if self.status is not None:
|
||||||
|
clauses.append(CapabilityDefinition.status == self.status)
|
||||||
|
if self.q:
|
||||||
|
term = f"%{self.q.strip()}%"
|
||||||
|
clauses.append(
|
||||||
|
or_(
|
||||||
|
CapabilityDefinition.code.ilike(term),
|
||||||
|
CapabilityDefinition.name.ilike(term),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return clauses
|
||||||
|
|
||||||
|
def apply(self, stmt: Select) -> Select:
|
||||||
|
clauses = self.filter_clauses()
|
||||||
|
if clauses:
|
||||||
|
stmt = stmt.where(*clauses)
|
||||||
|
sort_key = self.sort_by if self.sort_by in CAP_SORT else "created_at"
|
||||||
|
column = getattr(CapabilityDefinition, sort_key)
|
||||||
|
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
||||||
|
return stmt.order_by(order)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class BundleListSpec:
|
||||||
|
status: CapabilityBundleStatus | None = None
|
||||||
|
q: str | None = None
|
||||||
|
sort_by: str = "created_at"
|
||||||
|
sort_dir: str = "desc"
|
||||||
|
|
||||||
|
def filter_clauses(self) -> list[ColumnElement]:
|
||||||
|
clauses: list[ColumnElement] = []
|
||||||
|
if self.status is not None:
|
||||||
|
clauses.append(CapabilityBundle.status == self.status)
|
||||||
|
if self.q:
|
||||||
|
term = f"%{self.q.strip()}%"
|
||||||
|
clauses.append(
|
||||||
|
or_(
|
||||||
|
CapabilityBundle.code.ilike(term),
|
||||||
|
CapabilityBundle.name.ilike(term),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return clauses
|
||||||
|
|
||||||
|
def apply(self, stmt: Select) -> Select:
|
||||||
|
clauses = self.filter_clauses()
|
||||||
|
if clauses:
|
||||||
|
stmt = stmt.where(*clauses)
|
||||||
|
sort_key = self.sort_by if self.sort_by in BUNDLE_SORT else "created_at"
|
||||||
|
column = getattr(CapabilityBundle, sort_key)
|
||||||
|
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
||||||
|
return stmt.order_by(order)
|
||||||
71
backend/services/delivery/app/specifications/routing.py
Normal file
71
backend/services/delivery/app/specifications/routing.py
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
"""Routing query specifications — Phase 10.6."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import Select, asc, desc, or_
|
||||||
|
from sqlalchemy.sql import ColumnElement
|
||||||
|
|
||||||
|
from app.models.routing import OptimizationRun, RoutePlan
|
||||||
|
from app.models.types import OptimizationRunStatus, RoutePlanStatus
|
||||||
|
|
||||||
|
ROUTE_SORT = frozenset({"created_at", "updated_at", "code", "status"})
|
||||||
|
OPT_SORT = frozenset({"created_at", "updated_at", "status"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RoutePlanListSpec:
|
||||||
|
status: RoutePlanStatus | None = None
|
||||||
|
organization_id: UUID | None = None
|
||||||
|
driver_id: UUID | None = None
|
||||||
|
q: str | None = None
|
||||||
|
sort_by: str = "created_at"
|
||||||
|
sort_dir: str = "desc"
|
||||||
|
|
||||||
|
def filter_clauses(self) -> list[ColumnElement]:
|
||||||
|
clauses: list[ColumnElement] = []
|
||||||
|
if self.status is not None:
|
||||||
|
clauses.append(RoutePlan.status == self.status)
|
||||||
|
if self.organization_id is not None:
|
||||||
|
clauses.append(RoutePlan.organization_id == self.organization_id)
|
||||||
|
if self.driver_id is not None:
|
||||||
|
clauses.append(RoutePlan.driver_id == self.driver_id)
|
||||||
|
if self.q:
|
||||||
|
term = f"%{self.q.strip()}%"
|
||||||
|
clauses.append(RoutePlan.code.ilike(term))
|
||||||
|
return clauses
|
||||||
|
|
||||||
|
def apply(self, stmt: Select) -> Select:
|
||||||
|
clauses = self.filter_clauses()
|
||||||
|
if clauses:
|
||||||
|
stmt = stmt.where(*clauses)
|
||||||
|
sort_key = self.sort_by if self.sort_by in ROUTE_SORT else "created_at"
|
||||||
|
column = getattr(RoutePlan, sort_key)
|
||||||
|
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
||||||
|
return stmt.order_by(order)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class OptimizationRunListSpec:
|
||||||
|
route_plan_id: UUID | None = None
|
||||||
|
status: OptimizationRunStatus | None = None
|
||||||
|
sort_by: str = "created_at"
|
||||||
|
sort_dir: str = "desc"
|
||||||
|
|
||||||
|
def filter_clauses(self) -> list[ColumnElement]:
|
||||||
|
clauses: list[ColumnElement] = []
|
||||||
|
if self.route_plan_id is not None:
|
||||||
|
clauses.append(OptimizationRun.route_plan_id == self.route_plan_id)
|
||||||
|
if self.status is not None:
|
||||||
|
clauses.append(OptimizationRun.status == self.status)
|
||||||
|
return clauses
|
||||||
|
|
||||||
|
def apply(self, stmt: Select) -> Select:
|
||||||
|
clauses = self.filter_clauses()
|
||||||
|
if clauses:
|
||||||
|
stmt = stmt.where(*clauses)
|
||||||
|
sort_key = self.sort_by if self.sort_by in OPT_SORT else "created_at"
|
||||||
|
column = getattr(OptimizationRun, sort_key)
|
||||||
|
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
||||||
|
return stmt.order_by(order)
|
||||||
45
backend/services/delivery/app/specifications/settlement.py
Normal file
45
backend/services/delivery/app/specifications/settlement.py
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
"""Settlement query specifications — Phase 10.8."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import Select, asc, desc, or_
|
||||||
|
from sqlalchemy.sql import ColumnElement
|
||||||
|
|
||||||
|
from app.models.settlement import SettlementIntent
|
||||||
|
from app.models.types import SettlementIntentStatus
|
||||||
|
|
||||||
|
INTENT_SORT = frozenset({"created_at", "updated_at", "code", "status"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class SettlementIntentListSpec:
|
||||||
|
status: SettlementIntentStatus | None = None
|
||||||
|
organization_id: UUID | None = None
|
||||||
|
driver_id: UUID | None = None
|
||||||
|
q: str | None = None
|
||||||
|
sort_by: str = "created_at"
|
||||||
|
sort_dir: str = "desc"
|
||||||
|
|
||||||
|
def filter_clauses(self) -> list[ColumnElement]:
|
||||||
|
clauses: list[ColumnElement] = []
|
||||||
|
if self.status is not None:
|
||||||
|
clauses.append(SettlementIntent.status == self.status)
|
||||||
|
if self.organization_id is not None:
|
||||||
|
clauses.append(SettlementIntent.organization_id == self.organization_id)
|
||||||
|
if self.driver_id is not None:
|
||||||
|
clauses.append(SettlementIntent.driver_id == self.driver_id)
|
||||||
|
if self.q:
|
||||||
|
term = f"%{self.q.strip()}%"
|
||||||
|
clauses.append(SettlementIntent.code.ilike(term))
|
||||||
|
return clauses
|
||||||
|
|
||||||
|
def apply(self, stmt: Select) -> Select:
|
||||||
|
clauses = self.filter_clauses()
|
||||||
|
if clauses:
|
||||||
|
stmt = stmt.where(*clauses)
|
||||||
|
sort_key = self.sort_by if self.sort_by in INTENT_SORT else "created_at"
|
||||||
|
column = getattr(SettlementIntent, sort_key)
|
||||||
|
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
||||||
|
return stmt.order_by(order)
|
||||||
99
backend/services/delivery/app/specifications/tracking.py
Normal file
99
backend/services/delivery/app/specifications/tracking.py
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
"""Tracking query specifications — Phase 10.7."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from uuid import UUID
|
||||||
|
|
||||||
|
from sqlalchemy import Select, asc, desc
|
||||||
|
from sqlalchemy.sql import ColumnElement
|
||||||
|
|
||||||
|
from app.models.tracking import CustomerTrackingToken, ProofOfDelivery, TrackingSession
|
||||||
|
from app.models.types import (
|
||||||
|
CustomerTrackingTokenStatus,
|
||||||
|
ProofOfDeliveryStatus,
|
||||||
|
TrackingSessionStatus,
|
||||||
|
)
|
||||||
|
|
||||||
|
SESSION_SORT = frozenset({"created_at", "updated_at", "status"})
|
||||||
|
POD_SORT = frozenset({"created_at", "updated_at", "status"})
|
||||||
|
TOKEN_SORT = frozenset({"created_at", "updated_at", "status"})
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TrackingSessionListSpec:
|
||||||
|
status: TrackingSessionStatus | None = None
|
||||||
|
dispatch_job_id: UUID | None = None
|
||||||
|
driver_id: UUID | None = None
|
||||||
|
sort_by: str = "created_at"
|
||||||
|
sort_dir: str = "desc"
|
||||||
|
|
||||||
|
def filter_clauses(self) -> list[ColumnElement]:
|
||||||
|
clauses: list[ColumnElement] = []
|
||||||
|
if self.status is not None:
|
||||||
|
clauses.append(TrackingSession.status == self.status)
|
||||||
|
if self.dispatch_job_id is not None:
|
||||||
|
clauses.append(TrackingSession.dispatch_job_id == self.dispatch_job_id)
|
||||||
|
if self.driver_id is not None:
|
||||||
|
clauses.append(TrackingSession.driver_id == self.driver_id)
|
||||||
|
return clauses
|
||||||
|
|
||||||
|
def apply(self, stmt: Select) -> Select:
|
||||||
|
clauses = self.filter_clauses()
|
||||||
|
if clauses:
|
||||||
|
stmt = stmt.where(*clauses)
|
||||||
|
sort_key = self.sort_by if self.sort_by in SESSION_SORT else "created_at"
|
||||||
|
column = getattr(TrackingSession, sort_key)
|
||||||
|
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
||||||
|
return stmt.order_by(order)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ProofOfDeliveryListSpec:
|
||||||
|
status: ProofOfDeliveryStatus | None = None
|
||||||
|
dispatch_job_id: UUID | None = None
|
||||||
|
sort_by: str = "created_at"
|
||||||
|
sort_dir: str = "desc"
|
||||||
|
|
||||||
|
def filter_clauses(self) -> list[ColumnElement]:
|
||||||
|
clauses: list[ColumnElement] = []
|
||||||
|
if self.status is not None:
|
||||||
|
clauses.append(ProofOfDelivery.status == self.status)
|
||||||
|
if self.dispatch_job_id is not None:
|
||||||
|
clauses.append(ProofOfDelivery.dispatch_job_id == self.dispatch_job_id)
|
||||||
|
return clauses
|
||||||
|
|
||||||
|
def apply(self, stmt: Select) -> Select:
|
||||||
|
clauses = self.filter_clauses()
|
||||||
|
if clauses:
|
||||||
|
stmt = stmt.where(*clauses)
|
||||||
|
sort_key = self.sort_by if self.sort_by in POD_SORT else "created_at"
|
||||||
|
column = getattr(ProofOfDelivery, sort_key)
|
||||||
|
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
||||||
|
return stmt.order_by(order)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class CustomerTrackingTokenListSpec:
|
||||||
|
status: CustomerTrackingTokenStatus | None = None
|
||||||
|
dispatch_job_id: UUID | None = None
|
||||||
|
sort_by: str = "created_at"
|
||||||
|
sort_dir: str = "desc"
|
||||||
|
|
||||||
|
def filter_clauses(self) -> list[ColumnElement]:
|
||||||
|
clauses: list[ColumnElement] = []
|
||||||
|
if self.status is not None:
|
||||||
|
clauses.append(CustomerTrackingToken.status == self.status)
|
||||||
|
if self.dispatch_job_id is not None:
|
||||||
|
clauses.append(
|
||||||
|
CustomerTrackingToken.dispatch_job_id == self.dispatch_job_id
|
||||||
|
)
|
||||||
|
return clauses
|
||||||
|
|
||||||
|
def apply(self, stmt: Select) -> Select:
|
||||||
|
clauses = self.filter_clauses()
|
||||||
|
if clauses:
|
||||||
|
stmt = stmt.where(*clauses)
|
||||||
|
sort_key = self.sort_by if self.sort_by in TOKEN_SORT else "created_at"
|
||||||
|
column = getattr(CustomerTrackingToken, sort_key)
|
||||||
|
order = desc(column) if self.sort_dir.lower() != "asc" else asc(column)
|
||||||
|
return stmt.order_by(order)
|
||||||
@ -15,16 +15,18 @@ async def test_health_capabilities_metrics(client):
|
|||||||
caps = await client.get("/capabilities")
|
caps = await client.get("/capabilities")
|
||||||
assert caps.status_code == 200
|
assert caps.status_code == 200
|
||||||
c = caps.json()
|
c = caps.json()
|
||||||
assert c["phase"] == "10.1"
|
assert c["phase"] == "10.8"
|
||||||
assert c["commercial_product"] == "Torbat Driver"
|
assert c["commercial_product"] == "Torbat Driver"
|
||||||
assert c["features"]["foundation"] is True
|
assert c["features"]["foundation"] is True
|
||||||
assert c["features"]["drivers"] is True
|
assert c["features"]["drivers"] is True
|
||||||
assert c["features"]["dispatch_engine"] is False
|
assert c["features"]["dispatch_engine"] is True
|
||||||
|
assert c["features"]["settlement"] is True
|
||||||
assert c["independence"]["integration_mode"] == "api_and_events_only"
|
assert c["independence"]["integration_mode"] == "api_and_events_only"
|
||||||
|
assert c["independence"]["no_journal_entries_in_delivery_db"] is True
|
||||||
|
|
||||||
metrics = await client.get("/metrics")
|
metrics = await client.get("/metrics")
|
||||||
assert metrics.status_code == 200
|
assert metrics.status_code == 200
|
||||||
assert metrics.json()["phase"] == "10.1"
|
assert metrics.json()["phase"] == "10.8"
|
||||||
|
|
||||||
|
|
||||||
async def test_foundation_flow_and_events(client):
|
async def test_foundation_flow_and_events(client):
|
||||||
|
|||||||
@ -32,6 +32,36 @@ FOUNDATION_MODELS = [
|
|||||||
models.DeliveryAuditLog,
|
models.DeliveryAuditLog,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
PHASE_TABLES = {
|
||||||
|
"drivers",
|
||||||
|
"driver_credentials",
|
||||||
|
"driver_documents",
|
||||||
|
"driver_lifecycle_events",
|
||||||
|
"fleets",
|
||||||
|
"vehicle_types",
|
||||||
|
"vehicles",
|
||||||
|
"vehicle_assignments",
|
||||||
|
"driver_availabilities",
|
||||||
|
"shifts",
|
||||||
|
"shift_assignments",
|
||||||
|
"working_zones",
|
||||||
|
"pricing_rules",
|
||||||
|
"capability_definitions",
|
||||||
|
"capability_bundles",
|
||||||
|
"dispatch_jobs",
|
||||||
|
"job_assignments",
|
||||||
|
"route_plans",
|
||||||
|
"route_stops",
|
||||||
|
"optimization_runs",
|
||||||
|
"tracking_sessions",
|
||||||
|
"tracking_points",
|
||||||
|
"proof_of_delivery",
|
||||||
|
"customer_tracking_tokens",
|
||||||
|
"settlement_intents",
|
||||||
|
"settlement_lines",
|
||||||
|
"outbox_events",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_all_models_have_tenant_id():
|
def test_all_models_have_tenant_id():
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
@ -52,59 +82,55 @@ def test_foundation_aggregates_are_independent():
|
|||||||
assert not model.__mapper__.relationships
|
assert not model.__mapper__.relationships
|
||||||
|
|
||||||
|
|
||||||
def test_no_dispatch_engine_tables():
|
def test_phase_tables_registered():
|
||||||
from app.core.database import Base
|
from app.core.database import Base
|
||||||
import app.models # noqa: F401
|
import app.models # noqa: F401
|
||||||
|
|
||||||
names = set(Base.metadata.tables.keys())
|
names = set(Base.metadata.tables.keys())
|
||||||
assert "drivers" in names
|
assert PHASE_TABLES.issubset(names)
|
||||||
assert "driver_credentials" in names
|
assert "journal_entries" not in names
|
||||||
assert "driver_documents" in names
|
|
||||||
assert "driver_lifecycle_events" in names
|
|
||||||
assert "outbox_events" in names
|
|
||||||
forbidden = {
|
|
||||||
"dispatch_jobs",
|
|
||||||
"routes",
|
|
||||||
"tracking_sessions",
|
|
||||||
"vehicles",
|
|
||||||
"fleets",
|
|
||||||
"proof_of_delivery",
|
|
||||||
}
|
|
||||||
assert forbidden.isdisjoint(names)
|
|
||||||
|
|
||||||
|
|
||||||
def test_permissions_defined():
|
def test_permissions_defined():
|
||||||
from app.permissions.definitions import ALL_PERMISSIONS, PERMISSION_PREFIXES
|
from app.permissions.definitions import ALL_PERMISSIONS, PERMISSION_PREFIXES
|
||||||
|
|
||||||
assert "delivery.view" in ALL_PERMISSIONS
|
assert "delivery.view" in ALL_PERMISSIONS
|
||||||
assert "delivery.organizations.create" in ALL_PERMISSIONS
|
|
||||||
assert "delivery.external_providers.manage" in ALL_PERMISSIONS
|
|
||||||
assert "delivery.routing_engines.view" in ALL_PERMISSIONS
|
|
||||||
assert "delivery.audit.view" in ALL_PERMISSIONS
|
|
||||||
assert "delivery.drivers.create" in ALL_PERMISSIONS
|
assert "delivery.drivers.create" in ALL_PERMISSIONS
|
||||||
assert "delivery.drivers.activate" in ALL_PERMISSIONS
|
assert "delivery.fleets.create" in ALL_PERMISSIONS
|
||||||
assert "delivery.drivers.credentials.manage" in ALL_PERMISSIONS
|
assert "delivery.vehicle_types.view" in ALL_PERMISSIONS
|
||||||
assert "delivery.dispatch.manage" in ALL_PERMISSIONS
|
assert "delivery.vehicles.assignments.manage" in ALL_PERMISSIONS
|
||||||
|
assert "delivery.availability.manage" in ALL_PERMISSIONS
|
||||||
|
assert "delivery.shifts.create" in ALL_PERMISSIONS
|
||||||
|
assert "delivery.working_zones.view" in ALL_PERMISSIONS
|
||||||
|
assert "delivery.pricing_rules.create" in ALL_PERMISSIONS
|
||||||
|
assert "delivery.capabilities.view" in ALL_PERMISSIONS
|
||||||
|
assert "delivery.bundles.manage" in ALL_PERMISSIONS
|
||||||
|
assert "delivery.dispatch.jobs.status" in ALL_PERMISSIONS
|
||||||
|
assert "delivery.routes.stops.manage" in ALL_PERMISSIONS
|
||||||
|
assert "delivery.optimization.runs.create" in ALL_PERMISSIONS
|
||||||
|
assert "delivery.tracking.sessions.view" in ALL_PERMISSIONS
|
||||||
|
assert "delivery.proof_of_delivery.create" in ALL_PERMISSIONS
|
||||||
|
assert "delivery.customer_tracking.revoke" in ALL_PERMISSIONS
|
||||||
|
assert "delivery.settlements.status" in ALL_PERMISSIONS
|
||||||
|
assert len(ALL_PERMISSIONS) >= 100
|
||||||
for prefix in PERMISSION_PREFIXES:
|
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():
|
def test_events_defined():
|
||||||
from app.events.types import DeliveryEventType
|
from app.events.types import DeliveryEventType
|
||||||
|
|
||||||
assert DeliveryEventType.ORGANIZATION_CREATED.value == "delivery.organization.created"
|
|
||||||
assert DeliveryEventType.HUB_CREATED.value == "delivery.hub.created"
|
|
||||||
assert (
|
|
||||||
DeliveryEventType.EXTERNAL_PROVIDER_REGISTERED.value
|
|
||||||
== "delivery.external_provider.registered"
|
|
||||||
)
|
|
||||||
assert (
|
|
||||||
DeliveryEventType.ROUTING_ENGINE_REGISTERED.value
|
|
||||||
== "delivery.routing_engine.registered"
|
|
||||||
)
|
|
||||||
assert DeliveryEventType.DRIVER_CREATED.value == "delivery.driver.created"
|
assert DeliveryEventType.DRIVER_CREATED.value == "delivery.driver.created"
|
||||||
assert DeliveryEventType.DRIVER_ACTIVATED.value == "delivery.driver.activated"
|
assert DeliveryEventType.FLEET_CREATED.value == "delivery.fleet.created"
|
||||||
assert DeliveryEventType.DRIVER_STATUS_CHANGED.value == "delivery.driver.status_changed"
|
assert DeliveryEventType.SHIFT_CREATED.value == "delivery.shift.created"
|
||||||
|
assert DeliveryEventType.PRICING_RULE_CREATED.value == "delivery.pricing_rule.created"
|
||||||
|
assert DeliveryEventType.DISPATCH_JOB_CREATED.value == "delivery.dispatch_job.created"
|
||||||
|
assert DeliveryEventType.ROUTE_PLAN_CREATED.value == "delivery.route_plan.created"
|
||||||
|
assert DeliveryEventType.TRACKING_SESSION_STARTED.value == "delivery.tracking_session.started"
|
||||||
|
assert DeliveryEventType.SETTLEMENT_INTENT_CREATED.value == "delivery.settlement_intent.created"
|
||||||
|
|
||||||
|
|
||||||
def test_platform_provider_contracts_exist():
|
def test_platform_provider_contracts_exist():
|
||||||
@ -122,15 +148,15 @@ def test_platform_provider_contracts_exist():
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert AccountingProvider is not None
|
assert AccountingProvider is not None
|
||||||
assert CommunicationProvider is not None
|
|
||||||
assert RoutingEngineProvider is not None
|
assert RoutingEngineProvider is not None
|
||||||
assert FleetProvider is not None
|
assert FleetProvider is not None
|
||||||
assert LoyaltyProvider is not None
|
|
||||||
assert CRMProvider is not None
|
|
||||||
assert NotificationProvider is not None
|
|
||||||
assert StorageProvider is not None
|
assert StorageProvider is not None
|
||||||
assert AIProvider is not None
|
|
||||||
assert IdentityProvider is not None
|
assert IdentityProvider is not None
|
||||||
|
assert CRMProvider is not None
|
||||||
|
assert CommunicationProvider is not None
|
||||||
|
assert NotificationProvider is not None
|
||||||
|
assert LoyaltyProvider is not None
|
||||||
|
assert AIProvider is not None
|
||||||
|
|
||||||
|
|
||||||
def test_no_forbidden_service_imports():
|
def test_no_forbidden_service_imports():
|
||||||
@ -159,8 +185,10 @@ def test_api_ownership_is_delivery_only():
|
|||||||
prefixes = {getattr(r, "path", "") for r in api_router.routes}
|
prefixes = {getattr(r, "path", "") for r in api_router.routes}
|
||||||
joined = " ".join(sorted(prefixes))
|
joined = " ".join(sorted(prefixes))
|
||||||
assert "/organizations" in joined or any("/organizations" in p for p in prefixes)
|
assert "/organizations" in joined or any("/organizations" in p for p in prefixes)
|
||||||
assert "/hubs" in joined or any("/hubs" in p for p in prefixes)
|
|
||||||
assert "/drivers" in joined or any("/drivers" in p for p in prefixes)
|
assert "/drivers" in joined or any("/drivers" in p for p in prefixes)
|
||||||
|
assert "/fleets" in joined or any("/fleets" in p for p in prefixes)
|
||||||
|
assert "/dispatch/jobs" in joined or any("/dispatch/jobs" in p for p in prefixes)
|
||||||
|
assert "/settlements" in joined or any("/settlements" in p for p in prefixes)
|
||||||
forbidden = ("accounting", "crm", "loyalty", "notification", "automation", "sports")
|
forbidden = ("accounting", "crm", "loyalty", "notification", "automation", "sports")
|
||||||
for name in forbidden:
|
for name in forbidden:
|
||||||
assert not re.search(rf"(^|/){re.escape(name)}(/|$|\s)", joined), name
|
assert not re.search(rf"(^|/){re.escape(name)}(/|$|\s)", joined), name
|
||||||
|
|||||||
@ -20,17 +20,51 @@ EXPECTED_TABLES = {
|
|||||||
"driver_documents",
|
"driver_documents",
|
||||||
"driver_lifecycle_events",
|
"driver_lifecycle_events",
|
||||||
"outbox_events",
|
"outbox_events",
|
||||||
|
"fleets",
|
||||||
|
"vehicle_types",
|
||||||
|
"vehicles",
|
||||||
|
"vehicle_assignments",
|
||||||
|
"driver_availabilities",
|
||||||
|
"shifts",
|
||||||
|
"shift_assignments",
|
||||||
|
"working_zones",
|
||||||
|
"pricing_rules",
|
||||||
|
"capability_definitions",
|
||||||
|
"capability_bundles",
|
||||||
|
"dispatch_jobs",
|
||||||
|
"job_assignments",
|
||||||
|
"route_plans",
|
||||||
|
"route_stops",
|
||||||
|
"optimization_runs",
|
||||||
|
"tracking_sessions",
|
||||||
|
"tracking_points",
|
||||||
|
"proof_of_delivery",
|
||||||
|
"customer_tracking_tokens",
|
||||||
|
"settlement_intents",
|
||||||
|
"settlement_lines",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def test_alembic_revision_exists():
|
def test_alembic_revision_exists():
|
||||||
versions = Path(__file__).resolve().parents[2] / "alembic" / "versions"
|
versions = Path(__file__).resolve().parents[2] / "alembic" / "versions"
|
||||||
files = list(versions.glob("0001_initial*.py"))
|
assert list(versions.glob("0001_initial*.py")), "missing 0001_initial migration"
|
||||||
assert files, "missing 0001_initial migration"
|
assert list(versions.glob("0002_phase_101_drivers*.py")), "missing 0002 migration"
|
||||||
phase101 = list(versions.glob("0002_phase_101_drivers*.py"))
|
assert list(versions.glob("0003_phase_102_fleet*.py")), "missing 0003 migration"
|
||||||
assert phase101, "missing 0002_phase_101_drivers migration"
|
assert list(versions.glob("0004_phase_103_availability*.py")), "missing 0004 migration"
|
||||||
|
assert list(versions.glob("0005_phase_104_pricing*.py")), "missing 0005 migration"
|
||||||
|
assert list(versions.glob("0006_phase_105_dispatch*.py")), "missing 0006 migration"
|
||||||
|
assert list(versions.glob("0007_phase_106_routing*.py")), "missing 0007 migration"
|
||||||
|
assert list(versions.glob("0008_phase_107_108_tracking_settlement*.py")), (
|
||||||
|
"missing 0008 migration"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_metadata_has_foundation_tables():
|
def test_metadata_has_all_tables():
|
||||||
names = set(Base.metadata.tables.keys())
|
names = set(Base.metadata.tables.keys())
|
||||||
assert EXPECTED_TABLES.issubset(names)
|
assert EXPECTED_TABLES.issubset(names)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_journal_entry_table():
|
||||||
|
names = set(Base.metadata.tables.keys())
|
||||||
|
assert "journal_entries" not in names
|
||||||
|
assert "JournalEntry".lower() not in {n.lower() for n in names}
|
||||||
|
|||||||
93
backend/services/delivery/app/tests/test_phase_102.py
Normal file
93
backend/services/delivery/app/tests/test_phase_102.py
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
"""Phase 10.2 — Fleet & Vehicle tests."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.events.publisher import get_event_publisher
|
||||||
|
from app.tests.conftest import TENANT_A, tenant_headers
|
||||||
|
|
||||||
|
|
||||||
|
async def _org(client, code="ORG_F102"):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/organizations",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={"code": code, "name": code, "status": "active"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
async def _vehicle_type(client, code="VT1"):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/vehicle-types",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={"code": code, "name": f"Type {code}"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.text
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fleet_vehicle_crud_and_assignment(client):
|
||||||
|
org = await _org(client)
|
||||||
|
vt = await _vehicle_type(client, "VAN")
|
||||||
|
|
||||||
|
fleet = await client.post(
|
||||||
|
"/api/v1/fleets",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={
|
||||||
|
"organization_id": org["id"],
|
||||||
|
"code": "FL1",
|
||||||
|
"name": "Main Fleet",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert fleet.status_code == 201, fleet.text
|
||||||
|
fleet_body = fleet.json()
|
||||||
|
|
||||||
|
vehicle = await client.post(
|
||||||
|
"/api/v1/vehicles",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={
|
||||||
|
"fleet_id": fleet_body["id"],
|
||||||
|
"vehicle_type_id": vt["id"],
|
||||||
|
"code": "VH1",
|
||||||
|
"plate_number": "12A345-11",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert vehicle.status_code == 201, vehicle.text
|
||||||
|
vid = vehicle.json()["id"]
|
||||||
|
|
||||||
|
driver = await client.post(
|
||||||
|
"/api/v1/drivers",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={
|
||||||
|
"organization_id": org["id"],
|
||||||
|
"code": "DRV_F102",
|
||||||
|
"display_name": "Fleet Driver",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert driver.status_code == 201
|
||||||
|
did = driver.json()["id"]
|
||||||
|
|
||||||
|
assign = await client.post(
|
||||||
|
f"/api/v1/vehicles/{vid}/assignments",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={"driver_id": did},
|
||||||
|
)
|
||||||
|
assert assign.status_code == 201, assign.text
|
||||||
|
assert assign.json()["status"] == "active"
|
||||||
|
|
||||||
|
listed = await client.get(
|
||||||
|
"/api/v1/vehicles",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
params={"fleet_id": fleet_body["id"]},
|
||||||
|
)
|
||||||
|
assert listed.status_code == 200
|
||||||
|
assert listed.json()["total"] == 1
|
||||||
|
|
||||||
|
events = {e.event_type for e in get_event_publisher().published}
|
||||||
|
assert "delivery.fleet.created" in events
|
||||||
|
assert "delivery.vehicle.created" in events
|
||||||
|
assert "delivery.vehicle_assignment.created" in events
|
||||||
83
backend/services/delivery/app/tests/test_phase_103.py
Normal file
83
backend/services/delivery/app/tests/test_phase_103.py
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
"""Phase 10.3 — Availability, Shifts & Working Zones tests."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.events.publisher import get_event_publisher
|
||||||
|
from app.tests.conftest import TENANT_A, tenant_headers
|
||||||
|
|
||||||
|
|
||||||
|
async def _org(client, code="ORG_A103"):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/organizations",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={"code": code, "name": code, "status": "active"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_availability_shift_zone_flow(client):
|
||||||
|
org = await _org(client)
|
||||||
|
driver = await client.post(
|
||||||
|
"/api/v1/drivers",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={
|
||||||
|
"organization_id": org["id"],
|
||||||
|
"code": "DRV_A103",
|
||||||
|
"display_name": "Shift Driver",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert driver.status_code == 201
|
||||||
|
did = driver.json()["id"]
|
||||||
|
|
||||||
|
avail = await client.put(
|
||||||
|
f"/api/v1/drivers/{did}/availability",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={"status": "online"},
|
||||||
|
)
|
||||||
|
assert avail.status_code == 200, avail.text
|
||||||
|
assert avail.json()["status"] == "online"
|
||||||
|
|
||||||
|
start = datetime.now(timezone.utc)
|
||||||
|
end = start + timedelta(hours=8)
|
||||||
|
shift = await client.post(
|
||||||
|
"/api/v1/shifts",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={
|
||||||
|
"organization_id": org["id"],
|
||||||
|
"code": "SH1",
|
||||||
|
"name": "Morning",
|
||||||
|
"starts_at": start.isoformat(),
|
||||||
|
"ends_at": end.isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert shift.status_code == 201, shift.text
|
||||||
|
sid = shift.json()["id"]
|
||||||
|
|
||||||
|
assign = await client.post(
|
||||||
|
f"/api/v1/shifts/{sid}/assignments",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={"driver_id": did},
|
||||||
|
)
|
||||||
|
assert assign.status_code == 201
|
||||||
|
|
||||||
|
zone = await client.post(
|
||||||
|
"/api/v1/working-zones",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={
|
||||||
|
"organization_id": org["id"],
|
||||||
|
"code": "ZN1",
|
||||||
|
"name": "North Zone",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert zone.status_code == 201, zone.text
|
||||||
|
|
||||||
|
events = {e.event_type for e in get_event_publisher().published}
|
||||||
|
assert "delivery.driver_availability.updated" in events
|
||||||
|
assert "delivery.shift.created" in events
|
||||||
|
assert "delivery.working_zone.created" in events
|
||||||
67
backend/services/delivery/app/tests/test_phase_104.py
Normal file
67
backend/services/delivery/app/tests/test_phase_104.py
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
"""Phase 10.4 — Pricing, Capabilities & Bundles tests."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.events.publisher import get_event_publisher
|
||||||
|
from app.tests.conftest import TENANT_A, tenant_headers
|
||||||
|
|
||||||
|
|
||||||
|
async def _org(client):
|
||||||
|
resp = await client.post(
|
||||||
|
"/api/v1/organizations",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={"code": "ORG_P104", "name": "Pricing Org", "status": "active"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
return resp.json()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_pricing_capability_bundle_flow(client):
|
||||||
|
org = await _org(client)
|
||||||
|
|
||||||
|
rule = await client.post(
|
||||||
|
"/api/v1/pricing-rules",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={
|
||||||
|
"organization_id": org["id"],
|
||||||
|
"code": "PR1",
|
||||||
|
"name": "Base Delivery",
|
||||||
|
"base_amount": "50000",
|
||||||
|
"currency_code": "IRR",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert rule.status_code == 201, rule.text
|
||||||
|
rid = rule.json()["id"]
|
||||||
|
|
||||||
|
cap = await client.post(
|
||||||
|
"/api/v1/capabilities",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={"code": "COLD", "name": "Cold Chain"},
|
||||||
|
)
|
||||||
|
assert cap.status_code == 201
|
||||||
|
|
||||||
|
bundle = await client.post(
|
||||||
|
"/api/v1/bundles",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={
|
||||||
|
"code": "BND1",
|
||||||
|
"name": "Cold Bundle",
|
||||||
|
"capability_codes": ["COLD"],
|
||||||
|
"pricing_rule_id": rid,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert bundle.status_code == 201, bundle.text
|
||||||
|
|
||||||
|
listed = await client.get(
|
||||||
|
"/api/v1/pricing-rules",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
)
|
||||||
|
assert listed.status_code == 200
|
||||||
|
assert listed.json()["total"] >= 1
|
||||||
|
|
||||||
|
events = {e.event_type for e in get_event_publisher().published}
|
||||||
|
assert "delivery.pricing_rule.created" in events
|
||||||
|
assert "delivery.capability.created" in events
|
||||||
|
assert "delivery.bundle.created" in events
|
||||||
73
backend/services/delivery/app/tests/test_phase_105.py
Normal file
73
backend/services/delivery/app/tests/test_phase_105.py
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
"""Phase 10.5 — Dispatch Engine tests."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.events.publisher import get_event_publisher
|
||||||
|
from app.models.types import DispatchJobAction
|
||||||
|
from app.tests.conftest import TENANT_A, tenant_headers
|
||||||
|
from app.validators.dispatch import ensure_dispatch_job_transition
|
||||||
|
|
||||||
|
|
||||||
|
def test_dispatch_transition_matrix():
|
||||||
|
from app.models.types import DispatchJobStatus
|
||||||
|
|
||||||
|
ensure_dispatch_job_transition(
|
||||||
|
action=DispatchJobAction.ASSIGN, current=DispatchJobStatus.PENDING
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_dispatch_job_lifecycle(client):
|
||||||
|
org = await client.post(
|
||||||
|
"/api/v1/organizations",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={"code": "ORG_D105", "name": "Dispatch Org", "status": "active"},
|
||||||
|
)
|
||||||
|
assert org.status_code == 201
|
||||||
|
org_id = org.json()["id"]
|
||||||
|
|
||||||
|
job = await client.post(
|
||||||
|
"/api/v1/dispatch/jobs",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={
|
||||||
|
"organization_id": org_id,
|
||||||
|
"code": "JOB1",
|
||||||
|
"external_order_ref": "ORD-100",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert job.status_code == 201, job.text
|
||||||
|
jid = job.json()["id"]
|
||||||
|
version = job.json()["version"]
|
||||||
|
|
||||||
|
assigned = await client.post(
|
||||||
|
f"/api/v1/dispatch/jobs/{jid}/status",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={"action": "assign", "version": version},
|
||||||
|
)
|
||||||
|
assert assigned.status_code == 200
|
||||||
|
assert assigned.json()["status"] == "assigned"
|
||||||
|
|
||||||
|
driver = await client.post(
|
||||||
|
"/api/v1/drivers",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={
|
||||||
|
"organization_id": org_id,
|
||||||
|
"code": "DRV_D105",
|
||||||
|
"display_name": "Dispatch Driver",
|
||||||
|
"status": "active",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert driver.status_code == 201
|
||||||
|
|
||||||
|
assignment = await client.post(
|
||||||
|
"/api/v1/dispatch/assignments",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={"dispatch_job_id": jid, "driver_id": driver.json()["id"]},
|
||||||
|
)
|
||||||
|
assert assignment.status_code == 201
|
||||||
|
|
||||||
|
events = {e.event_type for e in get_event_publisher().published}
|
||||||
|
assert "delivery.dispatch_job.created" in events
|
||||||
|
assert "delivery.dispatch_job.status_changed" in events
|
||||||
|
assert "delivery.job_assignment.created" in events
|
||||||
46
backend/services/delivery/app/tests/test_phase_106.py
Normal file
46
backend/services/delivery/app/tests/test_phase_106.py
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
"""Phase 10.6 — Routing & Optimization tests."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.events.publisher import get_event_publisher
|
||||||
|
from app.tests.conftest import TENANT_A, tenant_headers
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_route_plan_and_optimization(client):
|
||||||
|
org = await client.post(
|
||||||
|
"/api/v1/organizations",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={"code": "ORG_R106", "name": "Route Org", "status": "active"},
|
||||||
|
)
|
||||||
|
assert org.status_code == 201
|
||||||
|
org_id = org.json()["id"]
|
||||||
|
|
||||||
|
route = await client.post(
|
||||||
|
"/api/v1/routes",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={"organization_id": org_id, "code": "RT1"},
|
||||||
|
)
|
||||||
|
assert route.status_code == 201, route.text
|
||||||
|
pid = route.json()["id"]
|
||||||
|
|
||||||
|
stop = await client.post(
|
||||||
|
f"/api/v1/routes/{pid}/stops",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={"sequence": 0, "address_ref": "addr://1"},
|
||||||
|
)
|
||||||
|
assert stop.status_code == 201
|
||||||
|
|
||||||
|
run = await client.post(
|
||||||
|
"/api/v1/optimization/runs",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={"route_plan_id": pid, "routing_engine_ref": "engine://default"},
|
||||||
|
)
|
||||||
|
assert run.status_code == 201, run.text
|
||||||
|
assert run.json()["status"] == "completed"
|
||||||
|
|
||||||
|
events = {e.event_type for e in get_event_publisher().published}
|
||||||
|
assert "delivery.route_plan.created" in events
|
||||||
|
assert "delivery.route_stop.added" in events
|
||||||
|
assert "delivery.optimization_run.completed" in events
|
||||||
73
backend/services/delivery/app/tests/test_phase_107.py
Normal file
73
backend/services/delivery/app/tests/test_phase_107.py
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
"""Phase 10.7 — Tracking & Proof of Delivery tests."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.events.publisher import get_event_publisher
|
||||||
|
from app.tests.conftest import TENANT_A, tenant_headers
|
||||||
|
|
||||||
|
|
||||||
|
async def _dispatch_job(client):
|
||||||
|
org = await client.post(
|
||||||
|
"/api/v1/organizations",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={"code": "ORG_T107", "name": "Track Org", "status": "active"},
|
||||||
|
)
|
||||||
|
assert org.status_code == 201
|
||||||
|
job = await client.post(
|
||||||
|
"/api/v1/dispatch/jobs",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={"organization_id": org.json()["id"], "code": "TJ1"},
|
||||||
|
)
|
||||||
|
assert job.status_code == 201
|
||||||
|
return job.json()["id"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_tracking_pod_and_customer_token(client):
|
||||||
|
jid = await _dispatch_job(client)
|
||||||
|
|
||||||
|
session = await client.post(
|
||||||
|
"/api/v1/tracking/sessions",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={"dispatch_job_id": jid},
|
||||||
|
)
|
||||||
|
assert session.status_code == 201, session.text
|
||||||
|
sid = session.json()["id"]
|
||||||
|
|
||||||
|
point = await client.post(
|
||||||
|
f"/api/v1/tracking/sessions/{sid}/points",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={
|
||||||
|
"latitude": 35.6892,
|
||||||
|
"longitude": 51.389,
|
||||||
|
"recorded_at": datetime.now(timezone.utc).isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert point.status_code == 201
|
||||||
|
|
||||||
|
pod = await client.post(
|
||||||
|
"/api/v1/proof-of-delivery",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={
|
||||||
|
"dispatch_job_id": jid,
|
||||||
|
"signature_file_ref": "storage://pod/sig-1",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert pod.status_code == 201, pod.text
|
||||||
|
|
||||||
|
token = await client.post(
|
||||||
|
"/api/v1/customer-tracking/tokens",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={"dispatch_job_id": jid},
|
||||||
|
)
|
||||||
|
assert token.status_code == 201, token.text
|
||||||
|
assert token.json()["token"]
|
||||||
|
|
||||||
|
events = {e.event_type for e in get_event_publisher().published}
|
||||||
|
assert "delivery.tracking_session.started" in events
|
||||||
|
assert "delivery.tracking_point.recorded" in events
|
||||||
|
assert "delivery.proof_of_delivery.captured" in events
|
||||||
|
assert "delivery.customer_tracking_token.created" in events
|
||||||
80
backend/services/delivery/app/tests/test_phase_108.py
Normal file
80
backend/services/delivery/app/tests/test_phase_108.py
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
"""Phase 10.8 — Settlement tests (AccountingProvider refs only)."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from app.events.publisher import get_event_publisher
|
||||||
|
import app.models # noqa: F401
|
||||||
|
from app.tests.conftest import TENANT_A, tenant_headers
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_journal_entry_table_in_delivery_db():
|
||||||
|
names = set(Base.metadata.tables.keys())
|
||||||
|
forbidden = {
|
||||||
|
"journal_entries",
|
||||||
|
"journal_entry",
|
||||||
|
"accounting_journal_entries",
|
||||||
|
"accounting_journals",
|
||||||
|
}
|
||||||
|
assert names.isdisjoint(forbidden)
|
||||||
|
for name in names:
|
||||||
|
assert "journal" not in name.lower()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_settlement_intent_via_accounting_provider_ref(client):
|
||||||
|
org = await client.post(
|
||||||
|
"/api/v1/organizations",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={"code": "ORG_S108", "name": "Settlement Org", "status": "active"},
|
||||||
|
)
|
||||||
|
assert org.status_code == 201
|
||||||
|
org_id = org.json()["id"]
|
||||||
|
|
||||||
|
intent = await client.post(
|
||||||
|
"/api/v1/settlements",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={
|
||||||
|
"organization_id": org_id,
|
||||||
|
"code": "SET1",
|
||||||
|
"total_amount": "120000",
|
||||||
|
"currency_code": "IRR",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert intent.status_code == 201, intent.text
|
||||||
|
iid = intent.json()["id"]
|
||||||
|
version = intent.json()["version"]
|
||||||
|
assert intent.json()["external_settlement_ref"] is None
|
||||||
|
|
||||||
|
line = await client.post(
|
||||||
|
f"/api/v1/settlements/{iid}/lines",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={"kind": "delivery_fee", "amount": "100000", "currency_code": "IRR"},
|
||||||
|
)
|
||||||
|
assert line.status_code == 201
|
||||||
|
|
||||||
|
submitted = await client.post(
|
||||||
|
f"/api/v1/settlements/{iid}/submit",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={"version": version},
|
||||||
|
)
|
||||||
|
assert submitted.status_code == 200
|
||||||
|
assert submitted.json()["status"] == "submitted"
|
||||||
|
version = submitted.json()["version"]
|
||||||
|
|
||||||
|
settled = await client.post(
|
||||||
|
f"/api/v1/settlements/{iid}/settle",
|
||||||
|
headers=tenant_headers(TENANT_A),
|
||||||
|
json={"version": version},
|
||||||
|
)
|
||||||
|
assert settled.status_code == 200
|
||||||
|
assert settled.json()["status"] == "settled"
|
||||||
|
assert settled.json()["external_settlement_ref"] is not None
|
||||||
|
assert settled.json()["external_settlement_ref"].startswith("acct://")
|
||||||
|
|
||||||
|
events = {e.event_type for e in get_event_publisher().published}
|
||||||
|
assert "delivery.settlement_intent.created" in events
|
||||||
|
assert "delivery.settlement_intent.submitted" in events
|
||||||
|
assert "delivery.settlement_intent.settled" in events
|
||||||
|
assert "delivery.settlement_line.added" in events
|
||||||
36
backend/services/delivery/app/validators/availability.py
Normal file
36
backend/services/delivery/app/validators/availability.py
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
"""Availability validators — Phase 10.3."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from shared.exceptions import AppError
|
||||||
|
|
||||||
|
from app.validators.foundation import validate_non_empty
|
||||||
|
|
||||||
|
|
||||||
|
def validate_shift_window(*, starts_at: datetime, ends_at: datetime) -> None:
|
||||||
|
if ends_at <= starts_at:
|
||||||
|
raise AppError(
|
||||||
|
"زمان پایان شیفت باید بعد از زمان شروع باشد",
|
||||||
|
status_code=422,
|
||||||
|
error_code="invalid_shift_window",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_availability_window(
|
||||||
|
*, effective_from: datetime | None, effective_until: datetime | None
|
||||||
|
) -> None:
|
||||||
|
if effective_from and effective_until and effective_until <= effective_from:
|
||||||
|
raise AppError(
|
||||||
|
"بازه دسترسی نامعتبر است",
|
||||||
|
status_code=422,
|
||||||
|
error_code="invalid_availability_window",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_shift_name(name: str) -> str:
|
||||||
|
return validate_non_empty(name, "name")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_zone_name(name: str) -> str:
|
||||||
|
return validate_non_empty(name, "name")
|
||||||
84
backend/services/delivery/app/validators/dispatch.py
Normal file
84
backend/services/delivery/app/validators/dispatch.py
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
"""Dispatch validators — Phase 10.5."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from shared.exceptions import AppError
|
||||||
|
|
||||||
|
from app.models.types import DispatchJobAction, DispatchJobStatus
|
||||||
|
|
||||||
|
ALLOWED_TRANSITIONS: dict[DispatchJobAction, set[DispatchJobStatus]] = {
|
||||||
|
DispatchJobAction.ASSIGN: {DispatchJobStatus.PENDING},
|
||||||
|
DispatchJobAction.START: {DispatchJobStatus.ASSIGNED},
|
||||||
|
DispatchJobAction.COMPLETE: {
|
||||||
|
DispatchJobStatus.ASSIGNED,
|
||||||
|
DispatchJobStatus.IN_PROGRESS,
|
||||||
|
},
|
||||||
|
DispatchJobAction.CANCEL: {
|
||||||
|
DispatchJobStatus.PENDING,
|
||||||
|
DispatchJobStatus.ASSIGNED,
|
||||||
|
DispatchJobStatus.IN_PROGRESS,
|
||||||
|
},
|
||||||
|
DispatchJobAction.FAIL: {
|
||||||
|
DispatchJobStatus.PENDING,
|
||||||
|
DispatchJobStatus.ASSIGNED,
|
||||||
|
DispatchJobStatus.IN_PROGRESS,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
TARGET_STATUS: dict[DispatchJobAction, DispatchJobStatus] = {
|
||||||
|
DispatchJobAction.ASSIGN: DispatchJobStatus.ASSIGNED,
|
||||||
|
DispatchJobAction.START: DispatchJobStatus.IN_PROGRESS,
|
||||||
|
DispatchJobAction.COMPLETE: DispatchJobStatus.COMPLETED,
|
||||||
|
DispatchJobAction.CANCEL: DispatchJobStatus.CANCELLED,
|
||||||
|
DispatchJobAction.FAIL: DispatchJobStatus.FAILED,
|
||||||
|
}
|
||||||
|
|
||||||
|
TERMINAL = frozenset(
|
||||||
|
{DispatchJobStatus.COMPLETED, DispatchJobStatus.CANCELLED, DispatchJobStatus.FAILED}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dispatch_job_transition(
|
||||||
|
*, action: DispatchJobAction, current: DispatchJobStatus
|
||||||
|
) -> None:
|
||||||
|
if current in TERMINAL:
|
||||||
|
raise AppError(
|
||||||
|
"کار ارسال در وضعیت پایانی است",
|
||||||
|
status_code=409,
|
||||||
|
error_code="dispatch_job_terminal",
|
||||||
|
details={"status": current.value, "action": action.value},
|
||||||
|
)
|
||||||
|
allowed = ALLOWED_TRANSITIONS.get(action, set())
|
||||||
|
if current not in allowed:
|
||||||
|
raise AppError(
|
||||||
|
"انتقال وضعیت کار ارسال مجاز نیست",
|
||||||
|
status_code=409,
|
||||||
|
error_code="invalid_dispatch_job_transition",
|
||||||
|
details={
|
||||||
|
"from_status": current.value,
|
||||||
|
"action": action.value,
|
||||||
|
"allowed_from": sorted(s.value for s in allowed),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def target_status_for(action: DispatchJobAction) -> DispatchJobStatus:
|
||||||
|
return TARGET_STATUS[action]
|
||||||
|
|
||||||
|
|
||||||
|
def validate_dispatch_reason(reason: str | None, *, required: bool = False) -> str | None:
|
||||||
|
if reason is None or not str(reason).strip():
|
||||||
|
if required:
|
||||||
|
raise AppError(
|
||||||
|
"دلیل الزامی است",
|
||||||
|
status_code=422,
|
||||||
|
error_code="reason_required",
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
text = str(reason).strip()
|
||||||
|
if len(text) > 500:
|
||||||
|
raise AppError(
|
||||||
|
"دلیل بیش از حد طولانی است",
|
||||||
|
status_code=422,
|
||||||
|
error_code="reason_too_long",
|
||||||
|
)
|
||||||
|
return text
|
||||||
27
backend/services/delivery/app/validators/fleet.py
Normal file
27
backend/services/delivery/app/validators/fleet.py
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
"""Fleet validators — Phase 10.2."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from app.validators.foundation import validate_non_empty
|
||||||
|
|
||||||
|
|
||||||
|
def validate_fleet_name(name: str) -> str:
|
||||||
|
return validate_non_empty(name, "name")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_vehicle_type_name(name: str) -> str:
|
||||||
|
return validate_non_empty(name, "name")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_plate_number(plate: str | None) -> str | None:
|
||||||
|
if plate is None or not str(plate).strip():
|
||||||
|
return None
|
||||||
|
value = str(plate).strip()
|
||||||
|
if len(value) > 32:
|
||||||
|
from shared.exceptions import AppError
|
||||||
|
|
||||||
|
raise AppError(
|
||||||
|
"شماره پلاک بیش از حد طولانی است",
|
||||||
|
status_code=422,
|
||||||
|
error_code="plate_too_long",
|
||||||
|
)
|
||||||
|
return value
|
||||||
38
backend/services/delivery/app/validators/pricing.py
Normal file
38
backend/services/delivery/app/validators/pricing.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
"""Pricing validators — Phase 10.4."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from shared.exceptions import AppError
|
||||||
|
|
||||||
|
from app.validators.foundation import validate_currency_code, validate_non_empty
|
||||||
|
|
||||||
|
|
||||||
|
def validate_pricing_name(name: str) -> str:
|
||||||
|
return validate_non_empty(name, "name")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_base_amount(amount: Decimal) -> Decimal:
|
||||||
|
if amount <= 0:
|
||||||
|
raise AppError(
|
||||||
|
"مبلغ پایه باید بزرگتر از صفر باشد",
|
||||||
|
status_code=422,
|
||||||
|
error_code="invalid_base_amount",
|
||||||
|
)
|
||||||
|
return amount
|
||||||
|
|
||||||
|
|
||||||
|
def validate_line_amount(amount: Decimal) -> Decimal:
|
||||||
|
return validate_base_amount(amount)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_capability_name(name: str) -> str:
|
||||||
|
return validate_non_empty(name, "name")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_bundle_name(name: str) -> str:
|
||||||
|
return validate_non_empty(name, "name")
|
||||||
|
|
||||||
|
|
||||||
|
def validate_currency(currency_code: str) -> str:
|
||||||
|
return validate_currency_code(currency_code)
|
||||||
25
backend/services/delivery/app/validators/routing.py
Normal file
25
backend/services/delivery/app/validators/routing.py
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
"""Routing validators — Phase 10.6."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from shared.exceptions import AppError
|
||||||
|
|
||||||
|
|
||||||
|
def validate_route_code_present(code: str) -> str:
|
||||||
|
code = str(code).strip()
|
||||||
|
if not code:
|
||||||
|
raise AppError(
|
||||||
|
"کد مسیر الزامی است",
|
||||||
|
status_code=422,
|
||||||
|
error_code="route_code_required",
|
||||||
|
)
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
def validate_stop_sequence(sequence: int) -> int:
|
||||||
|
if sequence < 0:
|
||||||
|
raise AppError(
|
||||||
|
"ترتیب توقف نامعتبر است",
|
||||||
|
status_code=422,
|
||||||
|
error_code="invalid_stop_sequence",
|
||||||
|
)
|
||||||
|
return sequence
|
||||||
66
backend/services/delivery/app/validators/settlement.py
Normal file
66
backend/services/delivery/app/validators/settlement.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
"""Settlement validators — Phase 10.8."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from shared.exceptions import AppError
|
||||||
|
|
||||||
|
from app.models.types import SettlementIntentStatus
|
||||||
|
|
||||||
|
ALLOWED_TRANSITIONS: dict[str, set[SettlementIntentStatus]] = {
|
||||||
|
"submit": {SettlementIntentStatus.DRAFT},
|
||||||
|
"settle": {SettlementIntentStatus.SUBMITTED},
|
||||||
|
"fail": {SettlementIntentStatus.SUBMITTED},
|
||||||
|
"cancel": {SettlementIntentStatus.DRAFT, SettlementIntentStatus.PENDING},
|
||||||
|
}
|
||||||
|
|
||||||
|
TARGET_STATUS: dict[str, SettlementIntentStatus] = {
|
||||||
|
"submit": SettlementIntentStatus.SUBMITTED,
|
||||||
|
"settle": SettlementIntentStatus.SETTLED,
|
||||||
|
"fail": SettlementIntentStatus.FAILED,
|
||||||
|
"cancel": SettlementIntentStatus.CANCELLED,
|
||||||
|
}
|
||||||
|
|
||||||
|
TERMINAL = frozenset(
|
||||||
|
{
|
||||||
|
SettlementIntentStatus.SETTLED,
|
||||||
|
SettlementIntentStatus.FAILED,
|
||||||
|
SettlementIntentStatus.CANCELLED,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_settlement_transition(*, action: str, current: SettlementIntentStatus) -> None:
|
||||||
|
if current in TERMINAL:
|
||||||
|
raise AppError(
|
||||||
|
"تسویه در وضعیت پایانی است",
|
||||||
|
status_code=409,
|
||||||
|
error_code="settlement_terminal",
|
||||||
|
details={"status": current.value, "action": action},
|
||||||
|
)
|
||||||
|
allowed = ALLOWED_TRANSITIONS.get(action, set())
|
||||||
|
if current not in allowed:
|
||||||
|
raise AppError(
|
||||||
|
"انتقال وضعیت تسویه مجاز نیست",
|
||||||
|
status_code=409,
|
||||||
|
error_code="invalid_settlement_transition",
|
||||||
|
details={
|
||||||
|
"from_status": current.value,
|
||||||
|
"action": action,
|
||||||
|
"allowed_from": sorted(s.value for s in allowed),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def target_settlement_status(action: str) -> SettlementIntentStatus:
|
||||||
|
return TARGET_STATUS[action]
|
||||||
|
|
||||||
|
|
||||||
|
def validate_settlement_amount(amount: Decimal) -> Decimal:
|
||||||
|
if amount <= 0:
|
||||||
|
raise AppError(
|
||||||
|
"مبلغ تسویه باید بزرگتر از صفر باشد",
|
||||||
|
status_code=422,
|
||||||
|
error_code="invalid_settlement_amount",
|
||||||
|
)
|
||||||
|
return amount
|
||||||
30
backend/services/delivery/app/validators/tracking.py
Normal file
30
backend/services/delivery/app/validators/tracking.py
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
"""Tracking validators — Phase 10.7."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from shared.exceptions import AppError
|
||||||
|
|
||||||
|
|
||||||
|
def validate_coordinates(*, latitude: float, longitude: float) -> None:
|
||||||
|
if not (-90 <= latitude <= 90):
|
||||||
|
raise AppError(
|
||||||
|
"عرض جغرافیایی نامعتبر است",
|
||||||
|
status_code=422,
|
||||||
|
error_code="invalid_latitude",
|
||||||
|
)
|
||||||
|
if not (-180 <= longitude <= 180):
|
||||||
|
raise AppError(
|
||||||
|
"طول جغرافیایی نامعتبر است",
|
||||||
|
status_code=422,
|
||||||
|
error_code="invalid_longitude",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_pod_refs(
|
||||||
|
*, signature_file_ref: str | None, photo_file_ref: str | None
|
||||||
|
) -> None:
|
||||||
|
if not signature_file_ref and not photo_file_ref:
|
||||||
|
raise AppError(
|
||||||
|
"حداقل یک مرجع فایل برای اثبات تحویل الزامی است",
|
||||||
|
status_code=422,
|
||||||
|
error_code="pod_file_ref_required",
|
||||||
|
)
|
||||||
38
backend/services/delivery/scripts/build_all_phases.py
Normal file
38
backend/services/delivery/scripts/build_all_phases.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
"""Build all delivery phase 10.2-10.8 files."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT))
|
||||||
|
|
||||||
|
FILES: dict[str, str] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def register(rel: str, content: str) -> None:
|
||||||
|
FILES[rel] = content.rstrip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
_register_repositories()
|
||||||
|
_register_specifications()
|
||||||
|
_register_validators()
|
||||||
|
_register_policies()
|
||||||
|
_register_services()
|
||||||
|
_register_commands_queries()
|
||||||
|
_register_api()
|
||||||
|
_register_migrations()
|
||||||
|
_register_tests()
|
||||||
|
_register_updates()
|
||||||
|
|
||||||
|
for rel, content in sorted(FILES.items()):
|
||||||
|
path = ROOT / rel
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
path.write_text(content, encoding="utf-8")
|
||||||
|
print(f"wrote {rel}")
|
||||||
|
print(f"total: {len(FILES)} files")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user